// 余额工单服务(端点 13~16:创建 / 通过 / 驳回 / 撤销)。 // // 依据:unified/03-migration/academic-domain-design.md // - §4.1 端点 13~16;§2「4. t_user_balance_request」的资金变动触发点定义: // **「approved 由 0 → 1 的审批动作」是唯一触发点,且 Service 同事务内先调资金方法、后回写工单** // - §10.3 事务模板:`repository.WithinTx` + 事务内调下游 service **必须** `WithTxContext` 透传 // - §5.1 纪律 5(幂等回放等价):审批通过时以工单 `biz_no` 作 `request_id`—— // 同一工单被重复点"通过"不会二次入账(资金侧 `uk_request_type` 兜底,且 CAS 只让一次成功)。 // // --------------------------------------------------------------------------- // 为什么本服务存在(设计 §8 的 AC-12 文件清单里没有它) // --------------------------------------------------------------------------- // // AC-12 的文件清单只列了 handler 文件,但端点 14「通过」不是"读一眼再写一个状态": // 它必须**按工单类型分派到对应资金方法**(100 充值 / 101 坏账 / 202 退费 / 300 转结), // 且资金写入与工单状态回写必须**同一个事务**(§10.3)。把这套编排放在 Handler 里 // 会同时违反两条纪律(Handler 不写业务、事务边界只在 Service),故新增本服务承载, // 已在交付报告中登记为偏差。 // // --------------------------------------------------------------------------- // 审批状态机(只允许 0 → {1, -1, -2},终态不可再变) // --------------------------------------------------------------------------- // // 0 未处理 ──approve──▶ 1 已通过(同事务触发资金方法;以 biz_no 作幂等 request_id) // 0 未处理 ──reject───▶ -1 不通过(无资金动作) // 0 未处理 ──cancel───▶ -2 取消(仅申请人本人或 super_admin/finance;无资金动作) // 终态再点 → 3002(数据冲突):**不做"静默成功"**,因为"钱到底动没动"必须一眼可判 package service import ( "context" "crypto/rand" "encoding/hex" "fmt" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/port" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // 工单审批状态在"可操作"判断中的语义常量(复用 model 的取值,此处只做局部命名便于阅读)。 const ( // requestPending 未处理(唯一可被 approve/reject/cancel 的状态)。 requestPending int8 = model.BalanceRequestApprovedPending // requestPassed 已通过(终态)。 requestPassed int8 = model.BalanceRequestApprovedPassed // requestRejected 不通过(终态)。 requestRejected int8 = model.BalanceRequestApprovedRejected // requestCancelled 取消(终态)。 requestCancelled int8 = model.BalanceRequestApprovedCancelled ) // BalanceRequestService 余额工单服务(创建 / 审批 / 驳回 / 撤销)。 // // 字段: // - requests 工单仓储(加锁读 + CAS 推进); // - runner 事务运行器(资金方法 + 工单回写同一事务); // - fund 资金服务(充值/消费/退费/转结/坏账的**唯一实现**,本服务只分派、不重复实现); // - member 成员校验器(可 nil); // - sc 请求作用域(可 nil:系统任务视角)。 type BalanceRequestService struct { requests *repository.BalanceRequestRepo runner *repository.TxRunner fund *FundService member port.MembershipChecker sc *appscope.Scope } // NewBalanceRequestService 构造余额工单服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域);member port.MembershipChecker(可 nil)。 // 出参:*BalanceRequestService(内部 FundService 用同一 db/scope 装配,保证同一事务可透传)。 func NewBalanceRequestService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *BalanceRequestService { return &BalanceRequestService{ requests: repository.NewBalanceRequestRepo(db, sc), runner: repository.NewTxRunner(db), fund: NewFundService(db, sc, member), member: member, sc: sc, } } // ---------------------------------------------------------------- 端点 13:创建工单 // CreateRequestInput 创建工单入参(端点 13 `POST /admin/finance/requests`)。 // // 字段: // - Type 工单类型(100 普通充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结); // - UserID 目标用户; // - InstitutionID/CampusID 作用域维度; // - Money 金额(**已由 Handler 用 money.Parse 解析**,拒绝 float); // - TransferAccountsType 收款方式(0/1/2/3); // - OrderNo/Item/Imgs/Remark 附注(Imgs 只写不读,API 不回显); // - ToInstitutionID/ToCampusID 仅 type=300 转结使用。 type CreateRequestInput struct { // Type 工单类型。 Type int32 // UserID 目标用户(ULID)。 UserID string // InstitutionID 机构 ID。 InstitutionID int64 // CampusID 校区 ID。 CampusID int64 // ToInstitutionID 转入机构(仅 300)。 ToInstitutionID int64 // ToCampusID 转入校区(仅 300)。 ToCampusID int64 // Money 金额(元)。 Money money.Amount // TransferAccountsType 收款方式。 TransferAccountsType int8 // OrderNo 交易单号(可空)。 OrderNo string // Item 项目(可空)。 Item string // Imgs 凭证图片 JSON 串(可空;**仅落库,不回显**)。 Imgs string // Remark 备注(可空)。 Remark string // RequestUserID 申请人(ULID;由 Handler 从作用域取)。 RequestUserID string } // CreateRequestResult 创建工单结果(端点 13 响应 `{id,bizNo}`)。 type CreateRequestResult struct { // ID 工单 ID。 ID int64 `json:"id"` // BizNo 业务单号(对外单号,同时是审批通过时的幂等 request_id)。 BizNo string `json:"bizNo"` } // requestTypesCreatable 可创建的工单类型白名单(与 §2「4.」的类型枚举对齐)。 var requestTypesCreatable = map[int32]struct{}{ model.BalanceRequestTypeRecharge: {}, // 100 model.BalanceRequestTypeBadDebt: {}, // 101 model.BalanceRequestTypeRefund: {}, // 202 model.BalanceRequestTypeCancelRecharge: {}, // 203 model.BalanceRequestTypeTransfer: {}, // 300 } // Create 创建余额工单(端点 13)。 // // 入参:ctx;in CreateRequestInput。 // 出参:*CreateRequestResult;错误码 2001(缺参/类型非白名单)/2002(金额非法)/5002。 // // 红线(设计 §2):`approved` **一律 0**(不得创建即通过)——资金变动必须走审批链; // 本方法也**不**触发任何资金动作(真正的触发点是 Approve)。 func (s *BalanceRequestService) Create(ctx context.Context, in CreateRequestInput) (*CreateRequestResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 需要 user_id")) } if _, ok := requestTypesCreatable[in.Type]; !ok { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 工单类型不在白名单(100/101/202/203/300)")) } if in.InstitutionID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CreateRequest 需要 institution_id(>0)")) } if in.Type == model.BalanceRequestTypeTransfer { if in.ToInstitutionID <= 0 || in.ToCampusID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 转结工单必须指定 toInstitutionId/toCampusId")) } } else if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil { return nil, err } if in.Type == model.BalanceRequestTypeTransfer { if err := validatePositiveAmount(in.Money, "CreateRequest"); err != nil { return nil, err } } bizNo, err := newRequestBizNo() if err != nil { return nil, err } row := &model.UserBalanceRequest{ BizNo: bizNo, Type: in.Type, UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID, Money: in.Money.String(), TransferAccountsType: in.TransferAccountsType, RequestUserID: in.RequestUserID, Approved: requestPending, } now := time.Now() row.RequestTime = &now if in.Type == model.BalanceRequestTypeTransfer { toInst, toCampus := in.ToInstitutionID, in.ToCampusID row.ToInstitutionID = &toInst row.ToCampusID = &toCampus } row.OrderNo = optionalString(in.OrderNo) row.Item = optionalString(in.Item) row.Imgs = optionalString(in.Imgs) row.Remark = optionalString(in.Remark) if err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { return s.requests.Insert(repository.WithTxContext(ctx, tx), tx, row) }); err != nil { if strings.Contains(err.Error(), "重复的业务单号") { return nil, infraerrors.WithCause(infraerrors.ErrConflict, errString("service: 工单号重复(biz_no 撞键),请重试")) } return nil, err } return &CreateRequestResult{ID: row.ID, BizNo: row.BizNo}, nil } // newRequestBizNo 生成工单业务单号(32 位 hex = 16 字节随机,列宽 `varchar(32)`)。 // // 为什么不用 ULID:旧库 `t_user_balance_request.id` 是 32 位 UUID(无连字符), // 目标态 `biz_no` 尺寸就是按它定的;这里用同一长度/字符集的随机串, // 迁移与回滚时"旧 UUID ↔ 新 biz_no"在形态上不可区分(不需要额外转换规则)。 // // 出参:32 位小写 hex;随机源不可用 → ErrInternal(**不降级成时间戳**:可预测单号会被撞键)。 func newRequestBizNo() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 工单号随机源不可用: "+err.Error())) } return hex.EncodeToString(buf), nil } // ---------------------------------------------------------------- 端点 14:审批通过 // ApproveRequestInput 审批通过入参(端点 14 `POST /admin/finance/requests/:id/approve`)。 type ApproveRequestInput struct { // ID 工单 ID。 ID int64 // ResponseMsg 审批意见(可空)。 ResponseMsg string // OperatorID 审批人(ULID;写 response_user_id)。 OperatorID string } // ApproveRequestResult 审批通过结果(端点 14 响应 `{approved:1,result:{...}}`)。 type ApproveRequestResult struct { // Approved 恒 1(通过)。 Approved int8 `json:"approved"` // Type 工单类型(便于前端按类型解释 result 形状)。 Type int32 `json:"type"` // BizNo 工单号(= 资金流水的 request_id,对账锚点)。 BizNo string `json:"bizNo"` // Result 对应资金方法的返回值(各类型形状不同:`{logId,balance,repeated}` / `{logId,specialExpenses,...}` / `{logId106,logId206,...}`)。 Result any `json:"result"` // Repeated true = 资金侧幂等命中(工单此前已通过过、或同一 biz_no 已入账)。 Repeated bool `json:"repeated"` } // Approve 审批通过(端点 14):**同一事务**内先调资金方法、后回写工单状态。 // // 入参:ctx;in ApproveRequestInput。 // 出参:*ApproveRequestResult;错误码 2001/3001/3002/31xx(资金方法透传)/1004/5002。 // // 时序(不可调整,§10.3 + §5.1 纪律 5): // // ① 事务开始 // ② `GetForUpdate`:加行锁读工单(断言 approved == 0,否则 3002 —— 终态不再触发资金) // ③ 按类型分派资金方法(`WithTxContext(ctx, tx)` 透传同一事务) // 幂等键 = 工单 `biz_no`:重复审批时资金侧撞 `uk_request_type` → 回放首次结果 // ④ `UpdateApproved(+1)` CAS(`WHERE approved = 0`):影响 0 行 ⇒ 并发被抢 ⇒ 3002 回滚 // ⑤ 提交 // // ⚠ 203(取消充值活动)**在本端点不支持**:`t_user_balance_request` 没有 activity_id 列 // (dump 也没有),金额/活动只能靠 `item`/`order_no` 自由文本猜——猜错就是把钱退给错的活动。 // 该动作已有专用端点 30(`RefundService.CancelRechargeActivity`,入参含 activityId), // 因此这里显式 3140 并给出文案,**不静默跳过**(跳过会让"审批通过"变成什么都没发生)。 func (s *BalanceRequestService) Approve(ctx context.Context, in ApproveRequestInput) (*ApproveRequestResult, error) { if in.ID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Approve 需要工单 id")) } out := &ApproveRequestResult{} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) req, err := s.requests.GetForUpdate(txCtx, tx, in.ID) if err != nil { return err } if req.Approved != requestPending { return infraerrors.WithCause(infraerrors.ErrConflict, errString(fmt.Sprintf("service: 工单 %d 已处理(approved=%d),不会重复触发资金动作", req.ID, req.Approved))) } amount, perr := money.Parse(req.Money) if perr != nil { return infraerrors.WithCause(infraerrors.ErrDBError, perr) } result, repeated, derr := s.dispatchByType(txCtx, req, amount, in.OperatorID) if derr != nil { return derr } now := time.Now() operator := in.OperatorID rows, uerr := s.requests.UpdateApproved(txCtx, tx, req.ID, requestPassed, &operator, &now) if uerr != nil { return uerr } if rows == 0 { return infraerrors.WithCause(infraerrors.ErrConflict, errString("service: 工单状态已被并发改动(CAS 影响 0 行)")) } if strings.TrimSpace(in.ResponseMsg) != "" { if _, merr := s.requests.UpdateResponseMsg(txCtx, tx, req.ID, in.ResponseMsg); merr != nil { return merr } } out.Approved = requestPassed out.Type = req.Type out.BizNo = req.BizNo out.Result = result out.Repeated = repeated return nil }) if err != nil { return nil, err } return out, nil } // dispatchByType 按工单类型分派到对应资金方法(**唯一实现复用,不重复写资金逻辑**)。 // // 入参:txCtx 携带事务的上下文(必须透传给下游 service);req 工单行;amount 已解析金额;operatorID 操作人。 // 出参:(资金方法返回值, 是否幂等命中, error)。 // // 分派表(工单类型 → 资金方法 → 流水类型): // // 100 普通充值 → FundService.Recharge → type=100 // 101 坏账 → FundService.BadDebt → type=108(balance 与 special_expenses 双增) // 202 退费 → FundService.Refund → type=207 // 300 转结 → FundService.Transfer → type=106/206(两条流水共用 request_id) // 203 取消充值活动 → **不支持**(见 Approve 注释)→ 3140 func (s *BalanceRequestService) dispatchByType( txCtx context.Context, req *model.UserBalanceRequest, amount money.Amount, operatorID string, ) (any, bool, error) { remark := "" if req.Remark != nil { remark = *req.Remark } switch req.Type { case model.BalanceRequestTypeRecharge: res, err := s.fund.Recharge(txCtx, RechargeInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, Type: model.ExpenseLogTypeRecharge, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID, }) if err != nil { return nil, false, err } return res, res.Repeated, nil case model.BalanceRequestTypeBadDebt: res, err := s.fund.BadDebt(txCtx, BadDebtInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID, }) if err != nil { return nil, false, err } return res, res.Repeated, nil case model.BalanceRequestTypeRefund: res, err := s.fund.Refund(txCtx, RefundInput{ UserID: req.UserID, InstitutionID: req.InstitutionID, CampusID: req.CampusID, Money: amount, Type: model.ExpenseLogTypeRefund, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID, }) if err != nil { return nil, false, err } return res, res.Repeated, nil case model.BalanceRequestTypeTransfer: if req.ToInstitutionID == nil || req.ToCampusID == nil { return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 转结工单缺少 toInstitutionId/toCampusId,无法执行")) } res, err := s.fund.Transfer(txCtx, TransferInput{ UserID: req.UserID, FromInstitutionID: req.InstitutionID, FromCampusID: req.CampusID, ToInstitutionID: *req.ToInstitutionID, ToCampusID: *req.ToCampusID, Money: amount, RequestID: req.BizNo, Remark: remark, OperatorID: operatorID, }) if err != nil { return nil, false, err } return res, res.Repeated, nil case model.BalanceRequestTypeCancelRecharge: return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 工单类型 203(取消充值活动)无 activity_id 列,不能在本端点执行;"+ "请用活动端点 30 直接取消(RefundService.CancelRechargeActivity)")) default: return nil, false, infraerrors.WithCause(apperr.ErrBusinessParam, errString(fmt.Sprintf("service: 工单类型 %d 不支持审批入账", req.Type))) } } // ---------------------------------------------------------------- 端点 15 / 16:驳回 / 撤销 // RejectRequestInput 驳回入参(端点 15 `POST /admin/finance/requests/:id/reject`)。 type RejectRequestInput struct { // ID 工单 ID。 ID int64 // ResponseMsg 驳回意见(**必填**:驳回必须说明理由,便于对账与申诉)。 ResponseMsg string // OperatorID 审批人。 OperatorID string } // RejectRequestResult 驳回结果(端点 15 响应 `{approved:-1}`)。 type RejectRequestResult struct { // Approved 恒 -1。 Approved int8 `json:"approved"` // ID 工单 ID。 ID int64 `json:"id"` } // Reject 驳回工单(端点 15):仅 approved=0 可驳回;**无任何资金动作**。 // // 入参:ctx;in RejectRequestInput。出参:*RejectRequestResult;2001/3002/5002。 func (s *BalanceRequestService) Reject(ctx context.Context, in RejectRequestInput) (*RejectRequestResult, error) { if in.ID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要工单 id")) } if strings.TrimSpace(in.ResponseMsg) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Reject 需要 responseMsg")) } if err := s.pendingTransition(ctx, in.ID, requestRejected, in.OperatorID); err != nil { return nil, err } return &RejectRequestResult{Approved: requestRejected, ID: in.ID}, nil } // CancelRequestInput 撤销入参(端点 16 `POST /admin/finance/requests/:id/cancel`)。 type CancelRequestInput struct { // ID 工单 ID。 ID int64 // OperatorID 操作人(申请人本人或 super_admin/finance)。 OperatorID string // Role 操作人角色(作用域角色码;用于"本人或财务"判定)。 Role string // SuperAdmin 是否平台超管。 SuperAdmin bool } // CancelRequestResult 撤销结果(端点 16 响应 `{approved:-2}`)。 type CancelRequestResult struct { // Approved 恒 -2。 Approved int8 `json:"approved"` // ID 工单 ID。 ID int64 `json:"id"` } // Cancel 撤销工单(端点 16):仅 approved=0 且**申请人本人或 super_admin/finance**。 // // 入参:ctx;in CancelRequestInput。出参:*CancelRequestResult;2001/1003/3002/5002。 // // 权限判据(两条**或**关系,都必须能自证失败): // 1. `operatorID == req.request_user_id`(本人撤回自己的申请); // 2. 角色 ∈ {super_admin, finance}(管理员兜底处理僵尸单)。 // // 注意:**不要求** campus 一致(申请人可能是机构级财务代提),但机构由仓储 Scoped 保证。 func (s *BalanceRequestService) Cancel(ctx context.Context, in CancelRequestInput) (*CancelRequestResult, error) { if in.ID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: Cancel 需要工单 id")) } req, err := s.requests.GetByID(ctx, in.ID) if err != nil { return nil, err } isOwner := strings.TrimSpace(in.OperatorID) != "" && in.OperatorID == req.RequestUserID isManager := in.SuperAdmin || in.Role == "super_admin" || in.Role == "finance" if !isOwner && !isManager { return nil, infraerrors.WithCause(infraerrors.ErrForbidden, errString("service: 撤单仅限申请人本人或 super_admin/finance")) } if err := s.pendingTransition(ctx, in.ID, requestCancelled, in.OperatorID); err != nil { return nil, err } return &CancelRequestResult{Approved: requestCancelled, ID: in.ID}, nil } // pendingTransition 终态推进的公共实现(驳回/撤销共用:**无资金动作**,只做 CAS)。 // // 入参:ctx;id 工单 ID;to 目标终态(-1 驳回 / -2 取消);operatorID 操作人。 // 出参:nil 成功;3002(工单已被处理/不存在)/* 仓储错误。 // // 实现要点:**一次 CAS 搞定状态判断与推进**(`WHERE approved = 0`),不做"先读后写"—— // 先读会让两个并发请求都读到 0 再各自写,虽然终态相同但 response_time/审批人会互相覆盖。 func (s *BalanceRequestService) pendingTransition(ctx context.Context, id int64, to int8, operatorID string) error { return s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) now := time.Now() var operator *string if strings.TrimSpace(operatorID) != "" { operator = &operatorID } rows, err := s.requests.UpdateApproved(txCtx, tx, id, to, operator, &now) if err != nil { return err } if rows == 0 { return infraerrors.WithCause(infraerrors.ErrConflict, errString(fmt.Sprintf("service: 工单 %d 不存在或已处理(仅 approved=0 可推进)", id))) } return nil }) }