安全(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(市场 / 开发者中心 / 审核 / 分润)
859 lines
31 KiB
PHP
859 lines
31 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>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
namespace app\backend\controller;
|
||
|
||
use think\facade\Db;
|
||
use ywxapp\controller\BackendBase;
|
||
use ywxapp\library\AdminLogger;
|
||
|
||
/**
|
||
* 数据表管理(可视化建表 / 查看结构 / 优化 / 删表)
|
||
*
|
||
* 与「数据库备份」分开成两个控制器:那边是数据层面的备份还原,
|
||
* 这边是结构层面的 DDL。
|
||
*
|
||
* 安全约束(DDL 不可逆,这里比 CRUD 更保守):
|
||
* 1. 表名 / 字段名 / 类型 / 引擎 / 字符集全部走白名单,用户输入的字符串
|
||
* 一个字符都不会拼进 SQL(注释与默认值只做转义,不参与语法结构);
|
||
* 2. 建表只允许「新建不存在的表」,已存在直接拒绝,绝不覆盖(不做 DROP+CREATE);
|
||
* 3. 删表:核心表(backend_admin / backend_power 等)列入保护名单,
|
||
* 且要求前端回传表全名做二次确认;
|
||
* 4. 所有写操作进 AdminLogger 留痕。
|
||
*
|
||
* 权限节点(首次访问自动补录):datatable:index / datatable:create /
|
||
* datatable:drop / datatable:optimize / datatable:structure
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class Datatable extends BackendBase
|
||
{
|
||
use EnsuresPower;
|
||
|
||
protected $noNeedLogin = [];
|
||
protected $noNeedVerify = [];
|
||
|
||
/**
|
||
* 进程内只补录一次权限
|
||
* @var bool
|
||
*/
|
||
protected static $powerChecked = false;
|
||
|
||
/**
|
||
* install.sql 索引缓存:[裸表名 => install.sql 相对路径]
|
||
* @var array|null
|
||
*/
|
||
protected static $installMap = null;
|
||
|
||
/**
|
||
* 允许的数据类型(白名单)
|
||
* @var array
|
||
*/
|
||
protected const TYPES = [
|
||
'tinyint', 'smallint', 'mediumint', 'int', 'bigint',
|
||
'decimal', 'float', 'double',
|
||
'char', 'varchar', 'text', 'mediumtext', 'longtext',
|
||
'date', 'datetime', 'timestamp', 'time', 'year',
|
||
'json', 'blob', 'longblob',
|
||
];
|
||
|
||
/**
|
||
* 数值类型(可 unsigned / 可自增)
|
||
* @var array
|
||
*/
|
||
protected const NUMERIC = [
|
||
'tinyint', 'smallint', 'mediumint', 'int', 'bigint', 'decimal', 'float', 'double',
|
||
];
|
||
|
||
/**
|
||
* 需要长度定义的类型
|
||
* @var array
|
||
*/
|
||
protected const NEED_LEN = ['char', 'varchar', 'decimal', 'float', 'double'];
|
||
|
||
/**
|
||
* 允许的引擎
|
||
* @var array
|
||
*/
|
||
protected const ENGINES = ['InnoDB', 'MyISAM', 'MEMORY', 'Aria'];
|
||
|
||
/**
|
||
* 允许的字符集
|
||
* @var array
|
||
*/
|
||
protected const CHARSETS = ['utf8mb4', 'utf8', 'gbk', 'latin1'];
|
||
|
||
/**
|
||
* 允许的排序规则(与上面的字符集配对)
|
||
* @var array
|
||
*/
|
||
protected const COLLATIONS = [
|
||
'utf8mb4_general_ci', 'utf8mb4_unicode_ci', 'utf8mb4_bin',
|
||
'utf8_general_ci', 'utf8_bin',
|
||
'gbk_chinese_ci', 'latin1_swedish_ci',
|
||
];
|
||
|
||
/**
|
||
* 禁止删除的核心表(不含前缀)
|
||
* @var array
|
||
*/
|
||
protected const PROTECTED = [
|
||
'backend_admin', 'backend_power', 'backend_role', 'backend_group',
|
||
'backend_log', 'backend_menu',
|
||
];
|
||
|
||
protected function initialize()
|
||
{
|
||
if (!self::$powerChecked) {
|
||
self::$powerChecked = true;
|
||
$this->ensureMenus();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列表:全部数据表
|
||
*/
|
||
public function index()
|
||
{
|
||
if ($this->request->isAjax()) {
|
||
$page = $this->request->param('page/d', 1);
|
||
$limt = $this->request->param('limit/d', 15);
|
||
$kw = trim((string) $this->request->param('keyword', ''));
|
||
// 只看「库里有、但没登记进任何 install.sql」的表
|
||
$onlyUnregistered = (int) $this->request->param('unregistered/d', 0) === 1;
|
||
|
||
$prefix = $this->prefix();
|
||
$map = $this->installMap();
|
||
$rows = [];
|
||
try {
|
||
$rows = Db::query('SHOW TABLE STATUS');
|
||
} catch (\Throwable $e) {
|
||
$this->result->error('读取表状态失败:' . $e->getMessage());
|
||
}
|
||
|
||
$list = [];
|
||
foreach ($rows as $row) {
|
||
$row = array_change_key_case($row, CASE_LOWER);
|
||
$name = (string) ($row['name'] ?? '');
|
||
$short = $prefix !== '' && str_starts_with($name, $prefix)
|
||
? substr($name, strlen($prefix))
|
||
: $name;
|
||
if ($onlyUnregistered && $this->archiveInfo($short, $map)[0]) {
|
||
continue;
|
||
}
|
||
if ($kw !== ''
|
||
&& stripos($name, $kw) === false
|
||
&& stripos((string) ($row['comment'] ?? ''), $kw) === false) {
|
||
continue;
|
||
}
|
||
$archive = $this->archiveInfo($short, $map);
|
||
$list[] = [
|
||
'name' => $name,
|
||
'short' => $short,
|
||
'engine' => (string) ($row['engine'] ?? ''),
|
||
// 是否已在某个 install.sql / 模型自愈里登记(决定全新安装 / 自愈能否补出来)
|
||
'registered' => $archive[0],
|
||
'target' => $archive[1],
|
||
'rows' => (int) ($row['rows'] ?? 0),
|
||
'size' => (int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0)),
|
||
'size_text' => $this->formatBytes((int) (($row['data_length'] ?? 0) + ($row['index_length'] ?? 0))),
|
||
'collation' => (string) ($row['collation'] ?? ''),
|
||
'comment' => (string) ($row['comment'] ?? ''),
|
||
'protected' => in_array($short, self::PROTECTED, true),
|
||
];
|
||
}
|
||
|
||
$total = count($list);
|
||
$slice = array_slice($list, ($page - 1) * $limt, $limt);
|
||
$this->result->setCount($total)->success($slice);
|
||
}
|
||
|
||
$this->assign('prefix', $this->prefix());
|
||
$this->assign('charset', $this->charset());
|
||
return $this->fetch();
|
||
}
|
||
|
||
/**
|
||
* 查看表结构:字段 + 索引 + 建表语句
|
||
*/
|
||
public function structure()
|
||
{
|
||
$table = $this->safeTable((string) $this->request->param('table', ''));
|
||
if ($table === '') {
|
||
$this->result->error('参数错误:table');
|
||
}
|
||
if (! $this->tableExists($table)) {
|
||
$this->result->error('表不存在:' . $table, 404);
|
||
}
|
||
|
||
$columns = [];
|
||
foreach (Db::query('SHOW FULL COLUMNS FROM `' . $table . '`') as $c) {
|
||
$c = array_change_key_case($c, CASE_LOWER);
|
||
$columns[] = [
|
||
'field' => (string) ($c['field'] ?? ''),
|
||
'type' => (string) ($c['type'] ?? ''),
|
||
'null' => (string) ($c['null'] ?? ''),
|
||
'key' => (string) ($c['key'] ?? ''),
|
||
'default' => $c['default'] ?? null,
|
||
'extra' => (string) ($c['extra'] ?? ''),
|
||
'comment' => (string) ($c['comment'] ?? ''),
|
||
];
|
||
}
|
||
|
||
$indexes = [];
|
||
foreach (Db::query('SHOW INDEX FROM `' . $table . '`') as $i) {
|
||
$i = array_change_key_case($i, CASE_LOWER);
|
||
$indexes[] = [
|
||
'name' => (string) ($i['key_name'] ?? ''),
|
||
'column' => (string) ($i['column_name'] ?? ''),
|
||
'unique' => (int) ($i['non_unique'] ?? 1) === 0,
|
||
'seq' => (int) ($i['seq_in_index'] ?? 0),
|
||
];
|
||
}
|
||
|
||
$create = Db::query('SHOW CREATE TABLE `' . $table . '`');
|
||
$create = array_change_key_case((array) ($create[0] ?? []), CASE_LOWER);
|
||
|
||
$this->result->success([
|
||
'table' => $table,
|
||
'short' => $this->shortName($table),
|
||
'columns' => $columns,
|
||
'indexes' => $indexes,
|
||
'sql' => (string) ($create['create table'] ?? $create['create view'] ?? ''),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 生成建表 SQL(只预览,不执行)
|
||
*/
|
||
public function preview()
|
||
{
|
||
if (! $this->request->isPost()) {
|
||
$this->result->error('请求方式错误', 405);
|
||
}
|
||
try {
|
||
$def = $this->buildDefinition();
|
||
} catch (\Throwable $e) {
|
||
$this->result->error($e->getMessage());
|
||
return;
|
||
}
|
||
$this->result->success([
|
||
'sql' => $this->createSql($def),
|
||
'table' => $def['table'],
|
||
'short' => $this->shortName($def['table']),
|
||
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 执行建表
|
||
*/
|
||
public function create()
|
||
{
|
||
if (! $this->request->isPost()) {
|
||
$this->result->error('请求方式错误', 405);
|
||
}
|
||
try {
|
||
$def = $this->buildDefinition();
|
||
} catch (\Throwable $e) {
|
||
$this->result->error($e->getMessage());
|
||
return;
|
||
}
|
||
|
||
if ($this->tableExists($def['table'])) {
|
||
$this->result->error('表已存在,已中止(本页只做新建,不会覆盖或重建已有表):' . $def['table']);
|
||
}
|
||
|
||
$sql = $this->createSql($def);
|
||
try {
|
||
Db::execute($sql);
|
||
} catch (\Throwable $e) {
|
||
AdminLogger::write('datatable/create', '建表失败:' . $def['table'] . '|' . $e->getMessage());
|
||
$this->result->error('建表失败:' . $e->getMessage());
|
||
}
|
||
|
||
AdminLogger::write('datatable/create', '新建表:' . $def['table'] . '(' . count($def['fields']) . ' 个字段)');
|
||
$this->result->success([
|
||
'table' => $def['table'],
|
||
'short' => $this->shortName($def['table']),
|
||
'sql' => $sql,
|
||
'command' => 'php think addon:crud <插件> -t ' . $this->shortName($def['table']),
|
||
], '表创建成功:' . $def['table']);
|
||
}
|
||
|
||
/**
|
||
* 删除表(危险:不可逆)
|
||
*
|
||
* 必须回传 confirm=表全名,与前端输入框完全一致才放行。
|
||
*/
|
||
public function drop()
|
||
{
|
||
if (! $this->request->isPost()) {
|
||
$this->result->error('请求方式错误', 405);
|
||
}
|
||
$table = $this->safeTable((string) $this->request->param('table', ''));
|
||
$confirm = (string) $this->request->param('confirm', '');
|
||
if ($table === '') {
|
||
$this->result->error('参数错误:table');
|
||
}
|
||
if ($confirm !== $table) {
|
||
$this->result->error('确认表名不一致,已中止');
|
||
}
|
||
if (in_array($this->shortName($table), self::PROTECTED, true)) {
|
||
$this->result->error('该表是系统核心表,禁止删除:' . $table);
|
||
}
|
||
if (! $this->tableExists($table)) {
|
||
$this->result->error('表不存在:' . $table, 404);
|
||
}
|
||
|
||
try {
|
||
Db::execute('DROP TABLE `' . $table . '`');
|
||
} catch (\Throwable $e) {
|
||
$this->result->error('删除失败:' . $e->getMessage());
|
||
}
|
||
|
||
AdminLogger::write('datatable/drop', '删除表:' . $table);
|
||
$this->result->success(['table' => $table], '已删除表:' . $table);
|
||
}
|
||
|
||
/**
|
||
* 优化表
|
||
*/
|
||
public function optimize()
|
||
{
|
||
if (! $this->request->isPost()) {
|
||
$this->result->error('请求方式错误', 405);
|
||
}
|
||
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
|
||
if (! $tables) {
|
||
$this->result->error('请选择要优化的表');
|
||
}
|
||
$ok = [];
|
||
foreach ($tables as $t) {
|
||
$table = $this->safeTable((string) $t);
|
||
if ($table === '' || ! $this->tableExists($table)) {
|
||
continue;
|
||
}
|
||
try {
|
||
Db::execute('OPTIMIZE TABLE `' . $table . '`');
|
||
$ok[] = $table;
|
||
} catch (\Throwable $e) {
|
||
// 单表失败不影响其它表
|
||
}
|
||
}
|
||
AdminLogger::write('datatable/optimize', '优化表:' . implode(', ', $ok));
|
||
$this->result->success(['tables' => $ok], '已优化 ' . count($ok) . ' 张表');
|
||
}
|
||
|
||
/**
|
||
* 导出安装 SQL(只生成文本,不写任何文件、不执行)
|
||
*
|
||
* 目的:把「运行时在页面上建出来的表」回写到项目里的 install.sql,
|
||
* 否则换库全新安装 / 老站自愈都拿不到这张表。
|
||
* 输出形式统一为 `CREATE TABLE IF NOT EXISTS `__PREFIX__xxx``,
|
||
* 与各 install.sql 现有写法一致(自愈类按 __PREFIX__ 占位符解析执行)。
|
||
*/
|
||
public function export()
|
||
{
|
||
if (! $this->request->isPost()) {
|
||
$this->result->error('请求方式错误', 405);
|
||
}
|
||
$tables = array_values(array_filter((array) $this->request->param('tables/a', [])));
|
||
if (! $tables) {
|
||
$this->result->error('请选择要导出的表');
|
||
}
|
||
|
||
$map = $this->installMap();
|
||
$items = [];
|
||
foreach ($tables as $t) {
|
||
$table = $this->safeTable((string) $t);
|
||
if ($table === '' || ! $this->tableExists($table)) {
|
||
continue;
|
||
}
|
||
$sql = $this->exportSql($table);
|
||
if ($sql === '') {
|
||
continue;
|
||
}
|
||
$short = $this->shortName($table);
|
||
[$registered, $target] = $this->archiveInfo($short, $map);
|
||
$items[] = [
|
||
'table' => $table,
|
||
'short' => $short,
|
||
'registered' => $registered,
|
||
'target' => $target,
|
||
'sql' => $sql,
|
||
];
|
||
}
|
||
if (! $items) {
|
||
$this->result->error('没有可导出的表(表不存在或无法读取建表语句)');
|
||
}
|
||
|
||
$unregistered = array_values(array_filter($items, fn ($i) => ! $i['registered']));
|
||
AdminLogger::write('datatable/export', '导出安装 SQL:' . implode(', ', array_column($items, 'table')));
|
||
|
||
$this->result->success([
|
||
'items' => $items,
|
||
'text' => implode("\n\n", array_column($items, 'sql')),
|
||
'unregistered' => array_column($unregistered, 'short'),
|
||
], '已生成 ' . count($items) . ' 张表的安装 SQL');
|
||
}
|
||
|
||
/* ============================ 建表定义解析 ============================ */
|
||
|
||
/**
|
||
* 解析前端提交的建表参数(全部走白名单,非法直接抛错)
|
||
*
|
||
* @return array{table:string,comment:string,engine:string,charset:string,collation:string,fields:array}
|
||
* @throws \Throwable
|
||
*/
|
||
protected function buildDefinition(): array
|
||
{
|
||
$short = trim((string) $this->request->param('table', ''));
|
||
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $short)) {
|
||
throw new \InvalidArgumentException('表名不合法:只允许字母、数字、下划线,且以字母开头');
|
||
}
|
||
$table = $this->prefix() . $short;
|
||
if (strlen($table) > 64) {
|
||
throw new \InvalidArgumentException('表名过长(含前缀超过 64 字符)');
|
||
}
|
||
|
||
$engine = (string) $this->request->param('engine', 'InnoDB');
|
||
$charset = (string) $this->request->param('charset', $this->charset());
|
||
$collation = (string) $this->request->param('collation', '');
|
||
if (! in_array($engine, self::ENGINES, true)) {
|
||
throw new \InvalidArgumentException('不支持的存储引擎:' . $engine);
|
||
}
|
||
if (! in_array($charset, self::CHARSETS, true)) {
|
||
throw new \InvalidArgumentException('不支持的字符集:' . $charset);
|
||
}
|
||
if ($collation !== '' && ! in_array($collation, self::COLLATIONS, true)) {
|
||
throw new \InvalidArgumentException('不支持的排序规则:' . $collation);
|
||
}
|
||
|
||
$fields = [];
|
||
foreach ((array) $this->request->param('fields/a', []) as $f) {
|
||
$fields[] = $this->parseField((array) $f);
|
||
}
|
||
if (! $fields) {
|
||
throw new \InvalidArgumentException('至少需要一个字段');
|
||
}
|
||
|
||
// 字段名重复 / 多个自增列是 MySQL 会直接报错的情况,提前拦掉
|
||
$seen = [];
|
||
$auto = 0;
|
||
foreach ($fields as $f) {
|
||
if (isset($seen[$f['name']])) {
|
||
throw new \InvalidArgumentException('字段名重复:' . $f['name']);
|
||
}
|
||
$seen[$f['name']] = true;
|
||
if ($f['autoinc']) {
|
||
$auto++;
|
||
}
|
||
}
|
||
if ($auto > 1) {
|
||
throw new \InvalidArgumentException('只能有一个自增字段');
|
||
}
|
||
foreach ($fields as $f) {
|
||
// 自增列必须是主键(MySQL 硬性要求)
|
||
if ($f['autoinc'] && ! $f['pk']) {
|
||
throw new \InvalidArgumentException('自增字段「' . $f['name'] . '」必须同时设为主键');
|
||
}
|
||
}
|
||
|
||
return [
|
||
'table' => $table,
|
||
'comment' => trim((string) $this->request->param('comment', '')),
|
||
'engine' => $engine,
|
||
'charset' => $charset,
|
||
'collation' => $collation,
|
||
'fields' => $fields,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析单个字段
|
||
* @throws \Throwable
|
||
*/
|
||
protected function parseField(array $f): array
|
||
{
|
||
$name = trim((string) ($f['name'] ?? ''));
|
||
$type = strtolower(trim((string) ($f['type'] ?? '')));
|
||
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/', $name)) {
|
||
throw new \InvalidArgumentException('字段名不合法:' . ($name === '' ? '(空)' : $name));
|
||
}
|
||
if (! in_array($type, self::TYPES, true)) {
|
||
throw new \InvalidArgumentException('不支持的字段类型:' . $type);
|
||
}
|
||
|
||
// 长度:仅数值精度与字符长度允许,形如 10 或 10,2
|
||
$length = trim((string) ($f['length'] ?? ''));
|
||
if ($length !== '' && ! preg_match('/^\d{1,10}(,\d{1,4})?$/', $length)) {
|
||
throw new \InvalidArgumentException('字段「' . $name . '」的长度 / 精度不合法');
|
||
}
|
||
if ($length === '' && in_array($type, self::NEED_LEN, true)) {
|
||
$length = $type === 'varchar' ? '255' : ($type === 'decimal' ? '10,2' : '255');
|
||
}
|
||
if (! in_array($type, self::NEED_LEN, true)) {
|
||
$length = ''; // 整型带显示宽度在 MySQL 8 已废弃,统一不输出
|
||
}
|
||
|
||
// 默认值要区分「没传」和「传了空串」:空串是合法默认值(DEFAULT ''),
|
||
// 一律 trim 成 '' 会让 NOT NULL 的 varchar 拿不到默认值。
|
||
$hasDefault = array_key_exists('default', $f)
|
||
&& $f['default'] !== null
|
||
&& $f['default'] !== false;
|
||
$default = $hasDefault ? (string) $f['default'] : '';
|
||
$unsigned = ! empty($f['unsigned']) && in_array($type, self::NUMERIC, true);
|
||
$nullable = ! empty($f['nullable']);
|
||
$autoinc = ! empty($f['autoinc']) && in_array($type, self::NUMERIC, true)
|
||
&& ! in_array($type, ['decimal', 'float', 'double'], true);
|
||
$pk = ! empty($f['pk']);
|
||
|
||
// 自增列不能为空、不能有默认值
|
||
if ($autoinc) {
|
||
$nullable = false;
|
||
$default = '';
|
||
$hasDefault = false;
|
||
}
|
||
|
||
$index = strtolower(trim((string) ($f['index'] ?? '')));
|
||
if (! in_array($index, ['', 'index', 'unique'], true)) {
|
||
$index = '';
|
||
}
|
||
|
||
return [
|
||
'name' => $name,
|
||
'type' => $type,
|
||
'length' => $length,
|
||
'default' => $default,
|
||
'has_default' => $hasDefault,
|
||
'comment' => trim((string) ($f['comment'] ?? '')),
|
||
'unsigned' => $unsigned,
|
||
'nullable' => $nullable,
|
||
'autoinc' => $autoinc,
|
||
'pk' => $pk,
|
||
'index' => $index,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 生成 CREATE TABLE 语句
|
||
*/
|
||
protected function createSql(array $def): string
|
||
{
|
||
$lines = [];
|
||
$pks = [];
|
||
$keys = [];
|
||
|
||
foreach ($def['fields'] as $f) {
|
||
$type = $f['type'] . ($f['length'] !== '' ? '(' . $f['length'] . ')' : '');
|
||
$part = ' `' . $f['name'] . '` ' . $type;
|
||
if ($f['unsigned']) {
|
||
$part .= ' unsigned';
|
||
}
|
||
$part .= $f['nullable'] ? ' NULL' : ' NOT NULL';
|
||
|
||
// 默认值:NULL / CURRENT_TIMESTAMP / 数字字面量不加引号,其余按字符串处理
|
||
if ($f['has_default']) {
|
||
$d = strtoupper($f['default']);
|
||
if ($d === 'NULL' && $f['nullable']) {
|
||
$part .= ' DEFAULT NULL';
|
||
} elseif ($d === 'CURRENT_TIMESTAMP' || $d === 'CURRENT_TIMESTAMP()') {
|
||
$part .= ' DEFAULT CURRENT_TIMESTAMP';
|
||
} elseif ($this->isNumericLiteral($f['default'])) {
|
||
$part .= ' DEFAULT ' . $f['default'];
|
||
} else {
|
||
$part .= " DEFAULT '" . $this->escape($f['default']) . "'";
|
||
}
|
||
} elseif ($f['nullable']) {
|
||
$part .= ' DEFAULT NULL';
|
||
}
|
||
|
||
if ($f['autoinc']) {
|
||
$part .= ' AUTO_INCREMENT';
|
||
}
|
||
if ($f['comment'] !== '') {
|
||
$part .= " COMMENT '" . $this->escape($f['comment']) . "'";
|
||
}
|
||
$lines[] = $part;
|
||
|
||
if ($f['pk']) {
|
||
$pks[] = '`' . $f['name'] . '`';
|
||
}
|
||
if ($f['index'] === 'unique') {
|
||
$keys[] = ' UNIQUE KEY `' . $this->keyName('uk_' . $f['name']) . '` (`' . $f['name'] . '`)';
|
||
} elseif ($f['index'] === 'index') {
|
||
$keys[] = ' KEY `' . $this->keyName('idx_' . $f['name']) . '` (`' . $f['name'] . '`)';
|
||
}
|
||
}
|
||
|
||
if ($pks) {
|
||
$lines[] = ' PRIMARY KEY (' . implode(',', $pks) . ')';
|
||
}
|
||
$lines = array_merge($lines, $keys);
|
||
|
||
$tail = 'ENGINE=' . $def['engine'] . ' DEFAULT CHARSET=' . $def['charset'];
|
||
if ($def['collation'] !== '') {
|
||
$tail .= ' COLLATE=' . $def['collation'];
|
||
}
|
||
if ($def['comment'] !== '') {
|
||
$tail .= " COMMENT='" . $this->escape($def['comment']) . "'";
|
||
}
|
||
|
||
return 'CREATE TABLE `' . $def['table'] . "` (\n" . implode(",\n", $lines) . "\n) " . $tail . ';';
|
||
}
|
||
|
||
/* ============================ 内部方法 ============================ */
|
||
|
||
/**
|
||
* 全项目 install.sql 索引:[裸表名 => 相对项目根的 install.sql 路径]
|
||
*
|
||
* 覆盖三处:核心 public/install/install.sql、应用 app/{应用}/install.sql、
|
||
* 插件 addon/{插件}/install.sql。表名里的 __PREFIX__ 与写死的前缀都会去掉,
|
||
* 与实际的裸表名对齐。结果进程内缓存一次,避免每次列表都读一遍文件。
|
||
*/
|
||
protected function installMap(): array
|
||
{
|
||
if (self::$installMap !== null) {
|
||
return self::$installMap;
|
||
}
|
||
|
||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||
$files = array_merge(
|
||
[$root . '/public/install/install.sql'],
|
||
glob($root . '/app/*/install.sql') ?: [],
|
||
glob($root . '/addon/*/install.sql') ?: []
|
||
);
|
||
|
||
$map = [];
|
||
$prefix = $this->prefix();
|
||
foreach ($files as $file) {
|
||
if (! is_file($file) || ! is_readable($file)) {
|
||
continue;
|
||
}
|
||
$sql = file_get_contents($file);
|
||
if ($sql === false || $sql === '') {
|
||
continue;
|
||
}
|
||
if (! preg_match_all('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`([^`]+)`/i', $sql, $m)) {
|
||
continue;
|
||
}
|
||
$rel = ltrim(str_replace($root, '', $file), '/\\');
|
||
foreach ($m[1] as $table) {
|
||
$short = str_replace('__PREFIX__', '', $table);
|
||
if ($prefix !== '' && str_starts_with($short, $prefix)) {
|
||
$short = substr($short, strlen($prefix));
|
||
}
|
||
if (! isset($map[$short])) {
|
||
$map[$short] = $rel;
|
||
}
|
||
}
|
||
}
|
||
|
||
return self::$installMap = $map;
|
||
}
|
||
|
||
/**
|
||
* 归档状态:[是否已归档, 归档位置描述]
|
||
*
|
||
* 归档有两条合法路径,都要算「已归档」,否则会误报:
|
||
* 1. install.sql(核心 / 应用 / 插件)—— 安装与自愈都靠它;
|
||
* 2. 模型自愈 DDL(如 common_addonconf,见 BaseModel 注释)——
|
||
* 不在任何 install.sql 里,由模型 ensureSchema() 运行时补表。
|
||
*
|
||
* @param array $map installMap() 结果
|
||
* @return array{0:bool,1:string}
|
||
*/
|
||
protected function archiveInfo(string $short, array $map): array
|
||
{
|
||
if (isset($map[$short])) {
|
||
return [true, $map[$short]];
|
||
}
|
||
if ($this->selfHealed($short)) {
|
||
return [true, '模型自愈 DDL'];
|
||
}
|
||
return [false, $this->guessTarget($short)];
|
||
}
|
||
|
||
/**
|
||
* 是否被某个模型 / 服务的自愈 DDL 登记过
|
||
*
|
||
* 判定方式:模型目录里的 PHP 源码中出现该表名字符串。
|
||
* 属于宽松匹配(宁可算已归档,也不在列表里制造假警报),
|
||
* 精确事实源仍是各 install.sql 与模型 ensureSchema()。
|
||
*/
|
||
protected function selfHealed(string $short): bool
|
||
{
|
||
static $source = null;
|
||
if ($source === null) {
|
||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||
$dirs = array_merge(
|
||
[$root . '/ywxapp/model'],
|
||
glob($root . '/app/*/model') ?: [],
|
||
glob($root . '/addon/*/model') ?: []
|
||
);
|
||
$source = '';
|
||
foreach ($dirs as $dir) {
|
||
foreach (glob($dir . '/*.php') ?: [] as $file) {
|
||
$body = file_get_contents($file);
|
||
if ($body !== false) {
|
||
$source .= $body;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return $source !== '' && strpos($source, $short) !== false;
|
||
}
|
||
|
||
/**
|
||
* 单表导出语句:SHOW CREATE TABLE → __PREFIX__ 占位符 + IF NOT EXISTS
|
||
*
|
||
* 顺手去掉 AUTO_INCREMENT=当前值(自增值是运行时状态,不该进安装脚本)。
|
||
*/
|
||
protected function exportSql(string $table): string
|
||
{
|
||
try {
|
||
$rows = Db::query('SHOW CREATE TABLE `' . $table . '`');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
$row = array_change_key_case((array) ($rows[0] ?? []), CASE_LOWER);
|
||
$sql = (string) ($row['create table'] ?? '');
|
||
if ($sql === '') {
|
||
return '';
|
||
}
|
||
|
||
$prefix = $this->prefix();
|
||
if ($prefix !== '') {
|
||
$sql = str_replace('`' . $prefix, '`__PREFIX__', $sql);
|
||
}
|
||
$sql = (string) preg_replace('/^CREATE TABLE `/i', 'CREATE TABLE IF NOT EXISTS `', $sql, 1);
|
||
$sql = (string) preg_replace('/AUTO_INCREMENT=\d+\s*/i', '', $sql);
|
||
|
||
return rtrim($sql, "; \t\n\r\0\x0B") . ';';
|
||
}
|
||
|
||
/**
|
||
* 未归档表的目标 install.sql 猜测(按表名前缀匹配模块)
|
||
*
|
||
* 猜不出来返回空串,由前端提示人工选择,绝不默认塞进核心 SQL。
|
||
*/
|
||
protected function guessTarget(string $short): string
|
||
{
|
||
$root = rtrim((string) app()->getRootPath(), '/\\');
|
||
foreach (glob($root . '/addon/*/install.sql') ?: [] as $file) {
|
||
$module = basename(dirname($file));
|
||
if ($module !== '' && str_starts_with($short, $module . '_')) {
|
||
return 'addon/' . $module . '/install.sql';
|
||
}
|
||
}
|
||
foreach (glob($root . '/app/*/install.sql') ?: [] as $file) {
|
||
$module = basename(dirname($file));
|
||
if ($module !== '' && str_starts_with($short, $module . '_')) {
|
||
return 'app/' . $module . '/install.sql';
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 表名白名单过滤(去掉反引号等非法字符)
|
||
*/
|
||
protected function safeTable(string $name): string
|
||
{
|
||
$name = trim(str_replace('`', '', $name));
|
||
return preg_match('/^[a-zA-Z0-9_]{1,64}$/', $name) ? $name : '';
|
||
}
|
||
|
||
/**
|
||
* 表是否存在(参数绑定,防注入)
|
||
*/
|
||
protected function tableExists(string $table): bool
|
||
{
|
||
try {
|
||
return ! empty(Db::query('SHOW TABLES LIKE ?', [$table]));
|
||
} catch (\Throwable $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 索引名(截断到 64 字符)
|
||
*/
|
||
protected function keyName(string $name): string
|
||
{
|
||
$name = preg_replace('/[^a-zA-Z0-9_]/', '_', $name) ?: 'idx';
|
||
return substr($name, 0, 64);
|
||
}
|
||
|
||
/**
|
||
* 默认值是否数字字面量(决定是否加引号)
|
||
*/
|
||
protected function isNumericLiteral(string $v): bool
|
||
{
|
||
return preg_match('/^-?\d+(\.\d+)?$/', $v) === 1;
|
||
}
|
||
|
||
/**
|
||
* 单引号转义(不依赖 mysql_real_escape_string,避免需要连接句柄)
|
||
*/
|
||
protected function escape(string $v): string
|
||
{
|
||
return str_replace(["\\", "'"], ["\\\\", "\\'"], $v);
|
||
}
|
||
|
||
/**
|
||
* 表前缀
|
||
*/
|
||
protected function prefix(): string
|
||
{
|
||
try {
|
||
return (string) Db::connect()->getConfig('prefix');
|
||
} catch (\Throwable $e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 默认字符集
|
||
*/
|
||
protected function charset(): string
|
||
{
|
||
try {
|
||
$c = (string) Db::connect()->getConfig('charset');
|
||
} catch (\Throwable $e) {
|
||
$c = '';
|
||
}
|
||
return in_array($c, self::CHARSETS, true) ? $c : 'utf8mb4';
|
||
}
|
||
|
||
/**
|
||
* 去掉前缀的短表名
|
||
*/
|
||
protected function shortName(string $table): string
|
||
{
|
||
$prefix = $this->prefix();
|
||
return $prefix !== '' && str_starts_with($table, $prefix) ? substr($table, strlen($prefix)) : $table;
|
||
}
|
||
|
||
/**
|
||
* 字节数格式化
|
||
*/
|
||
protected function formatBytes(int $bytes): string
|
||
{
|
||
if ($bytes <= 0) {
|
||
return '0 B';
|
||
}
|
||
$units = ['B', 'KB', 'MB', 'GB'];
|
||
$i = (int) min(count($units) - 1, floor(log($bytes, 1024)));
|
||
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
|
||
}
|
||
}
|