签入版本
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user