docs(agents): 落实按主题单一文档原则,合并规则到对应技能

- AGENTS.md 代码分层铁律精简为入口摘要,链接指向技能详情
- 合并 ulthon-timer-multi-node 规则到 ulthon-timer 技能(多节点协调章节)
- 合并 ulthon-database-design 规则到 ulthon-scheme-definition 技能(含字段约定、组件类型)
- 合并 ulthon-testing 规则到 ulthon-testing 技能(含设计哲学、决策树、测试约束)
- rules-manager 边界原则从规则/技能二分改为按主题单一文档
- AGENTS.md 通用基础规范中表结构规范链接改向技能
- 工作流索引中三个技能描述扩充(明确承载原规则内容)
This commit is contained in:
augushong
2026-07-19 09:07:42 +08:00
parent 1f91abe9d2
commit e392db007a
8 changed files with 340 additions and 430 deletions

View File

@@ -1,76 +0,0 @@
# 表结构设计规范
## 特殊字段
| 字段名 | 用途 | 说明 |
|--------|------|------|
| `status` | 默认开关字段 | - |
| `create_time` | 创建时间 | 尽量 NOT NULL默认值 0TP 会自动填充) |
| `update_time` | 更新时间 | 尽量 NOT NULL默认值 0TP 会自动填充) |
| `delete_time` | 删除时间 | 尽量 NOT NULL默认值 0CURD 默认开启软删除,删除标志为 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:启用"
- 分隔符默认为竖线 `|`

View File

@@ -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 + 写测试
│ 余额扣减、状态流转、绑定约束、支付回调、库存扣减...
├─ 后果可承受,但逻辑被多处复用 → 抽 serviceDRY测试看情况
│ 消息发送、文件上传、数据导出、编号生成...
└─ 后果可承受,且只用一次 → 写控制器里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**。
事务 APIthink-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 吧?(第六节)
- [ ] 致命约束的不变量用测试锁死了吗?(不只是覆盖当前行为,要锁死"不能怎样"的约束)

View File

@@ -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)