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>
This commit is contained in:
augushong
2026-09-09 14:38:10 +00:00
parent e54c772868
commit 553047025c
18 changed files with 371 additions and 242 deletions

View File

@@ -153,13 +153,13 @@ CREATE DATABASE IF NOT EXISTS ulthon_admin_test
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
``` ```
第二步,建表 + 种子(测试库已建后执行): 第二步,建表 + 初始化数据(测试库已建后执行):
```bash ```bash
php tests/setup_test_db.php php tests/setup_test_db.php
``` ```
这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。 这个脚本会在同进程内依次跑 `migrate:run`(系统表 + 全部初始数据)、`scheme:sync`(业务表),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。
### 日常运行 ### 日常运行
@@ -347,7 +347,7 @@ class XxxServiceTest extends TestCase
1. **装依赖**:`composer install`(`phpunit/phpunit: ^10` 已在 require-dev) 1. **装依赖**:`composer install`(`phpunit/phpunit: ^10` 已在 require-dev)
2. **建测试库**:用 root 连接执行 `CREATE DATABASE {env.database}_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`(库名 = .env 的 `DATABASE` + `_test`) 2. **建测试库**:用 root 连接执行 `CREATE DATABASE {env.database}_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`(库名 = .env 的 `DATABASE` + `_test`)
3. **建表 + 种子**:`php tests/setup_test_db.php` 3. **建表 + 初始化数据**:`php tests/setup_test_db.php`
为什么建表必须跑两套机制(这是框架最重要的特点之一,缺一不可): 为什么建表必须跑两套机制(这是框架最重要的特点之一,缺一不可):
@@ -356,7 +356,7 @@ class XxxServiceTest extends TestCase
| Phinx 迁移 | `migrate:run` | `database/migrations/` | 系统表(system_admin / system_menu / system_auth_node / system_config / system_host / system_timer_* 等) | | Phinx 迁移 | `migrate:run` | `database/migrations/` | 系统表(system_admin / system_menu / system_auth_node / system_config / system_host / system_timer_* 等) |
| Scheme 同步 | `scheme:sync` | `app/admin/scheme/` | 业务表(各项目自己用 PHP 8 Attribute 声明的表) | | Scheme 同步 | `scheme:sync` | `app/admin/scheme/` | 业务表(各项目自己用 PHP 8 Attribute 声明的表) |
只跑 migrate 拿不到业务表,只跑 scheme 拿不到系统表(框架无法登录鉴权)。`setup_test_db.php` 在同进程内按顺序调这三条命令(migrate:run / scheme:sync / seed:run),并对每条带 `--force-force` 跳过交互确认(非 TTY 下 `scheme:sync` 会因 confirm 抛 Aborted)。 只跑 migrate 拿不到业务表,只跑 scheme 拿不到系统表(框架无法登录鉴权)。`setup_test_db.php` 在同进程内按顺序调这两条命令(migrate:run / scheme:sync),并对每条带 `--force-force` 跳过交互确认(非 TTY 下 `scheme:sync` 会因 confirm 抛 Aborted)。
为什么用 config 级覆盖而不是环境变量:ThinkPHP 的 Env 加载 `.env`,且 `.env` 值优先于 OS 环境变量。`putenv('DATABASE=...')`、`$env:DATABASE=...`、bash 前缀变量都无法覆盖 `.env` 里已有的连接键。唯一可靠覆盖点是 config 级,在 `App::initialize()` 之后改写 `config/database.php` 解析出的连接数组。`tests/bootstrap.php` 和 `tests/setup_test_db.php` 都用这个模式。 为什么用 config 级覆盖而不是环境变量:ThinkPHP 的 Env 加载 `.env`,且 `.env` 值优先于 OS 环境变量。`putenv('DATABASE=...')`、`$env:DATABASE=...`、bash 前缀变量都无法覆盖 `.env` 里已有的连接键。唯一可靠覆盖点是 config 级,在 `App::initialize()` 之后改写 `config/database.php` 解析出的连接数组。`tests/bootstrap.php` 和 `tests/setup_test_db.php` 都用这个模式。
@@ -397,7 +397,7 @@ php tests/setup_test_db.php
| 文件 | 作用 | | 文件 | 作用 |
|------|------| |------|------|
| `tests/bootstrap.php` | PHPUnit 引导:autoload + 容器初始化 + config 级覆盖到测试库 | | `tests/bootstrap.php` | PHPUnit 引导:autoload + 容器初始化 + config 级覆盖到测试库 |
| `tests/setup_test_db.php` | 测试库建表脚本:migrate:run + scheme:sync + seed:run + 自验证 | | `tests/setup_test_db.php` | 测试库建表脚本:migrate:run + scheme:sync + 自验证 |
| `tests/setup_test_db.ps1` / `.sh` | Windows / Linux 包装器 | | `tests/setup_test_db.ps1` / `.sh` | Windows / Linux 包装器 |
| `tests/README.md` | 测试库建库与建表的完整说明 | | `tests/README.md` | 测试库建库与建表的完整说明 |
| `phpunit.xml.dist` | PHPUnit 配置:testsuite 定义,bootstrap 指向 tests/bootstrap.php | | `phpunit.xml.dist` | PHPUnit 配置:testsuite 定义,bootstrap 指向 tests/bootstrap.php |

View File

@@ -43,12 +43,9 @@ DEBUG=true
PREFIX=ul_ PREFIX=ul_
Step 4, install the database Step 4, install the database (create tables + initialize data)
php think migrate:run php think migrate:run
Step 5: Initialize database data
php think seed:run
Finally, run locally temporarily Finally, run locally temporarily
php think run php think run

View File

@@ -45,12 +45,9 @@ DEBUG=true
PREFIX=ul_ PREFIX=ul_
第四步, 安装数据库 第四步, 安装数据库(建表 + 初始化数据)
php think migrate:run php think migrate:run
第五步,初始化数据库数据
php think seed:run
最后,本地临时运行 最后,本地临时运行
php think run php think run

View File

@@ -1,9 +0,0 @@
<?php
namespace app\admin\service;
use base\admin\service\AdminInitServiceBase;
class AdminInitService extends AdminInitServiceBase
{
}

View File

@@ -0,0 +1,16 @@
<?php
namespace app\admin\service;
use base\admin\service\InitDataAlignServiceBase;
/**
* 初始化数据对齐服务(app 层入口).
*
* 六大数据域(超管/角色/权限节点/配置/快捷入口/商城演示)按快照对齐:
* 由数据型 migration 调用,把 extend/base/admin/service/adminInitData/
* 下的快照对齐到当前库。需要定制对齐行为时覆盖父类方法,保持方法签名一致。
*/
class InitDataAlignService extends InitDataAlignServiceBase
{
}

View File

@@ -0,0 +1,36 @@
<?php
use app\admin\service\InitDataAlignService;
use think\console\Output;
use think\migration\Migrator;
/**
* 系统初始化数据对齐 - 种子退役后初始数据的唯一安装途径
*
* 背景:seed:run 退役,migrate:run 成为唯一安装命令(建表 + 全部初始数据)。
* 本迁移调用 InitDataAlignService 把 adminInitData/ 快照对齐到当前库:
* - 超管 id=1 只插不改(密码永不覆盖,软删行不复活)
* - 角色 id=1/id=6 逐行只插
* - 权限节点跳过(注解动态扫描)
* - 配置按 (group, name) 只插缺失,插后清 sysconfig 缓存
* - 快捷入口按 href 只插缺失(软删行不复活)
* - 商城演示分表守卫(cate/goods/tag 各自空则插)
*
* 幂等:phinxlog 按文件记账保证只执行一次;重放由服务内的存在性检查兜底。
* down:数据对齐迁移不提供对称回滚(无法精确还原对齐前的用户态数据),
* 如需回退参考 adminInitData/ 下各快照手工处理。
*/
class SystemInitData extends Migrator
{
public function up()
{
$service = new InitDataAlignService(new Output());
$service->align();
}
public function down()
{
// 有意留空:见类注释 down 说明
}
}

1
database/seeds/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# 种子已退役:migrate:run 是唯一安装命令;下游业务仍可放自己的种子文件

View File

@@ -1,52 +0,0 @@
<?php
use app\admin\model\SystemConfig;
use app\admin\service\AdminInitService;
use app\admin\service\TriggerService;
use think\migration\Seeder;
class InitBaseAdminData extends Seeder
{
/**
* Run Method.
*
* Write your database seeder using this method.
*
* More information on writing seeders is available here:
* http://docs.phinx.org/en/latest/seeding.html
*/
public function run():void
{
$output = $this->getOutput();
TriggerService::updateSysconfig();
$install_lock = sysconfig('base_admin_install', true, 0);
if ($install_lock == 1) {
$output->writeln('系统已初始化,跳过当前程序');
return;
}
$model_config = SystemConfig::where('group', 'system')
->where('name', 'base_admin_install')
->find();
if (empty($model_config)) {
$model_config = new SystemConfig();
$model_config->group = 'system';
$model_config->name = 'base_admin_install';
$model_config->remark = '系统初始化锁定,请勿修改,避免插入重复数据';
}
$model_config->value = 1;
$model_config->save();
TriggerService::updateSysconfig();
(new AdminInitService($output))->init();
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace base\admin\service;
use app\admin\model\MallCate;
use app\admin\model\MallGoods;
use app\admin\model\MallTag;
use app\admin\model\SystemAdmin;
use app\admin\model\SystemAuth;
use app\admin\model\SystemAuthNode;
use app\admin\model\SystemConfig;
use app\admin\model\SystemMenu;
use app\admin\model\SystemQuick;
use app\common\constants\AdminConstant;
class AdminInitServiceBase
{
protected $output = null;
public function __construct($output)
{
$this->output = $output;
}
public function init()
{
$this->initAdmin();
$this->initAuth();
$this->initConfig();
$this->initMenu();
$this->initQuick();
$this->initMall();
}
public function initMall()
{
$output = $this->output;
$output->writeln('开始初始化商城案例');
$list_cate = $this->requireData('MallCate');
$list_goods = $this->requireData('MallGoods');
foreach ($list_cate as $data_cate) {
$model_cate = MallCate::create($data_cate);
foreach ($list_goods as $data_goods) {
$data_goods['cate_id'] = $model_cate->id;
MallGoods::create($data_goods);
}
}
$list_tag = $this->requireData('MallTag');
$this->installData(MallTag::class, $list_tag);
}
public function initQuick()
{
$output = $this->output;
$output->writeln('开始初始化快捷入口');
$list_quick = $this->requireData('SystemQuick');
$this->installData(SystemQuick::class, $list_quick);
}
public function initMenu()
{
$output = $this->output;
$output->writeln('开始初始化系统菜单');
$list_menu = $this->requireData('SystemMenu');
$this->installData(SystemMenu::class, $list_menu);
}
public function initConfig()
{
$output = $this->output;
$output->writeln('开始初始化系统设置');
$list_config = $this->requireData('SystemConfig');
$this->installData(SystemConfig::class, $list_config);
}
public function initAdmin()
{
$output = $this->output;
$output->writeln('创建超级管理员');
$model_admin = SystemAdmin::find(AdminConstant::SUPER_ADMIN_ID);
if (empty($model_admin)) {
$model_admin = new SystemAdmin();
$model_admin->id = AdminConstant::SUPER_ADMIN_ID;
$model_admin->head_img = '/static/admin/images/head.jpg';
$model_admin->username = 'admin';
$model_admin->password = password(123456);
$model_admin->status = 1;
$model_admin->save();
$output->writeln('创建超级管理员成功');
} else {
$output->writeln('超级管理员已存在,无需初始化');
}
}
public function initAuth()
{
$output = $this->output;
$output->writeln('开始初始化权限');
$list_auth = $this->requireData('SystemAuth');
$this->installData(SystemAuth::class, $list_auth);
$output->writeln('开始初始化权限节点');
$list_auth_node = $this->requireData('SystemAuthNode');
$this->installData(SystemAuthNode::class, $list_auth_node);
}
protected function installData($model_name, $list)
{
foreach ($list as $key => $value) {
$model_name::create($value);
}
}
protected function requireData($table_name)
{
return include __DIR__ . '/adminInitData/' . $table_name . '.php';
}
}

View File

@@ -0,0 +1,262 @@
<?php
/**
* @internal-framework
*
* 此文件为框架核心服务
*
* 用途:初始化数据对齐服务(六大数据域按快照对齐,migrate:run 唯一安装路径)
* 维护者:框架维护者
*
* 背景:种子机制退役后,adminInitData/*.php 快照是初始数据的唯一数据源,
* 由本服务随数据型 migration 安装(migrate:run 是文档化安装命令,phinxlog
* 保证幂等)。取代原 AdminInitServiceBase 的种子初始化。
*
* 对齐语义(保守,尊重用户自定义):
* - 超管 :id=1 不存在才插入;密码永不覆盖(软删行存在同样视为已存在,不复活)
* - 角色 :按显式 id 逐行只插(含游客权限 id=6)
* - 权限节点:快照为空数组,直接跳过(该表由 auth 注解动态扫描生成)
* - 配置 :按 (group, name) check-then-insert(表无唯一键,纯应用层判重),
* 只插缺失不覆盖 value;发生过插入则清一次 sysconfig 缓存
* - 快捷入口:按 href 只插缺失;软删行存在视为已存在,不复活
* - 商城演示:分表守卫(cate 空则插分类;goods 空则插商品并注入分类 id;
* tag 空则插标签),三守卫相互独立
*
* 硬性实现约束:auth/quick/admin 三表均有 delete_time,存在性检查必须用
* Db::name 原生查询绕过软删除作用域——否则软删行被判「缺失」→ INSERT →
* 主键冲突崩溃或复活用户删除的行。沿用 MenuAlignServiceBase 已验证模式。
*/
namespace base\admin\service;
use think\console\Output;
use think\facade\Db;
class InitDataAlignServiceBase
{
/**
* @var Output|null 命令行输出(migration 场景传入,便于追踪对齐过程)
*/
protected $output = null;
public function __construct($output = null)
{
$this->output = $output;
}
/**
* 按快照对齐全部初始数据域(幂等,可重复执行).
*
* @return void
*/
public function align(): void
{
$this->writeln('开始对齐初始化数据(超管/角色/权限节点/配置/快捷入口/商城演示)');
$this->alignAdmin();
$this->alignAuth();
$this->alignAuthNode();
$this->alignConfig();
$this->alignQuick();
$this->alignMall();
$this->writeln('初始化数据对齐完成');
}
/**
* 超管对齐:id=1 不存在才插入,密码永不覆盖.
*/
protected function alignAdmin(): void
{
$snapshot = include __DIR__ . '/adminInitData/SystemAdmin.php';
$now = time();
foreach ($snapshot as $row) {
// 原生查询不过滤软删除:软删的超管行仍占用主键,视为已存在,不复活不覆盖
$exists = Db::name('system_admin')->where('id', $row['id'])->find();
if (!empty($exists)) {
$this->writeln(' - 超管 id=' . $row['id'] . ' 已存在,跳过(密码永不覆盖)');
continue;
}
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('system_admin')->insert($data);
$this->writeln(' - 新增超管 id=' . $row['id'] . ' ' . $row['username']);
}
}
/**
* 角色对齐:按显式 id 逐行只插(快照含管理员 id=1 与游客权限 id=6).
*/
protected function alignAuth(): void
{
$snapshot = include __DIR__ . '/adminInitData/SystemAuth.php';
$now = time();
foreach ($snapshot as $row) {
// 原生查询不过滤软删除:软删的角色行仍占用主键,视为已存在,不复活
$exists = Db::name('system_auth')->where('id', $row['id'])->find();
if (!empty($exists)) {
$this->writeln(' - 角色 id=' . $row['id'] . ' ' . $row['title'] . ' 已存在,跳过');
continue;
}
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('system_auth')->insert($data);
$this->writeln(' - 新增角色 id=' . $row['id'] . ' ' . $row['title']);
}
}
/**
* 权限节点对齐:快照为空数组,直接跳过(该表由 auth 注解动态扫描生成).
*/
protected function alignAuthNode(): void
{
$this->writeln(' - 权限节点由注解动态扫描生成,跳过快照对齐');
}
/**
* 配置对齐:按 (group, name) check-then-insert,只插缺失不覆盖 value;
* 表无唯一键,判重纯靠应用层;发生过插入则清一次 sysconfig 缓存.
*/
protected function alignConfig(): void
{
$snapshot = include __DIR__ . '/adminInitData/SystemConfig.php';
$now = time();
$inserted = 0;
foreach ($snapshot as $row) {
$exists = Db::name('system_config')
->where('group', $row['group'])
->where('name', $row['name'])
->find();
if (!empty($exists)) {
continue;
}
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
Db::name('system_config')->insert($data);
++$inserted;
}
$this->writeln(' - 系统配置新增 ' . $inserted . ' 条(缺失补齐,不覆盖已有 value)');
if ($inserted > 0) {
TriggerServiceBase::updateSysconfig();
$this->writeln(' - 已刷新系统设置缓存');
}
}
/**
* 快捷入口对齐:按 href 只插缺失;软删行存在视为已存在,不复活.
*/
protected function alignQuick(): void
{
$snapshot = include __DIR__ . '/adminInitData/SystemQuick.php';
$now = time();
$inserted = 0;
foreach ($snapshot as $row) {
// 原生查询不过滤软删除:软删的快捷入口仍占用 href 语义,视为已存在,不复活
$exists = Db::name('system_quick')->where('href', $row['href'])->find();
if (!empty($exists)) {
continue;
}
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('system_quick')->insert($data);
++$inserted;
}
$this->writeln(' - 快捷入口新增 ' . $inserted . ' 条(按 href 判重)');
}
/**
* 商城演示对齐:分表守卫(三守卫相互独立):
* - cate 空则插分类(快照无显式 id,捕获自增 id)
* - goods 空则插商品,注入分类 id(复刻旧 initMall 逻辑)
* - tag 空则插标签(快照含显式 id 1-4)
*/
protected function alignMall(): void
{
$now = time();
// 分类表守卫:空则插入快照分类,捕获新自增 id 供商品注入
$cateId = 0;
if ($this->isTableEmpty('mall_cate')) {
$snapshotCate = include __DIR__ . '/adminInitData/MallCate.php';
foreach ($snapshotCate as $row) {
$data = $row;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('mall_cate')->insert($data);
$cateId = (int) Db::name('mall_cate')->getLastInsID();
$this->writeln(' - 新增商品分类 id=' . $cateId . ' ' . $row['title']);
}
} else {
$this->writeln(' - 商品分类表已有数据,跳过');
}
// 商品表守卫:空则插入快照商品,cate_id 注入分类 id(无快照分类时取库内首个分类)
if ($this->isTableEmpty('mall_goods')) {
if ($cateId === 0) {
$cateId = (int) Db::name('mall_cate')->order('id', 'asc')->value('id');
}
$snapshotGoods = include __DIR__ . '/adminInitData/MallGoods.php';
foreach ($snapshotGoods as $row) {
$data = $row;
$data['cate_id'] = $cateId;
$data['create_time'] = $now;
$data['update_time'] = $now;
$data['delete_time'] = 0;
Db::name('mall_goods')->insert($data);
$this->writeln(' - 新增商品 ' . $row['title'] . ' (cate_id=' . $cateId . ')');
}
} else {
$this->writeln(' - 商品表已有数据,跳过');
}
// 标签表守卫:空则插入快照标签(显式 id,快照行自带时间戳与 delete_time)
if ($this->isTableEmpty('mall_tag')) {
$snapshotTag = include __DIR__ . '/adminInitData/MallTag.php';
foreach ($snapshotTag as $row) {
Db::name('mall_tag')->insert($row);
$this->writeln(' - 新增商品标签 id=' . $row['id'] . ' ' . $row['title']);
}
} else {
$this->writeln(' - 商品标签表已有数据,跳过');
}
}
/**
* 判断表是否为空(原生查询,不受任何作用域影响).
*/
protected function isTableEmpty(string $table): bool
{
return Db::name($table)->count() == 0;
}
protected function writeln(string $message): void
{
if ($this->output !== null) {
$this->output->writeln($message);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* @internal-framework
*
* 此文件为框架核心初始化数据
*
* 用途:超级管理员初始化(password 为 password('123456') 的 40 位字面量哈希,
* 不得调用 password() 生成,保证迁移上下文可用且结果确定)
* 维护者:框架维护者
*
* 注意:此文件属于框架内核,业务开发者不应修改
*/
$ul_system_admin = array(
array(
"id" => 1,
"head_img" => "/static/admin/images/head.jpg",
"username" => "admin",
"password" => "ac1e198122f74121a69b7bc9048561973a433daa",
"status" => 1,
)
);
return $ul_system_admin;

View File

@@ -38,7 +38,7 @@ use think\facade\Db;
* Mcp-Protocol-Version 用 initialize 返回的协商版本。 * Mcp-Protocol-Version 用 initialize 返回的协商版本。
* *
* ===== 超管创建者 ===== * ===== 超管创建者 =====
* 不造 admin id=1(seed 已存在);用全新管理员 + 全新角色(避开 SystemAuthNode 60s * 不造 admin id=1(初始化迁移已创建);用全新管理员 + 全新角色(避开 SystemAuthNode 60s
* 缓存与 adminInfo autoCache 窗口,learnings Task 4/6 的既定模式)。 * 缓存与 adminInfo autoCache 窗口,learnings Task 4/6 的既定模式)。
* *
* ===== 403 分支 ===== * ===== 403 分支 =====

View File

@@ -212,7 +212,7 @@ class McpServiceTest extends TestCase
public function test_drift_super_admin_creator_gets_full_whitelist(): void public function test_drift_super_admin_creator_gets_full_whitelist(): void
{ {
// 超管(seed 的 id=1,auth_ids 空):判定链超管直通,白名单全保留 // 超管(初始化迁移创建的 id=1,auth_ids 空):判定链超管直通,白名单全保留
// (含未注册节点也放行——超管对全量节点可见) // (含未注册节点也放行——超管对全量节点可见)
$fakeNode = 'nonexist.fake_node/action'; $fakeNode = 'nonexist.fake_node/action';
$key = $this->createKey(1, [self::NODE_A, $fakeNode, self::NODE_B]); $key = $this->createKey(1, [self::NODE_A, $fakeNode, self::NODE_B]);

View File

@@ -1,7 +1,8 @@
# 测试数据库初始化 # 测试数据库初始化
本目录提供测试数据库(默认 `ulthon_admin_test`)的初始化脚本。运行一次即可在与 `.env` 本目录提供测试数据库(默认 `ulthon_admin_test`)的初始化脚本。运行一次即可在与 `.env`
同实例的 MySQL 上建出框架运行所需的**全部表**(系统表 + 业务表),并插入最小参考数据。 同实例的 MySQL 上建出框架运行所需的**全部表**(系统表 + 业务表),并写入全部初始数据
(超管、角色、系统配置、快捷入口、菜单等,由数据迁移随 `migrate:run` 一并交付)。
## 测试库策略:参数化,无需专用 Docker 容器 ## 测试库策略:参数化,无需专用 Docker 容器
@@ -23,7 +24,7 @@ ulthon 是框架仓库,测试库策略**与衍生项目(wkbox)不同**:
| 文件 | 作用 | | 文件 | 作用 |
|------|------| |------|------|
| `setup_test_db.php` | 真正的运行器(PHP)。启动 ThinkPHP 容器、读 `.env`、PDO 建测试库、config 级覆盖连接、按序调用 `migrate:run` / `scheme:sync` / `seed:run`、查 `information_schema` 自验证。 | | `setup_test_db.php` | 真正的运行器(PHP)。启动 ThinkPHP 容器、读 `.env`、PDO 建测试库、config 级覆盖连接、按序调用 `migrate:run` / `scheme:sync`、查 `information_schema` 自验证。 |
| `setup_test_db.ps1` | Windows / PowerShell 包装器:检测 php 在 PATH → 调 PHP 运行器 → 透传退出码。 | | `setup_test_db.ps1` | Windows / PowerShell 包装器:检测 php 在 PATH → 调 PHP 运行器 → 透传退出码。 |
| `setup_test_db.sh` | bash / CI 包装器:逻辑同上,Linux / macOS / CI 用。 | | `setup_test_db.sh` | bash / CI 包装器:逻辑同上,Linux / macOS / CI 用。 |
| `bootstrap.php` | PHPUnit 引导脚本(已存在,独立维护)。连接覆盖逻辑与本脚本完全一致,二者必须保持同步——否则 PHPUnit 跑的测试库与本脚本初始化的不是同一个。 | | `bootstrap.php` | PHPUnit 引导脚本(已存在,独立维护)。连接覆盖逻辑与本脚本完全一致,二者必须保持同步——否则 PHPUnit 跑的测试库与本脚本初始化的不是同一个。 |
@@ -61,10 +62,12 @@ ulthon 的建表有**两个独立的来源**,二者不能互相替代:
- **只跑 `scheme:sync`**:得到业务表,但没有系统表,框架无法登录、无法鉴权。 - **只跑 `scheme:sync`**:得到业务表,但没有系统表,框架无法登录、无法鉴权。
- **必须两个都跑**,业务表通常引用系统表(如外键关联 `system_admin`)。 - **必须两个都跑**,业务表通常引用系统表(如外键关联 `system_admin`)。
`seed:run` 负责最小参考数据(超级管理员、权限节点、系统设置、菜单、快捷入口等),由 初始数据不再走 seed:`migrate:run` 建表的同时由数据迁移(`20260909130000_system_init_data.php`)
`database/seeds/InitBaseAdminData.php` 提供,自带 install-lock,重复执行安全跳过。 写入全部初始数据(超级管理员、角色、系统配置、快捷入口、商城演示、菜单等),数据源自
`extend/base/admin/service/adminInitData/` 快照。seed 机制已退役,`seed:run` 为空操作,
仅保留给下游业务项目放自己的种子。
本脚本在**同一进程**内依次调用这三条命令(见下节),确保它们都指向测试库。 本脚本在**同一进程**内依次调用这两条命令(见下节),确保它们都指向测试库。
## 技术细节:为什么用 config 级覆盖,而不是环境变量 ## 技术细节:为什么用 config 级覆盖,而不是环境变量
@@ -79,7 +82,7 @@ ThinkPHP 的 `Env` 组件加载 `.env` 文件,且 **`.env` 的值优先于 OS
**唯一可靠**的覆盖点是 **config 级**:在 `App::initialize()` 之后,直接改写 **唯一可靠**的覆盖点是 **config 级**:在 `App::initialize()` 之后,直接改写
`config/database.php` 解析出的连接配置数组。因为 `Db` facade 运行时读 config, `config/database.php` 解析出的连接配置数组。因为 `Db` facade 运行时读 config,
config 被改写后,后续所有 DB 操作(包括 `migrate:run` / `scheme:sync` / `seed:run` config 被改写后,后续所有 DB 操作(包括 `migrate:run` / `scheme:sync`
内部)都连到测试库。 内部)都连到测试库。
`setup_test_db.php` 的核心步骤: `setup_test_db.php` 的核心步骤:
@@ -122,7 +125,6 @@ if (stripos(\think\facade\Config::get('database.connections.main.database'), 'te
// 同进程内调用 think 命令,config 覆盖对它们同样生效 // 同进程内调用 think 命令,config 覆盖对它们同样生效
\think\facade\Console::call('migrate:run', ['--force-force']); \think\facade\Console::call('migrate:run', ['--force-force']);
\think\facade\Console::call('scheme:sync', ['--force-force']); // --force-force 跳过交互确认 \think\facade\Console::call('scheme:sync', ['--force-force']); // --force-force 跳过交互确认
\think\facade\Console::call('seed:run', ['--force-force']);
``` ```
### 关于 `--force-force`(`-ff`) ### 关于 `--force-force`(`-ff`)
@@ -131,7 +133,7 @@ if (stripos(\think\facade\Config::get('database.connections.main.database'), 'te
数据库吗?")。非 TTY 环境(脚本、CI)下 `think\console\Output::confirm` 会抛 数据库吗?")。非 TTY 环境(脚本、CI)下 `think\console\Output::confirm` 会抛
"Aborted" 而失败。框架提供了全局 `--force-force` / `-ff` 选项(见 "Aborted" 而失败。框架提供了全局 `--force-force` / `-ff` 选项(见
`extend/base/common/provider/ConsoleBase.php`、`extend/base/common/console/OutputBase.php`), `extend/base/common/provider/ConsoleBase.php`、`extend/base/common/console/OutputBase.php`),
命中时 `confirm()` 直接返回默认值,跳过所有交互确认。本脚本对三条命令统一带上该参数, 命中时 `confirm()` 直接返回默认值,跳过所有交互确认。本脚本对两条命令统一带上该参数,
保证非交互可运行、可重跑。 保证非交互可运行、可重跑。
### 关于默认连接名 `main` ### 关于默认连接名 `main`
@@ -144,9 +146,8 @@ if (stripos(\think\facade\Config::get('database.connections.main.database'), 'te
脚本可安全重复执行: 脚本可安全重复执行:
- `migrate:run`:已执行的迁移记录在 `ul_migrations`(phinxlog),二次运行直接 "All Done" 无操作。 - `migrate:run`:已执行的迁移记录在 `ul_migrations`(phinxlog),二次运行直接 "All Done" 无操作(初始数据由迁移交付,同样只执行一次且幂等)。
- `scheme:sync`:无差异时打印 "未检测到 Scheme 变更";有差异时先备份原表(`ul_backup_<时间戳>_<表名>`)再改,不报错。 - `scheme:sync`:无差异时打印 "未检测到 Scheme 变更";有差异时先备份原表(`ul_backup_<时间戳>_<表名>`)再改,不报错。
- `seed:run`:`InitBaseAdminData` 自带 install-lock(`base_admin_install` 配置项),二次运行打印 "系统已初始化,跳过当前程序"。
- `CREATE DATABASE IF NOT EXISTS`:库已存在时无操作。 - `CREATE DATABASE IF NOT EXISTS`:库已存在时无操作。
> 注:`scheme:sync` 每次遇到差异都会生成 `ul_backup_*` 备份表,这是框架的设计行为 > 注:`scheme:sync` 每次遇到差异都会生成 `ul_backup_*` 备份表,这是框架的设计行为

View File

@@ -30,7 +30,7 @@
* - 不 require 全局 common.php(App::initialize() + autoload 已够引导 facade, * - 不 require 全局 common.php(App::initialize() + autoload 已够引导 facade,
* helper.php 全局函数测试基建用不到) * helper.php 全局函数测试基建用不到)
* - 不开事务(事务隔离由 TestCaseBase.setUp 负责) * - 不开事务(事务隔离由 TestCaseBase.setUp 负责)
* - 不跑 migrate/scheme/seed(由独立的 setup 脚本负责) * - 不跑 migrate/scheme(由独立的 setup 脚本负责)
* - 不销毁容器(容器跨测试复用,tearDown 只回滚事务) * - 不销毁容器(容器跨测试复用,tearDown 只回滚事务)
*/ */

View File

@@ -2,10 +2,10 @@
/** /**
* 测试数据库初始化脚本(参数化版). * 测试数据库初始化脚本(参数化版).
* *
* 在与 .env 同实例的 MySQL 上建出 ulthon_admin_test 库,并初始化全部表: * 在与 .env 同实例的 MySQL 上建出 ulthon_admin_test 库,并初始化全部表与初始数据:
* 1. migrate:run 建系统/框架表(database/migrations/,如 ul_system_admin / ul_system_menu) * 1. migrate:run 建系统/框架表(database/migrations/,如 ul_system_admin / ul_system_menu),
* 并写入全部初始数据(超管、配置、菜单等,数据迁移随迁移一同执行)
* 2. scheme:sync 建业务表(app/admin/scheme/) * 2. scheme:sync 建业务表(app/admin/scheme/)
* 3. seed:run 插入最小参考数据(database/seeds/InitBaseAdminData)
* *
* 与 wkbox 衍生项目的区别: * 与 wkbox 衍生项目的区别:
* - 完全参数化:连接参数(hostname/hostport/username/password/charset/prefix/database) * - 完全参数化:连接参数(hostname/hostport/username/password/charset/prefix/database)
@@ -19,7 +19,7 @@
* 都无法覆盖已存在的 .env 键,唯一可靠覆盖点是 config 级。 * 都无法覆盖已存在的 .env 键,唯一可靠覆盖点是 config 级。
* - think\Config::set 的第二参数只支持单段一级名,不能传 'database.connections.main'。 * - think\Config::set 的第二参数只支持单段一级名,不能传 'database.connections.main'。
* *
* 幂等:migrate:run / scheme:sync / seed:run 均可安全重复执行(详见 README)。 * 幂等:migrate:run / scheme:sync 均可安全重复执行(详见 README)。
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -135,12 +135,10 @@ if (!is_string($resolvedDb) || stripos($resolvedDb, 'test') === false) {
// scheme:sync 在非 TTY 下不带它会被 parent::confirm 抛 "Aborted"。 // scheme:sync 在非 TTY 下不带它会被 parent::confirm 抛 "Aborted"。
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
$commands = [ $commands = [
// 系统/框架表(Phinx 迁移):ul_system_admin / ul_system_menu / ul_system_auth_node / ul_system_config / ... // 系统/框架表 + 全部初始数据(Phinx 迁移):ul_system_admin / ul_system_menu / ul_system_config / ...
['migrate:run', '系统/框架表(database/migrations/)'], ['migrate:run', '系统/框架表 + 初始数据(database/migrations/)'],
// 业务表(Scheme 同步):app/admin/scheme/ 下声明的表 // 业务表(Scheme 同步):app/admin/scheme/ 下声明的表
['scheme:sync', '业务表(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 = (new \ReflectionProperty(\think\console\Output::class, 'handle'));

View File

@@ -10,10 +10,10 @@
(e.g. ulthon_admin -> ulthon_admin_test) (e.g. ulthon_admin -> ulthon_admin_test)
3. CREATE DATABASE IF NOT EXISTS on the same MySQL instance (no Docker) 3. CREATE DATABASE IF NOT EXISTS on the same MySQL instance (no Docker)
4. config-level override connections.main -> test DB 4. config-level override connections.main -> test DB
5. php think migrate:run -> system/framework tables (database/migrations/) 5. php think migrate:run -> system/framework tables + all initial data
(database/migrations/)
6. php think scheme:sync -> business tables (app/admin/scheme/) 6. php think scheme:sync -> business tables (app/admin/scheme/)
7. php think seed:run -> reference data (database/seeds/) 7. queries information_schema to verify the tables landed in the test DB
8. queries information_schema to verify the tables landed in the test DB
Fully parameterized: no hardcoded host/port/credentials. The MySQL instance Fully parameterized: no hardcoded host/port/credentials. The MySQL instance
is whatever .env points at (typically a single local MySQL). is whatever .env points at (typically a single local MySQL).

View File

@@ -6,10 +6,9 @@
# 2. 派生测试库名 = {env.database} + '_test'(如 ulthon_admin -> ulthon_admin_test) # 2. 派生测试库名 = {env.database} + '_test'(如 ulthon_admin -> ulthon_admin_test)
# 3. 在同一 MySQL 实例上 CREATE DATABASE IF NOT EXISTS(无需专用 Docker 容器) # 3. 在同一 MySQL 实例上 CREATE DATABASE IF NOT EXISTS(无需专用 Docker 容器)
# 4. config 级覆盖 connections.main 指向测试库 # 4. config 级覆盖 connections.main 指向测试库
# 5. php think migrate:run 建系统/框架表(database/migrations/) # 5. php think migrate:run 建系统/框架表 + 全部初始数据(database/migrations/)
# 6. php think scheme:sync 建业务表(app/admin/scheme/) # 6. php think scheme:sync 建业务表(app/admin/scheme/)
# 7. php think seed:run 插参考数据(database/seeds/) # 7. 查 information_schema 验证表已落到测试库
# 8. 查 information_schema 验证表已落到测试库
# #
# 完全参数化:不硬编码 host/port/credentials。MySQL 实例 = .env 指向的那个。 # 完全参数化:不硬编码 host/port/credentials。MySQL 实例 = .env 指向的那个。
# 详见 tests/README.md。 # 详见 tests/README.md。