| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- // Package response 统一响应契约(HTTP 恒 200 + body code 信封)。
- //
- // 本包是"分包铁律第 6 条"的实现:所有接口(含业务错误)HTTP 状态码恒为 200,
- // 真实业务状态在 body 的 R.Code 中(成功 200,失败为分段错误码,见包 errors)。
- // 该契约源自教务 Go 重构在产验证 + 教学 api-conventions 双侧确证,
- // 设计决策见 ../../../../unified/01-architecture/backend-technical-architecture.md §3.1。
- //
- // 典型使用(Handler 层):
- //
- // func GetUser(c *fiber.Ctx) error {
- // var req GetUserReq
- // if !infra.Validate(c, &req) { // 校验失败:包内已回写 body(code=2001) 并返回 false
- // return nil
- // }
- // user, err := svc.Get(ctx, req.ID)
- // if err != nil {
- // return response.Fail(c, err) // 非 *errors.AppError 会被包成 5001 系统繁忙
- // }
- // return response.OK(c, user)
- // }
- package response
- import (
- "log"
- "yuansiwei/unified-backend/internal/infra/errors"
- "github.com/gofiber/fiber/v2"
- )
- // R 统一响应信封(所有接口的出参顶层结构)。
- //
- // 前端约定:一律读 body 而非 HTTP 状态码——
- // 成功判断 res.success === true(兼容旧前端 res.success 写法);
- // 分页列表读 res.data.list / res.data.total(统一嵌套在 data 内,彻底消灭旧系统
- // Success(total 顶层) 与 SuccessPage(total 在 data 内) 两处结构不一致的坑);
- // 失败时 res.code 为分段错误码(1xxx/2xxx/3xxx/4xxx/5xxx),res.message 为可直接展示给用户的文案。
- //
- // Data 语义(统一约定):
- // - 分页列表接口 → *PageData(对象,含 list/total/page/pageSize/pages);
- // - 单实体接口 → 实体对象或 nil(操作成功无数据体)。
- // 即"分页接口 data 恒为对象、单实体接口 data 恒为对象/省略",前端 useListPage 无需区分两套读法。
- //
- // 典型 JSON 形态:
- //
- // // 成功(分页,Page 函数写出)
- // {"code":200,"message":"ok","data":{"list":[...],"total":42,"page":1,"pageSize":10,"pages":5},"success":true}
- // // 成功(单实体,OK 函数写出)
- // {"code":200,"message":"ok","data":{"id":"...","name":"..."},"success":true}
- // // 业务失败(注意:HTTP 状态码仍是 200)
- // {"code":3001,"message":"记录不存在","success":false}
- type R struct {
- // Code 业务状态码:200 表示成功;失败为分段错误码(1xxx 认证 / 2xxx 参数 / 3xxx 数据 / 4xxx 依赖 / 5xxx 系统)。
- Code int `json:"code"`
- // Message 面向用户的错误文案。脱敏铁律:不得包含 SQL 片段、堆栈、密码哈希等敏感信息。
- Message string `json:"message"`
- // Data 业务数据体(成功时填充;失败时省略)。分页接口传 *PageData,单实体接口传实体对象。
- Data interface{} `json:"data,omitempty"`
- // Success 成功标志,兼容旧前端 res.success 判断,与 Code 冗余但保留。
- Success bool `json:"success"`
- }
- // PageData 统一分页数据体(分页列表接口的 Data 固定结构,前端 useListPage 统一读法)。
- //
- // 设计要点:分页元信息(total/page/pageSize/pages)与数据(list)**同聚于 data 内**,
- // 不再散落顶层——旧系统 Success 的 total 在顶层、SuccessPage 的 total 在 data 内,
- // 前端两套读法各异的坑由此彻底消灭。
- //
- // 典型 JSON 形态:
- //
- // {"list":[{...},{...}],"total":42,"page":1,"pageSize":10,"pages":5}
- type PageData struct {
- // List 当前页数据切片;空列表必须传非 nil 的 []T{}(零值 nil 会序列化为 JSON null,前端取 list.map 报错)。
- List interface{} `json:"list"`
- // Total 符合筛选条件的总记录数(分页器显示"共 N 条")。
- Total int64 `json:"total"`
- // Page 当前页码(从 1 开始,与请求 query 的 page 回显一致)。
- Page int `json:"page"`
- // PageSize 每页条数(与请求 query 的 pageSize 回显一致;被 ParsePage 钳制后 ≤ 200)。
- PageSize int `json:"pageSize"`
- // Pages 总页数,= ceil(total / pageSize)(pageSize 为 0 时恒 0,防除零)。
- Pages int64 `json:"pages"`
- }
- // OK 写出成功响应(HTTP 200,body code=200,Data 填充业务数据)。
- //
- // 入参:
- // - c *fiber.Ctx:Fiber 请求上下文(提供 c.JSON 写出能力;调用前无需手动设置状态码,本函数内部恒置 200)。
- // - data interface{}:业务数据体;允许 nil(无数据体的成功响应,如"操作成功"类接口)。
- //
- // 出参:
- // - error:仅当 JSON 序列化/写出失败时返回(如客户端已断开);业务失败请用 Fail,不要用本函数表达。
- //
- // 样例:
- //
- // return response.OK(c, fiber.Map{"user": user, "roles": roles})
- func OK(c *fiber.Ctx, data interface{}) error {
- return c.Status(fiber.StatusOK).JSON(R{Code: 200, Message: "ok", Data: data, Success: true})
- }
- // Page 写出成功分页响应(HTTP 200,body code=200,Data 为 PageData 嵌套对象)。
- //
- // 出参形态:
- //
- // {"code":200,"message":"ok","data":{"list":[...],"total":42,"page":1,"pageSize":10,"pages":5},"success":true}
- //
- // 入参:
- // - c *fiber.Ctx:Fiber 请求上下文。
- // - list interface{}:当前页数据切片(通常 []T);空列表请传非 nil 的 `[]T{}`,避免 JSON 输出 null。
- // - page int:当前页码(从 1 开始);惯例经 ParsePage(c) 解析,缺省 1。
- // - pageSize int:每页条数;惯例经 ParsePage(c) 解析,缺省 10、上限钳制 200。
- // - total int64:符合筛选条件的总记录数(用于前端分页器;查询惯例见架构规格 §3.1"先 pluck 分页 id → 批量取详情"防 N+1)。
- //
- // 出参:
- // - error:同 OK,仅写出层失败时返回。
- //
- // 异常场景:
- // - list 传 nil:PageData.List 序列化为 JSON null,前端 list.map 报错——调用方必须保证传非 nil 切片;
- // - total < 0:不钳制(视为调用方 bug,应单测暴露);Pages 按 ceil 计算,负数 total 会得到 0 页。
- //
- // 样例:
- //
- // page, size := response.ParsePage(c) // page 默认 1、pageSize 默认 10(上限 200)
- // ids, total, err := repo.PagedIDs(ctx, filter, page, size)
- // users, err := repo.BatchGet(ctx, ids) // 一次批量取,防 N+1
- // return response.Page(c, users, page, size, total)
- func Page(c *fiber.Ctx, list interface{}, page, pageSize int, total int64) error {
- pages := int64(0)
- if pageSize > 0 {
- pages = (total + int64(pageSize) - 1) / int64(pageSize) // ceil,防除零
- }
- return c.Status(fiber.StatusOK).JSON(R{
- Code: 200, Message: "ok",
- Data: PageData{List: list, Total: total, Page: page, PageSize: pageSize, Pages: pages},
- Success: true,
- })
- }
- // Fail 写出业务失败响应(HTTP 仍为 200,body code 为业务错误码,Data 省略)。
- //
- // 入参:
- // - c *fiber.Ctx:Fiber 请求上下文。
- // - err error:业务错误。约定:
- // - *errors.AppError:按其 Code 写出,文案取 **PublicMsg 优先、其次 Msg**(可预期业务错误,
- // Service 层应返回此类 sentinel;需要告诉用户具体数字时用 `errors.WithPublicMsg`);
- // - 其它 error:降级为 errors.ErrInternal(code=5001 系统繁忙)。
- // - 注意:`Cause` **永不**进响应体(可能含 SQL / 内部分层名 / gorm 原始错误),只进日志。
- //
- // 出参:
- // - error:仅写出层失败时返回。
- //
- // 异常场景:
- // - err 为 nil:按内部错误 5001 处理(调用方逻辑错误,应在单测中暴露);
- // - AppError.Msg 为空:兜底为"系统繁忙",防止把空文案透传给前端。
- // - code >= 5000:额外用标准库 log 打一条(含 Cause),保证服务端故障有痕;
- // 1xxx~3xxx 不记日志(可预期业务错误,记了会淹没真问题)。
- //
- // 样例:
- //
- // user, err := svc.Get(ctx, id)
- // if err != nil {
- // return response.Fail(c, err) // ErrNotFound → {"code":3001,"message":"记录不存在"}
- // }
- func Fail(c *fiber.Ctx, err error) error {
- e, ok := err.(*errors.AppError)
- if !ok {
- e = errors.ErrInternal // 非业务错误(含 err==nil)一律降级 5001,细节只进日志
- }
- // 5001/5002 等**服务端故障**必须留痕:这类错误对用户是"系统繁忙",
- // 若不记日志,排查时既看不到错误码来源也看不到 Cause(数据异常会彻底静默)。
- //
- // 为什么这里用标准库 log 而不是项目里的 zap:本包是**纯响应封装**,
- // 注入 logger 会改变 Fail 的签名并波及全部调用点;标准库 log 写到 stderr
- // 可被容器日志采集,且零配置就有痕。接 zap 结构化日志登记为台账 F34。
- //
- // 只记 5xxx(服务端故障);1xxx~3xxx 是可预期业务错误,记日志会淹没真问题。
- if e.Code >= 5000 {
- log.Printf("[response.Fail] code=%d msg=%s cause=%v", e.Code, e.Msg, e.Cause)
- }
- // 对外文案:PublicMsg(出错点填写的具体事实)优先,其次 sentinel 的默认 Msg。
- //
- // ⚠ Cause **永不**进响应体:里面可能有 SQL 片段、内部分层名、gorm 原始错误。
- msg := e.PublicMsg
- if msg == "" {
- msg = e.Msg
- }
- if msg == "" {
- msg = "系统繁忙"
- }
- return c.Status(fiber.StatusOK).JSON(R{Code: e.Code, Message: msg, Success: false})
- }
- // ParsePage 解析分页参数(page/pageSize,带默认值与上限钳制)。
- //
- // 入参:
- // - c *fiber.Ctx:Fiber 请求上下文,从 query 中读取 page 与 pageSize。
- //
- // 出参:
- // - page int:页码,从 1 开始;query 缺失或非法(<1)时返回默认值 1。
- // - pageSize int:每页条数;query 缺失或非法(<1)时返回默认值 10,超过 200 时钳制为 200(防大查询打满数据库)。
- //
- // 异常场景:
- // - query 值非数字:c.QueryInt 返回 0,本函数按默认值处理(不报错,前端传错参数不产生 5xx)。
- //
- // 样例:
- //
- // page, size := response.ParsePage(c)
- // // page=0 非法 → 1;pageSize=500 → 200
- func ParsePage(c *fiber.Ctx) (page, pageSize int) {
- page = c.QueryInt("page", 1)
- if page < 1 {
- page = 1
- }
- pageSize = c.QueryInt("pageSize", 10)
- if pageSize < 1 {
- pageSize = 10
- }
- if pageSize > 200 {
- pageSize = 200
- }
- return page, pageSize
- }
|