// +---------------------------------------------------------------------- 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 */ 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]; } }