Files
EasyGoAdmin_GoFrame_EleVue/document/wiki/04_模块开发规范与CRUD基座.txt
T
2026-10-20 11:37:25 +08:00

267 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
================================================================================
EasyGoAdmin 后端开发者文档 · 04 模块开发规范与 CRUD 基座
================================================================================
代码位置:api/v1/{模块}.go、internal/controller/{模块}.go、
internal/logic/{模块}.go、internal/service/{模块}.go、
internal/logic/module_spec.go(ModuleSpec 基座)
--------------------------------------------------------------------------------
1. 新增模块四件套(平铺放置,同包 init 自动注册)
--------------------------------------------------------------------------------
api/v1/{模块}.go 全部 Req/Res 结构体 + gmeta 注解(单包 v1)
internal/controller/{模块}.go 控制器 CXxx,方法签名统一:
func (c *CXxx) Page(ctx, req *v1.XxxReq) (*v1.XxxRes, error)
internal/logic/{模块}.go 业务实现 sXxx + var xxxSpec = ModuleSpec[...]{...}
+ init(){ service.RegisterXxx(&sXxx{}) }
internal/service/{模块}.go 手维护接口 IXxx + RegisterXxx + 取用函数 Xxx()
路由:router.go registerControllers 追加 Bind(s, new(controller.CXxx))
(生成器自动写入;手动新增则自行追加在最后一个 Bind 行后)
dao 四件(dao/{m}.go、dao/internal/{m}.go、entity、do)由 gf gen dao 或
生成器产出。
文件命名:表名去 goframe_ 前缀的 snake_case(config_item.go / file_template.go);
前端文件仍用驼峰(fileTemplate.ts)。
--------------------------------------------------------------------------------
2. 标准七件套 + 扩展
--------------------------------------------------------------------------------
方法 HTTP 路由模板 说明
Page GET /api/{prefix}/page 分页(pageNo/pageSize + 筛选)
List GET /api/{prefix}/list 全量/树形(下拉、树选择)
Detail GET /api/{prefix}/detail/:id 详情
Add POST /api/{prefix}/add 新增,返回 {id}
Update PUT /api/{prefix}/update 修改(body 含 id)
Delete DELETE /api/{prefix}/delete/:id 软删(del_flag=1)
BatchDelete DELETE /api/{prefix}/batchDelete body {ids:[]}
Import POST /api/{prefix}/import multipart file(xls/xlsx)
Export GET /api/{prefix}/export 导出文件
Status PUT /api/{prefix}/status 状态切换(有 status 列的模块)
命名约定:结构体 {Mod}{Action}Req / {Mod}{Action}Res;controller 方法名 =
logic 方法名 = 上表方法名。日志标题 "{模块中文名}-{动作}",动作映射:
page=查询分页记录 list=查询列表(树形=查询树形列表) detail=查询详情
add=新增记录 update=修改记录 delete=删除记录 batchDelete=批量删除记录
import=导入数据 export=导出数据 status=设置状态 reset=重置。
--------------------------------------------------------------------------------
3. gmeta 注解完整示例
--------------------------------------------------------------------------------
type UserPageReq struct {
gmeta.Meta `method:"get" path:"/api/user/page" summary:"用户分页"
permission:"sys:user:page" log:"用户管理-查询分页记录" logType:"query"`
common.PageReq
Keyword string `json:"keyword" dc:"关键词"`
Status int `json:"status" dc:"状态"`
}
* permission 空 = 登录即可(如 /api/index/*、/api/job/daemonStatus、
字典别名 /api/dictionary/* 等)
* public:"true" 仅用于免登录路由,且必须同时出现在 middleware/public.go
* 入参 json 一律驼峰;路由参数 :id 由 gmeta path 声明、req 结构同名字段承接
--------------------------------------------------------------------------------
4. ModuleSpec 声明式 CRUD 基座(module_spec.go)
--------------------------------------------------------------------------------
泛型 ModuleSpec[Item, LItem, DRes]:
Item 分页行类型(Page/AfterPageList)
LItem 列表行类型(List/树形,可与 Item 相同)
DRes 详情类型
设计纪律:
1) 基座禁止“按模块分支”——差异只进声明字段与钩子;写不进钩子 → 手写(合法逃生舱)
2) 钩子用具体函数类型;req any 经 mustAs[T] 断言(失败=编程错误,panic)
3) 钩子内可改元素/data,禁止 append、禁止 HTTP/文件 IO(文件迁移统一在 saveTransform)
4) applyFilters 固定顺序:LikeFields → EqFields → KeywordFields → Filter → DataScope;
租户/软删隔离由 dao.Ctx 先于本链注入
5) ToData:Add=全量必填;Update=全量表单语义(前端 Detail 回显 → 全量 PUT)
6) 钩子错误记 stage 日志后原样上抛(保留 gerror 语义码)
4.1 声明字段
Model func(ctx) *gdb.Model 数据源(通常 dao.Xxx.Ctx)
LikeFields []string 逐列 LIKE(值取 req 同名驼峰字段,空跳过)
EqFields []string 等值(零值跳过)
KeywordFields []string req.keyword 多列 OR(能力保留在基座,
模块默认不声明,需要时再用)
Filter func(ctx, m, req) *gdb.Model 复杂条件逃生舱
OrderBy 默认排序(如 "id DESC");ListOrderBy 列表排序;
OrderableFields 前端排序白名单(orderField/orderType 仅白名单内生效)
IsTree/ParentOf/ChildSetter 树形(List 末尾建树;缺 ParentOf fail-fast)
SerializeMaps map[字段]字典编码 {field}Text 填充
FileFields / MultiFileFields / ContentFields /
FileSaveDir 文件/富文本声明(写侧转正、读侧补 URL/还原占位符,见 05 章)
FieldLabels 唯一校验提示覆盖
UniqueFields []UniqueRule{Field, Group} 唯一性预检(Group 支持分组唯一,
如 Group:"tenant_id")
DataScope 行级数据权限(共享工厂 ApplyDataScope(deptColumn),
logic/data_scope.go;nil 跳过;仅 Page/List/导出生效;
模块一行声明即接线,机制见 03 章第 6 节,安全边界见 17 章)
钩子 BeforeAdd/AfterAdd/BeforeUpdate/AfterUpdate/BeforeDelete/
AfterDelete/BeforeBatchDelete/AfterBatchDelete/
AfterPageList(禁 append)/AfterDetail
ToData req → map[string]any(新增/修改共用转换)
4.2 基座流水线
Page applyFilters → Count → applyOrder.Page → structRows → readTransform
→ AfterPageList
List applyFilters → 排序 → req.limit 上限 → structRows → readTransform
→ IsTree 时 buildTree(叶子 Children 预置 [] 而非 null)
Detail Model.Where(id).One → Struct → readTransform → AfterDetail
Add ToData → BeforeAdd → saveTransform(文件/富文本转正)→ checkUniqueRules
→ Insert → LastInsertId → AfterAdd → 返回 id
Update ToData → BeforeUpdate → saveTransform → checkUniqueRules(排除自身)
→ Update(仅更新 data 中出现的键)→ AfterUpdate
Delete BeforeDelete → Delete(钩子改写软删)→ AfterDelete
BatchDelete BeforeBatchDelete → 逐条 BeforeDelete+软删 → AfterBatchDelete
MergeOld(data, id):把 data 中缺失/零值键用库中旧值补齐(全量覆盖辅助)
4.3 标准模块声明示例(节选自 job 模块)
var jobSpec = ModuleSpec[v1.JobItem, v1.JobItem, v1.JobDetailRes]{
Model: dao.Job.Ctx,
LikeFields: []string{"job_name", "job_alias"},
EqFields: []string{"status"},
OrderBy: "id DESC",
SerializeMaps: map[string]string{"status": "job_status", ...},
ToData: jobToData,
BeforeAdd: jobDefaultStatus,
BeforeUpdate: jobExistGuard,
AfterUpdate: jobRemount,
BeforeDelete: jobDeleteGuard,
}
业务方法三行委托:
func (s *sJob) Page(ctx, req) (*v1.JobPageRes, error) {
list, total, err := jobSpec.Page(ctx, req, req.GetPageNo(), req.GetPageSize())
...
return v1.NewPageData(..., derefSlice(list)), nil
}
4.4 唯一校验语义(重要)
UniqueFields 为“先查后插”的 UX 快速报错,存在 TOCTOU 竞态;并发唯一性以
数据库 uk_* 唯一索引为准(调用点必须保证对应索引存在)。
Group 语义:校验范围必须等于唯一索引范围——索引含 tenant_id 时必须
Group:"tenant_id",否则范围取窄漏报 1062、取宽跨租户误报。
手写检查(如 user 的 username/mobile)按“被改行现租户”校验,理由相同。
4.5 模块文件结构图(以 config_item 为例)
api/v1/config_item.go
├── type ConfigItemPageReq { gmeta.Meta(get /api/config/item/page …), PageReq, 筛选字段 }
├── type ConfigItemAddReq / UpdateReq / DeleteReq / BatchDeleteReq / …
└── type ConfigItemItem / ListItem / DetailRes / …Res
internal/controller/config_item.go
└── type CConfigItem struct{};方法转发 service.ConfigItem().Page(ctx, req)…
internal/service/config_item.go(手维护)
├── interface IConfigItem { Page/List/Detail/Add/Update/Delete/BatchDelete… }
├── var localConfigItem IConfigItem
├── func ConfigItem() IConfigItem ← controller 取用
└── func RegisterConfigItem(i IConfigItem) ← logic init 注册
internal/logic/config_item.go
├── type sConfigItem struct{}
├── var configItemSpec = ModuleSpec[…]{…} ← 差异全部进声明
├── init(){ service.RegisterConfigItem(&sConfigItem{}) }
└── 方法:三行委托基座 + 手写扩展接口(逃生舱)
internal/dao/config_item.go + dao/internal + entity + do ← 生成器/gf gen dao 产出
4.6 CRUD 流水线流程图(基座执行顺序,横切逻辑落点)
Page/List:
Model(ctx) ─▶ LikeFields ─▶ EqFields ─▶ KeywordFields ─▶ Filter ─▶ DataScope
└─ 前置:dao.Ctx 已注入 del_flag=0 + 租户 IN(0,自身)
─▶ Count ─▶ applyOrder(白名单) ─▶ .Page(pageNo,pageSize) ─▶ structRows[T]
─▶ readTransform:FileFields 补URL → MultiFileFields 补URL
→ ContentFields 展开[IMG_URL] → FillTextFields({field}Text)
─▶ AfterPageList(禁 append)/IsTree 时 List 走 buildTree
Add:
ToData(req) ─▶ BeforeAdd ─▶ saveTransform(temp→正式, 富文本占位符化)
─▶ checkUniqueRules(UniqueFields 预检,Group 支持分组唯一)
─▶ Insert(hooks: 盖章 create_*/del_flag/tenant_id,主键自增)
─▶ LastInsertId ─▶ AfterAdd ─▶ 返回 {id}
Update:
ToData(req) ─▶ BeforeUpdate(可用 MergeOld 补零值)─▶ saveTransform
─▶ checkUniqueRules(排除自身) ─▶ Update(仅更新 data 键,hooks 盖章)
─▶ AfterUpdate
Delete / BatchDelete:
BeforeDelete(守卫: 存在性/子级/引用) ─▶ Delete(deleteHook 改写 del_flag=1)
─▶ AfterDelete;批量为逐条循环 + Before/AfterBatchDelete
--------------------------------------------------------------------------------
5. 字典驱动的 {field}Text
--------------------------------------------------------------------------------
模块头部声明 SerializeMaps = map[字段]字典编码,编码规范
{模块snake}_{字段snake}(如 user_status、job_execute_policy)。
填充路径(二选一,机制同源):
* 基座 FillTextFields:结构体存在 {Field}Text string 字段即自动填充
(反射计划按类型缓存)
* map 场景 SerializeMapOnRead(ctx, row, maps):原地补 row[field+"Text"]
取值 DictGetText(ctx, code, value):查 dict→dict_item 的 name,
进程内缓存 300s(dictTextCache);未命中返回原值。
文案唯一真相在字典管理页(dict_item.name),修改最迟 5 分钟生效
(或调 GET /api/dict/refreshCache 刷新)。
例外:operation_log.type 存注解常量值,用静态表 LogTypeText
(consts.LogTypeNames),不走字典。
--------------------------------------------------------------------------------
6. 筛选对齐三件套(前端 ↔ 后端)
--------------------------------------------------------------------------------
前端 views/{域}/{模块}/querySchemas.ts 每个筛选输入后端对应:
字符串 Input(LikeFields)→ 逐列 LIKE,req 同名驼峰字段取值
Select/InputNumber(EqFields)→ 等值,零值跳过
关键词框(KeywordFields,可选)→ req.keyword 多列 OR
导出统一走 xxxSpec.applyFilters 与列表同套筛选(保证导出=所见)。
声明式之外的复杂条件写 Filter 钩子,不要重复 Like/Eq 已覆盖的条件。
--------------------------------------------------------------------------------
7. service 接口文件规范(手维护)
--------------------------------------------------------------------------------
internal/service/{模块}.go 结构:
type IXCity interface {
Page(ctx context.Context, req *v1.CityPageReq) (res *v1.CityPageRes, err error)
...
}
var localCity ICity
func City() ICity {
if localCity == nil { panic("implement not found ... forgot register?") }
return localCity
}
func RegisterCity(ic ICity) { localCity = ic }
logic 的 init() 调用 RegisterXxx;controller 通过 service.Xxx() 取用。
logic 方法签名变更时同步手工更新接口文件;禁止整体运行 gf gen service。
--------------------------------------------------------------------------------
8. 导入导出实现模式(user / level / example 启用)
--------------------------------------------------------------------------------
Import:POST multipart file(*ghttp.UploadFile + p:"file" 标签)
excel.ReadSheet 解析(首行表头,全单元格字符串,空行跳过)→
逐行映射/校验(唯一、上限、字典值)→ 批量 Insert(默认密码/角色/城市编码
等按模块规则处理)→ 返回导入统计。
Export:GET,与列表同筛选 → ChunkQuery 分批(默认 500/页,防 OOM)→
excel.WriteSheet 写临时文件 → 返回文件 URL(/upload/temp/...)。
日志:logType import/export;权限 sys:{module}:import/export。
--------------------------------------------------------------------------------
9. 跨模块共享的包级函数(logic 单包的红利)
--------------------------------------------------------------------------------
因全部 logic 同包,跨模块复用无需 import:
ResolveCityNames(ctx, codes, sep) / ResolveCityCodes(ctx, cityInfo)(city.go,
user 的 Add/Update/Import/Detail 已接入)
LoadIdentity / LoadTenant(auth.go,供中间件接线与登录路径复用)
ApplyDataScope(deptColumn)(data_scope.go,行级数据权限工厂,
spec 声明 DataScope 即接线,见 03 章第 6 节)
DictGetText / SerializeMapOnRead / FillTextFields(base.go / module_spec.go)
新增包级共享函数时注意 base.go 的“导出函数上限 15 个”纪律(超出拆子包)。
--------------------------------------------------------------------------------
10. 开发一个新模块的推荐步骤
--------------------------------------------------------------------------------
1) 设计表结构(统一审计列 + del_flag + uk_* 唯一索引 + 列注释枚举
"状态:1-正常 2-禁用"),执行 DDL
2) 用生成器生成全套文件(见 07 章),或手动按四件套模板编写
3) 调整 logic:声明 xxxSpec(筛选/树形/字典/文件字段/唯一规则)、补充扩展接口;
表含部门归属列且需行级可见性时补 DataScope: ApplyDataScope("dept_id")
4) 维护 service 接口文件与 router 注册行
5) go build ./... + gofmt + 重启;在菜单管理中为角色勾选新权限节点