267 lines
16 KiB
Plaintext
267 lines
16 KiB
Plaintext
================================================================================
|
||
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 + 重启;在菜单管理中为角色勾选新权限节点
|