From e392db007a0a0c4e53eed2f8cdce921e90f24de2 Mon Sep 17 00:00:00 2001 From: augushong Date: Sun, 19 Jul 2026 09:07:42 +0800 Subject: [PATCH] =?UTF-8?q?docs(agents):=20=E8=90=BD=E5=AE=9E=E6=8C=89?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=8D=95=E4=B8=80=E6=96=87=E6=A1=A3=E5=8E=9F?= =?UTF-8?q?=E5=88=99=EF=BC=8C=E5=90=88=E5=B9=B6=E8=A7=84=E5=88=99=E5=88=B0?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md 代码分层铁律精简为入口摘要,链接指向技能详情 - 合并 ulthon-timer-multi-node 规则到 ulthon-timer 技能(多节点协调章节) - 合并 ulthon-database-design 规则到 ulthon-scheme-definition 技能(含字段约定、组件类型) - 合并 ulthon-testing 规则到 ulthon-testing 技能(含设计哲学、决策树、测试约束) - rules-manager 边界原则从规则/技能二分改为按主题单一文档 - AGENTS.md 通用基础规范中表结构规范链接改向技能 - 工作流索引中三个技能描述扩充(明确承载原规则内容) --- .agents/rules/ulthon-database-design.md | 76 ------ .agents/rules/ulthon-testing.md | 177 -------------- .agents/rules/ulthon-timer-multi-node.md | 33 --- .agents/skills/ulthon-rules-manager/SKILL.md | 33 ++- .../skills/ulthon-scheme-definition/SKILL.md | 149 ++++++++---- .agents/skills/ulthon-testing/SKILL.md | 230 ++++++++++++++---- .agents/skills/ulthon-timer/SKILL.md | 43 ++-- AGENTS.md | 29 +-- 8 files changed, 340 insertions(+), 430 deletions(-) delete mode 100644 .agents/rules/ulthon-database-design.md delete mode 100644 .agents/rules/ulthon-testing.md delete mode 100644 .agents/rules/ulthon-timer-multi-node.md diff --git a/.agents/rules/ulthon-database-design.md b/.agents/rules/ulthon-database-design.md deleted file mode 100644 index 6bcdab2..0000000 --- a/.agents/rules/ulthon-database-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# 表结构设计规范 - -## 特殊字段 - -| 字段名 | 用途 | 说明 | -|--------|------|------| -| `status` | 默认开关字段 | - | -| `create_time` | 创建时间 | 尽量 NOT NULL,默认值 0(TP 会自动填充) | -| `update_time` | 更新时间 | 尽量 NOT NULL,默认值 0(TP 会自动填充) | -| `delete_time` | 删除时间 | 尽量 NOT NULL,默认值 0;CURD 默认开启软删除,删除标志为 0 | - -## 字段后缀约定 - -以特殊字符结尾的字段会自动识别为对应类型: - -| 后缀 | 类型 | -|------|------| -| `image`、`logo`、`photo`、`icon` | 单图片 | -| `images`、`photos`、`icons` | 多图片 | -| `file` | 单文件 | -| `files` | 多文件 | - -## 注释语法 - -字段注释支持通过特殊格式定义表单类型和数据集: - -``` -名称 {类型} (数据集) -``` - -- **类型**:用 `{}` 包起来,例如 `{radio}` -- **数据集**:用 `()` 包起来,例如 `(1:男, 2:女, 0:未知)` -- 数据集索引可以用数字或英文单词,不要使用其他字符和空格 - -示例:`性别 {radio} (1:男, 2:女, 0:未知)` - -## 类型大全 - -| 类型 | 说明 | 是否需要数据集 | 注释案例 | -|------|------|----------------|----------| -| text | 普通文本框 | 否 | `店铺名称 {text}`(一般不需要写 text) | -| image | 单图片 | 否 | `店铺logo {image}` | -| images | 多图片 | 否 | `店铺环境 {images}`,分隔符默认为竖线 | -| file | 单文件 | 否 | `演示资料 {file}` | -| files | 多文件 | 否 | `演示资料 {files}`,默认分隔符为竖线 | -| date | 时间组件 | 是 | `生日 {date} (datetime)` | -| editor | 富文本 | 否 | `店铺详情 {editor}` | -| textarea | 多行文本 | 否 | `店铺简介 {textarea}` | -| select | 下拉选择 | 是 | `版本 {select} (trial:免费版,office:正式版)` | -| switch | 开关组件 | 是 | `状态 {switch} (0:关闭,1:开启)` | -| checkbox | 多选框 | 是 | `功能权限 {checkbox} (mall:商城,blog:博客)` | -| radio | 单选框 | 是 | `状态 {radio} (0:未审核,1:审核中)` | -| relation | 关联表 | 是(格式见下) | `标签 {relation} (table:tag,relationBindSelect:title)` | -| table | 表格选择器 | 是(格式见下) | `商品标签 {table} (table:mall_tag,type:checkbox,valueField:id,fieldName:title)` | -| city | 城市选择器 | 是 | `仓库 {city} (level:city)` | - -## 关联表注释参数 - -| 参数 | 说明 | 备注 | -|------|------|------| -| `table` | 关联表名 | 必填 | -| `primaryKey` | 关联表主键 | 非必填 | -| `modelFilename` | 模型文件 | 非必填,不建议指定,可自动生成 | -| `onlyFileds` | 列表页显示字段 | 可指定,用竖线分割。**键名是 `onlyFileds`(而非 `onlyFields`),需与 `CurdBase.php` 解析逻辑一致** | -| `relationBindSelect` | 表单下拉关联字段 | 必填 | - -完整写法示例: - -``` -标签 {relation} (table:tag,relationBindSelect:title,primaryKey:id,onlyFileds:title|time_image|username|phone) -``` - -## 其他细节 - -- 设计时尽量设置默认值。例如 `status` 默认值为 1,添加数据时表单会自动将 radio 选中"1:启用" -- 分隔符默认为竖线 `|` diff --git a/.agents/rules/ulthon-testing.md b/.agents/rules/ulthon-testing.md deleted file mode 100644 index 9ae2728..0000000 --- a/.agents/rules/ulthon-testing.md +++ /dev/null @@ -1,177 +0,0 @@ -# 测试设计哲学与边界规范 - -> 来源:框架内置(ulthon-) -> 作用域:所有测试编写、service 抽象、控制器逻辑组织 -> 触发条件:判断"该不该抽 service / 该不该写测试"、写 PHPUnit、动测试库时加载 - -本规则回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。 - -## 一、核心设计哲学 - -防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。 - -### 1.1 控制器中心主义 - -业务逻辑写在控制器里,是设计如此,不是不规范。 - -框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见 1.2),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。 - -不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里,把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。 - -### 1.2 页面接口同体 - -一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。 - -严禁拆成 `ApiController` + `PageController` 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条: - -1. **一套逻辑不会分叉**:同一方法同一代码路径,三端拿到的结果数学上完全一致。 -2. **接口测试 ROI 放大**:测一条路径等于同时覆盖页面和接口,不用维护两套测试。 -3. **改一处生效三端**:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口。 -4. **框架机制保证三模式自动切换**:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感。 -5. **这不是图省事,是消除分叉风险**:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长)。 - -详细机制见技能 [ulthon-page-api-dual-mode](../skills/ulthon-page-api-dual-mode/SKILL.md)。 - -### 1.3 按需 service - -框架对 service 的规范是:**多应用复用才放 `app/common/service/`**。 - -大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service,只是把代码搬离控制器,没有复用收益,反而增加了跳转和传参成本。 - -只有两种情况才值得抽 service: - -- **(a) 产品工具型逻辑**:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程。 -- **(b) 不可逆且致命的约束**:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归。 - -### 1.4 做应用业务 ≠ 开发产品工具 - -这两类代码的价值取向完全相反,不能用同一套规范套。 - -- **应用业务**追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍。 -- **产品工具**追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护。 - -判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。 - -## 二、判断决策树:该不该抽 service / 该不该写测试 - -判断标准有且只有两个维度:**错了的后果** + **是否多处复用**。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。 - -``` -这段逻辑错了会怎样? -├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试 -│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减... -├─ 后果可承受,但逻辑被多处复用 → 抽 service(DRY),测试看情况 -│ 消息发送、文件上传、数据导出、编号生成... -└─ 后果可承受,且只用一次 → 写控制器里,http:call 验证就够 - 字段名、展示顺序、列表筛选、表单校验、页面跳转... -``` - -三条分支的落地: - -- **后果不可承受**:抽到 `app/common/service/`,配 PHPUnit 集成测试(见第三节"核心 service"层)。测试要锁死不变量,不只是覆盖当前行为。 -- **后果可承受 + 多处复用**:抽 service 是为了 DRY,不是为了测试。测试看 ROI,纯函数值得测,带状态的可放可不放。 -- **只用一次**:留在控制器里。用 `php think tools:http:call`(见技能 ulthon-tools-http-call)模拟请求跑通增删改查即可,这是应用业务的主力验证方式,不需要 PHPUnit。 - -## 三、4 层测试模型 - -大部分应用业务逻辑【不测】。只在下表对应层介入。 - -| 层 | 测什么 | 工具 | 适用场景 | -|------|--------|------|----------| -| 纯函数 | 编号解析、算法、格式转换 | 裸 `assert` / 极简 PHPUnit | 产品工具型逻辑 | -| 控制器 | 列表、表单、筛选、跳转、增删改查 | `php think tools:http:call` | 应用业务主力 | -| 核心 service | 致命约束、状态流转、不可逆操作 | PHPUnit 集成测试(TestCaseBase) | 不可逆且致命 | -| 并发专项 | 抢购、库存扣减、临界区 | 独立并发脚本 | 高并发临界区 | - -说明: - -- **控制器层**用 `tools:http:call` 而非 PHPUnit,因为它能跑通"鉴权 + 路由 + 中间件 + 控制器 + 模型 + 视图"整条链,PHPUnit 单测控制器往往需要 mock 一堆依赖,ROI 很低。这与 AGENTS.md"调试与验证优先使用框架内置命令行工具"一致。 -- **核心 service 层**才用 PHPUnit 集成测试,且必须继承 `app\common\test\TestCase`(见第五、六节),跑在真实测试库 + 事务回滚隔离里。 -- **纯函数层**可以用裸 `assert`(一个 `php` 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护。 - -## 四、service 层测试边界 - -框架规范"多应用复用才放 `app/common/service/`"(第一节 1.3)和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。 - -何时破例:**致命约束型 service 值得破例抽出来测**。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture,控制器方法很难提供这两个条件(请求上下文、事务、鉴权状态都会干扰),service 的纯函数式签名天生适合测试。 - -反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 `tools:http:call` 验证,更符合应用业务的响应变化需求。 - -典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。这些逻辑错了就是真金白银或合规事故,抽出来 + 写 PHPUnit 集成测试锁死不变量。 - -## 五、测试数据库安全规范 - -### 强制使用专用测试库 - -所有 PHPUnit 测试**必须**连专用测试库,**禁止**连 `.env` 配置的开发/生产库。 - -测试库连接策略(参数化,复用开发库同实例): - -| 项 | 值 | -|----|----| -| host / port / username / password / charset / prefix | **从 .env 读取**(复用开发库同实例的连接参数) | -| database | `.env` 中 `DATABASE` 值 + `_test` 后缀(如 `ulthon_admin` → `ulthon_admin_test`) | - -这样单机 MySQL 只需建一个同实例的 `_test` 库,无需额外 Docker 容器。覆盖实现在 `tests/bootstrap.php`(config 级覆盖 `connections.main`)。 - -### 第一道安全闸门 - -`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。 - -`ulthon_admin_test` 含 `test`,放行;远程生产库(不含 `test`)拦截。 - -### 测试库初始化 - -用 `tests/setup_test_db.php` 初始化表结构(参数化,复用 .env 连接 + database=`_test`)。脚本内部跑 `migrate:run`(系统表)+ `scheme:sync --force-force`(业务表)+ `seed:run`(参考数据),幂等可重复执行。详见 `tests/README.md`。 - -### 为什么用 config 级覆盖而不是环境变量 - -ThinkPHP 的 `Env` 组件加载 `.env`,且 `.env` 值【优先于】OS 环境变量(`getenv` 仅作为 `.env` 中不存在键的 fallback)。因此 `putenv('DATABASE=...')`、`$env:DATABASE=...`、bash 前缀变量都**无法**覆盖 `.env` 里已存在的连接键。唯一可靠覆盖点是 config 级:在 `App::initialize()` 之后直接改写 `config/database.php` 解析出的连接数组。 - -关键坑点(think 8):`Config::set(array, $name)` 的第二参数只支持单段一级名,不像 `Config::get` 那样解析点号路径。正确做法是把整段 `database` 配置 pull 出来,原地改 `connections.main`,再 `Config::set($dbConfig, 'database')` 写回。 - -## 六、fixture 使用规范(通用原则) - -### 继承应用层入口 - -业务测试用例继承 `app\common\test\TestCase`(继承自内核 `base\common\test\TestCaseBase`)。**不要**直接继承 `TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到 fixture 工厂和事务隔离)。 - -### 事务自动回滚(无需手动清理) - -`TestCaseBase::setUp()` 开启事务,`tearDown()` 自动 `Db::rollback()` 撤销本测试的一切 DB 写入。**无需手动 truncate 或 delete**。 - -事务 API(think-orm 3.0,实测确认):`Db::startTrans()` 开启 / `Db::rollback()` 回滚 / `Db::commit()` 提交。注意**没有** `rollbackTrans` / `commitTrans` 这两个方法。 - -### Db::name vs Model 选择原则 - -造 fixture 数据时,默认用 `Model::create()`(走模型,享受自动时间戳等特性)。但遇到这种情况要回退 `Db::name()`: - -- 表的 Scheme **没有** `update_time` 列,但模型继承 `TimeModel`(`autoWriteTimestamp=true`,`updateTime='update_time'`),用 `Model::create` 会因 `fields_strict=true` 写不存在的列而抛错。 -- 此时走 `Db::name($table)->insertGetId($data)` 显式只写表里实际存在的列,再 `Model::find($id)` 回查返回模型实例保持类型一致。 - -这是"model 不适用就回退 Db::name"的典型场景,不是偷懒,是绕开 ORM 与表结构不匹配的必要手段。 - -### 软删除 fixture - -需要造软删数据时,用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])` 绕开 `SoftDelete` trait 的拦截复杂度,对任意模型类型确定生效。不要试图通过模型实例触发软删(trait 的拦截逻辑在不同模型配置下行为不一)。 - -### 业务 fixture 工厂由各项目自行实现 - -框架层(`TestCaseBase`)只提供通用基建: - -- 事务回滚隔离(`setUp` / `tearDown`) -- DB 断言(`assertDatabaseHas` / `assertDatabaseMissing`) -- 隔离探针(`assertIsolationWorks`) -- Plan B(`truncateTables`,事务回滚失效时的降级) - -**不提供**业务 fixture 工厂(如 `createUser` / `createOrder` 等)。各衍生项目在自己的 `app/common/test/TestCase`(继承 `TestCaseBase`)中实现业务工厂方法。工厂方法应运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理。 - -## 七、自查清单 - -写测试或动 service 前,对照本规则自查: - -- [ ] 这段逻辑错了后果可承受吗?(第一节 1.4 + 第二节决策树)后果可承受且只用一次,留在控制器 + `tools:http:call`,不要硬抽 service。 -- [ ] 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗?(第三节"核心 service"层) -- [ ] 测试继承 `app\common\test\TestCase` 了吗?连的是 `_test` 库吗?(第五节) -- [ ] fixture 用工厂方法了吗?没有手动 truncate 吧?(第六节) -- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束) diff --git a/.agents/rules/ulthon-timer-multi-node.md b/.agents/rules/ulthon-timer-multi-node.md deleted file mode 100644 index bdbaad9..0000000 --- a/.agents/rules/ulthon-timer-multi-node.md +++ /dev/null @@ -1,33 +0,0 @@ -# 定时任务多节点协调 - -> 来源:框架内置(ulthon-) -> 作用域:定时任务相关模块(TimerConfig、TimerLog、Host) -> 触发条件:涉及定时任务开发、多节点部署、主节点选举等场景时加载 - -## 规则内容 - -- 多节点定时任务协调以数据库为主协调中心 -- run_type 调度模式:auto / main / all / manual -- 支持主节点自动选举与手动切换 -- 执行日志必须记录,支持查看与清理 -- 定时任务配置管理通过 UI 管理 - -## 相关数据表 - -- ul_system_timer_config -- ul_system_timer_log -- ul_system_host(含 is_master 字段) - -## 相关命令 - -- `php think admin:timer:log:clean [--days=30]` — 清理过期执行日志 - -## 相关管理页面 - -- 定时器配置管理:/admin/system.timer_config/index -- 定时器执行日志:/admin/system.timer_log/index -- 主机列表增强(主节点标识、切换主节点) - -## 相关技能 - -- [ulthon-timer](../skills/ulthon-timer/SKILL.md) diff --git a/.agents/skills/ulthon-rules-manager/SKILL.md b/.agents/skills/ulthon-rules-manager/SKILL.md index d59c013..8374e4b 100644 --- a/.agents/skills/ulthon-rules-manager/SKILL.md +++ b/.agents/skills/ulthon-rules-manager/SKILL.md @@ -16,16 +16,33 @@ Rules 可包含以下类型的内容: ## Rules 与 Skills 的边界 -| 维度 | Rule(规则) | Skill(技能) | -|------|-------------|---------------| -| 核心问题 | "是什么""不能做什么""为什么这样设计" | "怎么做""一步步如何完成" | -| 知识形态 | 静态声明(A 对应 B、禁止 C) | 动态流程(第 1 步...第 2 步...) | -| 触发时机 | 涉及某模块时需先了解其规则 | 需要执行某操作时按步骤调用 | -| 典型例子 | URL 映射规则、目录约定、多节点设计 | CURD 生成流程、定时任务配置、菜单创建 | +### 核心原则:按主题单一文档 -**判断方法**:如果内容主要是"告知性"的(让智能体知道某个事实/约束/设计),放 Rule。如果内容主要是"操作性"的(让智能体按步骤完成某件事),放 Skill。 +**默认情况下,一个主题只应有一个文档**。如果某个主题既有设计决策("为什么"、"约束"),又有操作流程("怎么做"),应合并为一个文档,避免分散维护导致的重复和遗漏。 -**灰色地带处理**:部分内容混合了规则和操作(如 Base/App 架构文档既有铁律又有场景指南),此时保留为 Skill,因为其核心价值在"指导如何操作"。 +合并方向:**优先合并到 Skill**。Skill 作为"按场景调用"的入口更符合实际触发逻辑——智能体在执行任务时按需打开,单文件就能完整理解。 + +### 何时保留独立的 Rule + +只有满足以下条件之一时,才创建独立的 Rule 文件: + +- **纯静态声明**:内容只有约束/约定/设计决策,没有"按步骤操作"的流程(如命名约定、URL 映射规则、目录约定、文件级覆盖机制) +- **跨多个 Skill 共享**:约束被多个技能共同引用,独立出来更便于复用 +- **不依附于任何具体操作场景的全局铁律** + +### 反例:不要为同一主题拆分 Rule + Skill + +例如「测试」主题: +- 错误做法:Rule 讲"设计哲学 + 4 层模型",Skill 讲"如何运行测试"——读者需要打开两个文件才能完整理解,且边界容易模糊导致重复 +- 正确做法:合并为一个 Skill,"设计哲学"作为开篇章节,"如何运行"作为操作章节 + +### 归属判断表 + +| 内容性质 | 归属 | 例子 | +|---------|------|------| +| 纯约束/约定/设计决策(无操作流程) | Rule | 命名规范、URL 映射、目录约定、文件级覆盖机制 | +| 操作流程(含必要的设计背景) | Skill | 测试工作流(含设计哲学)、Scheme 定义(含字段约定)、定时器(含多节点设计) | +| 致命铁律(如架构分层) | Skill 中的独立章节 | Base/App 架构铁律(在 ulthon-base-app-architecture 技能里) | ## PROJECT.md 与子规则的关系 diff --git a/.agents/skills/ulthon-scheme-definition/SKILL.md b/.agents/skills/ulthon-scheme-definition/SKILL.md index 222dc84..fb6a6c4 100644 --- a/.agents/skills/ulthon-scheme-definition/SKILL.md +++ b/.agents/skills/ulthon-scheme-definition/SKILL.md @@ -1,6 +1,6 @@ --- name: "ulthon-scheme-definition" -description: "指导编写 Ulthon Admin 的 Scheme 架构定义文件。当需要新增表结构、修改字段定义或配置组件显示时调用。" +description: "指导编写 Ulthon Admin 的 Scheme 文件,定义数据库表结构与后台管理界面组件。涵盖注解方式(scheme:sync:代码 → DB)与字段注释方式(scheme:make:DB → 代码)。" --- # Ulthon Scheme 定义指南 @@ -9,7 +9,13 @@ description: "指导编写 Ulthon Admin 的 Scheme 架构定义文件。当需 参考文档:[表结构-ulthon_admin](https://doc.ulthon.com/read/augushong/ulthon_admin/619efc9d7af62/zh-cn/2.x.html) -## 基本结构 +## 何时调用 + +- 新增表结构、修改字段定义或配置后台管理界面组件呈现方式 +- 通过字段注释反向生成 Scheme 代码(`scheme:make`,DB → 代码) +- 通过 Scheme 类正向同步到数据库(`scheme:sync`,代码 → DB) + +## Scheme 类基本结构 Scheme 文件是一个 PHP 类,继承自 `BaseScheme`,并使用 PHP 8 注解(Attribute)来描述元数据。 @@ -51,18 +57,18 @@ class YourClassName extends BaseScheme ## 注解详解 -### 1. #[Table] (类注解) +### #[Table] (类注解) - `name`: 数据库表名(建议以 `ul_` 开头)。 - `comment`: 表注释。 - `engine`: 存储引擎,默认 `InnoDB`。 - `charset`: 字符集,默认 `utf8mb4`。 -### 2. #[Index] (类注解,可重复) +### #[Index] (类注解,可重复) - `columns`: 索引列,字符串或数组(如 `['cate_id', 'status']`)。 - `name`: 索引名称。 - `type`: 索引类型:`NORMAL`, `UNIQUE`, `FULLTEXT`。 -### 3. #[Field] (属性注解) +### #[Field] (属性注解) - `type`: 字段类型 (e.g., `int`, `bigint`, `char`, `varchar`, `text`, `decimal`, `tinyint`)。 - `length`: 长度 (对于 char/varchar/int)。 - `precision`: 精度 (对于 decimal)。 @@ -74,45 +80,100 @@ class YourClassName extends BaseScheme - `autoIncrement`: 是否自增 (bool)。 - `primary`: 是否为主键 (bool)。 -### 4. #[Component] (属性注解,可选) -定义在后台管理页面中该字段使用的 UI 组件。 -- `type`: 组件类型。常用值: - - `text`: 普通文本框(默认)。 - - `image`: 单图片上传。 - - `images`: 多图片上传(默认分隔符为 `|`)。 - - `file`: 单文件上传。 - - `files`: 多文件上传(默认分隔符为 `|`)。 - - `date`: 时间/日期组件。需配合 `options` 指定格式,如 `datetime` 或 `date`。 - - `editor`: 富文本编辑器。 - - `textarea`: 文本域。 - - `select`: 下拉选择框(需配合 `options`)。 - - `switch`: 开关组件(需配合 `options`,如 `['0' => '关闭', '1' => '开启']`)。 - - `checkbox`: 复选框(需配合 `options`)。 - - `radio`: 单选框(需配合 `options`)。 - - `relation`: 关联表下拉选择。`options` 需包含: - - `table`: 关联表名。 - - `relationBindSelect`: 显示的字段名。 - - `primaryKey`: 关联表主键(可选)。 - - `onlyFileds`: 列表页显示的字段(可选,用 `|` 分隔)。**注意:键名是 `onlyFileds`(而非 `onlyFields`),需与 `extend/base/common/command/CurdBase.php` 解析逻辑保持一致;写错键名会导致列表字段配置静默失效。** - - `table`: 表格选择器。`options` 需包含: - - `table`: 关联表名。 - - `type`: 选择模式 (`checkbox`/`radio`)。 - - `valueField`: 值字段名。 - - `fieldName`: 显示字段名。 - - `city`: 城市选择器。`options` 需包含: - - `level`: 层级 (`province`/`city`/`area`)。 -- `options`: 选项数据。支持索引数组 `['禁用', '启用']` 或关联数组 `['1' => '男', '2' => '女']`,对于 `relation`/`table`/`city` 类型,则是配置参数数组。 +### #[Component] (属性注解,可选) +定义在后台管理页面中该字段使用的 UI 组件。详见下方「组件类型清单」。 + +## 特殊字段约定 + +| 字段名 | 用途 | 说明 | +|--------|------|------| +| `status` | 默认开关字段 | 设计建议默认值为 `1`(启用),添加数据时表单会自动选中"启用" | +| `create_time` | 创建时间 | 尽量 NOT NULL,默认值 0(TP 自动填充) | +| `update_time` | 更新时间 | 尽量 NOT NULL,默认值 0(TP 自动填充) | +| `delete_time` | 删除时间 | 尽量 NOT NULL,默认值 0;存在此字段时模型自动启用软删除,删除标志为 0 | + +## 字段后缀约定(自动推断组件类型) + +字段名以特殊字符结尾时会自动识别为对应类型(若未显式指定 `#[Component]`): + +| 后缀 | 类型 | +|------|------| +| `image`、`logo`、`photo`、`icon` | 单图片 | +| `images`、`photos`、`icons` | 多图片 | +| `file` | 单文件 | +| `files` | 多文件 | + +## 组件类型清单 + +适用于 `#[Component]` 注解(`scheme:sync`:代码 → DB)与数据库字段注释(`scheme:make`:DB → 代码)两种场景。 + +| 类型 | 说明 | 是否需要数据集 | +|------|------|----------------| +| text | 普通文本框(默认,一般不需要写) | 否 | +| image | 单图片 | 否 | +| images | 多图片(默认分隔符 `\|`) | 否 | +| file | 单文件 | 否 | +| files | 多文件(默认分隔符 `\|`) | 否 | +| date | 时间组件 | 是(`datetime` / `date`) | +| editor | 富文本 | 否 | +| textarea | 多行文本 | 否 | +| select | 下拉选择 | 是 | +| switch | 开关组件 | 是(如 `0:关闭,1:开启`) | +| checkbox | 多选框 | 是 | +| radio | 单选框 | 是 | +| relation | 关联表下拉选择 | 是(参数见下) | +| table | 表格选择器 | 是(参数见下) | +| city | 城市选择器 | 是(`level`: `province`/`city`/`area`) | + +### 注解方式(推荐,`scheme:sync` 用) + +```php +#[Component(type: 'radio', options: ['1' => '男', '2' => '女'])] +public $gender; +``` + +`options` 支持索引数组 `['禁用', '启用']` 或关联数组 `['1' => '男', '2' => '女']`;对于 `relation`/`table`/`city` 类型,则是配置参数数组。 + +### 字段注释方式(`scheme:make` 用) + +通过字段注释语法在数据库层声明组件: + +``` +名称 {类型} (数据集) +``` + +- 类型用 `{}` 包起来,例如 `{radio}` +- 数据集用 `()` 包起来,例如 `(1:男, 2:女, 0:未知)` +- 数据集索引可用数字或英文单词,不要使用其他字符和空格 + +示例:`性别 {radio} (1:男, 2:女, 0:未知)` + +## 关联表(relation)参数 + +`relation` 类型用于关联表下拉选择,`options` 需包含: + +| 参数 | 说明 | 备注 | +|------|------|------| +| `table` | 关联表名 | 必填 | +| `relationBindSelect` | 表单下拉关联字段 | 必填 | +| `primaryKey` | 关联表主键 | 非必填 | +| `modelFilename` | 模型文件 | 非必填,不建议指定,可自动生成 | +| `onlyFields` | 列表页显示字段 | 可指定,用 `\|` 分隔。**写错键名会导致列表字段配置静默失效。** | + +字段注释完整写法示例: + +``` +标签 {relation} (table:tag,relationBindSelect:title,primaryKey:id,onlyFields:title|time_image|username|phone) +``` + +`table` 类型(表格选择器)参数类似,`options` 需包含:`table`、`type`(`checkbox`/`radio`)、`valueField`、`fieldName`。 ## 编写规范 -1. **命名规范**: 文件名采用大驼峰(PascalCase),且必须与类名一致。 -2. **时间字段**: 统一使用 `int` 存储 Unix 时间戳,默认 `0`,非空。不要使用 `datetime`/`timestamp`。 -3. **软删除**: `delete_time` 字段(`int`,默认 `0`)存在时,模型自动启用软删除机制。查询时不要手写 `delete_time` 条件。 -4. **状态表达**: 避免使用 MySQL ENUM 类型。优先使用 `int` 或 `tinyint` 配合 `#[Component(type: 'radio')]` 或 `#[Component(type: 'switch')]`。 -5. **注释约定**: `Field` 的 `comment` 会直接同步到数据库字段注释,同时也作为后台表单的 Label。 -6. **字段后缀约定**: 框架会根据字段名后缀自动推断部分组件类型(若未显式指定 `#[Component]`): - - `image`, `logo`, `photo`, `icon`: 默认为单图片。 - - `images`, `photos`, `icons`: 默认为多图片。 - - `file`: 默认为单文件。 - - `files`: 默认为多文件。 - +1. **命名规范**:文件名采用大驼峰(PascalCase),且必须与类名一致。 +2. **时间字段**:统一使用 `int` 存储 Unix 时间戳,默认 `0`,非空。不要使用 `datetime`/`timestamp`。 +3. **软删除**:`delete_time` 字段(`int`,默认 `0`)存在时,模型自动启用软删除机制。查询时不要手写 `delete_time` 条件。 +4. **状态表达**:避免使用 MySQL ENUM 类型。优先使用 `int` 或 `tinyint` 配合 `#[Component(type: 'radio')]` 或 `#[Component(type: 'switch')]`。 +5. **注释约定**:`Field` 的 `comment` 会直接同步到数据库字段注释,同时也作为后台表单的 Label。 +6. **默认值约定**:设计时尽量设置默认值。 +7. **分隔符**:多图片/多文件类型默认分隔符为竖线 `|`。 diff --git a/.agents/skills/ulthon-testing/SKILL.md b/.agents/skills/ulthon-testing/SKILL.md index b5312aa..66d4cc4 100644 --- a/.agents/skills/ulthon-testing/SKILL.md +++ b/.agents/skills/ulthon-testing/SKILL.md @@ -1,24 +1,148 @@ --- name: "ulthon-testing" -description: "框架测试工作流:如何运行测试、编写新测试(继承/fixture/事务回滚)、选择测试类型、设置测试环境、验证回归保护。需要跑 phpunit 或给致命约束/状态流转逻辑加测试时调用。" +description: "框架测试完整指南:设计哲学(控制器中心主义、按需 service)、决策依据(4 层模型、该不该抽 service)、测试约束(专用测试库、事务回滚)、如何运行与编写测试、环境设置、回归保护验证。" --- -# 测试工作流(运行 + 编写 + 决策) +# 测试工作流(设计 + 操作) -本技能是框架级(`ulthon-` 前缀)的测试入门指南。读完这一篇,新开发者或 AI 就能:把测试跑起来、照模板写一个新测试、选对测试工具、设置测试环境。 +本技能是框架级(`ulthon-` 前缀)的测试完整指南。涵盖:为什么这样设计、什么时候该写测试、必须遵守的约束、具体怎么运行和编写测试。 测试栈基线:PHPUnit 10.5.x(`composer.json` 里 `phpunit/phpunit: ^10`),ThinkPHP 8 容器,参数化测试库(复用 .env 连接,database = env+'_test'),事务回滚做隔离。 ## 何时调用 -- 第一次在这个项目跑测试,不知道怎么准备环境。 -- 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试。 -- 拿到一个 bug,想先用测试锁住复现路径再修。 -- 不确定该写 phpunit 还是直接用 `tools:http:call`。 +- 第一次在这个项目跑测试,不知道怎么准备环境 +- 要给一段状态流转、致命约束、软删除相关的 service 逻辑加回归测试 +- 拿到一个 bug,想先用测试锁住复现路径再修 +- 不确定该写 phpunit 还是直接用 `tools:http:call` +- 判断"该不该抽 service"、"这段逻辑要不要写测试" -## 1. 如何运行测试 +## 设计哲学 -### 1.1 首次准备(建测试库 + 建表,只做一次) +防止后人或 AI 把框架的刻意设计误判为缺陷,这里把四条取向讲透。回答两个问题:(1) 框架里那么多业务逻辑没写单元测试,是不是不规范?(2) 哪些逻辑必须抽 service 并锁测试?回答之前先理解框架的设计取向,否则会把"设计如此"误判成"技术债"去重构,反而制造破坏。 + +### 控制器中心主义 + +业务逻辑写在控制器里,是设计如此,不是不规范。 + +框架侧重控制器。控制器等于接口等于页面,业务入口、参数校验、结果反馈都在这里完成。一个控制器方法同时承担"渲染页面"和"返回 JSON"两种职责(见下文"页面接口同体"),所以把业务流程写在控制器里,流程跟入口是同一段代码,改起来最直接,调试链路最短。 + +不要因为"控制器太胖"就机械地拆 service。控制器胖是因为业务就发生在这里,把业务搬到 service 只是把同样的代码换个文件,还多了一层跳转成本。 + +### 页面接口同体 + +一套控制器代码同时服务三端:后台管理页、用户端接口、小程序接口。消除"接口分叉"是这套机制的核心价值。 + +严禁拆成 `ApiController` + `PageController` 两套。一旦拆开,三端逻辑就会各自漂移,今天改了页面端忘了同步接口端,明天接口端加了字段页面端没跟上,最终三端行为不一致。同体机制的价值有五条: + +1. **一套逻辑不会分叉**:同一方法同一代码路径,三端拿到的结果数学上完全一致 +2. **接口测试 ROI 放大**:测一条路径等于同时覆盖页面和接口,不用维护两套测试 +3. **改一处生效三端**:业务变更只改一个方法,三端同步生效,没有"忘了同步"的窗口 +4. **框架机制保证三模式自动切换**:同体不是手写 if-else 判断请求类型,框架按请求特征自动选择渲染或 JSON 响应,开发者无感 +5. **这不是图省事,是消除分叉风险**:同体是架构决策,不是偷懒。拆开的代价(三端不一致的线上事故)远大于合并的代价(一个方法稍长) + +详细机制见技能 [ulthon-page-api-dual-mode](./ulthon-page-api-dual-mode/SKILL.md)。 + +### 按需 service + +框架对 service 的规范是:**多应用复用才放 `app/common/service/`**。 + +大多数应用业务不需要封装 service。把"只用一次的业务流程"硬抽成 service,只是把代码搬离控制器,没有复用收益,反而增加了跳转和传参成本。 + +只有两种情况才值得抽 service: + +- **(a) 产品工具型逻辑**:算法、编号解析、格式转换这类"输入输出确定、不变、多处用"的逻辑。例如编号生成器、二维码解析器。它们是工具,不是业务流程 +- **(b) 不可逆且致命的约束**:错了会出真实事故的校验逻辑。例如余额扣减的"不能扣成负数"、绑定约束的"不能重复绑定"、状态流转的"不可逆转换"。这种逻辑必须从控制器流程里独立出来,单独测、单独审、单独防回归 + +### 应用业务 vs 产品工具 + +这两类代码的价值取向完全相反,不能用同一套规范套。 + +- **应用业务**追求快速响应变化。业务规则经常变(今天满减门槛 100 元,明天改 80 元),逻辑写死在控制器里反而最好改。过度封装 service 和写单元测试,会让"改一个数字"变成"改 service + 改测试 + 改 mock 数据"三件事,成为响应变化的障碍 +- **产品工具**追求绝对正确性。sqlite 管理、网盘协议、解析器这类工具,输入输出契约一旦确定就不该变,错了就是工具本身坏了。这里 service 和单元测试很有价值,因为不变量是死的,测试能长期守护 + +判断一段逻辑属于哪类:问"它会变吗,还是它该永远对?"。会变的是应用业务,该永远对的是产品工具。 + +## 该不该抽 service / 该不该写测试(决策树) + +判断标准有且只有两个维度:**错了的后果** + **是否多处复用**。注意,判断标准不是"会不会变"。业务会变不代表不该封装,余额规则再怎么变,"不能扣成负数""不能重复扣"这些不变量是死的,值得锁住。 + +``` +这段逻辑错了会怎样? +├─ 后果不可承受(钱/医疗/不可逆/法律)→ 必须抽 service + 写测试 +│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减... +├─ 后果可承受,但逻辑被多处复用 → 抽 service(DRY),测试看情况 +│ 消息发送、文件上传、数据导出、编号生成... +└─ 后果可承受,且只用一次 → 写控制器里,http:call 验证就够 + 字段名、展示顺序、列表筛选、表单校验、页面跳转... +``` + +三条分支的落地: + +- **后果不可承受**:抽到 `app/common/service/`,配 PHPUnit 集成测试。测试要锁死不变量,不只是覆盖当前行为 +- **后果可承受 + 多处复用**:抽 service 是为了 DRY,不是为了测试。测试看 ROI,纯函数值得测,带状态的可放可不放 +- **只用一次**:留在控制器里。用 `php think tools:http:call`(见技能 ulthon-tools-http-call)模拟请求跑通增删改查即可,这是应用业务的主力验证方式,不需要 PHPUnit + +## 4 层测试模型 + +大部分应用业务逻辑【不测】。只在下表对应层介入。 + +| 层 | 测什么 | 工具 | 适用场景 | +|------|--------|------|----------| +| 纯函数 | 编号解析、算法、格式转换 | 裸 `assert` / 极简 PHPUnit | 产品工具型逻辑 | +| 控制器 | 列表、表单、筛选、跳转、增删改查 | `php think tools:http:call` | 应用业务主力 | +| 核心 service | 致命约束、状态流转、不可逆操作 | PHPUnit 集成测试(TestCaseBase) | 不可逆且致命 | +| 并发专项 | 抢购、库存扣减、临界区 | 独立并发脚本 | 高并发临界区 | + +说明: + +- **控制器层**用 `tools:http:call` 而非 PHPUnit,因为它能跑通"鉴权 + 路由 + 中间件 + 控制器 + 模型 + 视图"整条链,PHPUnit 单测控制器往往需要 mock 一堆依赖,ROI 很低 +- **核心 service 层**才用 PHPUnit 集成测试,且必须继承 `app\common\test\TestCase`(见"测试约束"),跑在真实测试库 + 事务回滚隔离里 +- **纯函数层**可以用裸 `assert`(一个 `php` 脚本跑完),不一定要进 PHPUnit 套件,除非逻辑足够复杂值得长期守护 + +## service 层测试边界 + +"多应用复用才放 `app/common/service/`"和"可测性需求"之间有张力:有些逻辑只用一次,但错了后果不可承受。 + +何时破例:**致命约束型 service 值得破例抽出来测**。即使一段逻辑只在当前应用的某个流程里用一次,只要它错了是资金事故/法律事故/安全事故(决策树第一分支),就值得抽成 service 配测试。理由是测试需要稳定的入口和可复现的 fixture,控制器方法很难提供这两个条件(请求上下文、事务、鉴权状态都会干扰),service 的纯函数式签名天生适合测试。 + +反过来说,后果可承受的逻辑(决策树第二、三分支)不要为了"可测"硬抽 service。留在控制器里用 `tools:http:call` 验证,更符合应用业务的响应变化需求。 + +典型破例场景:余额扣减的"不能扣成负数"、唯一性绑定约束、不可逆状态流转、支付回调验签、库存扣减的"不能超卖"。 + +## 测试约束(铁律) + +以下约束不可协商。 + +### 强制使用专用测试库 + +- 所有 PHPUnit 测试**必须**连专用测试库(库名 = `.env` 中 `DATABASE` 值 + `_test` 后缀,如 `ulthon_admin` → `ulthon_admin_test`),**禁止**连开发/生产库 +- 连接参数(host/port/user/pass/charset/prefix)从 `.env` 读取,复用开发库同实例,单机 MySQL 只需建一个 `_test` 库 +- 覆盖实现位于 `tests/bootstrap.php`:在 `App::initialize()` 之后通过 config 级覆盖 `connections.main`(**不能用** `putenv` / `$env:VAR` / bash 前缀变量,因为 ThinkPHP 的 Env 加载 `.env`,`.env` 值优先于 OS 环境变量) + +### 第一道安全闸门 + +`base\common\test\TestCaseBase::setUp()` 的第一步是 `assertTestDatabase()`:读当前连接的库名,若不含子串 `test`(大小写不敏感),立即 `fail()` 中止测试。这保证即使 `tests/bootstrap.php` 的 config 覆盖写错、或 `.env` 被误改指向生产库,测试也绝不会对生产库写入任何数据。 + +### 业务测试必须继承 app 层入口 + +业务测试用例**必须**继承 `app\common\test\TestCase`(继承自内核 `base\common\test\TestCaseBase`)。**不要**直接继承 `TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到 fixture 工厂和事务隔离)。 + +### 隔离手段 = 事务回滚(禁止手动清表) + +- `TestCaseBase::setUp()` 开启事务,`tearDown()` 自动 `Db::rollback()` 撤销本测试的一切 DB 写入 +- **禁止**在测试里手动 `DELETE` / `TRUNCATE` 清数据 +- 怀疑隔离失效时可调基类的 `assertIsolationWorks()` 自验证;极端降级方案 `truncateTables(array $tables)` 正常情况下用不到 + +### 业务 fixture 工厂由项目自实现 + +- 框架层(`TestCaseBase`)只提供通用基建:事务回滚隔离、DB 断言(`assertDatabaseHas` / `assertDatabaseMissing`)、隔离探针、Plan B(`truncateTables`) +- **不提供**业务 fixture 工厂(如 `createUser` / `createOrder` 等) +- 各衍生项目在自己的 `app/common/test/TestCase` 中实现业务工厂方法,工厂方法运行在 `setUp` 事务内,`tearDown` 自动回滚 + +## 如何运行测试 + +### 首次准备(建测试库 + 建表,只做一次) 测试库复用开发库同实例的连接参数(host/port/user/pass 从 .env 读),仅库名不同(= .env 的 `DATABASE` + `_test`)。单机 MySQL 建一个 `_test` 库即可,无需专用 Docker 容器。 @@ -29,8 +153,6 @@ CREATE DATABASE IF NOT EXISTS ulthon_admin_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` -(库名 = .env 的 `DATABASE` 值 + `_test`,如 `ulthon_admin` → `ulthon_admin_test`) - 第二步,建表 + 种子(测试库已建后执行): ```bash @@ -39,7 +161,7 @@ php tests/setup_test_db.php 这个脚本会在同进程内依次跑 `migrate:run`(系统表)、`scheme:sync`(业务表)、`seed:run`(参考数据),并自查 `information_schema`。成功退出码 0。脚本幂等,可安全重跑。 -### 1.2 日常运行 +### 日常运行 全量跑(所有测试套件): @@ -71,11 +193,11 @@ vendor/bin/phpunit --testsuite feature 注意:Windows 下路径用反斜杠 `vendor\bin\phpunit`,Git Bash / WSL 下用正斜杠。 -## 2. 如何编写新测试 +## 如何编写新测试 -### 2.1 继承应用层入口 +### 继承应用层入口 -集成测试一律继承 `app\common\test\TestCase`。不要直接继承 `base\common\test\TestCaseBase`(那是内核层),也不要直接继承 `PHPUnit\Framework\TestCase`(拿不到事务隔离和 DB 断言)。 +集成测试一律继承 `app\common\test\TestCase`: ```php namespace tests\Integration; @@ -90,20 +212,20 @@ class XxxTest extends TestCase 继承之后,基类自动帮你做两件事,你完全不用操心: -- `setUp`:先校验当前连的是测试库(库名含 `test` 才放行,拒绝连生产库),再 `Db::startTrans()` 开事务。 -- `tearDown`:`Db::rollback()` 回滚事务,撤销本测试的一切 DB 写入。 +- `setUp`:先校验当前连的是测试库(库名含 `test` 才放行,拒绝连生产库),再 `Db::startTrans()` 开事务 +- `tearDown`:`Db::rollback()` 回滚事务,撤销本测试的一切 DB 写入 事务 API 是 `Db::startTrans()` 开启、`Db::rollback()` 回滚(think-orm 3.0 没有 `rollbackTrans` 这个方法)。这两个调用都在基类里,测试作者不要自己调。 -### 2.2 造数据(fixture) +### 造数据(fixture) -框架层(`TestCaseBase`)**不提供**业务 fixture 工厂(如 `createUser` / `createOrder`)。各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应: +各衍生项目在自己的 `app\common\test\TestCase` 里实现业务工厂方法。工厂方法应: -- 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理。 -- 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(见规则第六节"Db::name vs Model")。 -- 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait。 +- 运行在 `setUp` 事务内,`tearDown` 自动回滚,无需手动清理 +- 默认用 `Model::create()`;遇到表无 `update_time` 列但模型继承 `TimeModel` 时,回退 `Db::name()->insertGetId()`(绕开 ORM 与表结构不匹配) +- 造软删数据用 `Db::name($table)->where('id', $id)->update(['delete_time' => time()])`,绕开 `SoftDelete` trait 的拦截复杂度 -### 2.3 断言 +### 断言 PHPUnit 原生断言照常用(`assertTrue` / `assertFalse` / `assertEquals` / `assertStringContainsString` 等)。基类额外提供两个 DB 断言助手,参数都是逻辑表名(不含前缀,和 `Db::name()` 一致): @@ -118,15 +240,15 @@ $this->assertDatabaseMissing('system_admin', ['username' => 'deleted_user']); $this->assertFalse($result['ok'], 'reason: ' . ($result['reason'] ?? '')); ``` -### 2.4 隔离(无需手动清理) +### 隔离(无需手动清理) -事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。不要在测试里手动 `DELETE` / `TRUNCATE` 清数据。 +事务回滚是 PRIMARY 隔离手段。每个测试开事务、结束时回滚,数据不跨测试残留。 -如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL),可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。极端降级方案是 `truncateTables(array $tables)`,但正常情况下用不到。 +如果怀疑隔离失效(比如某个测试改了框架级单例、或跑了自动提交的 DDL),可以调用基类的 `assertIsolationWorks()` 做自验证(三步探针:先断言探针行不存在,插入,再断言可见;下一个测试的第一步如果失败就说明上一个测试没回滚干净)。 -## 3. 测试模板 +## 测试模板 -### 3.1 单元测试模板(纯函数 / 算法,无需 DB) +### 单元测试模板(纯函数 / 算法,无需 DB) 纯函数测试**不要**继承 `app\common\test\TestCase`(那会触发 setUp 连测试库、开事务,拖慢且强依赖容器)。直接继承 `PHPUnit\Framework\TestCase`: @@ -158,7 +280,7 @@ class SomePureServiceTest extends TestCase 放进 `tests/Unit/`,namespace 用 `tests\Unit`。 -### 3.2 集成测试模板(service / DB 写入 / 状态流转) +### 集成测试模板(service / DB 写入 / 状态流转) 集成测试继承 `app\common\test\TestCase`,用 fixture 造数据、调 service、断言: @@ -208,26 +330,24 @@ class XxxServiceTest extends TestCase 放进 `tests/Integration/`,namespace 用 `tests\Integration`。 -## 4. 何时写哪种测试(决策表) +## 测试类型速查 -不是所有验证都要写 phpunit。选错工具会浪费时间。下表交叉引用了规则文件 `.agents/rules/ulthon-testing.md` 的决策树。 +被测对象 → 工具/路径 的快速映射。选对工具后跳到对应小节查看详细操作。 -| 被测对象 | 用什么 | 放哪 | 原因 | -|----------|--------|------|------| -| 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` | 无副作用,跑得快,不依赖容器 | -| 致命约束 / 状态流转 service(余额扣减、状态机、软删历史、绑定约束) | phpunit 集成测试 | `tests/Integration/` | 要造 DB 前置、断言历史记录,事务回滚保证隔离 | -| 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit | 模拟登录请求即可验证,写 phpunit 成本高收益低 | -| 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 | phpunit 单进程模拟不了真并发,要起多进程 | +| 被测对象 | 用什么 | 放哪 | +|----------|--------|------| +| 纯函数 / 算法 / 格式化 | phpunit 单元测试 | `tests/Unit/` | +| 致命约束 / 状态流转 service(余额扣减、状态机、软删历史、绑定约束) | phpunit 集成测试 | `tests/Integration/` | +| 控制器列表 / 表单 / 筛选 / 增删改查链路 | `tools:http:call` | 不写 phpunit | +| 并发临界区 / 竞态(抢购抢锁等) | 独立并发脚本 | `runtime/agents/` 或临时脚本 | -一句话:**能复现就写测试,只是跑一次看返回就用 tools:http:call**。致命约束和状态流转必须用 phpunit 锁住,因为这类 bug 一旦上线就是事故级别。 - -## 5. 如何设置测试环境 +## 如何设置测试环境 完整步骤(首次或重装时执行): -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`)。 -3. **建表 + 种子**:`php tests/setup_test_db.php`。 +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`) +3. **建表 + 种子**:`php tests/setup_test_db.php` 为什么建表必须跑两套机制(这是框架最重要的特点之一,缺一不可): @@ -250,18 +370,28 @@ mysql -uroot -proot -e "DROP DATABASE IF EXISTS ulthon_admin_test; \ php tests/setup_test_db.php ``` -## 6. 如何验证回归保护(破坏 + 恢复法) +## 如何验证回归保护(破坏 + 恢复法) 写完一个不变量级测试后,要确认它真的能抓住回归。光跑绿不够,因为一个永远绿的测试可能是空壳。用"破坏 + 恢复法"五步验证: -1. **基线绿**:先跑测试,确认全绿。这是起点。 -2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许"。 -3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例。 -4. **回退代码**:`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状。 -5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题。 +1. **基线绿**:先跑测试,确认全绿。这是起点 +2. **临时破坏**:把生产代码里被锁的逻辑临时改错。例如把"余额不能扣成负数"的校验临时注释掉,或把"拒绝重复绑定"改成"允许" +3. **跑测试变红**:再跑测试,相关用例应该失败。如果这步还是绿,说明测试没锁住这个约束,白写了,回去补用例 +4. **回退代码**:`git checkout -- app/common/service/XxxService.php`(或对应文件),恢复生产代码原状 +5. **恢复绿**:再跑测试,确认全绿。证明破坏是测试抓到的,不是环境问题 这五步走完,这个测试的回归保护才算真的成立。之后任何人改这段代码,CI 会替你拦住。 +## 自查清单 + +写测试或动 service 前,对照自查: + +- [ ] 这段逻辑错了后果可承受吗?("设计哲学" + "决策树")后果可承受且只用一次,留在控制器 + `tools:http:call`,不要硬抽 service +- [ ] 如果错了不可承受,抽成 service 了吗?配 PHPUnit 集成测试了吗?(4 层模型"核心 service"层) +- [ ] 测试继承 `app\common\test\TestCase` 了吗?连的是 `_test` 库吗?(测试约束) +- [ ] fixture 用工厂方法了吗?没有手动 truncate 吧?(如何编写新测试) +- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束) + ## 相关文件 | 文件 | 作用 | @@ -275,4 +405,4 @@ php tests/setup_test_db.php | `app/common/test/TestCase.php` | 应用层入口:继承 TestCaseBase,各项目在此实现业务 fixture 工厂 | | `tests/Feature/IsolationSmokeTest.php` | 隔离机制冒烟测试(探针回滚 + 拒绝非测试库) | -交叉参考:测试类型决策与设计哲学见 `.agents/rules/ulthon-testing.md`(规则文件);控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md);Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。 +交叉参考:控制器联调验证见 [ulthon-tools-http-call](./ulthon-tools-http-call/SKILL.md);Base/App 架构分层见 [ulthon-base-app-architecture](./ulthon-base-app-architecture/SKILL.md)。 diff --git a/.agents/skills/ulthon-timer/SKILL.md b/.agents/skills/ulthon-timer/SKILL.md index 270bb09..ce7c834 100644 --- a/.agents/skills/ulthon-timer/SKILL.md +++ b/.agents/skills/ulthon-timer/SKILL.md @@ -220,21 +220,19 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do ## 多节点协调 -定时器支持多节点部署,以数据库(MySQL)作为协调中心。多个节点连接同一个数据库即可自动组成集群。 +定时器支持多节点部署,以数据库为协调中心。多个节点连接同一个数据库即自动组成集群,无需额外服务发现。 -### 节点注册 +### 设计决策 -每个节点启动后通过 `system_host_register` call 任务自动注册心跳(每 30 秒一次)。节点 ID 生成规则为 `{hostname}-{8位md5}`,持久化在 `runtime/node_id.lock` 文件中,重启后保持不变。 +- **以数据库为协调中心**:多节点连接同一个数据库即自动组成集群 +- **主节点自动选举**:第一个注册的节点自动成为主节点;管理员可在主机列表页面手动切换 +- **节点身份持久化**:节点 ID(`{hostname}-{8位md5}`)写入 `runtime/node_id.lock`,重启保持不变 +- **节点心跳**:通过 `system_host_register` call 任务每 30 秒自动注册一次 +- **配置通过 UI 管理**:`run_type`、`status`、手动触发等均由管理后台维护 -### 主节点选举 +### run_type 调度模式 -- 第一个注册的节点自动成为主节点 -- 管理员可在主机列表页面手动切换主节点 -- 相关 API:`HostService::setMasterNode()` / `HostService::getMasterNode()` - -### run_type 调度 - -`run_type` 仅对 `site` 类型任务生效,`call` 类型任务始终在所有节点执行。可选值: +`run_type` 仅对 `site` 类型任务生效;`call` 类型任务始终在所有节点执行。 | run_type | 行为 | 适用场景 | |----------|------|----------| @@ -243,13 +241,22 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do | `all` | 所有节点各自独立执行 | 节点本地清理等 | | `manual` | 仅当 DB 中 `manual_trigger=1` 时执行,执行后自动重置为 0。通过管理后台触发 | 运维手动触发 | -### 配置同步 +### 节点注册与主节点管理 -`TimerService::syncConfigToDatabase()` 在定时器启动时运行,将 PHP 配置文件中的任务同步到 `system_timer_config` 表。同步时不会覆盖数据库中已管理的字段(`run_type`、`status`),以管理后台的设置为准。 +- 节点启动后自动通过 `system_host_register` call 任务注册心跳(每 30 秒一次) +- 节点 ID 持久化在 `runtime/node_id.lock`(重启保持不变) +- 主节点切换 API:`HostService::setMasterNode()` / `HostService::getMasterNode()` -### 执行日志 +### 自动机制(开发者无需手动调用) -`TimerControllerBase::execute()` 自动包裹 `do()` 并调用 `logStart()` / `logEnd()` 记录执行日志,开发者无需手动调用。配置中写 `/do`,运行时 `TimerServiceBase` 自动重写为 `/execute` 以触发日志包裹。`host_id` 会自动注入到 site 任务的 URL 参数中,用于标识执行节点。 +- **配置同步**:定时器启动时 `TimerService::syncConfigToDatabase()` 将 PHP 配置同步到 `system_timer_config` 表,不覆盖 `run_type`、`status` 等管理字段(以 UI 设置为准) +- **执行日志**:`TimerControllerBase::execute()` 自动包裹 `do()` 并调用 `logStart()` / `logEnd()`。配置中写 `/do`,运行时 `TimerServiceBase` 自动重写为 `/execute` 以触发日志包裹。`host_id` 会自动注入到 site 任务的 URL 参数中 + +### 相关数据表 + +- `ul_system_timer_config`:任务协调配置(run_type、status、manual_trigger 等) +- `ul_system_timer_log`:执行日志记录 +- `ul_system_host`:节点注册信息(含 `is_master` 字段标识主节点) ### 管理后台 @@ -264,9 +271,3 @@ site 任务会按站点域名发起请求,默认从 `sysconfig('site','site_do ```bash php think admin:timer:log:clean --days=30 ``` - -### 相关数据表 - -- `ul_system_timer_config`:任务协调配置(run_type、status、manual_trigger 等) -- `ul_system_timer_log`:执行日志记录 -- `ul_system_host`:新增 `is_master` 字段,标识主节点 diff --git a/AGENTS.md b/AGENTS.md index 3ca5f3c..7f0c200 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,28 +32,18 @@ - 技术栈:ThinkPHP 8.x;PHP 8+;MySQL 8+;Layui 2.x;模板引擎:ThinkPHP 内置模板引擎 - 命名规范(必读):[ulthon-naming-convention.md](./.agents/rules/ulthon-naming-convention.md) - 代码风格(必读):遵循项目根目录 `.php-cs-fixer.php` -- 表结构设计规范(必读):[ulthon-database-design.md](./.agents/rules/ulthon-database-design.md) +- 表结构设计规范(必读):[ulthon-scheme-definition](./.agents/skills/ulthon-scheme-definition/SKILL.md)(含特殊字段约定、字段后缀、组件类型、关联表参数) - 项目文档主页(在线): ### 代码分层铁律(不可协商) -框架采用 Base/App 双层架构。根据当前任务的开发者身份,遵守对应约束: +框架采用 Base/App 双层架构:`extend/base/` 是框架内核(`*Base` 类),`app/` 是业务代码入口,两者目录路径一一对应。 -**所有身份通用**: -- `extend/base/` 是框架内核(`*Base`),`app/` 是业务代码入口,目录路径一一对应 +默认身份是**框架使用者**:严禁修改 `extend/base/` 下任何文件,所有开发只在 `app/` 进行;扩展框架内置能力时只改 `app/` 下对应入口类(必要时 `extends` 对应 `*Base`,保持方法签名一致)。 -**框架使用者(默认身份,除非明确说"修内核/改框架")**: -- 严禁修改 `extend/base/` 下任何文件 -- 所有开发只在 `app/` 目录进行;新写业务能力直接在 `app/` 下实现即可 -- 扩展内置能力时,只改 `app/` 下对应入口类(必要时 `extends` 对应 `*Base`),保持方法签名一致 +仅在任务明确涉及 `extend/base/` 时切换为**框架作者**身份,需额外遵守依赖倒置(Base 内部调用走 `app/` 入口类)、Base/App 同步提供、稳定性与通用性优先等约束。 -**框架作者(仅当任务涉及 `extend/base/` 时)**: -- 每新增一个 Base 类,必须同时在 `app/` 提供入口类(可为空类继承 Base) -- Base 内部禁止直接引用/调用 `extend/base/` 下的类(含内部互调),必须引用 `app/` 下的入口类 -- 例外:`app/common.php` 引用 `extend/base/helper.php` 是全局辅助函数的唯一特许 -- 核心层维护原则:稳定性优先(保证向下兼容);通用性优先(不引入具体业务逻辑) - -详细架构说明与操作流程:[ulthon-base-app-architecture](./.agents/skills/ulthon-base-app-architecture/SKILL.md) +完整规则、文件级覆盖机制、目录映射与常见误区:[ulthon-base-app-architecture](./.agents/skills/ulthon-base-app-architecture/SKILL.md) ### 其他规则 @@ -96,13 +86,10 @@ | 规则文件 | 作用域 | 说明 | |---------|--------|------| | [ulthon-naming-convention.md](./.agents/rules/ulthon-naming-convention.md) | 命名规范 | 目录命名与 PHP 文件命名约定 | -| [ulthon-database-design.md](./.agents/rules/ulthon-database-design.md) | 数据库设计 | 表结构设计:特殊字段、字段后缀、注释语法、类型大全 | | [ulthon-controller-url.md](./.agents/rules/ulthon-controller-url.md) | 控制器路由 | URL 与控制器/方法的映射规则 | | [ulthon-deploy-environment.md](./.agents/rules/ulthon-deploy-environment.md) | 部署与命令执行 | 部署栈模式与 Docker/宿主机命令判断 | | [ulthon-source-directory.md](./.agents/rules/ulthon-source-directory.md) | source/ 目录 | 子项目/多端代码的目录约定与安全要求 | | [ulthon-file-override-mechanism.md](./.agents/rules/ulthon-file-override-mechanism.md) | 文件加载机制 | app/ 覆盖 extend/base/ 的三类文件级覆盖机制与反例 | -| [ulthon-timer-multi-node.md](./.agents/rules/ulthon-timer-multi-node.md) | 定时任务相关 | 多节点协调规则与设计 | -| [ulthon-testing.md](./.agents/rules/ulthon-testing.md) | 测试规范 | 测试设计哲学、4层测试模型、该不该写测试、测试库安全、fixture 原则 | > 使用者业务规则索引见 `.agents/PROJECT.md` 的「规则索引」章节。 @@ -114,15 +101,15 @@ Skills 是"按场景调用的工作流说明",统一以 `.agents/skills/*/SKIL - 技能命名约定:`ulthon-` 前缀为框架内置技能,`project-` 前缀为项目业务技能。新增业务技能时使用 `project-` 前缀。 - Base/App 架构与扩展指南(含身份分章节):[ulthon-base-app-architecture](./.agents/skills/ulthon-base-app-architecture/SKILL.md) - Scheme + CURD 工作流:[ulthon-scheme-curd-workflow](./.agents/skills/ulthon-scheme-curd-workflow/SKILL.md) -- Scheme 定义指南:[ulthon-scheme-definition](./.agents/skills/ulthon-scheme-definition/SKILL.md) +- Scheme 定义指南(含表结构规范:特殊字段、字段后缀、组件类型、关联表参数):[ulthon-scheme-definition](./.agents/skills/ulthon-scheme-definition/SKILL.md) - 数据库调试命令(tools:db):[ulthon-db-tools-debug](./.agents/skills/ulthon-db-tools-debug/SKILL.md) - HTTP 调用工具(tools:http:call):[ulthon-tools-http-call](./.agents/skills/ulthon-tools-http-call/SKILL.md) -- 内置定时器与定时任务扩展:[ulthon-timer](./.agents/skills/ulthon-timer/SKILL.md) +- 内置定时器与定时任务扩展(含多节点协调、run_type 调度):[ulthon-timer](./.agents/skills/ulthon-timer/SKILL.md) - 页面 / 接口同体:[ulthon-page-api-dual-mode](./.agents/skills/ulthon-page-api-dual-mode/SKILL.md) - 登录认证(Session + Token):[ulthon-auth-session-token](./.agents/skills/ulthon-auth-session-token/SKILL.md) - 权限与角色管理(RBAC CLI):[ulthon-permission-cli](./.agents/skills/ulthon-permission-cli/SKILL.md) - 菜单管理(admin:menu:\* CLI):[ulthon-admin-menu-cli](./.agents/skills/ulthon-admin-menu-cli/SKILL.md) -- 测试工作流(运行/编写/决策/回归保护):[ulthon-testing](./.agents/skills/ulthon-testing/SKILL.md) +- 测试工作流(设计哲学/决策/约束/运行/编写/回归保护):[ulthon-testing](./.agents/skills/ulthon-testing/SKILL.md) - 框架更新工作流(admin:update 同步上游):[ulthon-update-workflow](./.agents/skills/ulthon-update-workflow/SKILL.md) - 零散规则管理(新增/维护 `.agents/rules/`):[ulthon-rules-manager](./.agents/skills/ulthon-rules-manager/SKILL.md)