// 退费与清零服务(自定义退费双 log + 余额清零 + 取消充值活动)——AC-05。 // // 依据:unified/03-migration/academic-domain-design.md §3.1 #8~#10(SP 映射与 dump 行号) // / §4.6(`CustomRefundResult` / `ClearBalanceResult` 字段契约,**逐字对齐**) // / §7.2(自定义退费时序:双账户锁 + 双 log + 活动失效 + 锁序) // / §3.8-b R1/R2(相对 dump 的两处有意强化)/ §10.3(事务)/ §10.7(金额)。 // // SP 映射: // // #8 sp_user_expense_cancel_recharge_activity → CancelRechargeActivity(跨域读 t_campus.school_id) // #9 sp_user_expense_custom_refund → CustomRefund(**双 log:先 108 后 207**) // #10 sp_user_expense_school_cleared_balance → ClearBalance(★全局表扣减基数 = campusBalance) // // 三条与 dump 的**有意偏离**(设计 §3.8-b 已登记,双跑报告按 `INTENTIONAL-DEV` 归因): // // R1 校区查不到:dump 的 `IF(NULL)` 让 `campusSelfSupport` 保持 0 → 静默走**加盟**分支; // Go 硬化:`CampusReader` 取不到 → **3104**("加盟校区退费误失效本校区活动"是不可接受的静默行为)。 // R2 并发:dump 读余额**无 `FOR UPDATE`**(生产存在并发超退);Go 对双账户显式加锁。 // 加锁顺序 **全局 `t_user_expense` → 校区 `t_user_expense_school`**(与 §7.2 / §1.4 的写序一致)。 // R-3 清零/退费前置失败:dump 静默 `LEAVE`(前端"成功但没变");Go 返回 3120 + 原因。 // // 账务恒等式(CustomRefund,写进单测与 AC-17 ④,**不是各比各的**): // // requested = refunded + badDebt // 账户净变动 = -refunded = 207(−requested) + 108(+badDebt) ← 三条流水的代数和恒等于净扣减 // balance_after = balance_before - refunded package service import ( "context" "errors" "strings" "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" ) // 自定义退费 / 余额清零 / 取消充值活动的默认备注(dump `IF inputRemark=”`,逐字对齐)。 const ( // defaultCustomRefundRemark SP9 默认备注。 defaultCustomRefundRemark = "余额退费" // defaultClearBalanceRemark SP10 默认备注。 defaultClearBalanceRemark = "余额清零" // defaultCancelRechargeRemark SP8 默认备注。 defaultCancelRechargeRemark = "取消充值活动" // badDebtRemarkSuffix SP9 dump 追加文案(`
` 原样保留,前端按 HTML 渲染)。 badDebtRemarkSuffix = "
退费的金额大于可退费部分,超出部分为坏账部分。" ) // RefundService 退费与清零服务(depends: 余额仓储 + 活动失效器 + 校区只读端口)。 // // 字段: // - repo:余额账户仓储(全局 + 校区 + 流水); // - runner:事务运行器(顶层 BEGIN,事务内透传); // - campus:校区只读端口(跨域读 `t_campus`:self_support / institution_id,禁止跨库 SQL); // - member:成员关系校验器(可 nil:系统任务模式); // - expirer:活动失效器(与资金同事务)。 type RefundService struct { repo *repository.ExpenseRepo runner *repository.TxRunner campus port.CampusReader member port.MembershipChecker expirer *ActivityExpirer } // NewRefundService 构造退费与清零服务。 // // 入参: // - db *gorm.DB:数据库句柄(必填); // - sc *appscope.Scope:本次请求作用域; // - campus port.CampusReader:校区只读端口(**必须注入**:自营/加盟判定与 3104 硬化都依赖它; // 实现见 `port.NewOrgCampusReader`,事务内读取走其 `TxCampusReader` 扩展); // - member port.MembershipChecker:成员校验器(可 nil)。 // // 出参:*RefundService。 func NewRefundService( db *gorm.DB, sc *appscope.Scope, campus port.CampusReader, member port.MembershipChecker, ) *RefundService { return &RefundService{ repo: repository.NewExpenseRepo(db, sc), runner: repository.NewTxRunner(db), campus: campus, member: member, expirer: NewActivityExpirer(db, sc), } } // ---------------------------------------------------------------- DTO(§4.6 逐字对齐) // Warning warn 级提示(HTTP 200 / body code 200,前端按 code 展示文案)。 type Warning struct { // Code 31xx 业务码(当前仅 3103 可退金额不足)。 Code int `json:"code"` // Message 后端出的人话文案(前端直显)。 Message string `json:"message"` } // CustomRefundResult 自定义退费响应(端点 6 / SP9)。 // // 业务语义(对齐 dump `sp_user_expense_custom_refund`,双 log): // // 前置 balance <= 0 → 3102(无 result);requested <= 0 → 2002(不做 no-op,因本流程含活动失效副作用)。 // requested > balance:badDebt = requested - balance,扣减仅 balance; // 先写 108(+badDebt,remark 追加"退费的金额大于可退费部分,超出部分为坏账部分。"), // 再写 207(-requested,**全额**,★ 与扣减额不同,前端勿据此推导余额变化)。 // requested <= balance:badDebt = 0,无 108 流水,207 = -requested。 // 净资金变化 = -(requested - badDebt) = -(refunded)。 type CustomRefundResult struct { Requested money.Amount `json:"requested"` // 请求退费金额(回显输入;>0) Refunded money.Amount `json:"refunded"` // 实际退出资金净额 = requested - badDebt(≥0) BadDebt money.Amount `json:"badDebt"` // 差额坏账 = max(requested-可退, 0);0 = 无坏账 RefundableAmount money.Amount `json:"refundableAmount"` // 退费前可退金额快照 = max(校区 balance, 0)(本流程扣减基数;**不含**赠送额扣减,与 SP5 查询口径不同,见 FundService.RefundableAmount 注释) Balance money.Amount `json:"balance"` // 退费后校区账户余额 TotalBalance money.Amount `json:"totalBalance"` // 退费后全局账户(跨校区)余额快照 TotalIncome money.Amount `json:"totalIncome"` // 退费后校区账户 total_income 快照 SpecialExpenses money.Amount `json:"specialExpenses"` // 退费后校区账户特殊费用累计(坏账累计口径) LogID207 int64 `json:"logId207"` // type=207 流水 ID(money = -requested 全额) LogID108 *int64 `json:"logId108"` // type=108 流水 ID(money = +badDebt);无坏账 → null ExpiredActivityIDs []int64 `json:"expiredActivityIds"` // 本次被置为 status=4 的活动 ID(空 → []) Warnings []Warning `json:"warnings"` // warn 级提示(非阻断;空 → []) Repeated bool `json:"repeated"` // true = 幂等命中,其余字段回放首次结果 } // ClearBalanceResult 余额清零响应(端点 9 / SP10)。 // // 三条前置校验失败(campusBalance<=0 / totalBalance<=0 / campusBalance>totalBalance)→ 3120 + reason,无 result。 // // ★ 漂移项(设计 §3.1 #10):全局表 `t_user_expense` 的扣减基数是 **campusBalance**(不是 totalBalance), // 即清零只清"本校区那部分",全局钱包剩余部分保留。 type ClearBalanceResult struct { Cleared money.Amount `json:"cleared"` // 已清零金额 = 清零前校区 balance(= 208 流水绝对值) CampusBalanceBefore money.Amount `json:"campusBalanceBefore"` // 清零前校区余额快照 TotalBalanceBefore money.Amount `json:"totalBalanceBefore"` // 清零前全局余额快照 Balance money.Amount `json:"balance"` // 清零后校区余额(恒 "0.00") TotalBalance money.Amount `json:"totalBalance"` // 清零后全局余额 SpecialExpenses money.Amount `json:"specialExpenses"` // 清零后特殊费用累计 LogID208 int64 `json:"logId208"` // type=208 流水 ID(money = -cleared) ExpiredActivityIDs []int64 `json:"expiredActivityIds"` // 失效活动 ID(空 → []) Repeated bool `json:"repeated"` // 幂等命中(SP10 无请求号 → 恒 false) } // CancelRechargeActivityResult 取消充值活动结果(端点 30 / SP8)。 type CancelRechargeActivityResult struct { // InstitutionID 由校区反查出的机构 ID(dump 末尾 `SELECT selectSchoolid AS schoolid`)。 InstitutionID int64 `json:"institutionId"` // LogID type=209 流水 ID。 LogID int64 `json:"logId"` // Balance 取消后校区账户余额。 Balance money.Amount `json:"balance"` // Repeated true = 幂等命中。 Repeated bool `json:"repeated"` } // ---------------------------------------------------------------- 入参 // CustomRefundInput 自定义退费入参(SP9 7 参;`RequestID` **必填**,作幂等键)。 type CustomRefundInput struct { // UserID 退费用户(必填)。 UserID string `json:"userId"` // InstitutionID 机构 ID(必填 >0)。 InstitutionID int64 `json:"institutionId"` // CampusID 校区 ID(必填 >0)。 CampusID int64 `json:"campusId"` // Money 请求退费金额(requested;必须 >0,允许超过可退金额 → 差额计坏账)。 Money money.Amount `json:"money"` // RequestID 幂等请求号(**必填**;空则失去幂等保护,直接 2001)。 RequestID string `json:"requestId"` // Remark 备注(空 → "余额退费")。 Remark string `json:"remark"` // OperatorID 操作人(→ create_user_id)。 OperatorID string `json:"-"` } // ClearBalanceInput 余额清零入参(SP10 5 参;**dump 无请求号**)。 type ClearBalanceInput struct { // UserID 用户(必填)。 UserID string `json:"userId"` // InstitutionID 机构 ID(必填 >0)。 InstitutionID int64 `json:"institutionId"` // CampusID 校区 ID(必填 >0)。 CampusID int64 `json:"campusId"` // Remark 备注(空 → "余额清零")。 Remark string `json:"remark"` // OperatorID 操作人。 OperatorID string `json:"-"` } // CancelRechargeActivityInput 取消充值活动入参(SP8 7 参)。 type CancelRechargeActivityInput struct { // UserID 用户(必填)。 UserID string `json:"userId"` // ActivityID 活动 ID(必填 >0)。 ActivityID int64 `json:"activityId"` // CampusID 校区 ID(必填 >0;机构由 `t_campus.institution_id` 反查)。 CampusID int64 `json:"campusId"` // Money 取消金额(>0;流水落 `-Money`)。 Money money.Amount `json:"money"` // RequestID 幂等请求号(空 = 不享幂等)。 RequestID string `json:"requestId"` // Remark 备注(空 → "取消充值活动")。 Remark string `json:"remark"` // OperatorID 操作人。 OperatorID string `json:"-"` } // ---------------------------------------------------------------- SP9 自定义退费 // CustomRefund 自定义余额退费(SP9 `sp_user_expense_custom_refund`,**双账户锁 + 双 log + 活动失效**)。 // // 时序(设计 §7.2 逐步骤,顺序不可调整): // // ① 幂等预检(207 为恒存在的主流水)→ 命中即回放首次结果(repeated=true) // ② 作用域断言(全局钱包无作用域列 → Service 显式校验) // ③ **加锁:全局 → 校区**(固定顺序,与充值写序一致,避免"充值持全局等校区 / 退费持校区等全局"死锁) // └ 校区账户不存在 或 balance<=0 → **3102**(dump "余额不能≤0") // ④ 拆分:refundable = max(balance,0);refunded = min(requested, refundable); // badDebt = requested - refunded // ⑤ 双表**同额**扣 refunded(balance−, total_income−),badDebt>0 时再 special_expenses+badDebt // ⑥ **先 108(+badDebt)再 207(−requested 全额)**(dump 顺序;扣减额 ≠ 207,两者差额恰为 108) // ⑦ 活动失效(同一事务,自营/加盟分流 → ActivityExpirer) // ⑧ 快照回填(退费后余额/累计/特殊费用) // // 与 SP10 的口径差异(**不得混淆**):本方法**不做** `campusBalance <= totalBalance` 校验—— // 那是 `cleared_balance` 的规则(设计 §3.1 #9 明确标注);退费只受"本校区可退金额"约束。 // // 入参:ctx / in。出参:*CustomRefundResult;错误码 2001/2002/3140/3102/3104/1004/3001/5002。 func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput) (*CustomRefundResult, error) { if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "CustomRefund", true); err != nil { return nil, err } if err := validatePositiveAmount(in.Money, "CustomRefund"); err != nil { return nil, err } if strings.TrimSpace(in.RequestID) == "" { // 本流程含"活动失效"副作用,没有幂等键就无法防重放 → 必填(设计 §4.1 端点 6) return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: CustomRefund 需要 requestId(幂等键,必填)")) } if s == nil || s.runner == nil || s.repo == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配")) } remark := strings.TrimSpace(in.Remark) if remark == "" { remark = defaultCustomRefundRemark } var out CustomRefundResult err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) // ① 幂等预检 hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeRefund) if e != nil { return e } if hit != nil { return s.replayCustomRefund(txCtx, tx, in, &out, hit) } // ② 作用域断言 if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil { return e } // ③ 加锁:**全局 → 校区**(顺序是硬约束,单测用 SQL 顺序断言钉住) if _, e := s.repo.GetForUpdate(txCtx, tx, in.UserID); e != nil { return e // 全局账户不存在 → 3001(dump 会静默 0 行更新,Go 显式失败) } school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID) if e != nil { if errors.Is(e, infraerrors.ErrNotFound) { return infraerrors.WithCause(apperr.ErrNoBalanceToRefund, errString("service: CustomRefund 校区账户不存在,余额不能≤0")) } return e } balance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance") if e != nil { return e } if balance <= 0 { return infraerrors.WithCause(apperr.ErrNoBalanceToRefund, errString("service: CustomRefund 余额不能≤0(dump 原文文案)")) } // ④ 可退金额 + 坏账拆分 refundable := balance if refundable < 0 { refundable = 0 } refunded := in.Money if refunded > refundable { refunded = refundable } badDebt := in.Money.Sub(refunded) if badDebt < 0 { badDebt = 0 // 防御:refunded <= requested 恒成立,此处只为可读性 } // ⑤ 双表同额扣减(badDebt>0 时同步累加 special_expenses) deltas := []repository.ColumnDelta{ {Column: repository.ExpenseColumnBalance, Delta: refunded.Neg()}, {Column: repository.ExpenseColumnTotalIncome, Delta: refunded.Neg()}, } if badDebt > 0 { deltas = append(deltas, repository.ColumnDelta{ Column: repository.ExpenseColumnSpecialExpenses, Delta: badDebt, }) } if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CustomRefund", in.UserID, in.InstitutionID, in.CampusID, deltas...); e != nil { return e } after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID) if e != nil { return e } balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance") if e != nil { return e } // `balance_after` 快照口径(§3.8-f ②-bis):**全局账户**余额,写完账户后取一次, // 108 与 207 两条流水**写同一个值**(响应体的 Balance 仍是校区余额,两者语义不同)。 globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID) if e != nil { return e } // ⑥ 双 log:**先 108(+badDebt)再 207(−requested 全额)** // // ★ 兼容注释:type=108 有两种来源,此处写的是**第二种**(退费差额型)—— // - 差额型(本方法):只累加 special_expenses(见 ⑤),balance 净减 = refunded; // 108.money = +badDebt,207.money = **−requested 全额**,坏账部分已含在 207 内, // 故该 108 **不构成**一次独立的余额增加(判据:同 logid 存在 207); // - 独立坏账(`FundService.BadDebt`,SP7):balance 与 special_expenses **双加**, // 108.money = +m 真实对应余额增加(判据:同 logid 无 207)。 // 对账口径详见 `FundService.BadDebt` 方法注释;AC-17 ④ 的恒等式判别按此落地。 var logID108 *int64 if badDebt > 0 { lg108 := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, badDebt, in.RequestID, remark+badDebtRemarkSuffix, in.OperatorID, &globalSnapshot) if e := s.insertLog(txCtx, tx, lg108); e != nil { return e } id := lg108.ID logID108 = &id } lg207 := newExpenseLog(in.UserID, model.ExpenseLogTypeRefund, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &globalSnapshot) if e := s.insertLog(txCtx, tx, lg207); e != nil { return e } // ⑦ 活动失效(同事务;自营 `IN (campus,0)` / 加盟 `= campus`) expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID) if e != nil { return e } // ⑧ 快照回填 globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID) if e != nil { return e } totalBalance, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance") if e != nil { return e } totalIncome, e := parseAccountAmount(after.TotalIncome, "t_user_expense_school.total_income") if e != nil { return e } special, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses") if e != nil { return e } out = CustomRefundResult{ Requested: in.Money, Refunded: refunded, BadDebt: badDebt, RefundableAmount: refundable, Balance: balanceAfter, TotalBalance: totalBalance, TotalIncome: totalIncome, SpecialExpenses: special, LogID207: lg207.ID, LogID108: logID108, ExpiredActivityIDs: expired, Warnings: refundWarnings(badDebt), Repeated: false, } return nil }) if err != nil { // 并发幂等命中(撞 uk_request_type)→ 整笔回滚后回放首次结果 if errors.Is(err, apperr.ErrRepeated) { return s.replayCustomRefundAfterRace(ctx, in) } return nil, err } return &out, nil } // replayCustomRefund 幂等回放(**事务内**路径:预检命中,未产生任何写入)。 // // 入参:txCtx(含 tx)/ tx / in / out / hit(首次 207 流水)。 // 出参:nil(out 已填充)/ 错误。 // // 回放口径:`requested/badDebt/refunded/logId207/logId108/warnings` 由**首次流水**重建; // `balance/totalBalance/totalIncome/specialExpenses` 取**当前**账户快照 // (中间若发生别的资金操作,这些快照会前进——属可接受的"当前值",不伪造历史)。 func (s *RefundService) replayCustomRefund( ctx context.Context, tx *gorm.DB, in CustomRefundInput, out *CustomRefundResult, hit *model.UserExpenseLog, ) error { requested, err := money.Parse(strings.TrimPrefix(hit.Money, "-")) if err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 首次 207 流水金额非法")) } // 使用票据金额的绝对值:207 恒为 −requested requested = absAmount(requested) var badDebt money.Amount var logID108 *int64 hit108, e := s.repo.FindLogByRequest(ctx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt) if e != nil { return e } if hit108 != nil { if v, pe := money.Parse(hit108.Money); pe == nil { badDebt = absAmount(v) id := hit108.ID logID108 = &id } } res := CustomRefundResult{ Requested: requested, BadDebt: badDebt, Refunded: requested.Sub(badDebt), RefundableAmount: requested.Sub(badDebt), LogID207: hit.ID, LogID108: logID108, ExpiredActivityIDs: make([]int64, 0), Warnings: refundWarnings(badDebt), Repeated: true, } if acc, ae := s.repo.GetCampusAccount(ctx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil { if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil { res.Balance = v } if v, pe := parseAccountAmount(acc.TotalIncome, "t_user_expense_school.total_income"); pe == nil { res.TotalIncome = v } if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil { res.SpecialExpenses = v } } if gl, ge := s.repo.GetGlobalAccount(ctx, tx, in.UserID); ge == nil { if v, pe := parseAccountAmount(gl.Balance, "t_user_expense.balance"); pe == nil { res.TotalBalance = v } } *out = res return nil } // replayCustomRefundAfterRace 并发幂等命中后的回放(**事务外**路径:本次事务已整笔回滚)。 // // 入参:ctx / in。出参:*CustomRefundResult(Repeated=true)/ 错误。 // // 为什么要等事务回滚后再查:并发对手的流水只有在它提交后才可见, // 因此这里是"先让本次事务彻底结束(回滚),再重新读首次结果"(设计 §1.4 幂等模板)。 func (s *RefundService) replayCustomRefundAfterRace( ctx context.Context, in CustomRefundInput, ) (*CustomRefundResult, error) { hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeRefund) if err != nil { return nil, err } if hit == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: CustomRefund 幂等命中但首次流水已不可见")) } var out CustomRefundResult if err := s.replayCustomRefund(ctx, nil, in, &out, hit); err != nil { return nil, err } return &out, nil } // refundWarnings 按 badDebt 生成 warn 列表(3103 不阻断,§4.6)。 // // 入参:badDebt 坏账金额。出参:[]Warning(**空时返回长度 0 的非 nil 切片**,JSON 序列化为 [])。 func refundWarnings(badDebt money.Amount) []Warning { if badDebt <= 0 { return make([]Warning, 0) } return []Warning{{ Code: apperr.CodeRefundableShort, Message: "可退金额不足,差额 " + badDebt.String() + " 已计入坏账", }} } // absAmount 取金额绝对值(流水方向 → 金额绝对值,回放用)。 func absAmount(a money.Amount) money.Amount { if a < 0 { return a.Neg() } return a } // ---------------------------------------------------------------- SP10 余额清零 // ClearBalance 余额清零(SP10 `sp_user_expense_school_cleared_balance`)。 // // dump 语义(:764295,★**全局表扣减基数是 campusBalance**,不是 totalBalance,设计 §3.1 #10): // // ① 读校区余额;ISNULL 或 <=0 → LEAVE // ② 读全局余额;ISNULL 或 <=0 → LEAVE // ③ campusBalance > totalBalance → LEAVE // ④ UPDATE 校区:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance // UPDATE 全局:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance // ⑤ INSERT log(type=208, money=−campusBalance) // ⑥ 活动失效(与 SP9 同一段 SQL) // // Go 的差异:①②③ 失败 → **3120 + 原因**(dump 静默 LEAVE;前端会显示"成功但余额没变")。 // // 幂等:SP10 **没有请求号**(dump 签名只有 5 参),因此无幂等键可落;重复调用第二次 // `campusBalance=0` → 3120(天然幂等,不会重复清零)。`Repeated` 恒 false。 // // 入参:ctx / in。出参:*ClearBalanceResult;错误码 2001/3140/3120/3104/1004/3001/5002。 func (s *RefundService) ClearBalance(ctx context.Context, in ClearBalanceInput) (*ClearBalanceResult, error) { if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "ClearBalance", true); err != nil { return nil, err } if s == nil || s.runner == nil || s.repo == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配")) } remark := strings.TrimSpace(in.Remark) if remark == "" { remark = defaultClearBalanceRemark } var out ClearBalanceResult err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil { return e } // ① 加锁:全局 → 校区(同 §7.2 基线) global, e := s.repo.GetForUpdate(txCtx, tx, in.UserID) if e != nil { if errors.Is(e, infraerrors.ErrNotFound) { return clearBalanceRejected("全局账户不存在(totalBalance 视为 0)") } return e } school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID) if e != nil { if errors.Is(e, infraerrors.ErrNotFound) { return clearBalanceRejected("校区账户不存在(campusBalance 视为 0)") } return e } campusBalance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance") if e != nil { return e } totalBalance, e := parseAccountAmount(global.Balance, "t_user_expense.balance") if e != nil { return e } // ② 三条前置校验(失败 → 3120 + 原因,**不做 no-op**) if campusBalance <= 0 { return clearBalanceRejected("校区余额 ≤ 0,无需清零") } if totalBalance <= 0 { return clearBalanceRejected("全局余额 ≤ 0,无需清零") } if campusBalance > totalBalance { return clearBalanceRejected("校区余额大于全局余额(数据异常,拒绝清零)") } // ③ 双表同额扣减:balance−campusBalance、special_expenses+campusBalance、spend+campusBalance // ★ 全局表扣减基数同样是 campusBalance(漂移项,不得改成 totalBalance) if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "ClearBalance", in.UserID, in.InstitutionID, in.CampusID, repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: campusBalance.Neg()}, repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: campusBalance}, repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: campusBalance}, ); e != nil { return e } after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID) if e != nil { return e } balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance") if e != nil { return e } specialAfter, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses") if e != nil { return e } // ④ 流水 type=208,money = −campusBalance(无请求号 → request_id/log_id 均为 NULL) // `balance_after` = 清零后**全局账户**余额快照(§3.8-f ②-bis;响应体 Balance 仍是校区余额 0) globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID) if e != nil { return e } lg := newExpenseLog(in.UserID, model.ExpenseLogTypeClearBalance, campusBalance.Neg(), "", remark, in.OperatorID, &globalSnapshot) if e := s.insertLog(txCtx, tx, lg); e != nil { return e } // ⑤ 活动失效(同事务,与 SP9 同源) expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID) if e != nil { return e } globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID) if e != nil { return e } totalAfter, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance") if e != nil { return e } out = ClearBalanceResult{ Cleared: campusBalance, CampusBalanceBefore: campusBalance, TotalBalanceBefore: totalBalance, Balance: balanceAfter, TotalBalance: totalAfter, SpecialExpenses: specialAfter, LogID208: lg.ID, ExpiredActivityIDs: expired, Repeated: false, } return nil }) if err != nil { return nil, err } return &out, nil } // clearBalanceRejected 构造 3120(前置条件不满足)+ 具体原因(设计 §3.1 #10 的显式化)。 // // 入参:reason 人话原因(会进日志,不进响应体正文之外的结构)。 // 出参:*infraerrors.AppError(3120)。 func clearBalanceRejected(reason string) error { return infraerrors.WithCause(apperr.ErrClearBalanceRejected, errString("service: ClearBalance "+reason)) } // ---------------------------------------------------------------- SP8 取消充值活动 // CancelRechargeActivity 取消充值活动(SP8 `sp_user_expense_cancel_recharge_activity`)。 // // dump 语义(:763935): // // ① `SELECT school_id FROM yuansiwei.t_school_campus WHERE id=inputCampusid` // → ISNULL 或 0:`msg='找不到学校信息'` + LEAVE(success=0) // ② 校区账户/全局账户不存在则建户 // ③ UPDATE 校区:balance−money, total_income−money ;UPDATE 全局:同 // ④ UPDATE t_courses_activity_signed SET status=**0** WHERE userid AND activityid(注意不是 4) // ⑤ INSERT log(type=209, money=**−money**) // // Go 的两处收敛: // - 跨库读 `yuansiwei.t_school_campus` → `port.CampusReader.GetSchoolID`(同事务只读,禁止跨库 SQL,§1.6); // 取不到 → **3104**(dump 返回 success=0 + 文案,无错误码); // - 幂等:`request_id + type(209)` 唯一键(dump 无幂等)。 // // 归属说明:设计 §3.1 #8 把本方法挂在 `RecommendService.CancelRechargeActivity`(AC-08); // 本批(AC-05)先落在 RefundService 上——两者是同一个资金动作 + 同一句活动状态回写, // AC-08 落地时 RecommendService 可直接转调本方法(签名不变),避免同一逻辑写两份。 // // 入参:ctx / in。出参:*CancelRechargeActivityResult;错误码 2001/2002/3140/3104/1004/3001/5002。 func (s *RefundService) CancelRechargeActivity( ctx context.Context, in CancelRechargeActivityInput, ) (*CancelRechargeActivityResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: CancelRechargeActivity 需要 user_id")) } if in.CampusID <= 0 { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: CancelRechargeActivity 需要 campus_id > 0")) } if in.ActivityID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: CancelRechargeActivity 需要 activity_id > 0")) } if err := validatePositiveAmount(in.Money, "CancelRechargeActivity"); err != nil { return nil, err } if s == nil || s.runner == nil || s.repo == nil || s.campus == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配")) } remark := strings.TrimSpace(in.Remark) if remark == "" { remark = defaultCancelRechargeRemark } var out CancelRechargeActivityResult err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct) if e != nil { return e } if hit != nil { out = CancelRechargeActivityResult{LogID: hit.ID, Repeated: true} // 响应体 Balance 是**校区**余额(流水 balance_after 自 v0.1.16 起是全局口径), // 故按"校区 → 机构 → 账户"重新回读当前值;读不到就回退 0(不伪造)。 if inst, ie := s.readCampus(txCtx, tx, in.CampusID); ie == nil { out.InstitutionID = inst.SchoolID if inst.SchoolID > 0 { if bal, pe := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, inst.SchoolID, in.CampusID); pe == nil { out.Balance = bal } } } return nil } // ① 跨域读校区 → 机构 ID(失败 3104) info, e := s.readCampus(txCtx, tx, in.CampusID) if e != nil { return e } if info.SchoolID <= 0 { return infraerrors.WithCause(apperr.ErrCampusNotFound, errString("service: CancelRechargeActivity 校区未归属机构")) } if e := s.assertInScope(txCtx, in.UserID, info.SchoolID, in.CampusID); e != nil { return e } // ② 建户(dump 的 COUNT 判存在 → UPSERT) if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil { return e } if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{ UserID: in.UserID, InstitutionID: info.SchoolID, CampusID: in.CampusID, }); e != nil { return e } // ③ 余额−、总充值− if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CancelRechargeActivity", in.UserID, info.SchoolID, in.CampusID, repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money.Neg()}, repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()}, ); e != nil { return e } // ④ 报名状态归 0(dump:`SET status=0`,**不是 4**) if _, e := s.expirer.SetSignedStatus( txCtx, tx, in.UserID, []int64{in.ActivityID}, model.ActivitySignedCancelled, ); e != nil { return e } acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, info.SchoolID, in.CampusID) if e != nil { return e } bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance") if e != nil { return e } // ⑤ 流水 type=209,money = −money // `balance_after` = 取消后**全局账户**余额快照(§3.8-f ②-bis;响应体 Balance 仍是校区余额) globalSnapshot, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID) if e != nil { return e } lg := newExpenseLog(in.UserID, model.ExpenseLogTypeCancelRechargeAct, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &globalSnapshot) if e := s.insertLog(txCtx, tx, lg); e != nil { return e } out = CancelRechargeActivityResult{ InstitutionID: info.SchoolID, LogID: lg.ID, Balance: bal, } return nil }) if err != nil { if errors.Is(err, apperr.ErrRepeated) { hit, re := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct) if re != nil { return nil, re } if hit == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: CancelRechargeActivity 幂等命中但首次流水已不可见")) } bal, pe := balanceAfterOf(hit) if pe != nil { return nil, pe } return &CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}, nil } return nil, err } return &out, nil } // ---------------------------------------------------------------- 内部助手 // assertInScope 对象级越权断言(RefundService 版本,语义同 FundService)。 func (s *RefundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error { if s == nil || s.member == nil { return nil // 系统任务/割接脚本模式 } return s.member.AssertInScope(ctx, userID, institutionID, campusID) } // insertLog 写流水(撞 `uk_request_type` → 内部幂等哨兵,语义同 FundService.insertLog)。 func (s *RefundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error { err := s.repo.InsertLog(txCtx, tx, lg) if err == nil { return nil } if errors.Is(err, repository.ErrDuplicateLog) { return apperr.ErrRepeated } return err } // readCampus 同事务读校区(优先 `TxCampusReader`,退化到连接池版 `CampusReader`)。 // // 入参:ctx / tx / campusID(>0)。 // 出参:*port.CampusInfo;3104(校区不存在)/ 3140(campusID<=0)/ 5002。 // // 为什么优先 TxCampusReader:dump 是同库 JOIN(同一快照),目标态跨域读必须显式传 tx, // 否则读到的是连接池的默认隔离级别快照,与本次资金事务不一致(§1.6)。 func (s *RefundService) readCampus(ctx context.Context, tx *gorm.DB, campusID int64) (*port.CampusInfo, error) { if s.campus == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: 未注入 port.CampusReader(无法判定自营/机构)")) } if txReader, ok := s.campus.(port.TxCampusReader); ok { return txReader.GetByIDTx(ctx, tx, campusID) } return s.campus.GetByID(ctx, campusID) } // expireRechargeActivities 判定自营/加盟并在同一事务内失效活动(SP9/SP10 共用)。 // // 入参:txCtx / tx / userID / institutionID / campusID。 // 出参:(失效活动 ID 列表, error);3104(校区不存在,R1 硬化)/ 5002。 // // 分流(dump `IF(campusSelfSupport)`): // - 自营 true → `activity.campus_id IN (campusID, 0)`(含"全机构池"活动) // - 加盟 false → `activity.campus_id = campusID`(**不含** 0,避免误失效他校区活动) func (s *RefundService) expireRechargeActivities( ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64, ) ([]int64, error) { info, err := s.readCampus(ctx, tx, campusID) if err != nil { return nil, err } if info.SchoolID > 0 && info.SchoolID != institutionID { // 校区归属机构与入参不一致 → 同码 3104(不泄露存在性,不误写) return nil, infraerrors.WithCause(apperr.ErrCampusNotFound, errString("service: 校区归属机构与入参 institution_id 不一致")) } return s.expirer.ExpireForUser(ctx, tx, userID, institutionID, campusID, info.SelfSupport) }