// 财务端点处理器(端点 1~16,`/admin/finance/*`)。 // // 依据:unified/03-migration/academic-domain-design.md §4.1(逐行对应 18 个端点里的 16 个); // 端点 17/21 在 bill_handler.go。路由挂载在 AC-14 完成(本文件只定义方法 + 路径常量)。 // // --------------------------------------------------------------------------- // 端点 → 方法 → 服务(一览,评审按此表核对覆盖) // --------------------------------------------------------------------------- // // 1 GET /admin/finance/accounts/:userId GetAccount → FinanceQueryService.AccountSnapshot // 2 GET /admin/finance/accounts/:userId/refundable GetRefundable → FundService.RefundableAmount // 3 POST /admin/finance/recharges CreateRecharge → FundService.Recharge // 4 POST /admin/finance/expenditures CreateExpenditure → FundService.Expenditure // 5 POST /admin/finance/refunds CreateRefund → FundService.Refund // 6 POST /admin/finance/refunds/custom CreateCustomRefund→ RefundService.CustomRefund // 7 POST /admin/finance/transfers CreateTransfer → FundService.Transfer // 8 POST /admin/finance/bad-debts CreateBadDebt → FundService.BadDebt // 9 POST /admin/finance/cleared-balances ClearBalance → RefundService.ClearBalance // 10 POST /admin/finance/spends AddSpend → FundService.AccumulateSpend // 11 GET /admin/finance/logs ListLogs → FinanceQueryService.ListLogs // 12 GET /admin/finance/requests ListRequests → FinanceQueryService.ListRequests // 13 POST /admin/finance/requests CreateRequest → BalanceRequestService.Create // 14 POST /admin/finance/requests/:id/approve ApproveRequest → BalanceRequestService.Approve // 15 POST /admin/finance/requests/:id/reject RejectRequest → BalanceRequestService.Reject // 16 POST /admin/finance/requests/:id/cancel CancelRequest → BalanceRequestService.Cancel // // --------------------------------------------------------------------------- // 三条口径备注(都是"看起来能省、其实一改就错"的地方) // --------------------------------------------------------------------------- // // 1. **端点 5 的 `changed` 恒 true**(§3.8-R6 定稿:金额 <=0 一律入口 2002): // 字段仅为契约兼容保留,语义 = "成功即发生账户写入"。**不要**改成"按实际变化置 false"—— // 前端契约 v1.2 已按恒 true 对接,改成 false 会让前端走"未发生写入"的降级分支(静默不刷新)。 // 2. **错误码一律引 apperr 常量**(31xx 段 19 个码),Handler 内**不出现数字字面量**: // 这样"错误码是否越界/是否重复"由 `apperr.codes_test.go` 统一守,handler 改动不会漏检。 // Handler 自己需要的通用码(1001/2001/2002/5002)引 infra/errors 的常量。 // 3. **枚举白名单在 Handler 与 Service 各校验一次**:Handler 抢先是为了给出**字段级**文案 // ("type 不在消费白名单"),Service 再校验是红线(Handler 可被绕过/复用,业务不变量不能只靠接入层)。 package handler import ( "strings" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/service" infraerrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" ) // billSpendTypes 端点 4 允许的消费类型白名单(与 dump `sp_user_expense_school_expenditure` 一致)。 var expenditureAllowedTypes = map[int32]struct{}{ model.ExpenseLogTypeCourseCampus: {}, // 101 model.ExpenseLogTypeCourseSchoolA: {}, // 104 model.ExpenseLogTypeCourseSchoolB: {}, // 105 model.ExpenseLogTypeHistoryDeduct: {}, // 200 model.ExpenseLogTypeCourseCampusB: {}, // 201 model.ExpenseLogTypeCourseSchoolC: {}, // 204 model.ExpenseLogTypeCourseSchoolD: {}, // 205 } // requestAllowedTypes 端点 13 允许创建的工单类型白名单(§2「4.」类型枚举)。 var requestAllowedTypes = map[int32]struct{}{ model.BalanceRequestTypeRecharge: {}, // 100 model.BalanceRequestTypeBadDebt: {}, // 101 model.BalanceRequestTypeRefund: {}, // 202 model.BalanceRequestTypeCancelRecharge: {}, // 203 model.BalanceRequestTypeTransfer: {}, // 300 } // ---------------------------------------------------------------- 端点 1:账户快照 // GetAccount 账户快照(端点 1 `GET /admin/finance/accounts/:userId`)。 // // 入参(query,均可选):`institutionId`/`campusId`——越权判定输入 + 校区账户定位维度。 // 出参(成功):`AccountSnapshot{userId, global{6 列}, campus|null}`。 // 出参(失败):1001(无作用域)/2001(缺 userId)/1004(越权)/5002。 func (h *Handler) GetAccount(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } userID := strings.TrimSpace(c.Params("userId")) if userID == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空"))) } svc := h.servicesFor(sc) snapshot, err := svc.query.AccountSnapshot(c.Context(), userID, parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId"))) if err != nil { return response.Fail(c, err) } return response.OK(c, snapshot) } // ---------------------------------------------------------------- 端点 2:可退金额 // GetRefundable 可退金额(端点 2 `GET /admin/finance/accounts/:userId/refundable`)。 // // 入参(query,**必填**):`institutionId`/`campusId`(缺 → 2001,由 Service.validateUserScope 判定)。 // 出参(成功):`{refundableAmount}`(**两步修正**口径:截到全局余额 + 减已用赠送额,§3.1 #5)。 // 出参(失败):1001/2001/2370… → 具体为 2001/3140/1004/5002。 // // ⚠ 与端点 6 的同名字段**不是一回事**:端点 6 的 `refundableAmount` 是"校区 balance 本身"(SP9 口径), // 本端点返回的是两步修正后的值。前端/用例**不得**互相当基准(§4.6 口径警示)。 func (h *Handler) GetRefundable(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } userID := strings.TrimSpace(c.Params("userId")) if userID == "" { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("userId 不能为空"))) } svc := h.servicesFor(sc) amount, err := svc.fund.RefundableAmount(c.Context(), userID, parseIDParam(c.Query("institutionId")), parseIDParam(c.Query("campusId"))) if err != nil { return response.Fail(c, err) } return response.OK(c, fiber.Map{"refundableAmount": amount}) } // ---------------------------------------------------------------- 端点 3:充值 // CreateRecharge 充值(端点 3 `POST /admin/finance/recharges`,SP1 `sp_user_expense_recharge`)。 // // 出参(成功):`{logId,balance,repeated}`(`balance` = 充值后**校区**余额,`repeated=true` 表示幂等命中)。 // 出参(失败):1001/2001(缺参)/2002(金额非法或 <=0)/1004/5002。 func (h *Handler) CreateRecharge(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req RechargeReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.Recharge(c.Context(), service.RechargeInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CreateExpenditure 消费(端点 4 `POST /admin/finance/expenditures`,SP3)。 // // 出参(成功):`{logId,balance,repeated}`。 // 出参(失败):1001/2001/2002(金额非法 / type 不在白名单)/1004/5002。 func (h *Handler) CreateExpenditure(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req ExpenditureReq if !validateReq(c, &req) { return nil } if _, ok := expenditureAllowedTypes[req.Type]; !ok { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("type 不在消费白名单(101/104/105/200/201/204/205)"))) } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.Expenditure(c.Context(), service.ExpenditureInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CreateRefund 退费(端点 5 `POST /admin/finance/refunds`,SP4)。 // // 出参(成功):`{logId,balance,changed,repeated}`——**`changed` 恒 true**(§3.8-R6: // `money<=0` 一律 2002,故"成功"必然伴随账户写入;字段仅契约兼容保留)。 // 出参(失败):1001/2001/2002(金额非法/<=0 或 type 非法)/1004/5002。 func (h *Handler) CreateRefund(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req RefundReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.Refund(c.Context(), service.RefundInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, Type: req.Type, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CreateCustomRefund 自定义退费(端点 6 `POST /admin/finance/refunds/custom`,SP9 双 log 坏账)。 // // 出参(成功):`CustomRefundResult`(§4.6 逐字段;`warnings[]` 空时 `[]`、`logId108` 无坏账时 null)。 // 出参(失败):1001/2001(含缺 requestId)/2002(金额非法或 <=0)/3102(校区余额 <=0)/ // 3104(校区不存在)/1004/5002。 func (h *Handler) CreateCustomRefund(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CustomRefundReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.refund.CustomRefund(c.Context(), service.CustomRefundInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CreateTransfer 课费转结(端点 7 `POST /admin/finance/transfers`,SP6)。 // // 出参(成功):`{logId106,logId206,fromBalance,toBalance,repeated}` // (两条流水共用同一 `request_id`、`type` 不同故不撞幂等键;`balance` 本身不动)。 // 出参(失败):1001/2001/2002/3140(转入方机构冲突)/3001(源账户缺失)/1004/5002。 func (h *Handler) CreateTransfer(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req TransferReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.Transfer(c.Context(), service.TransferInput{ UserID: req.UserID, FromInstitutionID: req.FromInstitutionID, FromCampusID: req.FromCampusID, ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID, Money: amount, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CreateBadDebt 坏账处理(端点 8 `POST /admin/finance/bad-debts`,SP7)。 // // 出参(成功):`{logId,specialExpenses,balance,repeated}` // (⚠ dump 口径:坏账是 `balance` 与 `special_expenses` **双增**,故 `balance` 已含本次增量)。 // 出参(失败):1001/2001/2002/1004/5002。 func (h *Handler) CreateBadDebt(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req BadDebtReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.BadDebt(c.Context(), service.BadDebtInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, RequestID: strings.TrimSpace(req.RequestID), Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ClearBalance 余额清零(端点 9 `POST /admin/finance/cleared-balances`,SP10)。 // // 出参(成功):`{logId208,cleared,balance,totalBalance,specialExpenses,expiredActivityIds,repeated}`。 // 出参(失败):1001/2001/3120(三条前置校验之一不满足)/1004/5002。 func (h *Handler) ClearBalance(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req ClearBalanceReq if !validateReq(c, &req) { return nil } svc := h.servicesFor(sc) res, err := svc.refund.ClearBalance(c.Context(), service.ClearBalanceInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Remark: req.Remark, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // AddSpend 累计消费(端点 10 `POST /admin/finance/spends`,SP11)。 // // 出参(成功):`{spend}`(**`spend` 列,不是 `balance`**——本端点不动余额)。 // 出参(失败):1001/2001/2002/1004/5002。 func (h *Handler) AddSpend(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req SpendReq if !validateReq(c, &req) { return nil } amount, ok := parseMoney(c, req.SpendMoney, "spendMoney") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.fund.AccumulateSpend(c.Context(), service.SpendInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, SpendMoney: amount, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ---------------------------------------------------------------- 端点 11 / 12:列表 // ListLogs 流水列表(端点 11 `GET /admin/finance/logs`)。 // // 入参(query):`userId`(**非超管必填**,理由见 FinanceQueryService.ListLogs)、 // `type`、`from`、`to`、`page`、`pageSize`(钳制上限 200)。 // 出参(成功):`PageData{list,total,page,pageSize,pages}`。 // 出参(失败):1001/2001(缺 userId 且非超管)/2002(type/时间格式非法)/1004/5002。 func (h *Handler) ListLogs(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } typeFilter, ok := parseOptionalInt32(c, "type") if !ok { return nil } 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) svc := h.servicesFor(sc) in := service.ListLogsInput{ UserID: c.Query("userId"), Type: typeFilter, Page: page, PageSize: pageSize, } if !from.IsZero() { in.From = &from } if !to.IsZero() { in.To = &to } rows, total, err := svc.query.ListLogs(c.Context(), in) if err != nil { return response.Fail(c, err) } return response.Page(c, rows, page, pageSize, total) } // ListRequests 工单列表(端点 12 `GET /admin/finance/requests`)。 // // 入参(query):`type`(100/101/202/203/300)、`approved`(-2/-1/0/1)、`userId`、分页。 // 出参(成功):`PageData`;list 元素为 `RequestView`(**不含 `imgs`**,AC-12 验收 ⑥)。 // 出参(失败):1001/2002(type/approved 非法)/5002。 func (h *Handler) ListRequests(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } typeFilter, ok := parseOptionalInt32(c, "type") if !ok { return nil } approved, ok := parseOptionalInt8(c, "approved") if !ok { return nil } page, pageSize := parsePage(c) svc := h.servicesFor(sc) rows, total, err := svc.query.ListRequests(c.Context(), service.ListRequestsInput{ Type: typeFilter, Approved: approved, UserID: c.Query("userId"), Page: page, PageSize: pageSize, }) if err != nil { return response.Fail(c, err) } return response.Page(c, rows, page, pageSize, total) } // ---------------------------------------------------------------- 端点 13~16:工单写路径 // CreateRequest 创建余额工单(端点 13 `POST /admin/finance/requests`)。 // // 出参(成功):`{id,bizNo}`(`approved` 恒 0 —— 资金变动只由端点 14 触发)。 // 出参(失败):1001/2001(缺参/类型缺失)/2002(金额非法/类型不在白名单)/5002。 func (h *Handler) CreateRequest(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } var req CreateRequestReq if !validateReq(c, &req) { return nil } if _, ok := requestAllowedTypes[req.Type]; !ok { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("type 不在工单白名单(100/101/202/203/300)"))) } amount, ok := parseMoney(c, req.Money, "money") if !ok { return nil } svc := h.servicesFor(sc) res, err := svc.requests.Create(c.Context(), service.CreateRequestInput{ Type: req.Type, UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, ToInstitutionID: req.ToInstitutionID, ToCampusID: req.ToCampusID, Money: amount, TransferAccountsType: req.TransferAccountsType, OrderNo: req.OrderNo, Item: req.Item, Imgs: req.Imgs, Remark: req.Remark, RequestUserID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ApproveRequest 审批通过(端点 14 `POST /admin/finance/requests/:id/approve`)。 // // **资金触发点**:同一事务内先调对应资金方法(幂等键 = 工单 `bizNo`)、后 CAS 回写 `approved=1`。 // 出参(成功):`{approved:1,type,bizNo,result:{...},repeated}`。 // 出参(失败):1001/2001/3002(工单已处理/并发被抢)/3140(类型 203 不支持,或转结缺转入维度)/ // 资金方法的 31xx(3101/3102/3104/3120…)/1004/5002。 func (h *Handler) ApproveRequest(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } id := parseIDParam(c.Params("id")) if id <= 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法"))) } // body 可空("一键通过"),因此**不**用 validateReq(否则空 body 会被判 2001) var req ApproveReq if len(c.Body()) > 0 { if !validateReq(c, &req) { return nil } } svc := h.servicesFor(sc) res, err := svc.requests.Approve(c.Context(), service.ApproveRequestInput{ ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // RejectRequest 审批驳回(端点 15 `POST /admin/finance/requests/:id/reject`)。 // // 出参(成功):`{approved:-1,id}`;**无资金动作**。 // 出参(失败):1001/2001(缺 responseMsg)/3002(已处理)/5002。 func (h *Handler) RejectRequest(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } id := parseIDParam(c.Params("id")) if id <= 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法"))) } var req RejectReq if !validateReq(c, &req) { return nil } svc := h.servicesFor(sc) res, err := svc.requests.Reject(c.Context(), service.RejectRequestInput{ ID: id, ResponseMsg: req.ResponseMsg, OperatorID: sc.UserID, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // CancelRequest 撤销工单(端点 16 `POST /admin/finance/requests/:id/cancel`)。 // // 权限:申请人本人 或 super_admin/finance(Service 判定,越权 → **1003**)。 // 出参(成功):`{approved:-2,id}`;出参(失败):1001/2001/1003/3002/5002。 func (h *Handler) CancelRequest(c *fiber.Ctx) error { sc, err := mustScope(c) if err != nil { return response.Fail(c, err) } id := parseIDParam(c.Params("id")) if id <= 0 { return response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadParam, errString("工单 id 非法"))) } svc := h.servicesFor(sc) res, err := svc.requests.Cancel(c.Context(), service.CancelRequestInput{ ID: id, OperatorID: sc.UserID, Role: sc.Role, SuperAdmin: sc.SuperAdmin, }) if err != nil { return response.Fail(c, err) } return response.OK(c, res) } // ---------------------------------------------------------------- 查询参数助手 // parseOptionalInt32 解析可选 int32 query(未传 → nil;格式非法 → 2002 并返回 false)。 // // 入参:c;key query 名。 // 出参:(*int32, bool);bool=false 表示失败响应已写出。 func parseOptionalInt32(c *fiber.Ctx, key string) (*int32, bool) { raw := strings.TrimSpace(c.Query(key)) if raw == "" { return nil, true } value, ok := strconvInt32(raw) if !ok { _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("查询参数 "+key+" 非法(需整数)"))) return nil, false } return &value, true } // parseOptionalInt8 解析可选 int8 query(工单状态 -2/-1/0/1)。 // // 入参:c;key query 名。出参:(*int8, bool);bool=false 表示失败响应已写出。 func parseOptionalInt8(c *fiber.Ctx, key string) (*int8, bool) { raw := strings.TrimSpace(c.Query(key)) if raw == "" { return nil, true } value, ok := strconvInt64(raw) if !ok || value < -128 || value > 127 { _ = response.Fail(c, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("查询参数 "+key+" 非法(需整数)"))) return nil, false } out := int8(value) return &out, true } // strconvInt32 手写 int32 解析(接受可选负号;避免引入 strconv 依赖歧义)。 // // 入参:raw 字符串。出参:(值, 是否成功)。 func strconvInt32(raw string) (int32, bool) { value, ok := strconvInt64(raw) if !ok || value < -2147483648 || value > 2147483647 { return 0, false } return int32(value), true } // strconvInt64 手写 int64 解析(接受可选前导负号;非数字即失败)。 // // 入参:raw 字符串。出参:(值, 是否成功)。 func strconvInt64(raw string) (int64, bool) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return 0, false } negative := false if trimmed[0] == '-' { negative = true trimmed = trimmed[1:] } if trimmed == "" { return 0, false } var out int64 for _, ch := range trimmed { if ch < '0' || ch > '9' { return 0, false } next := out*10 + int64(ch-'0') if next < out { // 溢出回绕检测 return 0, false } out = next } if negative { out = -out } return out, true }