# 3.5 业务层开发(service) :::tip 概述 service 是业务逻辑的唯一去处。标准五方法(Page/List/Detail/Add/Update/Delete/BatchDelete)通过声明 `ModuleSpec` 委托基座,差异全部进声明字段与钩子;写不进钩子的复杂逻辑手写方法,合法逃生舱。 ::: ## 标准结构(三段式) ```go // internal/service/example.go package service // sExample 案例演示业务实现(空结构体仅作方法命名空间,不持有状态) type sExample struct{} func init() { Example = &sExample{} } // Example 案例演示业务实例(handler 层引用) var Example *sExample ``` 跨模块调用同包直调,无需 import:`User.Page(...)`、`repository.NewDeptRepo()`。 ## ModuleSpec 声明 ```go // internal/service/example.go var exampleSpec = ModuleSpec[dto.ExampleItem, dto.ExampleItem, dto.ExampleDetailRes]{ Model: func(ctx context.Context) *gorm.DB { return repository.NewExampleRepo().Ctx(ctx) }, LikeFields: []string{"name"}, // name 模糊,空串跳过 EqFields: []string{"type", "status"}, // 等值过滤,零值跳过 OrderBy: "sort, id", // 默认排序 SerializeMaps: exampleSerializeMaps, // status → 字典 example_status 回填 statusText FileFields: []string{"avatar"}, // 单图:写侧 temp 转正,读侧补域名 FileSaveDir: "example", // 转正到 upload/example ToData: exampleToData, // req → 萃库 map } ``` 泛型三参数:行结构 `Item`、列表元素 `LItem`(树形时可不同于 Item)、详情结构 `DRes`。 ## 查询能力声明(固定顺序生效) | 声明 | 生效顺序 | 行为 | |---|---|---| | Model | 1 | 数据源工厂,必填,通常就是 `NewXxxRepo().Ctx(ctx)` | | LikeFields | 2 | 逐列按请求同名字段拼 LIKE,空串跳过 | | EqFields | 3 | 等值过滤,零值跳过(需精确匹配零值的条件写 Filter) | | KeywordFields | 4 | 请求 keyword 字段对多列 LIKE,括号包成单个 AND | | Filter | 5 | 复杂条件追加(禁止重复声明前四项已覆盖的条件) | | DataScope | 6 | 数据权限:`DataScope: ApplyDataScope("dept_id")` | 排序链:请求 `orderField` 命中 `OrderableFields` 白名单时前端排序生效,否则用 `OrderBy`。白名单是排序列注入的唯一防线。 ## 输出与写入声明 | 声明 | 行为 | |---|---| | SerializeMaps | 键为枚举字段(snake),值为字典编码;读侧自动回填 `{field}Text` | | FileFields / MultiFileFields | 单文件 / 逗号分隔多文件:写侧 saveTransform 转正,读侧补访问域名 | | ContentFields | 富文本字段:写侧占位符化,读侧经 ExpandContentFields 还原 | | IsTree + ParentOf | 树形输出(仅 List 建树;ParentOf 返回 0 表根) | | UniqueFields | Add/Update 前逐条唯一性预检(并发唯一性仍以数据库 uk 索引为准) | ## ToData:请求转落库字段 ```go // internal/service/example.go func exampleToData(req any) map[string]any { switch r := req.(type) { case *dto.ExampleAddReq: return map[string]any{"name": r.Name, "avatar": r.Avatar, "type": r.Type, "status": r.Status, "sort": r.Sort} case *dto.ExampleUpdateReq: return map[string]any{"name": r.Name, "avatar": r.Avatar, "type": r.Type, "status": r.Status, "sort": r.Sort} } panic(fmt.Sprintf("exampleToData: 不支持的入参类型 %T", req)) } ``` 只映射业务字段——审计字段(create_user/create_time/update_user/update_time/del_flag/tenant_id)由全局钩子统一补齐,**不要在这里赋值**。 ## 标准方法(委托基座) ```go // internal/service/example.go func (s *sExample) Page(ctx context.Context, req *dto.ExamplePageReq) (*dto.PageData[dto.ExampleItem], error) { list, total, err := exampleSpec.Page(ctx, req, req.GetPageNo(), req.GetPageSize()) if err != nil { return nil, err } return dto.NewPageData(req.GetPageNo(), req.GetPageSize(), total, derefSlice(list)), nil } func (s *sExample) Add(ctx context.Context, req *dto.ExampleAddReq) (*dto.ExampleIdRes, error) { id, err := exampleSpec.Add(ctx, req) if err != nil { return nil, err } return &dto.ExampleIdRes{Id: id}, nil } func (s *sExample) Update(ctx context.Context, req *dto.ExampleUpdateReq) (*dto.EmptyRes, error) { if err := exampleSpec.Update(ctx, req.Id, req); err != nil { return nil, err } return &dto.EmptyRes{}, nil } func (s *sExample) Delete(ctx context.Context, req *dto.ExampleDeleteReq) (*dto.EmptyRes, error) { if err := exampleSpec.Delete(ctx, req.Id); err != nil { return nil, err } return &dto.EmptyRes{}, nil } ``` ## 手写方法(复杂逻辑) 标准方法覆盖不了的场景直接手写——这是合法逃生舱,不是妥协: ```go // internal/service/example.go // Status 手写单列更新(仅更新 status 一列) func (s *sExample) Status(ctx context.Context, req *dto.ExampleStatusReq) (*dto.EmptyRes, error) { if err := repository.NewExampleRepo().Ctx(ctx). Where("id = ?", req.Id).Updates(map[string]any{"status": req.Status}).Error; err != nil { return nil, err } return &dto.EmptyRes{}, nil } ``` 导入导出、登录、分配菜单、状态流转等都属于手写范畴。导出可复用基座过滤器 `spec.applyFilters(ctx, spec.Model(ctx), req)` 与字典查询 `DictGetText(ctx, dictCode, value)`。 ## 业务错误上抛 ```go // internal/service/auth.go(实际代码)——哨兵错误直接返回 return nil, consts.CodeTenantDisabled // internal/service/base.go(实际代码)——唯一性预检的格式化错误 return apperr.Newf(consts.CodeRecordExists.Code(), "%s已存在", label) ``` 错误原样上抛,**禁止在钩子与 service 内做 HTTP 响应或文件 IO 之外的越层处理**(钩子还禁止 append 修改切片)。 ## 编写检查清单 - [ ] 三段式结构(sXxx / init / var) - [ ] 标准方法全部委托 ModuleSpec,业务差异进声明与钩子 - [ ] ToData 不映射审计字段 - [ ] 跨模块同包直调,事务用 `db.WithTrans` 且回调内用入参 ctx - [ ] dict 模块 init 中调用 `SetDictLookup(...)` 注册字典查询(涉及字典的模块) ## 下一步 → [3.6 接口层开发(handler)](/zh/guide/handler.html)