// +---------------------------------------------------------------------- namespace ywxapp\model; use think\facade\Config; use think\facade\Db; use think\Model; /** * BaseModel 类 * * 内聚了 SchemaGuard 数据库结构自愈引擎(纯通用能力,不含任何业务表清单)。 * 各模型在自己的 ensureSchema() 里调用本类静态方法确保「自己这张表」, * 建表责任与触发点绑定到模型本身,核心库不再按业务域聚合自愈。 * * @author ywxapp */ 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', ]; } /** * 将扁平的父子结构数组转换为带层级缩进的树形下拉数据 * @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($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); } }