CRUD 生成规范与产物结构
唯一事实源:
server/app/command/MakeCrudCommand.php与server/app/service/system/CodeGeneratorService.php的真实实现。分层硬规则以根目录AGENTS.md为权威表述,本文只在涉及生成产物时复述必要片段,完整分层细节见docs/ai/architecture.md。
1. 使用方式
make:crud 有两个入口,二者共用同一个 CodeGeneratorService::generate() 方法生成文件内容,是唯一的产物来源——命令行只是多了 --preview/--force 两个交互选项,管理后台页面则直接调用生成 + 写入。
1.1 命令行:php think make:crud
真实参数定义(来源:server/app/command/MakeCrudCommand.php configure()):
| 参数 | 说明 | 是否必填 | 默认值 |
|---|---|---|---|
table(参数) | 数据表名 | 是 | — |
-m, --module | 模块名(如 system、content、business) | 否 | business |
--model | 模型名(大驼峰,如 Article),不传则从表名自动推断 | 否 | 从表名推断 |
-c, --comment | 模块中文说明(如 文章),不传则取表注释 | 否 | 表注释 |
-p, --preview | 仅预览文件列表,不实际写入 | 否 | 不启用 |
-f, --force | 覆盖已有文件 | 否 | 不启用 |
用法示例:
php think make:crud articles --module=content --model=Article
php think make:crud articles --module=content --model=Article --preview
php think make:crud articles --module=content --model=Article --force--module 同时决定后端五层子目录(app/service/{module}/、app/repository/{module}/ 等)与前端 admin/src/views/{module}/ 分组。按 AGENTS.md 约定,{module} 应为业务模块名小写,新增模块直接传模块名本身(如 brand)。仓库中现存的 article 模块实际是「后端 app/service/article/ + 前端 admin/src/views/content/article/」这种后端用 article、前端归到 content 分组下的历史布局,与上面 --module=content 生成前后端一致目录的方式不同;上述命令示例仅演示参数语法,并不代表 article 模块当初的实际生成参数,新建模块请勿照抄 --module=content 这个值。
执行流程:命令先调用 CodeGeneratorService::getTableColumns() 校验表是否存在(内部对表名做白名单校验,防止 SQL 注入),推断模型名/注释后组装 $config 调用 generate() 得到 11 类产物的文件数组;--preview 只打印文件清单不写盘;正常模式调用 writeFiles()(已存在文件跳过,路由文件走追加逻辑);--force 模式下命令自身实现覆盖写入(路由文件仍走追加逻辑,不会被强制覆盖)。
1.2 管理后台「代码生成」页面
入口:管理后台「系统管理 → 代码生成」页面(前端源码 admin/src/views/system/generator/index.vue,接口封装 admin/src/api/generator.ts),对应后端 server/app/adminapi/controller/v1/system/CodeGeneratorController.php,路由挂在 server/app/adminapi/route/system.php 的 generator 分组下:
GET /adminapi/system/generator/tables:列出数据表(CodeGeneratorService::getTables())GET /adminapi/system/generator/columns:列出表字段(CodeGeneratorService::getTableColumns())POST /adminapi/system/generator/preview:调用generate()预览文件内容,不写盘POST /adminapi/system/generator/generate:调用generate()+writeFiles()直接写入(页面入口没有--force等价选项,已存在文件同样会被跳过)
命令行入口与后台页面入口都不允许绕过 CodeGeneratorService::generate() 手写产物结构——它是模板骨架的唯一事实来源。
2. 产物清单表
generate() 主流程(CodeGeneratorService.php:87)依次生成 11 类产物,写入 $files 数组后返回:
| # | 产物 | 输出路径模式(来源方法) | 内容要点 | 是否含 status 相关方法 |
|---|---|---|---|---|
| 1 | Model | app/model/{module}/{Model}.php(generateModel,:250) | $fillable、$type 类型映射;若表含 status 字段,自动加 protected $append = ['status_text'] 与 getStatusTextAttr() 访问器 | 是(status_text 访问器) |
| 2 | Repository | app/repository/{module}/{Model}Repository.php(generateRepository,:330) | getModel() + getPageList() 分页查询方法 | 否 |
| 3 | Service | app/service/{module}/{Model}Service.php(generateService,:376) | getList/getDetail/create/update/delete;表含 status 字段时追加 updateStatus(int $id, int $status) | 是(updateStatus) |
| 4 | Controller | app/adminapi/controller/v1/{module}/{Model}Controller.php(generateController,:524) | index/show/store/update/delete/batchDelete,每个方法带 #[Permission] 与 OpenAPI 注解;表含 status 字段时追加 status() 接口 | 是(status() 接口方法) |
| 5 | Validate | app/adminapi/validate/v1/{module}/{Model}Validate.php(generateValidate,:766) | $rule/$message/$scene(create、update 两个场景) | 否(status 字段固定生成 integer|in:0,1 规则,无独立方法) |
| 6 | Route | app/adminapi/route/{module}.php(追加/新建)(generateRoute,:851) | Route::group 内 index/batchDelete/show/store/update/delete;表含 status 字段时插入 PUT :id/status;新建路由文件时,生成器通过 appendRoute()(CodeGeneratorService.php:210 起,中间件挂载语句在约 :221 行)自动为 Route::group 包裹 ->middleware(['admin_auth', 'admin_permission', 'admin_log']),与 AGENTS.md 的路由约定一致,无需手工补充 | 是(PUT :id/status 路由) |
| 7 | 前端 API | admin/src/api/{kebab-model}.ts(generateFrontendApi,:875) | getList/getDetail/create/update/delete/batchDelete;表含 status 字段时追加 updateStatus();文件顶部内嵌 TS 接口定义(见第 10 项) | 是(updateStatus()) |
| 8 | 前端列表页 | admin/src/views/{module}/{kebab-model}/index.vue(generateFrontendPage,:947) | 搜索表单、表格、分页、新增/编辑弹窗引用;表含 status 字段时表格渲染 el-switch 并绑定 handleStatusChange | 是(el-switch + 状态变更处理函数) |
| 9 | 前端表单组件 | admin/src/views/{module}/{kebab-model}/components/{Model}Form.vue(generateFrontendForm,:1307) | el-dialog 表单,字段按第 3 章类型映射规则渲染对应控件与校验规则 | 否(status 字段渲染为 el-radio-group,属字段级渲染,非独立方法) |
| 10 | TS 接口 | 内嵌于产物 7(admin/src/api/{kebab-model}.ts)文件顶部,无独立文件路径(generateTypeScriptInterfaces,:1524) | {Model}CreateReq/{Model}UpdateReq/{Model}Info 三个 interface | 否 |
| 11 | Migration | database/migrations/{时间戳}_create_{table}_table.php(generateMigration,:1584) | 按字段类型 addColumn,MUL/UNI 索引键自动生成 addIndex | 否 |
补充说明:
{module}为--module传入的模块名;{Model}为大驼峰模型名;{kebab-model}为模型名转 kebab-case(toKebab())。status字段的判定标准是列名精确等于status(hasColumn($columns, 'status')),产物 1、3、4、6、7、8 均由这一个布尔值驱动是否生成状态相关代码。- 产物 10(TS 接口)不是独立文件,而是
generateFrontendApi()内部调用generateTypeScriptInterfaces()拼接进产物 7 的文件头部;之所以在产物清单中单列一行,是因为它是独立的代码生成职责(字段类型到 TS 类型的映射),与 API 方法生成逻辑分离。
3. 字段类型映射
生成器对每个字段做元信息推断(getTableColumns() 中调用的 guessFormType/isSearchable/shouldShowInList/shouldShowInForm),驱动表单控件、校验规则、列表展示三处产物。
3.1 表单控件(guessFormType(),决定 form_type)
| 字段名/类型特征 | form_type | 前端渲染控件(generateFrontendForm) |
|---|---|---|
字段名为 status/is_system/is_default/is_show/is_visible | switch | el-radio-group(启用/禁用两个选项) |
字段名为 sort/order/level/weight | number | el-input-number |
字段名包含 price/amount/money | number | el-input-number |
字段名包含 image/avatar/logo/cover | image | el-input + 上传按钮占位 |
字段名包含 content 且类型含 text | textarea | el-input type="textarea" |
类型含 text(未匹配以上规则) | textarea | el-input type="textarea" |
类型为 decimal/float/double | number | el-input-number |
类型为 datetime/timestamp/date | datepicker | el-date-picker(datetime 类型,value-format="YYYY-MM-DD HH:mm:ss") |
字段名包含 type 且类型为 tinyint | select | el-select(生成占位选项,需人工补充) |
| 未匹配以上任何规则 | input | el-input |
3.2 校验规则(generateValidate())
- 非空约束:字段
nullable === false且不是status/sort时追加require规则 - 数值类型:
int/bigint/tinyint/smallint→integer;decimal/float/double→float - 字符串长度:
raw_type含varchar(N)时追加max:N status字段固定规则为integer|in:0,1,覆盖上述通用推断sort字段固定规则为integer|>=:0,覆盖上述通用推断- 有规则的字段同时进入
create/update两个校验场景($scene)
3.3 列表/表单可见性
isSearchable():字段名在name/title/username/email/phone/mobile/code/keyword/label/nickname白名单内,或字段名为status,则可用于列表关键词/状态搜索shouldShowInList():除deleted_at/updated_at/password/remember_token/content外的字段都进入列表页表格列shouldShowInForm():除id/created_at/updated_at/deleted_at/created_by/updated_by/login_count/last_login_ip/last_login_time外的字段都进入表单
4. 模板骨架 + AI 增量原则
基础 CRUD(增删改查、分页、搜索、状态切换)完全由 CodeGeneratorService 的模板方法生成,是确定性的字符串拼接,不消耗任何 AI token,且天然 100% 符合 Controller → Service → Repository → Model 分层规则(因为分层结构本身就写死在模板里)。
AI 在这一基础上只做增量:
- 审核流转、状态机、跨表编排等业务逻辑,是骨架模板不覆盖的部分,需要 AI 在骨架文件基础上新增方法
- 禁止为了实现业务需求而绕过生成器手写一整套基础 CRUD 文件——应先用
make:crud生成骨架,再对骨架做增量修改 - AI 修改生成器产物文件时,必须保持骨架已有方法的签名不变(如
getList(array $params)、create(array $data)、updateStatus(int $id, int $status)等),新增方法应追加在已有方法之后,不得删除或修改骨架方法的参数、返回类型 - AI 新增的方法必须遵守与骨架相同的分层规则:Service 新增方法只调用 Repository,不得直接使用
Db::table()或 Model 静态方法;副作用通过$this->trigger('event.name', $data)交给 Listener,不写内联
权限标识格式:生成器骨架的权限标识为三段式 {module}.{kebab}.动作,手写增量沿用所在模块 Controller 已有的格式,保持同一模块内一致(例如 article 模块历史上采用的是两段式 article.动作,增量新增权限也应延续两段式,不必强行改造成三段式)。
5. AI 增量的正确姿势示例:给 article 增加审核功能
场景:article 模块已用 make:crud 生成基础骨架,现需要新增「审核」功能(编辑部审核通过/驳回文章)。以下只列改动文件与各文件的职责分配,不写具体实现代码——避免示例代码喧宾夺主、掩盖"改哪里"这个核心问题。
| 文件 | 改动职责 |
|---|---|
app/service/article/ArticleService.php | 新增 audit(int $id, int $status, string $remark = '') 方法:校验记录存在、写入审核状态与审核意见;若涉及多表更新则用 Db::startTrans()/commit()/rollback() 包裹;成功后通过 $this->trigger('article.audit.success', [...]) 触发事件,不在方法内联通知/日志逻辑 |
app/repository/article/ArticleRepository.php | 按需新增审核相关查询方法(如按审核状态筛选的列表查询),供 Service 的 audit() 与 getList() 复用 |
app/adminapi/controller/v1/article/ArticleController.php | 新增 audit() 接口方法,标注 #[Permission('article.audit')](沿用 article 模块现有的两段式权限标识,与 article.list/article.update 等保持一致)与对应 OpenAPI 注解,调用 ArticleService::audit() |
app/adminapi/validate/v1/article/ArticleValidate.php | 新增 audit 场景($scene['audit']),校验审核状态、审核意见等字段 |
app/adminapi/route/article.php | 追加一行路由,如 Route::put(':id/audit', 'v1.article.ArticleController/audit') |
app/listener/article/ArticleAuditedListener.php(新建) | 处理审核通过/驳回后的副作用(如通知作者、清理相关缓存),并在 app/event.php 中注册到 'article.audit.success' 事件——判断标准:失败不影响主流程的操作放这里 |
admin/src/api/article.ts | 新增 audit() 请求方法,对应新路由 |
| 前端页面(骨架之外,需人工/AI 手写) | 列表页或详情页增加审核按钮与审核弹窗交互,这部分不由生成器覆盖,需在生成的 index.vue/表单组件基础上手动扩展 |
以上改动全部是在骨架文件上追加,未修改任何骨架已有方法的签名,符合第 4 章的增量原则。