Files
YwxAppThink/docs/guide/model_selfheal.md
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

7.5 KiB
Raw Blame History

模型自愈机制(Schema Self-Heal)

YwxApp 的数据库结构自愈引擎收敛在 ywxapp/model/BaseModel.php 的静态方法中。 它的定位是 「缺表 / 缺列兜底」,不是迁移工具——旧表改名、跨前缀重整等问题仍需手动执行迁移 SQL(见 docs/migrations/)。

一、自愈引擎能力(BaseModel 静态方法)

方法 作用 说明
ensureTable($table, $sql) 表不存在则 CREATE $table 为已带前缀的完整表名;内部先 SHOW TABLES LIKE 探测
ensureTableFromInstall($prefix, $table) 从 public/install/install.sql 正则提取建表 DDL 执行 install.sql 是唯一事实源;$table 为不含前缀的表名
ensureColumn($table, $col, $def) 列不存在则 ALTER ADD MySQL 不支持 ADD COLUMN IF NOT EXISTS,故先探测
ensureAutoIncrementPk($table, $col='id') 修复 id 非自增主键 老库 id 定义成 NOT NULL 但无 AUTO_INCREMENT 时,create() 会报 1364
tableExists($table) 探测表是否存在 SHOW TABLES LIKE,异常时返回 false
currentPrefix() 取运行时表前缀 优先 config/database.php,回退 Db::getConfig('prefix')

所有方法内部都包了 try/catch:自愈失败(如数据库账号无 CREATE/ALTER 权限、NO_BACKSLASH_ESCAPES 导致 SQL 出错)会被静默吞掉并记录日志,不会阻断业务——但意味着「自愈没起作用」时不会立刻报错,需要查日志才能发现。

模块自带 SQL 的情况:ensureTableFromInstall() 只认核心的 public/install/install.sql。 中心站专用模块 appmall 有自己的一份事实源 app/appmall/install.sql,由 app/appmall/library/AppmallSchema 用同样的「解析 SQL → ensureTable()」方式执行, 模块内不再内联重复 DDL。

二、模型壳约定

每个需要自愈的模型都自带一个静态方法:

public static function ensureSchema(): void
{
    try {
        $prefix = \think\facade\Db::getConfig('prefix') ?: '';
        $table  = $prefix . 'notice';
        // 缺表建表(优先 ensureTableFromInstall,或从 install.sql 同款 DDL 兜底)
        self::ensureTable($table, "CREATE TABLE IF NOT EXISTS `{$prefix}notice` (...)");
        // 老库扩展列兜底(幂等)
        self::ensureColumn($table, 'type', "tinyint(1) NOT NULL DEFAULT 1");
    } catch (\Throwable $e) {
        // 忽略自愈失败,由业务暴露
    }
}

幂等性:所有自愈都先探测(SHOW TABLES / SHOW COLUMNS)再操作,已存在则跳过,重复调用安全。

自动触发(appmall 采用):app/appmall/model/ 下的 13 个模型与本模块 13 张表一一对应 (清单登记在 AppmallSchema::MODELS,加表必查),每个都在 initialize() 里调用 self::ensureSchema() —— 模型一被使用(含 Model::where() 这类静态查询)就先兜底建表 + 补老库扩展列, 业务入口无需记得手动调用,不会再撞 1146(缺表)/ 1054(缺列)。 AppmallSchema::ensureTable() 内部按「表」和「表+列」做了进程内 memo,每请求每表最多一次 SHOW TABLES, 开销可忽略。(核心库同样有先例:MemberProp::initialize() / CommonCard::initialize()。)

应用级中间件兜底(appmall 采用):app/appmall/middleware.php 注册 AppmallSchemaMiddleware,任何进入 /appmall/* 的请求(前台页 / 后台页 / 对外 API / 未注册路由) 在业务之前先 AppmallSchema::ensure() 一次(见第三节第 3 小节)。

为什么要「模型 + 中间件」双层:模型兜底保证直接使用模型的代码路径不缺表; 中间件兜底保证任何入口(含控制器里大量 Db::name('appmall_xxx') 直查、 以及将来新增的页面 / 接口)不会因为某处忘了调用而缺表。两者都幂等,重复执行无副作用。

三、触发时机(谁在什么时候调用 ensureSchema)

自愈不会定时 / 不会自动,全部是「业务请求时按需触发」。

1. 核心库(每次相关请求都跑一次,开销极小)

入口 调用 触发场景
ywxapp/library/AdminAuth.php (login/register) BackendAdmin::ensureSchema() 后台登录 / 注册
ywxapp/controller/MemberBase.php (_initialize) MemberUser::ensureSchema() 访问任意会员中心控制器

2. 业务方法内触发

模型 / 类 触发方法 触发场景
ywxapp/model/Notice.php getActiveTopNotices() / getList() 前台公告条渲染、公告列表页
ywxapp/model/Ad.php getByPosition() / getSlots() 广告位渲染

3. 中间件

入口 调用 触发场景
ywxapp/middleware/SpiderStat(全局) ensureTables() 每次请求(蜘蛛日志 / 统计表)
app/appmall/middleware/AppmallSchemaMiddleware(应用级) AppmallSchema::ensure() 任意 /appmall/* 请求(前台 / 后台 / 对外 API)

4. 插件安装 / 升级

插件 触发入口 触发场景
blog BlogSchema::ensure* 插件业务方法
forum ForumSchema::ensure* 插件业务方法
appmall(中心站) 应用中间件 AppmallSchema::ensure() + 各模型 ensureSchema()(initialize() 自动触发) 任意 /appmall/* 请求,或直接使用任一 appmall 模型
haonav / seo Addon.php::upgrade() / ensureTablesFromInstallSql() 插件升级 / 安装
wxchat / iot / mqpay 等 各自 Schema 类 插件初始化

四、线上老库迁移不在自愈范围

自愈只负责「缺表 / 缺列兜底」,不会做 RENAME、不会跨前缀重整。旧库改名必须手动执行迁移 SQL:

  • docs/migrations/migrate_admin_user_to_backend_member_wxapp.sql — admin/user → backend/member
  • docs/migrations/rename_table_prefix.sql — 批量改前缀
  • docs/migrations/fix_missing_wxapp_notice.sql — 自愈因权限被吞时手动补表

五、排错:遇到 1146 Table doesn't exist

  1. 确认表是否在 install.sql 中有建表语句(事实源)。
  2. 若是核心 / 插件表,确认对应 ensureSchema() 是否被调用(见第三节入口)。
  3. ⚠️ 高频根因:运行时没取到表前缀。ensureSchema() 若用 Db::getConfig('prefix') 取前缀,在部分线上环境(多应用路由 / CLI / Db 配置未就绪)会返回空字符串,导致自愈建出无前缀的表(如 notice),而模型 name 拼前缀后查的是 wxapp_notice → 1146。
    • 修复:所有模型壳 / 控制器一律改用 BaseModel::currentPrefix() 取前缀(优先 config/database.php,回退 Db::getConfig),并已统一修复 Notice / Ad;appmall 的控制器 / 服务原先各自内联 DDL 且用 Db::getConfig('prefix'),现已全部收敛到 AppmallSchema(事实源 app/appmall/install.sql,统一走 currentPrefix()),并由应用中间件统一触发。
    • 自查:grep "Db::getConfig('prefix')" 应只剩 0 处(全部换成 currentPrefix)。
  4. 若前缀正确、ensureSchema() 已调用仍 1146 → 多半是线上 DB 账号无 CREATE/ALTER 权限,自愈被静默吞掉。
    • 根治:给 DB 用户授予建表 / 改表权限,自愈会自动补齐;
    • 兜底:手动执行 docs/migrations/ 下对应修复 SQL(如 fix_missing_wxapp_notice.sql)。
  5. 若是改名类(admin_* / user_*)→ 必须手动跑迁移 SQL,自愈无法处理。