divide_handler.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. // 分账端点处理器(端点 38 分账树 / 43 流水列表)——AC-13 第 1 批(读路径优先)。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 B/C 表 + 设计 §3.3 #22~#25。
  4. //
  5. // 本批**只做两条读端点**,理由见文件末"本批范围";41/42 是写路径(要新增 `DeleteLogByID`
  6. // 等 Service 方法并配套事务用例),放在第 2 批,避免"半实现的写端点"混进首次提交。
  7. //
  8. // ---------------------------------------------------------------------------
  9. // 量纲红线(mock 已在此错过一次,后端不得重犯)
  10. // ---------------------------------------------------------------------------
  11. //
  12. // `quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串),**不是** 0~1 比率。
  13. //
  14. // 本文件金额出参一律 `money.Amount`(JSON 两位小数字符串),DTO 里**不出现 float64**。
  15. //
  16. // ---------------------------------------------------------------------------
  17. // 作用域口径(`t_user_divide_into*` 无 institution_id/campus_id,§1.7 例外二)
  18. // ---------------------------------------------------------------------------
  19. //
  20. // 两张表都是**全局推广体系**(推广关系不归属某个机构),数据层无法按机构过滤 ⇒ 防线只有两条:
  21. // 1. `scope.FromFiber` 必须存在(取不到 → 1001,避免匿名读全平台分账数据);
  22. // 2. 端点角色白名单(AC-14 落 RBAC,**以契约 §2.3 为唯一权威**):
  23. // 端点 38~42 → `super_admin, academic`;端点 43(分账流水列表)→ `super_admin, academic, finance`。
  24. //
  25. // ⚠ **曾经写错的一处**:本文件原写"预期 `super_admin / finance / market`",与契约 §2.3 冲突。
  26. // `market` 是**旧系统角色残留**(`t_role` 字典里的历史角色码,新系统 6 个角色码见契约 §1.7,
  27. // 不含 `market`);按旧写法落种子会得到一条**没有任何角色命中**的白名单 + 一条多余策略,
  28. // 症状是"分账树对 academic 报 1003"(看起来像 RBAC 没配,实际是这里写错)。
  29. // 已按契约改正;`RoleMarket` 常量仍保留(路由表角色拼写校验用),但**不得**再出现在本域白名单里。
  30. //
  31. // 这也是它与其他端点的本质差异:**分账树是跨机构的,不存在"本机构分账树"**。
  32. package handler
  33. import (
  34. "strings"
  35. "github.com/gofiber/fiber/v2"
  36. "yuansiwei/unified-backend/internal/academic/service"
  37. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  38. "yuansiwei/unified-backend/internal/infra/response"
  39. appscope "yuansiwei/unified-backend/internal/infra/scope"
  40. )
  41. // 分账域路径常量(**不含 `/api`**,§4 口径表)。
  42. const (
  43. // PathDivideTree 端点 38:分账树(`GET /admin/divide-into/tree`)。
  44. PathDivideTree = "/admin/divide-into/tree"
  45. // PathDivideMembers 端点 39:加入下线(`POST /admin/divide-into/members`)。
  46. PathDivideMembers = "/admin/divide-into/members"
  47. // PathDivideMemberQuota 端点 40:改分成额度(`PUT /admin/divide-into/members/:userId/quota`)。
  48. PathDivideMemberQuota = "/admin/divide-into/members/:userId/quota"
  49. // PathDivideLogs 端点 41/42/43:记 / 删 / 查分账流水(`/admin/divide-into/logs`)。
  50. //
  51. // 注意端点 42 的定位方式是 **`studentId` + `orderNo`(query/body)**,不是 `:id`
  52. // (契约 §2.3 端点 42 原文:`studentId,orderNo`;dump `divide_into_log_delete(stuid, orderid)` 两参同构)。
  53. PathDivideLogs = "/admin/divide-into/logs"
  54. )
  55. // GetDivideTree 分账树(端点 38 `GET /admin/divide-into/tree`)。
  56. //
  57. // 出参(成功):`{tree:[…], nodeCount, rootCount}` —— **包一层 `tree`**
  58. // (契约 §3.4 结构约定 2 的显式例外:树不是分页对象)。
  59. // 节点:`userId / realName / parentUserId|null / quota:金额 / cumulativeIncome:金额 / layer / children[]`。
  60. // 出参(失败):1001(无作用域)/ 5001(**Nested Set 不变式被破坏**,数据损坏)/ 5002(读库或金额异常)。
  61. //
  62. // ⚠ 5001 是**有意的**:树坏了就**不返回**一棵"看起来对"的树。位移错位无法从当前数据推断原值,
  63. // 猜测式修复会把"确定性错误"变成"随机正确"(不变式 I1~I4 见 `service.divide_tree_service.go` 文件头)。
  64. func (h *Handler) GetDivideTree(c *fiber.Ctx) error {
  65. sc, err := mustScope(c)
  66. if err != nil {
  67. return response.Fail(c, err)
  68. }
  69. tree, err := h.divideServiceFor(sc).GetTree(c.Context(), c.Query("rootUserId"))
  70. if err != nil {
  71. return response.Fail(c, err)
  72. }
  73. return response.OK(c, tree)
  74. }
  75. // ListDivideLogs 分账流水列表(端点 43 `GET /admin/divide-into/logs`)。
  76. //
  77. // 入参(query):
  78. // - `findUserId`(分成获得者;**表列是 `user_id`**,契约响应字段才叫 `findUserId`);
  79. // - `studentId`;
  80. // - `from`/`to`(**右开**:`from <= createTime < to`,与契约 §1.3 一致);
  81. // - `page`/`pageSize`(钳制上限 200)。
  82. //
  83. // 出参(成功):`分页`——`{list,total,page,pageSize,pages}`,行字段见 `service.DivideLogView`。
  84. //
  85. // ⚠ 列表端点用通用分页形状(键名 `list`),**不是**端点 17 的 `details`
  86. // (那是"汇总 + 明细"的复合 data,两者不要互相抄)。
  87. //
  88. // 出参(失败):1001 / 2001(`from > to`)/ 2002(时间格式非法)/ 5002。
  89. func (h *Handler) ListDivideLogs(c *fiber.Ctx) error {
  90. sc, err := mustScope(c)
  91. if err != nil {
  92. return response.Fail(c, err)
  93. }
  94. from, _, ferr := parseTimeParam(c.Query("from"), "from")
  95. if ferr != nil {
  96. return response.Fail(c, ferr)
  97. }
  98. to, _, terr := parseTimeParam(c.Query("to"), "to")
  99. if terr != nil {
  100. return response.Fail(c, terr)
  101. }
  102. page, pageSize := parsePage(c)
  103. q := service.DivideLogQuery{
  104. // 契约 §2.3 端点 43 的 query 参数名是 **`userId`**(响应行字段才叫 `findUserId`)。
  105. // 同时接受 `findUserId` 作为过渡别名(老调用方/双跑脚本用),两者都给时以 `userId` 为准。
  106. FindUserID: firstNonEmpty(c.Query("userId"), c.Query("findUserId")),
  107. StudentID: c.Query("studentId"),
  108. Page: page, PageSize: pageSize,
  109. }
  110. if !from.IsZero() {
  111. q.From = &from
  112. }
  113. if !to.IsZero() {
  114. q.To = &to
  115. }
  116. rows, total, err := h.divideServiceFor(sc).ListLogs(c.Context(), q)
  117. if err != nil {
  118. return response.Fail(c, err)
  119. }
  120. return response.Page(c, rows, page, pageSize, total)
  121. }
  122. // divideServiceFor 按作用域装配分账查询服务(分账域**不**做机构 scoping,见文件头"作用域口径")。
  123. //
  124. // 姓名解析器本批未注入(`realName` 返回空串):姓名在 identity/org 域,academic 未持有
  125. // `t_user` 结构(§1.3 跨域只读要经 port);AC-14 装配时注入真实实现即可,不影响树结构与金额。
  126. // **不编造**姓名 —— 空串是显式的"未解析",前端按"未填"展示。
  127. func (h *Handler) divideServiceFor(sc *appscope.Scope) *service.DivideTreeService {
  128. return service.NewDivideTreeService(h.db, sc, nil)
  129. }
  130. // 本批范围(AC-13 增量交付,明确边界防"以为做完了")
  131. //
  132. // ✅ 38 分账树(含 Nested Set 不变式 I1~I4 校验)
  133. // ✅ 43 分账流水列表(右开时间窗 + 同快照 total)
  134. // ⏳ 41 记流水 / 42 删流水 —— 需要新增 Service 写方法(`AddLog` 已存在、`DeleteLogByID` 需新增)
  135. // 并配套"撤销回滚 total_income 同事务"的跨表断言,放第 2 批
  136. // ⏳ 22~37 活动 / 39~40 分账额度 / 44~57 课程排班 / 58~63 课酬维护 —— 后续批次
  137. // ---------------------------------------------------------------- 端点 39:加入下线
  138. // DivideMemberReq 加入下线请求体(端点 39)。
  139. type DivideMemberReq struct {
  140. // ParentUserID 上级(ULID;必填)。
  141. ParentUserID string `json:"parentUserId" validate:"required,max=26"`
  142. // SubUserID 下线(ULID;必填)。
  143. SubUserID string `json:"subUserId" validate:"required,max=26"`
  144. }
  145. // AddDivideMember 加入下线(端点 39 `POST /admin/divide-into/members`)。
  146. //
  147. // 出参(成功):`{ancestorIds:[]}`(新节点的祖先链,由外向内、不含自身)。
  148. // 出参(失败):1001/2001/3111(层级超限,**两种语义见下**)/3001/5002。
  149. //
  150. // **3111 在本端点有两种判据,前端文案应区分**(F20,2026-11-10):
  151. // - ① **业务规则:层级超 3 层**(超管 → 校区主任 → 老师)。第 4 层直接拒,
  152. // 这是产品拍板的硬规则(前端 mock `divide-into.ts:26 MAX_DIVIDE_DEPTH = 3` 的落地)。
  153. // 文案示例:「分账最多支持 3 层(超管 → 校区主任 → 老师)」。
  154. // - ② 结构上限:层级超 100(防上行 BFS 不终止 / 递归爆栈)。正常业务撞不到,
  155. // 撞到通常意味着**库里已有脏数据**,不是用户操作问题,文案不宜照抄 ①。
  156. //
  157. // 判序固定「先 ① 后 ②」:① 是常规失败点,② 是兜底;反了会把业务限制误报成数据异常。
  158. // 全仓 3111 的**四义**(本批整理,勿只看本端点):
  159. // - 层级超 3 层(业务规则,本端点 + 端点 39 建节点时);
  160. // - 层级超 100(结构上限,本端点);
  161. // - 上行链路超 100 层 / 成环(端点数流水时 BFS 兜底,见端点 41);
  162. // - 新额度 > 父节点额度(端点 40 改额度,D9 有意增强)。
  163. //
  164. // ⚠ 新节点 `quota` 默认 **0.00**(D6)⇒ 前端应在成功后**引导设置额度**(端点 40),
  165. // 否则该链一分钱不分成且**不报错**(静默不入账)。
  166. func (h *Handler) AddDivideMember(c *fiber.Ctx) error {
  167. sc, err := mustScope(c)
  168. if err != nil {
  169. return response.Fail(c, err)
  170. }
  171. var req DivideMemberReq
  172. if !validateReq(c, &req) {
  173. return nil
  174. }
  175. res, err := h.divideServiceFor(sc).AddMember(c.Context(), req.ParentUserID, req.SubUserID)
  176. if err != nil {
  177. return response.Fail(c, err)
  178. }
  179. return response.OK(c, res)
  180. }
  181. // ---------------------------------------------------------------- 端点 40:改额度
  182. // DivideQuotaReq 改额度请求体(端点 40)。
  183. //
  184. // `Quota` 是**金额字符串**(`"100.00"`)——**不是** 0~1 比率、不是 number(mock 曾错)。
  185. type DivideQuotaReq struct {
  186. // Quota 新分成额度(元;必填,两位小数字符串)。
  187. Quota string `json:"quota" validate:"required"`
  188. }
  189. // SetDivideQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。
  190. //
  191. // 出参(成功):`{affectedUserIds:[]}`(含自身;= 子树内 `quota >= 新额度` 的节点)。
  192. // 出参(失败):1001/2001/2002(quota 格式非法或为负)/3111(新额度超父额度)/3001/5002。
  193. //
  194. // **3111 的判据是「新额度 > 父节点额度」**(D9 有意增强),不是 `quota > 1`:
  195. // 判据落在 `DivideService.UpdateQuota`(AC-09 已交付),本 handler 只做入参解析 + 幂等透传。
  196. // 注意:这是 3111 的**第 4 种语义**(金额超限),与端点 39 的层级超 3 层/超 100 层、
  197. // 端点 41 的上行超 100 层**共用同一个错误码**,前端不能只按错误码区分文案,
  198. // 必须拿 message 或按端点各自映射(详见 AddDivideMember 注释里的"3111 四义")。
  199. func (h *Handler) SetDivideQuota(c *fiber.Ctx) error {
  200. sc, err := mustScope(c)
  201. if err != nil {
  202. return response.Fail(c, err)
  203. }
  204. userID := trimOrEmpty(c.Params("userId"))
  205. if userID == "" {
  206. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空")))
  207. }
  208. var req DivideQuotaReq
  209. if !validateReq(c, &req) {
  210. return nil
  211. }
  212. quota, ok := parseMoney(c, req.Quota, "quota")
  213. if !ok {
  214. return nil
  215. }
  216. res, err := h.divideServiceFor(sc).SetQuota(c.Context(), userID, quota)
  217. if err != nil {
  218. return response.Fail(c, err)
  219. }
  220. return response.OK(c, res)
  221. }
  222. // ---------------------------------------------------------------- 端点 41 / 42:记 / 删流水
  223. // DivideLogReq 记/删分账流水请求体(端点 41 / 42 共用)。
  224. //
  225. // 契约 §2.3:41 `findUserId,studentId,orderNo,remark?`;42 `studentId,orderNo`。
  226. // `OrderNo` 是**字符串**(表列 `order_no varchar(50)`),向下转 int64 交给 AC-09 的服务
  227. // (dump 的 `recordid` 是整数;转换失败 → 2002,不静默丢单)。
  228. type DivideLogReq struct {
  229. // FindUserID 分成获得者(ULID;端点 41 必填、42 忽略)。
  230. FindUserID string `json:"findUserId" validate:"max=26"`
  231. // StudentID 学生(ULID;必填)。
  232. StudentID string `json:"studentId" validate:"required,max=26"`
  233. // OrderNo 订单号/上课记录号(必填;字符串 → 内部转 int64)。
  234. OrderNo string `json:"orderNo" validate:"required,max=50"`
  235. // Remark 备注(可空)。
  236. Remark string `json:"remark" validate:"max=1000"`
  237. }
  238. // CreateDivideLogRecord 记分账流水(端点 41 `POST /admin/divide-into/logs`)。
  239. //
  240. // 出参(成功):`{logged:true}`(dump 的 `log_add` 不返回 id;前端只关心"是否入账")。
  241. // 出参(失败):1001/2001/2002(orderNo 非数字)/5001(上行链路超 100 层或树自环 = **数据损坏**)/3001/5002。
  242. //
  243. // ⚠ 2026-11-11 拍板 A3:本端点**不再返回 3111**。此前的 3111 表示"链路成环/超长",
  244. // 那是数据损坏而非业务规则 —— 用户对"树坏了"无能为力,报"额度超上限"会把运维引向额度。
  245. // 现对外统一 5001(文案"系统繁忙"),真实原因进日志。前端**不要**给本端点写 3111 分支。
  246. //
  247. // 3111 在这里是**第 3 种语义**:不是"层级超 3 层",而是沿 `parent_user_id` 上行 BFS 时
  248. // 撞到结构上限(超过 100 级或成环)。撞到它说明**库里的链路本身有问题**(历史脏数据),
  249. // 前端文案应是"分账链路异常,请联系管理员",而不是"分账最多 3 层"。
  250. //
  251. // **无金额入参**:分成额取自节点 `quota`(客户端传金额 = 前端定分成额的资金漏洞)。
  252. func (h *Handler) CreateDivideLogRecord(c *fiber.Ctx) error {
  253. sc, err := mustScope(c)
  254. if err != nil {
  255. return response.Fail(c, err)
  256. }
  257. var req DivideLogReq
  258. if !validateReq(c, &req) {
  259. return nil
  260. }
  261. if trimOrEmpty(req.FindUserID) == "" {
  262. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("findUserId 不能为空")))
  263. }
  264. orderID, ok := parseInt64Param(c, req.OrderNo, "orderNo")
  265. if !ok {
  266. return nil
  267. }
  268. if err := h.divideServiceFor(sc).AddLog(c.Context(), req.FindUserID, req.StudentID, orderID, req.Remark); err != nil {
  269. return response.Fail(c, err)
  270. }
  271. return response.OK(c, fiber.Map{"logged": true})
  272. }
  273. // DeleteDivideLogRecord 删分账流水(端点 42 `DELETE /admin/divide-into/logs`)。
  274. //
  275. // 定位方式(**不是 `:id`**):`studentId` + `orderNo`(query 优先,其次 body),
  276. // 与契约 §2.3 / dump `divide_into_log_delete(stuid, orderid)` 两参同构。
  277. // 出参(成功):`{deleted:true}`;出参(失败):1001/2001/2002(orderNo 非数字)/5002。
  278. func (h *Handler) DeleteDivideLogRecord(c *fiber.Ctx) error {
  279. sc, err := mustScope(c)
  280. if err != nil {
  281. return response.Fail(c, err)
  282. }
  283. studentID := firstNonEmpty(c.Query("studentId"), "")
  284. orderNo := firstNonEmpty(c.Query("orderNo"), "")
  285. if studentID == "" || orderNo == "" {
  286. var req DivideLogReq
  287. if len(c.Body()) > 0 {
  288. if !validateReq(c, &req) {
  289. return nil
  290. }
  291. }
  292. studentID = firstNonEmpty(studentID, req.StudentID)
  293. orderNo = firstNonEmpty(orderNo, req.OrderNo)
  294. }
  295. if studentID == "" || orderNo == "" {
  296. return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam,
  297. errString("撤销分账需要 studentId 与 orderNo")))
  298. }
  299. orderID, ok := parseInt64Param(c, orderNo, "orderNo")
  300. if !ok {
  301. return nil
  302. }
  303. if err := h.divideServiceFor(sc).RevokeLog(c.Context(), studentID, orderID); err != nil {
  304. return response.Fail(c, err)
  305. }
  306. return response.OK(c, fiber.Map{"deleted": true})
  307. }
  308. // trimOrEmpty 去首尾空格(空串归一)。
  309. func trimOrEmpty(raw string) string { return strings.TrimSpace(raw) }
  310. // firstNonEmpty 取第一个非空(去空格)值。
  311. func firstNonEmpty(values ...string) string {
  312. for _, v := range values {
  313. if strings.TrimSpace(v) != "" {
  314. return strings.TrimSpace(v)
  315. }
  316. }
  317. return ""
  318. }
  319. // parseInt64Param 把字符串形式的整型入参转 int64(失败 → 2002 并返回 false)。
  320. //
  321. // 为什么要转:契约把 `orderNo` 定为字符串(表列 varchar(50)),而 dump 的
  322. // `divide_into_log_add/delete` 拿的是整数 `recordid`。**不做静默兜底**(如当成 0)——
  323. // 那会把"传错单号"变成"给 record 0 记了一笔分成",是最难查的一类错。
  324. func parseInt64Param(c *fiber.Ctx, raw, field string) (int64, bool) {
  325. trimmed := strings.TrimSpace(raw)
  326. if trimmed == "" {
  327. _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString(field+" 不能为空")))
  328. return 0, false
  329. }
  330. var out int64
  331. for _, ch := range trimmed {
  332. if ch < '0' || ch > '9' {
  333. _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  334. errString(field+" 必须是数字字符串(收到 "+trimmed+")")))
  335. return 0, false
  336. }
  337. out = out*10 + int64(ch-'0')
  338. }
  339. if out <= 0 {
  340. _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat,
  341. errString(field+" 必须 > 0")))
  342. return 0, false
  343. }
  344. return out, true
  345. }