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