Files
YwxAppThink/app/backend/controller/Datatable.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

859 lines
31 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>
// +----------------------------------------------------------------------
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];
}
}