Files
YwxAppThink/ywxapp/model/BaseModel.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

340 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}