Skip to content

CRUD 生成规范与产物结构

唯一事实源:server/app/command/MakeCrudCommand.phpserver/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覆盖已有文件不启用

用法示例:

bash
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.phpgenerator 分组下:

  • 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 相关方法
1Modelapp/model/{module}/{Model}.phpgenerateModel:250$fillable$type 类型映射;若表含 status 字段,自动加 protected $append = ['status_text']getStatusTextAttr() 访问器是(status_text 访问器)
2Repositoryapp/repository/{module}/{Model}Repository.phpgenerateRepository:330getModel() + getPageList() 分页查询方法
3Serviceapp/service/{module}/{Model}Service.phpgenerateService:376getList/getDetail/create/update/delete;表含 status 字段时追加 updateStatus(int $id, int $status)是(updateStatus
4Controllerapp/adminapi/controller/v1/{module}/{Model}Controller.phpgenerateController:524index/show/store/update/delete/batchDelete,每个方法带 #[Permission] 与 OpenAPI 注解;表含 status 字段时追加 status() 接口是(status() 接口方法)
5Validateapp/adminapi/validate/v1/{module}/{Model}Validate.phpgenerateValidate:766$rule/$message/$scenecreateupdate 两个场景)否(status 字段固定生成 integer|in:0,1 规则,无独立方法)
6Routeapp/adminapi/route/{module}.php(追加/新建)(generateRoute:851Route::groupindex/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前端 APIadmin/src/api/{kebab-model}.tsgenerateFrontendApi:875getList/getDetail/create/update/delete/batchDelete;表含 status 字段时追加 updateStatus();文件顶部内嵌 TS 接口定义(见第 10 项)是(updateStatus()
8前端列表页admin/src/views/{module}/{kebab-model}/index.vuegenerateFrontendPage:947搜索表单、表格、分页、新增/编辑弹窗引用;表含 status 字段时表格渲染 el-switch 并绑定 handleStatusChange是(el-switch + 状态变更处理函数)
9前端表单组件admin/src/views/{module}/{kebab-model}/components/{Model}Form.vuegenerateFrontendForm:1307el-dialog 表单,字段按第 3 章类型映射规则渲染对应控件与校验规则否(status 字段渲染为 el-radio-group,属字段级渲染,非独立方法)
10TS 接口内嵌于产物 7(admin/src/api/{kebab-model}.ts)文件顶部,无独立文件路径(generateTypeScriptInterfaces:1524{Model}CreateReq/{Model}UpdateReq/{Model}Info 三个 interface
11Migrationdatabase/migrations/{时间戳}_create_{table}_table.phpgenerateMigration:1584按字段类型 addColumnMUL/UNI 索引键自动生成 addIndex

补充说明:

  • {module}--module 传入的模块名;{Model} 为大驼峰模型名;{kebab-model} 为模型名转 kebab-case(toKebab())。
  • status 字段的判定标准是列名精确等于 statushasColumn($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_visibleswitchel-radio-group(启用/禁用两个选项)
字段名为 sort/order/level/weightnumberel-input-number
字段名包含 price/amount/moneynumberel-input-number
字段名包含 image/avatar/logo/coverimageel-input + 上传按钮占位
字段名包含 content 且类型含 texttextareael-input type="textarea"
类型含 text(未匹配以上规则)textareael-input type="textarea"
类型为 decimal/float/doublenumberel-input-number
类型为 datetime/timestamp/datedatepickerel-date-pickerdatetime 类型,value-format="YYYY-MM-DD HH:mm:ss"
字段名包含 type 且类型为 tinyintselectel-select(生成占位选项,需人工补充)
未匹配以上任何规则inputel-input

3.2 校验规则(generateValidate()

  • 非空约束:字段 nullable === false 且不是 status/sort 时追加 require 规则
  • 数值类型:int/bigint/tinyint/smallintintegerdecimal/float/doublefloat
  • 字符串长度:raw_typevarchar(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 章的增量原则。

基于 MIT 许可发布