Files
ulthon_admin/tests/setup_test_db.php
augushong 553047025c refactor(install): 种子退役,migrate:run 一条命令完成建表与初始数据
新增 InitDataAlignService 六域对齐(超管/角色/权限节点/配置/快捷入口/商城演示)与 20260909130000 迁移;SystemAdmin 快照以字面量哈希入库;删除种子三件与安装锁依赖,seeds 目录留 .gitkeep;README/tests/规则等 11 处说明同步。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-09 14:38:10 +00:00

211 lines
12 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
/**
* 测试数据库初始化脚本(参数化版).
*
* 在与 .env 同实例的 MySQL 上建出 ulthon_admin_test 库,并初始化全部表与初始数据:
* 1. migrate:run 建系统/框架表(database/migrations/,如 ul_system_admin / ul_system_menu),
* 并写入全部初始数据(超管、配置、菜单等,数据迁移随迁移一同执行)
* 2. scheme:sync 建业务表(app/admin/scheme/)
*
* 与 wkbox 衍生项目的区别:
* - 完全参数化:连接参数(hostname/hostport/username/password/charset/prefix/database)
* 全部从 .env 读取,复用开发库同实例的 MySQL,不依赖专用 Docker 容器,不硬编码端口。
* - 测试库名 = .env 中 DATABASE + '_test' 后缀(如 ulthon_admin → ulthon_admin_test)。
* - 脚本本身负责 CREATE DATABASE IF NOT EXISTS(单机 MySQL,库可能尚未创建)。
*
* 连接覆盖逻辑与 tests/bootstrap.php 完全一致(必须保持同步,否则脚本与 PHPUnit 跑的
* 不是同一个测试库)。关键技术点见 tests/README.md「技术细节」一节:
* - ThinkPHP 的 Env 加载 .env 且 .env 值优先于 OS 环境变量,putenv/$env:VAR/bash 前缀
* 都无法覆盖已存在的 .env 键,唯一可靠覆盖点是 config 级。
* - think\Config::set 的第二参数只支持单段一级名,不能传 'database.connections.main'。
*
* 幂等:migrate:run / scheme:sync 均可安全重复执行(详见 README)。
*/
declare(strict_types=1);
// ----------------------------------------------------------------------------
// 1. 启动 ThinkPHP 容器(必须先 require autoload,再 initialize)
// ----------------------------------------------------------------------------
require __DIR__ . '/../vendor/autoload.php';
$app = new \think\App();
$app->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_config / ...
['migrate:run', '系统/框架表 + 初始数据(database/migrations/)'],
// 业务表(Scheme 同步):app/admin/scheme/ 下声明的表
['scheme:sync', '业务表(app/admin/scheme/)'],
];
$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);