// 分账端点处理器(端点 38 分账树 / 43 流水列表)——AC-13 第 1 批(读路径优先)。 // // 依据:unified/03-migration/academic-frontend-contract-v1.md §3.4 B/C 表 + 设计 §3.3 #22~#25。 // // 本批**只做两条读端点**,理由见文件末"本批范围";41/42 是写路径(要新增 `DeleteLogByID` // 等 Service 方法并配套事务用例),放在第 2 批,避免"半实现的写端点"混进首次提交。 // // --------------------------------------------------------------------------- // 量纲红线(mock 已在此错过一次,后端不得重犯) // --------------------------------------------------------------------------- // // `quota` = **金额**(元/次分成,`DECIMAL(12,2)`,两位小数字符串),**不是** 0~1 比率。 // // 本文件金额出参一律 `money.Amount`(JSON 两位小数字符串),DTO 里**不出现 float64**。 // // --------------------------------------------------------------------------- // 作用域口径(`t_user_divide_into*` 无 institution_id/campus_id,§1.7 例外二) // --------------------------------------------------------------------------- // // 两张表都是**全局推广体系**(推广关系不归属某个机构),数据层无法按机构过滤 ⇒ 防线只有两条: // 1. `scope.FromFiber` 必须存在(取不到 → 1001,避免匿名读全平台分账数据); // 2. 端点角色白名单(AC-14 落 RBAC):预期 `super_admin / finance / market`。 // // 这也是它与其他端点的本质差异:**分账树是跨机构的,不存在"本机构分账树"**。 package handler import ( "strings" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // 分账域路径常量(**不含 `/api`**,§4 口径表)。 const ( // PathDivideTree 端点 38:分账树(`GET /admin/divide-into/tree`)。 PathDivideTree = "/admin/divide-into/tree" // PathDivideMembers 端点 39:加入下线(`POST /admin/divide-into/members`)。 PathDivideMembers = "/admin/divide-into/members" // PathDivideMemberQuota 端点 40:改分成额度(`PUT /admin/divide-into/members/:userId/quota`)。 PathDivideMemberQuota = "/admin/divide-into/members/:userId/quota" // PathDivideLogs 端点 41/42/43:记 / 删 / 查分账流水(`/admin/divide-into/logs`)。 // // 注意端点 42 的定位方式是 **`studentId` + `orderNo`(query/body)**,不是 `:id` // (契约 §2.3 端点 42 原文:`studentId,orderNo`;dump `divide_into_log_delete(stuid, orderid)` 两参同构)。 PathDivideLogs = "/admin/divide-into/logs" ) // GetDivideTree 分账树(端点 38 `GET /admin/divide-into/tree`)。 // // 出参(成功):`{tree:[…], nodeCount, rootCount}` —— **包一层 `tree`** // (契约 §3.4 结构约定 2 的显式例外:树不是分页对象)。 // 节点:`userId / realName / parentUserId|null / quota:金额 / cumulativeIncome:金额 / layer / children[]`。 // 出参(失败):1001(无作用域)/ 5001(**Nested Set 不变式被破坏**,数据损坏)/ 5002(读库或金额异常)。 // // ⚠ 5001 是**有意的**:树坏了就**不返回**一棵"看起来对"的树。位移错位无法从当前数据推断原值, // 猜测式修复会把"确定性错误"变成"随机正确"(不变式 I1~I4 见 `service.divide_tree_service.go` 文件头)。 func (h *Handler) GetDivideTree(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } tree, err := h.divideServiceFor(sc).GetTree(c.Context(), c.Query("rootUserId")) if err != nil { return response.Fail(c, err) } return response.OK(c, tree) } // ListDivideLogs 分账流水列表(端点 43 `GET /admin/divide-into/logs`)。 // // 入参(query): // - `findUserId`(分成获得者;**表列是 `user_id`**,契约响应字段才叫 `findUserId`); // - `studentId`; // - `from`/`to`(**右开**:`from <= createTime < to`,与契约 §1.3 一致); // - `page`/`pageSize`(钳制上限 200)。 // // 出参(成功):`分页`——`{list,total,page,pageSize,pages}`,行字段见 `service.DivideLogView`。 // // ⚠ 列表端点用通用分页形状(键名 `list`),**不是**端点 17 的 `details` // (那是"汇总 + 明细"的复合 data,两者不要互相抄)。 // // 出参(失败):1001 / 2001(`from > to`)/ 2002(时间格式非法)/ 5002。 func (h *Handler) ListDivideLogs(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } from, _, ferr := parseTimeParam(c.Query("from"), "from") if ferr != nil { return response.Fail(c, ferr) } to, _, terr := parseTimeParam(c.Query("to"), "to") if terr != nil { return response.Fail(c, terr) } page, pageSize := parsePage(c) q := service.DivideLogQuery{ // 契约 §2.3 端点 43 的 query 参数名是 **`userId`**(响应行字段才叫 `findUserId`)。 // 同时接受 `findUserId` 作为过渡别名(老调用方/双跑脚本用),两者都给时以 `userId` 为准。 FindUserID: firstNonEmpty(c.Query("userId"), c.Query("findUserId")), StudentID: c.Query("studentId"), Page: page, PageSize: pageSize, } if !from.IsZero() { q.From = &from } if !to.IsZero() { q.To = &to } rows, total, err := h.divideServiceFor(sc).ListLogs(c.Context(), q) if err != nil { return response.Fail(c, err) } return response.Page(c, rows, page, pageSize, total) } // divideServiceFor 按作用域装配分账查询服务(分账域**不**做机构 scoping,见文件头"作用域口径")。 // // 姓名解析器本批未注入(`realName` 返回空串):姓名在 identity/org 域,academic 未持有 // `t_user` 结构(§1.3 跨域只读要经 port);AC-14 装配时注入真实实现即可,不影响树结构与金额。 // **不编造**姓名 —— 空串是显式的"未解析",前端按"未填"展示。 func (h *Handler) divideServiceFor(sc *appscope.Scope) *service.DivideTreeService { return service.NewDivideTreeService(h.db, sc, nil) } // 本批范围(AC-13 增量交付,明确边界防"以为做完了") // // ✅ 38 分账树(含 Nested Set 不变式 I1~I4 校验) // ✅ 43 分账流水列表(右开时间窗 + 同快照 total) // ⏳ 41 记流水 / 42 删流水 —— 需要新增 Service 写方法(`AddLog` 已存在、`DeleteLogByID` 需新增) // 并配套"撤销回滚 total_income 同事务"的跨表断言,放第 2 批 // ⏳ 22~37 活动 / 39~40 分账额度 / 44~57 课程排班 / 58~63 课酬维护 —— 后续批次 // ---------------------------------------------------------------- 端点 39:加入下线 // DivideMemberReq 加入下线请求体(端点 39)。 type DivideMemberReq struct { // ParentUserID 上级(ULID;必填)。 ParentUserID string `json:"parentUserId" validate:"required,max=26"` // SubUserID 下线(ULID;必填)。 SubUserID string `json:"subUserId" validate:"required,max=26"` } // AddDivideMember 加入下线(端点 39 `POST /admin/divide-into/members`)。 // // 出参(成功):`{ancestorIds:[]}`(新节点的祖先链,由外向内、不含自身)。 // 出参(失败):1001/2001/3111(层级超 100)/3001/5002。 // // ⚠ 新节点 `quota` 默认 **0.00**(D6)⇒ 前端应在成功后**引导设置额度**(端点 40), // 否则该链一分钱不分成且**不报错**(静默不入账)。 func (h *Handler) AddDivideMember(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req DivideMemberReq if !validateReq(c, &req) { return nil } res, err := h.divideServiceFor(sc).AddMember(c.Context(), req.ParentUserID, req.SubUserID) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ---------------------------------------------------------------- 端点 40:改额度 // DivideQuotaReq 改额度请求体(端点 40)。 // // `Quota` 是**金额字符串**(`"100.00"`)——**不是** 0~1 比率、不是 number(mock 曾错)。 type DivideQuotaReq struct { // Quota 新分成额度(元;必填,两位小数字符串)。 Quota string `json:"quota" validate:"required"` } // SetDivideQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。 // // 出参(成功):`{affectedUserIds:[]}`(含自身;= 子树内 `quota >= 新额度` 的节点)。 // 出参(失败):1001/2001/2002(quota 格式非法或为负)/3111(**新额度 > 父额度**)/3001/5002。 // // **3111 的判据是「新额度 > 父节点额度」**(D9 有意增强),不是 `quota > 1`: // 判据落在 `DivideService.UpdateQuota`(AC-09 已交付),本 handler 只做入参解析 + 幂等透传。 func (h *Handler) SetDivideQuota(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } userID := trimOrEmpty(c.Params("userId")) if userID == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空"))) } var req DivideQuotaReq if !validateReq(c, &req) { return nil } quota, ok := parseMoney(c, req.Quota, "quota") if !ok { return nil } res, err := h.divideServiceFor(sc).SetQuota(c.Context(), userID, quota) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ---------------------------------------------------------------- 端点 41 / 42:记 / 删流水 // DivideLogReq 记/删分账流水请求体(端点 41 / 42 共用)。 // // 契约 §2.3:41 `findUserId,studentId,orderNo,remark?`;42 `studentId,orderNo`。 // `OrderNo` 是**字符串**(表列 `order_no varchar(50)`),向下转 int64 交给 AC-09 的服务 // (dump 的 `recordid` 是整数;转换失败 → 2002,不静默丢单)。 type DivideLogReq struct { // FindUserID 分成获得者(ULID;端点 41 必填、42 忽略)。 FindUserID string `json:"findUserId" validate:"max=26"` // StudentID 学生(ULID;必填)。 StudentID string `json:"studentId" validate:"required,max=26"` // OrderNo 订单号/上课记录号(必填;字符串 → 内部转 int64)。 OrderNo string `json:"orderNo" validate:"required,max=50"` // Remark 备注(可空)。 Remark string `json:"remark" validate:"max=1000"` } // CreateDivideLogRecord 记分账流水(端点 41 `POST /admin/divide-into/logs`)。 // // 出参(成功):`{logged:true}`(dump 的 `log_add` 不返回 id;前端只关心"是否入账")。 // 出参(失败):1001/2001/2002(orderNo 非数字)/3111(上行超 100 层)/3001/5002。 // // **无金额入参**:分成额取自节点 `quota`(客户端传金额 = 前端定分成额的资金漏洞)。 func (h *Handler) CreateDivideLogRecord(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req DivideLogReq if !validateReq(c, &req) { return nil } if trimOrEmpty(req.FindUserID) == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("findUserId 不能为空"))) } orderID, ok := parseInt64Param(c, req.OrderNo, "orderNo") if !ok { return nil } if err := h.divideServiceFor(sc).AddLog(c.Context(), req.FindUserID, req.StudentID, orderID, req.Remark); err != nil { return response.Fail(c, err) } return response.OK(c, fiber.Map{"logged": true}) } // DeleteDivideLogRecord 删分账流水(端点 42 `DELETE /admin/divide-into/logs`)。 // // 定位方式(**不是 `:id`**):`studentId` + `orderNo`(query 优先,其次 body), // 与契约 §2.3 / dump `divide_into_log_delete(stuid, orderid)` 两参同构。 // 出参(成功):`{deleted:true}`;出参(失败):1001/2001/2002(orderNo 非数字)/5002。 func (h *Handler) DeleteDivideLogRecord(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } studentID := firstNonEmpty(c.Query("studentId"), "") orderNo := firstNonEmpty(c.Query("orderNo"), "") if studentID == "" || orderNo == "" { var req DivideLogReq if len(c.Body()) > 0 { if !validateReq(c, &req) { return nil } } studentID = firstNonEmpty(studentID, req.StudentID) orderNo = firstNonEmpty(orderNo, req.OrderNo) } if studentID == "" || orderNo == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("撤销分账需要 studentId 与 orderNo"))) } orderID, ok := parseInt64Param(c, orderNo, "orderNo") if !ok { return nil } if err := h.divideServiceFor(sc).RevokeLog(c.Context(), studentID, orderID); err != nil { return response.Fail(c, err) } return response.OK(c, fiber.Map{"deleted": true}) } // trimOrEmpty 去首尾空格(空串归一)。 func trimOrEmpty(raw string) string { return strings.TrimSpace(raw) } // firstNonEmpty 取第一个非空(去空格)值。 func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } // parseInt64Param 把字符串形式的整型入参转 int64(失败 → 2002 并返回 false)。 // // 为什么要转:契约把 `orderNo` 定为字符串(表列 varchar(50)),而 dump 的 // `divide_into_log_add/delete` 拿的是整数 `recordid`。**不做静默兜底**(如当成 0)—— // 那会把"传错单号"变成"给 record 0 记了一笔分成",是最难查的一类错。 func parseInt64Param(c *fiber.Ctx, raw, field string) (int64, bool) { trimmed := strings.TrimSpace(raw) if trimmed == "" { _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString(field+" 不能为空"))) return 0, false } var out int64 for _, ch := range trimmed { if ch < '0' || ch > '9' { _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString(field+" 必须是数字字符串(收到 "+trimmed+")"))) return 0, false } out = out*10 + int64(ch-'0') } if out <= 0 { _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString(field+" 必须 > 0"))) return 0, false } return out, true }