Files
easygoadmin-wiki/EasyGoAdmin_Gin_AntdVue/zh/guide/service.md
T
2026-10-25 17:24:27 +08:00

160 lines
6.3 KiB
Markdown
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.
# 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)