| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- // 财务端点处理器(端点 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
- }
|