Files
YwxAppThink/app/appmall/library/AppmallSchema.php
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

155 lines
6.5 KiB
PHP

<?php
namespace app\appmall\library;
use think\facade\Log;
use ywxapp\model\BaseModel;
/**
* 应用市场(appmall)专属表结构自愈。
*
* 事实源:app/appmall/install.sql(本模块自带,随模块一起分发)。
* 本类不再内联任何建表 DDL —— 早期版本把 DDL 抄写在本类与多个控制器里,
* 彼此与 install.sql 逐份漂移(漏列 / 漏索引 / 缺 UK),现统一改为「解析 install.sql 执行」。
*
* 无版本号:缺表建表、缺列补列全部是幂等探测(SHOW TABLES / SHOW COLUMNS),
* 不依赖 schema 版本表、不需要迁移脚本,重复调用安全。
*
* 分工(谁的表谁兜底):
* - 本模块 13 张表**全部有模型**(见 self::MODELS,与 install.sql 一一对应):
* 各模型 ensureSchema() 提供「缺表 + 老库扩展列」,并在 initialize() 中被自动触发,
* 因此任何入口(控制器 / 服务 / 静态查询)都不会再撞 1146(缺表)/ 1054(缺列)。
* - 本类 ensure() 汇总「全部模型 + install.sql 里暂无模型的表」,供应用级中间件 / 控制器入口调用一次。
*/
class AppmallSchema
{
/**
* 本模块全部表的模型清单(表 ↔ 模型 一一对应,勿漏)。
* 新增表时按序:install.sql 建表 → 加模型(含 ensureSchema)→ 登记到此处。
*
* @var array<class-string>
*/
public const MODELS = [
\app\appmall\model\AddonList::class, // appmall_addon_list
\app\appmall\model\AddonSubmission::class, // appmall_addon_submissions
\app\appmall\model\AddonReviewLog::class, // appmall_addon_review_log
\app\appmall\model\Developer::class, // appmall_developers
\app\appmall\model\Revenue::class, // appmall_addon_revenues
\app\appmall\model\Withdrawal::class, // appmall_addon_withdrawals
\app\appmall\model\AddonOrder::class, // appmall_addon_orders
\app\appmall\model\AddonLicense::class, // appmall_addon_licenses
\app\appmall\model\AddonDownloadLog::class, // appmall_addon_download_logs
\app\appmall\model\Site::class, // appmall_sites
\app\appmall\model\FrameworkList::class, // appmall_framework_list
\app\appmall\model\FrameworkDownloadLog::class, // appmall_framework_download_log
\app\appmall\model\Throttle::class, // appmall_throttle
\app\appmall\model\AddonReview::class, // appmall_addon_reviews(用户评价)
];
/** 进程内已确保建表的表(不含前缀),避免每请求重复 SHOW TABLES */
private static $tableDone = [];
/** 进程内已确保过的扩展列 "表|列" */
private static $colDone = [];
/** install.sql 解析缓存:[不含前缀表名 => CREATE 语句] */
private static $ddlMap = null;
/**
* 确保单张 appmall 表就绪:缺表建表 + 补齐扩展列(幂等,进程内只探测一次)。
* 模型 ensureSchema() 直接委托这里,控制器无需再手写 DDL。
*
* @param string $table 不含前缀的表名,如 appmall_addon_list
* @param array<string,string> $columns 该表的老库扩展列 [列名 => 列定义]
*/
public static function ensureTable(string $table, array $columns = []): void
{
$table = self::bare($table);
$prefix = BaseModel::currentPrefix();
if (!isset(self::$tableDone[$table])) {
self::$tableDone[$table] = true;
$map = self::ddlMap();
if (isset($map[$table])) {
BaseModel::ensureTable($prefix . $table, str_replace('__PREFIX__', $prefix, $map[$table]));
}
}
foreach ($columns as $col => $def) {
$key = $table . '|' . $col;
if (isset(self::$colDone[$key])) {
continue;
}
self::$colDone[$key] = true;
BaseModel::ensureColumn($prefix . $table, $col, $def);
}
}
/**
* 确保全部 appmall 表就绪(幂等,进程内每表只探测一次)。
*
* 触发点:
* - app/appmall/middleware.php(应用级中间件)→ 任何 /appmall/* 请求先建全表;
* - 任意模型被使用 → 该模型 initialize() 自触发对应单表兜底。
*/
public static function ensure(): void
{
// 1) 模型清单:缺表建表 + 老库扩展列,随模型一起维护,不会漏
foreach (self::MODELS as $model) {
try {
if (class_exists($model) && method_exists($model, 'ensureSchema')) {
$model::ensureSchema();
}
} catch (\Throwable $e) {
// 单表自愈失败不阻断其余表(由业务暴露)
}
}
// 2) install.sql 里存在、但暂未建模型的表也兜底(防止「只加 SQL 忘了加模型」而漏表)
foreach (array_keys(self::ddlMap()) as $table) {
self::ensureTable($table);
}
}
/**
* 去掉可能已带上的表前缀,统一成 install.sql 里的裸表名。
*/
private static function bare(string $table): string
{
$prefix = BaseModel::currentPrefix();
if ($prefix !== '' && strpos($table, $prefix) === 0) {
return substr($table, strlen($prefix));
}
return $table;
}
/**
* 解析 install.sql,得到 [裸表名 => CREATE 语句](语句内保留 __PREFIX__ 占位符)。
* 结果进程内缓存一次;文件缺失时记日志并返回空表(自愈降级为不动作,不阻断业务)。
*/
private static function ddlMap(): array
{
if (self::$ddlMap !== null) {
return self::$ddlMap;
}
self::$ddlMap = [];
$file = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'install.sql';
if (!is_file($file)) {
try {
Log::error('[appmall] 建表事实源 install.sql 缺失,表结构自愈已跳过:' . $file);
} catch (\Throwable $e) {
// 日志不可用则忽略
}
return self::$ddlMap;
}
$content = (string) file_get_contents($file);
if (preg_match_all('/CREATE TABLE IF NOT EXISTS `__PREFIX__([a-z0-9_]+)`\s*\(.*?\)\s*ENGINE=[^;]*;/is', $content, $rows, PREG_SET_ORDER)) {
foreach ($rows as $row) {
self::$ddlMap[$row[1]] = $row[0];
}
}
return self::$ddlMap;
}
}