安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
340 lines
13 KiB
PHP
340 lines
13 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
namespace ywxapp\model;
|
||
|
||
use think\facade\Config;
|
||
use think\facade\Db;
|
||
use think\Model;
|
||
|
||
/**
|
||
* BaseModel 类
|
||
*
|
||
* 内聚了 SchemaGuard 数据库结构自愈引擎(纯通用能力,不含任何业务表清单)。
|
||
* 各模型在自己的 ensureSchema() 里调用本类静态方法确保「自己这张表」,
|
||
* 建表责任与触发点绑定到模型本身,核心库不再按业务域聚合自愈。
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class BaseModel extends Model
|
||
{
|
||
|
||
protected $autoWriteTimestamp = 'int';
|
||
protected $createTime = 'create_at';
|
||
protected $updateTime = 'update_at';
|
||
|
||
protected function getBaseOptions(): array
|
||
{
|
||
return [
|
||
'createTime' => 'create_at',
|
||
'updateTime' => 'update_at',
|
||
'dateFormat' => 'Y-m-d H:i:s',
|
||
];
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
* 通用表存在性兜底
|
||
*
|
||
* 背景:运行时数据/配置表被误删时(历史上出现过角色表、菜单节点被清空,
|
||
* 以及 wxapp_common_addon 整表缺失),模型查询会直接抛 1146,进而导致
|
||
* 后台整页不可用。这里在基类层面统一兜底:任何继承 BaseModel 的模型
|
||
* 实例化时校验自己的表,缺失则按 install.sql 事实源尝试重建。
|
||
* 子类因此无需各自实现一遍;install.sql 未收录的表(如 common_addonconf)
|
||
* 由该模型自身的 ensureSchema() 提供 DDL。
|
||
* ------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 本请求内已校验过的表,避免重复 SHOW TABLES
|
||
* @var array<string,bool>
|
||
*/
|
||
protected static array $schemaChecked = [];
|
||
|
||
protected function initialize()
|
||
{
|
||
parent::initialize();
|
||
// 构造顺序上 applyOptions() 可能尚未执行(取决于子类是否在 __construct 中调用),
|
||
// 这里补一次,确保取到的是含前缀的完整表名。
|
||
$this->applyOptions();
|
||
self::autoEnsureTable((string) $this->getTable());
|
||
}
|
||
|
||
/**
|
||
* 表缺失时按 install.sql 自愈(幂等,每请求每表最多一次探测)
|
||
*/
|
||
public static function autoEnsureTable(string $table): void
|
||
{
|
||
if ($table === '' || isset(self::$schemaChecked[$table])) {
|
||
return;
|
||
}
|
||
try {
|
||
if (self::tableExists($table)) {
|
||
self::$schemaChecked[$table] = true;
|
||
return;
|
||
}
|
||
$prefix = self::currentPrefix();
|
||
$bare = ($prefix !== '' && strpos($table, $prefix) === 0)
|
||
? substr($table, strlen($prefix))
|
||
: $table;
|
||
self::ensureTableFromInstall($prefix, $bare);
|
||
self::$schemaChecked[$table] = true;
|
||
} catch (\Throwable $e) {
|
||
// 自愈失败不阻断实例化,交由实际查询报错暴露
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将扁平的父子结构数组转换为带层级缩进的树形下拉数据
|
||
* @param array $cate 数据集(含 id/pid 字段)
|
||
* @param string $name 用于展示的字段名
|
||
* @param string $lefthtml 层级缩进符号
|
||
* @param int $pid 父级 ID
|
||
* @param int $level 当前层级
|
||
* @return array
|
||
*/
|
||
public static function cateTree($cate, $name = 'title', $lefthtml = '|— ', $pid = 0, $level = 0)
|
||
{
|
||
$arr = [];
|
||
foreach ($cate as $v) {
|
||
if (($v['pid'] ?? 0) == $pid) {
|
||
$v['level'] = $level;
|
||
$v[$name] = str_repeat($lefthtml, $level) . ($v[$name] ?? '');
|
||
$arr[] = $v;
|
||
$arr = array_merge($arr, self::cateTree($cate, $name, $lefthtml, $v['id'], $level + 1));
|
||
}
|
||
}
|
||
return $arr;
|
||
}
|
||
|
||
|
||
public function __construct(array $data = [])
|
||
{
|
||
// 自愈必须早于 parent::__construct():think-orm 在构造过程中就会读取表结构
|
||
// (PDOConnection::getTableInfo),若此时表尚不存在会直接抛 1146,
|
||
// 根本等不到 initialize() 钩子执行。
|
||
// 注意:这里不能用 $this->getTable() —— 它本身会触发 schema 读取而先抛异常,
|
||
// 因此直接读 name/table 属性自行拼表名(与 think-orm 的取名规则一致)。
|
||
// 这里刻意不调用 applyOptions():Model 内部属性(readonly/hidden/append 等)
|
||
// 此时还是 null,赋值会走 __set()->getMappingName() 触发 TypeError。
|
||
// 因此只读 getOptions() 的返回值推算表名——它仅返回配置数组,无副作用。
|
||
try {
|
||
$opts = method_exists($this, 'getOptions') ? (array) $this->getOptions() : [];
|
||
$bare = isset($opts['name']) ? (string) $opts['name'] : '';
|
||
if ($bare === '') {
|
||
$bare = strtolower((string) preg_replace(
|
||
'/(?<!^)[A-Z]/',
|
||
'_$0',
|
||
(new \ReflectionClass($this))->getShortName()
|
||
));
|
||
}
|
||
$table = ! empty($opts['table']) ? (string) $opts['table'] : self::currentPrefix() . $bare;
|
||
self::autoEnsureTable($table);
|
||
} catch (\Throwable $e) {
|
||
// 取表名失败等情况下不阻断实例化
|
||
}
|
||
parent::__construct($data);
|
||
$this->applyOptions();
|
||
}
|
||
|
||
/**
|
||
* 将子类 getOptions() 返回的模型配置落实到 think\Model 属性,
|
||
* 使 name/strict/schema/autoWriteTimestamp/readonly 等真正生效(之前是死代码)。
|
||
*/
|
||
protected function applyOptions(): void
|
||
{
|
||
if (!method_exists($this, 'getOptions')) {
|
||
return;
|
||
}
|
||
foreach ($this->getOptions() as $key => $value) {
|
||
if ($value === null) {
|
||
continue;
|
||
}
|
||
switch ($key) {
|
||
case 'name':
|
||
// think-orm 4.0 语义:$name 是不带前缀的表名,
|
||
// 框架会根据 database.prefix 自动拼接完整表名。
|
||
// 之前误写成 $this->table(含前缀语义),导致不拼前缀、
|
||
// 模型查询裸名表而自愈建的是带前缀表,引发 1146。
|
||
$this->name = $value;
|
||
$this->table = null;
|
||
break;
|
||
case 'strict':
|
||
$this->strict = (bool)$value;
|
||
break;
|
||
case 'schema':
|
||
$this->schema = $value;
|
||
break;
|
||
case 'autoWriteTimestamp':
|
||
$this->autoWriteTimestamp = $value;
|
||
break;
|
||
case 'createTime':
|
||
$this->createTime = $value;
|
||
break;
|
||
case 'updateTime':
|
||
$this->updateTime = $value;
|
||
break;
|
||
case 'readonly':
|
||
$this->readonly = $value;
|
||
break;
|
||
case 'hidden':
|
||
$this->hidden = $value;
|
||
break;
|
||
case 'append':
|
||
$this->append = $value;
|
||
break;
|
||
case 'dateFormat':
|
||
$this->dateFormat = $value;
|
||
break;
|
||
case 'deleteTime':
|
||
$this->deleteTime = $value;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
protected $tenantField = 'tenant_id';
|
||
|
||
// 自动添加 tenant_id 到查询和保存
|
||
|
||
public static function onAfterRead($model)
|
||
{
|
||
$user = request()->auth ?? null;
|
||
if ($user && $model->hasField('tenant_id')) {
|
||
if ($model->tenant_id != $user['tenant_id']) {
|
||
abort(403, '无权访问此数据');
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
public static function onBeforeWrite($model)
|
||
{
|
||
$user = request()->auth ?? null;
|
||
if ($user && $model->hasField('tenant_id') && ! $model->tenant_id) {
|
||
$model->tenant_id = $user['tenant_id'];
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
* 数据库结构自愈引擎(原 SchemaGuard,已内聚到 BaseModel)
|
||
* 唯一事实源是 public/install/install.sql;模型通过 ensureTableFromInstall()
|
||
* 从中提取 DDL 建表,避免 DDL 漂移。
|
||
* ------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 表是否存在(已带前缀的完整表名)
|
||
*/
|
||
public static function tableExists(string $table): bool
|
||
{
|
||
try {
|
||
return !empty(Db::query("SHOW TABLES LIKE '{$table}'"));
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 表不存在则创建(已带前缀的完整表名 + 完整 CREATE SQL)
|
||
*/
|
||
public static function ensureTable(string $table, string $sql): void
|
||
{
|
||
try {
|
||
if (!self::tableExists($table)) {
|
||
Db::execute($sql);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 忽略(如权限不足),由后续业务报错暴露
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保 id 列为自增主键(老库修复:id 定义成 NOT NULL 但无 PRIMARY KEY/AUTO_INCREMENT
|
||
* 时,模型 create() 不带 id 会报 1364 Field 'id' doesn't have a default value)。
|
||
* @param string $table 已带前缀的完整表名
|
||
* @param string $column 主键列名,默认 id
|
||
*/
|
||
public static function ensureAutoIncrementPk(string $table, string $column = 'id'): void
|
||
{
|
||
try {
|
||
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||
if (empty($cols)) {
|
||
return;
|
||
}
|
||
$col = $cols[0];
|
||
$extra = strtolower((string)($col['Extra'] ?? ''));
|
||
$key = strtoupper((string)($col['Key'] ?? ''));
|
||
if (strpos($extra, 'auto_increment') !== false) {
|
||
return; // 已是自增
|
||
}
|
||
$type = (string)($col['Type'] ?? 'int unsigned');
|
||
if ($key !== 'PRI') {
|
||
// 无主键:一并加主键 + 自增
|
||
Db::execute("ALTER TABLE `{$table}` MODIFY `{$column}` {$type} NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (`{$column}`)");
|
||
} else {
|
||
Db::execute("ALTER TABLE `{$table}` MODIFY `{$column}` {$type} NOT NULL AUTO_INCREMENT");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 忽略(如权限不足),由后续业务报错暴露
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列不存在则追加(MySQL 不支持 ADD COLUMN IF NOT EXISTS,故先探测)
|
||
* @param string $table 已带前缀的完整表名
|
||
*/
|
||
public static function ensureColumn(string $table, string $column, string $def): void
|
||
{
|
||
try {
|
||
$cols = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||
if (empty($cols)) {
|
||
Db::execute("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$def}");
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 自愈失败不应静默吞掉,记录日志便于排查(如 ALTER 权限不足)
|
||
try {
|
||
\think\facade\Log::error("[BaseModel] ensureColumn failed: {$table}.{$column} - " . $e->getMessage());
|
||
} catch (\Throwable $e2) {
|
||
// 日志也失败则彻底忽略
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取得运行时表前缀(CLI / 多应用路由下 Db::getConfig 可能为空,需兜底到配置)。
|
||
* 模型壳调用 ensureTableFromInstall() 时统一使用本方法取前缀,避免修错无前缀表。
|
||
*/
|
||
public static function currentPrefix(): string
|
||
{
|
||
$prefix = Config::get('database.connections.mysql.prefix', '');
|
||
if ($prefix === '') {
|
||
$prefix = Db::getConfig('prefix') ?: '';
|
||
}
|
||
return $prefix;
|
||
}
|
||
|
||
/**
|
||
* 从 install.sql 提取指定表的 CREATE TABLE 语句并执行建表(单一事实源)。
|
||
* @param string $prefix 运行时表前缀
|
||
* @param string $table 不含前缀的表名(如 backend / member_profile)
|
||
*/
|
||
public static function ensureTableFromInstall(string $prefix, string $table): void
|
||
{
|
||
$p = $prefix;
|
||
$sqlFile = root_path() . 'public/install/install.sql';
|
||
if (!is_file($sqlFile)) {
|
||
return;
|
||
}
|
||
$content = file_get_contents($sqlFile);
|
||
$pattern = '/CREATE TABLE IF NOT EXISTS `__PREFIX__' . preg_quote($table, '/') . '`\s*\(.*?\)\s*ENGINE=[^;]*;/s';
|
||
if (!preg_match($pattern, $content, $m)) {
|
||
return;
|
||
}
|
||
$ddl = str_replace('__PREFIX__', $p, $m[0]);
|
||
self::ensureTable($p . $table, $ddl);
|
||
}
|
||
}
|