initialize(); // ---------------------------------------------------------------------------- // 2. 从 .env 读取连接参数,组装测试库连接配置 // 与 tests/bootstrap.php 完全一致:复用开发库同实例连接,仅替换 database 名。 // ---------------------------------------------------------------------------- $envHostname = \think\facade\Env::get('database.hostname'); $envHostport = \think\facade\Env::get('database.hostport'); $envUsername = \think\facade\Env::get('database.username'); $envPassword = \think\facade\Env::get('database.password'); $envCharset = \think\facade\Env::get('database.charset'); $envPrefix = \think\facade\Env::get('database.prefix'); $envDatabase = \think\facade\Env::get('database.database'); // 测试库名策略:原库名 + _test 后缀(如 ulthon_admin → ulthon_admin_test) $testDatabase = is_string($envDatabase) && $envDatabase !== '' ? $envDatabase . '_test' : 'ulthon_admin_test'; $hostname = is_string($envHostname) && $envHostname !== '' ? $envHostname : '127.0.0.1'; $hostport = is_string($envHostport) && $envHostport !== '' ? $envHostport : '3306'; $username = is_string($envUsername) && $envUsername !== '' ? $envUsername : 'root'; $password = is_string($envPassword) ? $envPassword : ''; $charset = is_string($envCharset) && $envCharset !== '' ? $envCharset : 'utf8mb4'; $prefix = is_string($envPrefix) && $envPrefix !== '' ? $envPrefix : 'ul_'; // ---------------------------------------------------------------------------- // 3. 早期安全护栏:解析出的库名必须含 'test',否则拒绝继续 // (在任何写操作之前触发,避免误连开发库) // ---------------------------------------------------------------------------- if (stripos($testDatabase, 'test') === false) { throw new \RuntimeException("拒绝执行:解析到的测试库名 [{$testDatabase}] 不含 'test',已中止。"); } fwrite(STDOUT, "==== 测试库目标:{$testDatabase} @ {$hostname}:{$hostport}(user: {$username}) ====\n"); // ---------------------------------------------------------------------------- // 4. 确保测试库存在(用 PDO 直连,不指定 database,执行 CREATE DATABASE IF NOT EXISTS) // 单机 MySQL 场景下测试库可能尚未创建;Docker 预 provision 的库也无妨,IF NOT EXISTS 幂等。 // ---------------------------------------------------------------------------- try { $dsn = "mysql:host={$hostname};port={$hostport};charset={$charset}"; $pdo = new \PDO($dsn, $username, $password, [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, ]); $quotedDb = '`' . str_replace('`', '``', $testDatabase) . '`'; $pdo->exec("CREATE DATABASE IF NOT EXISTS {$quotedDb} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); fwrite(STDOUT, "==== 确保 TEST_DB 已存在:{$testDatabase}(utf8mb4 / utf8mb4_unicode_ci) ====\n"); } catch (\Throwable $e) { fwrite(STDERR, "[ERROR] 创建测试库失败:{$e->getMessage()}\n"); fwrite(STDERR, " 请检查 .env 中 HOSTNAME/HOSTPORT/USERNAME/PASSWORD 是否能连上 MySQL。\n"); exit(1); } // ---------------------------------------------------------------------------- // 5. config 级覆盖 connections.main 指向测试库 // 覆盖目标 = connections.main(本项目 config/database.php 的默认连接名是 main)。 // 坑点:think\Config::set(array, $name) 的 $name 只支持单段一级配置名, // 不像 Config::get 那样解析点号多级路径。正确做法:把整段 'database' 配置 pull 出来, // 原地改 connections.main,再 Config::set($dbConfig, 'database') 写回。 // 必须保留 ['query' => \app\common\provider\db\Query::class],否则 ORM 行为异常。 // ---------------------------------------------------------------------------- $dbConfig = \think\facade\Config::get('database'); if (!is_array($dbConfig)) { $dbConfig = []; } $dbConfig['default'] = 'main'; if (!isset($dbConfig['connections']) || !is_array($dbConfig['connections'])) { $dbConfig['connections'] = []; } if (!isset($dbConfig['connections']['main']) || !is_array($dbConfig['connections']['main'])) { $dbConfig['connections']['main'] = []; } $dbConfig['connections']['main'] = array_merge( $dbConfig['connections']['main'], [ 'type' => 'mysql', 'hostname' => $hostname, 'hostport' => $hostport, 'database' => $testDatabase, 'username' => $username, 'password' => $password, 'charset' => $charset, 'prefix' => $prefix, 'fields_cache' => false, ], // 必须保留框架自定义 query 类,否则 ORM 行为异常。 ['query' => \app\common\provider\db\Query::class], ); \think\facade\Config::set($dbConfig, 'database'); // 二次护栏:写回 config 之后再校验一次,确认覆盖真的生效。 $resolvedDb = \think\facade\Config::get('database.connections.main.database'); if (!is_string($resolvedDb) || stripos($resolvedDb, 'test') === false) { throw new \RuntimeException('Config 覆盖未生效,resolved database = ' . var_export($resolvedDb, true)); } // ---------------------------------------------------------------------------- // 6. 逐条执行建表/建库命令(同进程 Console::call,config 覆盖对命令同样生效) // Console::call 返回 think\console\Output,命令输出被写进其私有 handle(Buffer)。 // 用反射取出 Buffer 内容回显,便于排查;同时去掉 ANSI 着色码避免终端转义污染日志。 // --force-force 是框架全局选项,命中时 confirm() 直接返回默认值,跳过所有交互确认—— // scheme:sync 在非 TTY 下不带它会被 parent::confirm 抛 "Aborted"。 // ---------------------------------------------------------------------------- $commands = [ // 系统/框架表(Phinx 迁移):ul_system_admin / ul_system_menu / ul_system_auth_node / ul_system_config / ... ['migrate:run', '系统/框架表(database/migrations/)'], // 业务表(Scheme 同步):app/admin/scheme/ 下声明的表 ['scheme:sync', '业务表(app/admin/scheme/)'], // 参考数据(seed):InitBaseAdminData,自带 install-lock 跳过,可安全重跑 ['seed:run', '参考数据(database/seeds/)'], ]; $handleProp = (new \ReflectionProperty(\think\console\Output::class, 'handle')); $handleProp->setAccessible(true); foreach ($commands as [$cmd, $desc]) { fwrite(STDOUT, "\n>>>> [{$cmd}] {$desc}\n"); try { /** @var \think\console\Output $output */ $output = \think\facade\Console::call($cmd, ['--force-force']); // 取出 Buffer 里命令自身打印的内容并回显 $handle = $handleProp->getValue($output); $captured = ($handle instanceof \think\console\output\driver\Buffer) ? $handle->fetch() : ''; if ($captured !== '') { // 去掉 ANSI 着色码,避免终端转义污染日志 $captured = preg_replace('/\x1b\[[0-9;]*m/', '', $captured); fwrite(STDOUT, rtrim($captured) . "\n"); } fwrite(STDOUT, "<<<< [{$cmd}] 完成(无异常)\n"); } catch (\Throwable $e) { // 单条命令失败不立刻退出,继续后续命令,最后由验证段汇总。 fwrite(STDERR, "<<<< [{$cmd}] 异常:{$e->getMessage()}\n"); } } // ---------------------------------------------------------------------------- // 7. 验证:直接查 information_schema.tables WHERE table_schema=?,证明表确实落在测试库 // ulthon 是框架仓库,没有 app_* 业务表,只断言系统表 ul_system_* 关键四张存在。 // ---------------------------------------------------------------------------- fwrite(STDOUT, "\n==== 验证:{$resolvedDb} 内表清单 ====\n"); try { // 用裸 SQL 查 information_schema,绕开 ORM 的前缀/字段缓存 quirks $rows = \think\facade\Db::connect('main')->query( 'SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? ORDER BY table_name', [$resolvedDb], ); $names = array_column($rows, 't'); $totalCount = count($names); fwrite(STDOUT, "表总数:{$totalCount}\n"); // 分类展示:系统表 vs 业务表 vs 其它(按命名约定 ul_system_* / ul_app_* / 其它) $systemTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'system') !== false)); $appTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'app_') !== false)); $otherTables = array_values(array_diff($names, $systemTables, $appTables)); fwrite(STDOUT, "系统表(system,来自 migrate:run):" . (empty($systemTables) ? '(无)' : implode(', ', $systemTables)) . "\n"); fwrite(STDOUT, "业务表(app_,来自 scheme:sync):" . (empty($appTables) ? '(无)' : implode(', ', $appTables)) . "\n"); if (!empty($otherTables)) { fwrite(STDOUT, "其它表(migrations / debug_log / backup 等):" . implode(', ', $otherTables) . "\n"); } // 关键系统表必存在断言(ulthon 框架核心:登录、菜单、权限节点、配置) $required = ['ul_system_admin', 'ul_system_menu', 'ul_system_auth_node', 'ul_system_config']; $missing = array_values(array_filter($required, static fn ($n) => !in_array($n, $names, true))); if (!empty($missing)) { fwrite(STDERR, "\n[警告] 缺少关键系统表:" . implode(', ', $missing) . "\n"); fwrite(STDERR, " 检查 migrate:run 是否真的连到测试库并成功执行。\n"); exit(2); } fwrite(STDOUT, "\n[OK] 关键系统表全部存在:system_admin / system_menu / system_auth_node / system_config\n"); } catch (\Throwable $e) { fwrite(STDERR, "验证查询失败:{$e->getMessage()}\n"); exit(3); } fwrite(STDOUT, "\n==== 测试数据库初始化完成 ====\n"); exit(0);