Переглянути джерело

refactor(response): 统一分页契约为嵌套 PageData{list,total,page,pageSize,pages}

- R 信封删顶层 total 字段,Data 语义统一:分页接口传 *PageData,单实体接口传实体
- 新增 PageData 结构体:元信息(total/page/pageSize/pages)与 list 同聚于 data 内
  彻底消灭旧 Success(total 顶层) vs SuccessPage(total 在 data 内) 的结构不一致
- Page 函数签名 Page(c, list, page, pageSize, total),自动算 pages=ceil(total/pageSize)
- ParsePage 不变(默认 1/10,上限 200)
- 同步 README §3.2、scope.go 包注释示例、EXAMPLES 索引(导出符号增 PageData)
- go build + go vet 全绿(go 1.27.3)
Q 4 днів тому
батько
коміт
da3fe5b770
4 змінених файлів з 63 додано та 15 видалено
  1. 1 1
      EXAMPLES.md
  2. 2 1
      README.md
  3. 57 11
      internal/infra/response/response.go
  4. 3 2
      internal/infra/scope/scope.go

+ 1 - 1
EXAMPLES.md

@@ -11,7 +11,7 @@
 
 | 包 | 路径 | 落地的架构规格节 | 承载的 ADR / 铁律 | 核心导出 |
 |---|---|---|---|---|
-| 统一响应 | `internal/infra/response` | §3.1 接口规范 | 铁律 6(HTTP 恒 200 + body code) | `R` / `OK` / `Page` / `Fail` / `ParsePage` |
+| 统一响应 | `internal/infra/response` | §3.1 接口规范 | 铁律 6(HTTP 恒 200 + body code) | `R` / `PageData` / `OK` / `Page` / `Fail` / `ParsePage` |
 | 三层错误 | `internal/infra/errors` | §3.2 异常处理 | 错误码分段 1xxx–5xxx | `AppError` / `New` / `WithCause` / sentinel `ErrNotFound` 等 |
 | 多租户作用域 | `internal/infra/scope` | §3.5 作用域注入 | 铁律 2(强制作用域) | `Scope` / `Set` / `FromFiber` / `BaseRepo` / `NewBaseRepo` |
 | 入参校验 | `internal/infra/validator` | §3.1 入参惯例 | 铁律 7(validateReq 语义) | `Validate` |

+ 2 - 1
README.md

@@ -109,7 +109,8 @@ nginx → HTTP 接入层(cmd/api:Recover→RequestID→CORS→Throttle→Log
 
 ### 3.2 接口规范
 
-- **统一响应契约**:HTTP 恒 200,真实状态在 body `R.code`(成功 200;`{code, data, message, total?, success}` 信封,顶层 `total` 解决旧 Success/SuccessPage 不一致);e2e 断言 **body code** 而非 HTTP status。
+- **统一响应契约**:HTTP 恒 200,真实状态在 body `R.code`(成功 200;信封 `{code, data, message, success}`);e2e 断言 **body code** 而非 HTTP status。
+  - **统一分页**:分页接口 `data` 恒为 `PageData` 对象 `{list, total, page, pageSize, pages}`,元信息随数据同聚(彻底消灭旧系统 `Success`(total 顶层) vs `SuccessPage`(total 在 data 内) 的结构不一致);单实体接口 `data` 为实体对象或省略;前端统一读 `res.data.list` / `res.data.total`。
 - **错误码分段**:`1xxx` 认证/权限(1001 未登录/1002 失效/1003 无权限/1004 作用域越界)· `2xxx` 参数/校验 · `3xxx` 数据(3001 不存在/3002 冲突/3003 唯一约束)· `4xxx` 依赖(Agent/支付/第三方)· `5xxx` 系统(5001 内部错误/5003 限流)。
 - **入参校验**:`validator.Validate(c, &req) bool`——失败时内部已写 body(code=2001)并返 false,调用方 `if !ok { return nil }`;**禁用 `err != nil` 判**(误判通过继续落库——教务踩坑)。
 - **路由纪律**:`/{端}/{模块}/{资源}` RESTful;**字面路径先于 `/:id`** 注册;同 method+path 不重复注册;新增端点先全局 grep 同路径;公开路由(登录/SSO)在 Auth Group **之前**注册。

+ 57 - 11
internal/infra/response/response.go

@@ -30,13 +30,21 @@ import (
 //
 // 前端约定:一律读 body 而非 HTTP 状态码——
 // 成功判断 res.success === true(兼容旧前端 res.success 写法);
-// 分页列表读顶层 res.total(统一放顶层,解决旧系统 Success/SuccessPage 两处 total 位置不一致的坑);
+// 分页列表读 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 形态:
 //
-//	// 成功(带分页)
-//	{"code":200,"message":"ok","data":[...],"total":42,"success":true}
+//	// 成功(分页,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 {
@@ -44,14 +52,34 @@ type R struct {
 	Code int `json:"code"`
 	// Message 面向用户的错误文案。脱敏铁律:不得包含 SQL 片段、堆栈、密码哈希等敏感信息。
 	Message string `json:"message"`
-	// Data 业务数据体(成功时填充;失败时省略)。
+	// Data 业务数据体(成功时填充;失败时省略)。分页接口传 *PageData,单实体接口传实体对象。
 	Data interface{} `json:"data,omitempty"`
-	// Total 分页总数,仅在列表分页响应中填充(omitempty 省略非分页场景)。
-	Total int64 `json:"total,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 填充业务数据)。
 //
 // 入参:
@@ -68,24 +96,42 @@ 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 为列表、Total 为总数)。
+// 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 := parsePage(c) // page 默认 1、pageSize 默认 10
+//	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, total)
-func Page(c *fiber.Ctx, list interface{}, total int64) error {
-	return c.Status(fiber.StatusOK).JSON(R{Code: 200, Message: "ok", Data: list, Total: total, Success: true})
+//	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 省略)。

+ 3 - 2
internal/infra/scope/scope.go

@@ -23,9 +23,10 @@
 //	    sc := scope.FromFiber(c)          // Auth 中间件已写入
 //	    if sc == nil { return response.Fail(c, errors.ErrUnauth) }
 //	    repo := newCourseRepo(db, sc)      // 作用域绑定到仓储
-//	    list, total, err := repo.List(c.Context(), Filter{...})
+//	    page, size := response.ParsePage(c) // 统一分页参数(默认 1/10,上限 200)
+//	    list, total, err := repo.List(c.Context(), Filter{...}, page, size)
 //	    if err != nil { return response.Fail(c, err) }
-//	    return response.Page(c, list, total)
+//	    return response.Page(c, list, page, size, total)
 //	}
 //
 //	// Repository(业务域内)