refund_service.go 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
  1. // 退费与清零服务(自定义退费双 log + 余额清零 + 取消充值活动)——AC-05。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.1 #8~#10(SP 映射与 dump 行号)
  4. // / §4.6(`CustomRefundResult` / `ClearBalanceResult` 字段契约,**逐字对齐**)
  5. // / §7.2(自定义退费时序:双账户锁 + 双 log + 活动失效 + 锁序)
  6. // / §3.8-b R1/R2(相对 dump 的两处有意强化)/ §10.3(事务)/ §10.7(金额)。
  7. //
  8. // SP 映射:
  9. //
  10. // #8 sp_user_expense_cancel_recharge_activity → CancelRechargeActivity(跨域读 t_campus.school_id)
  11. // #9 sp_user_expense_custom_refund → CustomRefund(**双 log:先 108 后 207**)
  12. // #10 sp_user_expense_school_cleared_balance → ClearBalance(★全局表扣减基数 = campusBalance)
  13. //
  14. // 三条与 dump 的**有意偏离**(设计 §3.8-b 已登记,双跑报告按 `INTENTIONAL-DEV` 归因):
  15. //
  16. // R1 校区查不到:dump 的 `IF(NULL)` 让 `campusSelfSupport` 保持 0 → 静默走**加盟**分支;
  17. // Go 硬化:`CampusReader` 取不到 → **3104**("加盟校区退费误失效本校区活动"是不可接受的静默行为)。
  18. // R2 并发:dump 读余额**无 `FOR UPDATE`**(生产存在并发超退);Go 对双账户显式加锁。
  19. // 加锁顺序 **全局 `t_user_expense` → 校区 `t_user_expense_school`**(与 §7.2 / §1.4 的写序一致)。
  20. // R-3 清零/退费前置失败:dump 静默 `LEAVE`(前端"成功但没变");Go 返回 3120 + 原因。
  21. //
  22. // 账务恒等式(CustomRefund,写进单测与 AC-17 ④,**不是各比各的**):
  23. //
  24. // requested = refunded + badDebt
  25. // 账户净变动 = -refunded = 207(−requested) + 108(+badDebt) ← 三条流水的代数和恒等于净扣减
  26. // balance_after = balance_before - refunded
  27. package service
  28. import (
  29. "context"
  30. "errors"
  31. "strings"
  32. "gorm.io/gorm"
  33. "yuansiwei/unified-backend/internal/academic/apperr"
  34. "yuansiwei/unified-backend/internal/academic/model"
  35. "yuansiwei/unified-backend/internal/academic/money"
  36. "yuansiwei/unified-backend/internal/academic/port"
  37. "yuansiwei/unified-backend/internal/academic/repository"
  38. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  39. appscope "yuansiwei/unified-backend/internal/infra/scope"
  40. )
  41. // 自定义退费 / 余额清零 / 取消充值活动的默认备注(dump `IF inputRemark=”`,逐字对齐)。
  42. const (
  43. // defaultCustomRefundRemark SP9 默认备注。
  44. defaultCustomRefundRemark = "余额退费"
  45. // defaultClearBalanceRemark SP10 默认备注。
  46. defaultClearBalanceRemark = "余额清零"
  47. // defaultCancelRechargeRemark SP8 默认备注。
  48. defaultCancelRechargeRemark = "取消充值活动"
  49. // badDebtRemarkSuffix SP9 dump 追加文案(`<br/>` 原样保留,前端按 HTML 渲染)。
  50. badDebtRemarkSuffix = "<br/>退费的金额大于可退费部分,超出部分为坏账部分。"
  51. )
  52. // RefundService 退费与清零服务(depends: 余额仓储 + 活动失效器 + 校区只读端口)。
  53. //
  54. // 字段:
  55. // - repo:余额账户仓储(全局 + 校区 + 流水);
  56. // - runner:事务运行器(顶层 BEGIN,事务内透传);
  57. // - campus:校区只读端口(跨域读 `t_campus`:self_support / institution_id,禁止跨库 SQL);
  58. // - member:成员关系校验器(可 nil:系统任务模式);
  59. // - expirer:活动失效器(与资金同事务)。
  60. type RefundService struct {
  61. repo *repository.ExpenseRepo
  62. runner *repository.TxRunner
  63. campus port.CampusReader
  64. member port.MembershipChecker
  65. expirer *ActivityExpirer
  66. }
  67. // NewRefundService 构造退费与清零服务。
  68. //
  69. // 入参:
  70. // - db *gorm.DB:数据库句柄(必填);
  71. // - sc *appscope.Scope:本次请求作用域;
  72. // - campus port.CampusReader:校区只读端口(**必须注入**:自营/加盟判定与 3104 硬化都依赖它;
  73. // 实现见 `port.NewOrgCampusReader`,事务内读取走其 `TxCampusReader` 扩展);
  74. // - member port.MembershipChecker:成员校验器(可 nil)。
  75. //
  76. // 出参:*RefundService。
  77. func NewRefundService(
  78. db *gorm.DB, sc *appscope.Scope, campus port.CampusReader, member port.MembershipChecker,
  79. ) *RefundService {
  80. return &RefundService{
  81. repo: repository.NewExpenseRepo(db, sc),
  82. runner: repository.NewTxRunner(db),
  83. campus: campus,
  84. member: member,
  85. expirer: NewActivityExpirer(db, sc),
  86. }
  87. }
  88. // ---------------------------------------------------------------- DTO(§4.6 逐字对齐)
  89. // Warning warn 级提示(HTTP 200 / body code 200,前端按 code 展示文案)。
  90. type Warning struct {
  91. // Code 31xx 业务码(当前仅 3103 可退金额不足)。
  92. Code int `json:"code"`
  93. // Message 后端出的人话文案(前端直显)。
  94. Message string `json:"message"`
  95. }
  96. // CustomRefundResult 自定义退费响应(端点 6 / SP9)。
  97. //
  98. // 业务语义(对齐 dump `sp_user_expense_custom_refund`,双 log):
  99. //
  100. // 前置 balance <= 0 → 3102(无 result);requested <= 0 → 2002(不做 no-op,因本流程含活动失效副作用)。
  101. // requested > balance:badDebt = requested - balance,扣减仅 balance;
  102. // 先写 108(+badDebt,remark 追加"退费的金额大于可退费部分,超出部分为坏账部分。"),
  103. // 再写 207(-requested,**全额**,★ 与扣减额不同,前端勿据此推导余额变化)。
  104. // requested <= balance:badDebt = 0,无 108 流水,207 = -requested。
  105. // 净资金变化 = -(requested - badDebt) = -(refunded)。
  106. type CustomRefundResult struct {
  107. Requested money.Amount `json:"requested"` // 请求退费金额(回显输入;>0)
  108. Refunded money.Amount `json:"refunded"` // 实际退出资金净额 = requested - badDebt(≥0)
  109. BadDebt money.Amount `json:"badDebt"` // 差额坏账 = max(requested-可退, 0);0 = 无坏账
  110. RefundableAmount money.Amount `json:"refundableAmount"` // 退费前可退金额快照 = max(校区 balance, 0)
  111. Balance money.Amount `json:"balance"` // 退费后校区账户余额
  112. TotalBalance money.Amount `json:"totalBalance"` // 退费后全局账户(跨校区)余额快照
  113. TotalIncome money.Amount `json:"totalIncome"` // 退费后校区账户 total_income 快照
  114. SpecialExpenses money.Amount `json:"specialExpenses"` // 退费后校区账户特殊费用累计(坏账累计口径)
  115. LogID207 int64 `json:"logId207"` // type=207 流水 ID(money = -requested 全额)
  116. LogID108 *int64 `json:"logId108"` // type=108 流水 ID(money = +badDebt);无坏账 → null
  117. ExpiredActivityIDs []int64 `json:"expiredActivityIds"` // 本次被置为 status=4 的活动 ID(空 → [])
  118. Warnings []Warning `json:"warnings"` // warn 级提示(非阻断;空 → [])
  119. Repeated bool `json:"repeated"` // true = 幂等命中,其余字段回放首次结果
  120. }
  121. // ClearBalanceResult 余额清零响应(端点 9 / SP10)。
  122. //
  123. // 三条前置校验失败(campusBalance<=0 / totalBalance<=0 / campusBalance>totalBalance)→ 3120 + reason,无 result。
  124. //
  125. // ★ 漂移项(设计 §3.1 #10):全局表 `t_user_expense` 的扣减基数是 **campusBalance**(不是 totalBalance),
  126. // 即清零只清"本校区那部分",全局钱包剩余部分保留。
  127. type ClearBalanceResult struct {
  128. Cleared money.Amount `json:"cleared"` // 已清零金额 = 清零前校区 balance(= 208 流水绝对值)
  129. CampusBalanceBefore money.Amount `json:"campusBalanceBefore"` // 清零前校区余额快照
  130. TotalBalanceBefore money.Amount `json:"totalBalanceBefore"` // 清零前全局余额快照
  131. Balance money.Amount `json:"balance"` // 清零后校区余额(恒 "0.00")
  132. TotalBalance money.Amount `json:"totalBalance"` // 清零后全局余额
  133. SpecialExpenses money.Amount `json:"specialExpenses"` // 清零后特殊费用累计
  134. LogID208 int64 `json:"logId208"` // type=208 流水 ID(money = -cleared)
  135. ExpiredActivityIDs []int64 `json:"expiredActivityIds"` // 失效活动 ID(空 → [])
  136. Repeated bool `json:"repeated"` // 幂等命中(SP10 无请求号 → 恒 false)
  137. }
  138. // CancelRechargeActivityResult 取消充值活动结果(端点 30 / SP8)。
  139. type CancelRechargeActivityResult struct {
  140. // InstitutionID 由校区反查出的机构 ID(dump 末尾 `SELECT selectSchoolid AS schoolid`)。
  141. InstitutionID int64 `json:"institutionId"`
  142. // LogID type=209 流水 ID。
  143. LogID int64 `json:"logId"`
  144. // Balance 取消后校区账户余额。
  145. Balance money.Amount `json:"balance"`
  146. // Repeated true = 幂等命中。
  147. Repeated bool `json:"repeated"`
  148. }
  149. // ---------------------------------------------------------------- 入参
  150. // CustomRefundInput 自定义退费入参(SP9 7 参;`RequestID` **必填**,作幂等键)。
  151. type CustomRefundInput struct {
  152. // UserID 退费用户(必填)。
  153. UserID string `json:"userId"`
  154. // InstitutionID 机构 ID(必填 >0)。
  155. InstitutionID int64 `json:"institutionId"`
  156. // CampusID 校区 ID(必填 >0)。
  157. CampusID int64 `json:"campusId"`
  158. // Money 请求退费金额(requested;必须 >0,允许超过可退金额 → 差额计坏账)。
  159. Money money.Amount `json:"money"`
  160. // RequestID 幂等请求号(**必填**;空则失去幂等保护,直接 2001)。
  161. RequestID string `json:"requestId"`
  162. // Remark 备注(空 → "余额退费")。
  163. Remark string `json:"remark"`
  164. // OperatorID 操作人(→ create_user_id)。
  165. OperatorID string `json:"-"`
  166. }
  167. // ClearBalanceInput 余额清零入参(SP10 5 参;**dump 无请求号**)。
  168. type ClearBalanceInput struct {
  169. // UserID 用户(必填)。
  170. UserID string `json:"userId"`
  171. // InstitutionID 机构 ID(必填 >0)。
  172. InstitutionID int64 `json:"institutionId"`
  173. // CampusID 校区 ID(必填 >0)。
  174. CampusID int64 `json:"campusId"`
  175. // Remark 备注(空 → "余额清零")。
  176. Remark string `json:"remark"`
  177. // OperatorID 操作人。
  178. OperatorID string `json:"-"`
  179. }
  180. // CancelRechargeActivityInput 取消充值活动入参(SP8 7 参)。
  181. type CancelRechargeActivityInput struct {
  182. // UserID 用户(必填)。
  183. UserID string `json:"userId"`
  184. // ActivityID 活动 ID(必填 >0)。
  185. ActivityID int64 `json:"activityId"`
  186. // CampusID 校区 ID(必填 >0;机构由 `t_campus.institution_id` 反查)。
  187. CampusID int64 `json:"campusId"`
  188. // Money 取消金额(>0;流水落 `-Money`)。
  189. Money money.Amount `json:"money"`
  190. // RequestID 幂等请求号(空 = 不享幂等)。
  191. RequestID string `json:"requestId"`
  192. // Remark 备注(空 → "取消充值活动")。
  193. Remark string `json:"remark"`
  194. // OperatorID 操作人。
  195. OperatorID string `json:"-"`
  196. }
  197. // ---------------------------------------------------------------- SP9 自定义退费
  198. // CustomRefund 自定义余额退费(SP9 `sp_user_expense_custom_refund`,**双账户锁 + 双 log + 活动失效**)。
  199. //
  200. // 时序(设计 §7.2 逐步骤,顺序不可调整):
  201. //
  202. // ① 幂等预检(207 为恒存在的主流水)→ 命中即回放首次结果(repeated=true)
  203. // ② 作用域断言(全局钱包无作用域列 → Service 显式校验)
  204. // ③ **加锁:全局 → 校区**(固定顺序,与充值写序一致,避免"充值持全局等校区 / 退费持校区等全局"死锁)
  205. // └ 校区账户不存在 或 balance<=0 → **3102**(dump "余额不能≤0")
  206. // ④ 拆分:refundable = max(balance,0);refunded = min(requested, refundable);
  207. // badDebt = requested - refunded
  208. // ⑤ 双表**同额**扣 refunded(balance−, total_income−),badDebt>0 时再 special_expenses+badDebt
  209. // ⑥ **先 108(+badDebt)再 207(−requested 全额)**(dump 顺序;扣减额 ≠ 207,两者差额恰为 108)
  210. // ⑦ 活动失效(同一事务,自营/加盟分流 → ActivityExpirer)
  211. // ⑧ 快照回填(退费后余额/累计/特殊费用)
  212. //
  213. // 与 SP10 的口径差异(**不得混淆**):本方法**不做** `campusBalance <= totalBalance` 校验——
  214. // 那是 `cleared_balance` 的规则(设计 §3.1 #9 明确标注);退费只受"本校区可退金额"约束。
  215. //
  216. // 入参:ctx / in。出参:*CustomRefundResult;错误码 2001/2002/3140/3102/3104/1004/3001/5002。
  217. func (s *RefundService) CustomRefund(ctx context.Context, in CustomRefundInput) (*CustomRefundResult, error) {
  218. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "CustomRefund", true); err != nil {
  219. return nil, err
  220. }
  221. if err := validatePositiveAmount(in.Money, "CustomRefund"); err != nil {
  222. return nil, err
  223. }
  224. if strings.TrimSpace(in.RequestID) == "" {
  225. // 本流程含"活动失效"副作用,没有幂等键就无法防重放 → 必填(设计 §4.1 端点 6)
  226. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  227. errString("service: CustomRefund 需要 requestId(幂等键,必填)"))
  228. }
  229. if s == nil || s.runner == nil || s.repo == nil {
  230. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
  231. }
  232. remark := strings.TrimSpace(in.Remark)
  233. if remark == "" {
  234. remark = defaultCustomRefundRemark
  235. }
  236. var out CustomRefundResult
  237. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  238. txCtx := repository.WithTxContext(ctx, tx)
  239. // ① 幂等预检
  240. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeRefund)
  241. if e != nil {
  242. return e
  243. }
  244. if hit != nil {
  245. return s.replayCustomRefund(txCtx, tx, in, &out, hit)
  246. }
  247. // ② 作用域断言
  248. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  249. return e
  250. }
  251. // ③ 加锁:**全局 → 校区**(顺序是硬约束,单测用 SQL 顺序断言钉住)
  252. if _, e := s.repo.GetForUpdate(txCtx, tx, in.UserID); e != nil {
  253. return e // 全局账户不存在 → 3001(dump 会静默 0 行更新,Go 显式失败)
  254. }
  255. school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID)
  256. if e != nil {
  257. if errors.Is(e, infraerrors.ErrNotFound) {
  258. return infraerrors.WithCause(apperr.ErrNoBalanceToRefund,
  259. errString("service: CustomRefund 校区账户不存在,余额不能≤0"))
  260. }
  261. return e
  262. }
  263. balance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance")
  264. if e != nil {
  265. return e
  266. }
  267. if balance <= 0 {
  268. return infraerrors.WithCause(apperr.ErrNoBalanceToRefund,
  269. errString("service: CustomRefund 余额不能≤0(dump 原文文案)"))
  270. }
  271. // ④ 可退金额 + 坏账拆分
  272. refundable := balance
  273. if refundable < 0 {
  274. refundable = 0
  275. }
  276. refunded := in.Money
  277. if refunded > refundable {
  278. refunded = refundable
  279. }
  280. badDebt := in.Money.Sub(refunded)
  281. if badDebt < 0 {
  282. badDebt = 0 // 防御:refunded <= requested 恒成立,此处只为可读性
  283. }
  284. // ⑤ 双表同额扣减(badDebt>0 时同步累加 special_expenses)
  285. deltas := []repository.ColumnDelta{
  286. {Column: repository.ExpenseColumnBalance, Delta: refunded.Neg()},
  287. {Column: repository.ExpenseColumnTotalIncome, Delta: refunded.Neg()},
  288. }
  289. if badDebt > 0 {
  290. deltas = append(deltas, repository.ColumnDelta{
  291. Column: repository.ExpenseColumnSpecialExpenses, Delta: badDebt,
  292. })
  293. }
  294. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CustomRefund", in.UserID,
  295. in.InstitutionID, in.CampusID, deltas...); e != nil {
  296. return e
  297. }
  298. after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  299. if e != nil {
  300. return e
  301. }
  302. balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance")
  303. if e != nil {
  304. return e
  305. }
  306. // ⑥ 双 log:**先 108(+badDebt)再 207(−requested 全额)**
  307. var logID108 *int64
  308. if badDebt > 0 {
  309. lg108 := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, badDebt, in.RequestID,
  310. remark+badDebtRemarkSuffix, in.OperatorID, &balanceAfter)
  311. if e := s.insertLog(txCtx, tx, lg108); e != nil {
  312. return e
  313. }
  314. id := lg108.ID
  315. logID108 = &id
  316. }
  317. lg207 := newExpenseLog(in.UserID, model.ExpenseLogTypeRefund, in.Money.Neg(), in.RequestID,
  318. remark, in.OperatorID, &balanceAfter)
  319. if e := s.insertLog(txCtx, tx, lg207); e != nil {
  320. return e
  321. }
  322. // ⑦ 活动失效(同事务;自营 `IN (campus,0)` / 加盟 `= campus`)
  323. expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  324. if e != nil {
  325. return e
  326. }
  327. // ⑧ 快照回填
  328. globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID)
  329. if e != nil {
  330. return e
  331. }
  332. totalBalance, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance")
  333. if e != nil {
  334. return e
  335. }
  336. totalIncome, e := parseAccountAmount(after.TotalIncome, "t_user_expense_school.total_income")
  337. if e != nil {
  338. return e
  339. }
  340. special, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses")
  341. if e != nil {
  342. return e
  343. }
  344. out = CustomRefundResult{
  345. Requested: in.Money,
  346. Refunded: refunded,
  347. BadDebt: badDebt,
  348. RefundableAmount: refundable,
  349. Balance: balanceAfter,
  350. TotalBalance: totalBalance,
  351. TotalIncome: totalIncome,
  352. SpecialExpenses: special,
  353. LogID207: lg207.ID,
  354. LogID108: logID108,
  355. ExpiredActivityIDs: expired,
  356. Warnings: refundWarnings(badDebt),
  357. Repeated: false,
  358. }
  359. return nil
  360. })
  361. if err != nil {
  362. // 并发幂等命中(撞 uk_request_type)→ 整笔回滚后回放首次结果
  363. if errors.Is(err, apperr.ErrRepeated) {
  364. return s.replayCustomRefundAfterRace(ctx, in)
  365. }
  366. return nil, err
  367. }
  368. return &out, nil
  369. }
  370. // replayCustomRefund 幂等回放(**事务内**路径:预检命中,未产生任何写入)。
  371. //
  372. // 入参:txCtx(含 tx)/ tx / in / out / hit(首次 207 流水)。
  373. // 出参:nil(out 已填充)/ 错误。
  374. //
  375. // 回放口径:`requested/badDebt/refunded/logId207/logId108/warnings` 由**首次流水**重建;
  376. // `balance/totalBalance/totalIncome/specialExpenses` 取**当前**账户快照
  377. // (中间若发生别的资金操作,这些快照会前进——属可接受的"当前值",不伪造历史)。
  378. func (s *RefundService) replayCustomRefund(
  379. ctx context.Context, tx *gorm.DB, in CustomRefundInput, out *CustomRefundResult, hit *model.UserExpenseLog,
  380. ) error {
  381. requested, err := money.Parse(strings.TrimPrefix(hit.Money, "-"))
  382. if err != nil {
  383. return infraerrors.WithCause(infraerrors.ErrDBError, errString("service: 首次 207 流水金额非法"))
  384. }
  385. // 使用票据金额的绝对值:207 恒为 −requested
  386. requested = absAmount(requested)
  387. var badDebt money.Amount
  388. var logID108 *int64
  389. hit108, e := s.repo.FindLogByRequest(ctx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
  390. if e != nil {
  391. return e
  392. }
  393. if hit108 != nil {
  394. if v, pe := money.Parse(hit108.Money); pe == nil {
  395. badDebt = absAmount(v)
  396. id := hit108.ID
  397. logID108 = &id
  398. }
  399. }
  400. res := CustomRefundResult{
  401. Requested: requested,
  402. BadDebt: badDebt,
  403. Refunded: requested.Sub(badDebt),
  404. RefundableAmount: requested.Sub(badDebt),
  405. LogID207: hit.ID,
  406. LogID108: logID108,
  407. ExpiredActivityIDs: make([]int64, 0),
  408. Warnings: refundWarnings(badDebt),
  409. Repeated: true,
  410. }
  411. if acc, ae := s.repo.GetCampusAccount(ctx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
  412. if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
  413. res.Balance = v
  414. }
  415. if v, pe := parseAccountAmount(acc.TotalIncome, "t_user_expense_school.total_income"); pe == nil {
  416. res.TotalIncome = v
  417. }
  418. if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
  419. res.SpecialExpenses = v
  420. }
  421. }
  422. if gl, ge := s.repo.GetGlobalAccount(ctx, tx, in.UserID); ge == nil {
  423. if v, pe := parseAccountAmount(gl.Balance, "t_user_expense.balance"); pe == nil {
  424. res.TotalBalance = v
  425. }
  426. }
  427. *out = res
  428. return nil
  429. }
  430. // replayCustomRefundAfterRace 并发幂等命中后的回放(**事务外**路径:本次事务已整笔回滚)。
  431. //
  432. // 入参:ctx / in。出参:*CustomRefundResult(Repeated=true)/ 错误。
  433. //
  434. // 为什么要等事务回滚后再查:并发对手的流水只有在它提交后才可见,
  435. // 因此这里是"先让本次事务彻底结束(回滚),再重新读首次结果"(设计 §1.4 幂等模板)。
  436. func (s *RefundService) replayCustomRefundAfterRace(
  437. ctx context.Context, in CustomRefundInput,
  438. ) (*CustomRefundResult, error) {
  439. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeRefund)
  440. if err != nil {
  441. return nil, err
  442. }
  443. if hit == nil {
  444. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  445. errString("service: CustomRefund 幂等命中但首次流水已不可见"))
  446. }
  447. var out CustomRefundResult
  448. if err := s.replayCustomRefund(ctx, nil, in, &out, hit); err != nil {
  449. return nil, err
  450. }
  451. return &out, nil
  452. }
  453. // refundWarnings 按 badDebt 生成 warn 列表(3103 不阻断,§4.6)。
  454. //
  455. // 入参:badDebt 坏账金额。出参:[]Warning(**空时返回长度 0 的非 nil 切片**,JSON 序列化为 [])。
  456. func refundWarnings(badDebt money.Amount) []Warning {
  457. if badDebt <= 0 {
  458. return make([]Warning, 0)
  459. }
  460. return []Warning{{
  461. Code: apperr.CodeRefundableShort,
  462. Message: "可退金额不足,差额 " + badDebt.String() + " 已计入坏账",
  463. }}
  464. }
  465. // absAmount 取金额绝对值(流水方向 → 金额绝对值,回放用)。
  466. func absAmount(a money.Amount) money.Amount {
  467. if a < 0 {
  468. return a.Neg()
  469. }
  470. return a
  471. }
  472. // ---------------------------------------------------------------- SP10 余额清零
  473. // ClearBalance 余额清零(SP10 `sp_user_expense_school_cleared_balance`)。
  474. //
  475. // dump 语义(:764295,★**全局表扣减基数是 campusBalance**,不是 totalBalance,设计 §3.1 #10):
  476. //
  477. // ① 读校区余额;ISNULL 或 <=0 → LEAVE
  478. // ② 读全局余额;ISNULL 或 <=0 → LEAVE
  479. // ③ campusBalance > totalBalance → LEAVE
  480. // ④ UPDATE 校区:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance
  481. // UPDATE 全局:balance−campusBalance, special_expenses+campusBalance, spend+campusBalance
  482. // ⑤ INSERT log(type=208, money=−campusBalance)
  483. // ⑥ 活动失效(与 SP9 同一段 SQL)
  484. //
  485. // Go 的差异:①②③ 失败 → **3120 + 原因**(dump 静默 LEAVE;前端会显示"成功但余额没变")。
  486. //
  487. // 幂等:SP10 **没有请求号**(dump 签名只有 5 参),因此无幂等键可落;重复调用第二次
  488. // `campusBalance=0` → 3120(天然幂等,不会重复清零)。`Repeated` 恒 false。
  489. //
  490. // 入参:ctx / in。出参:*ClearBalanceResult;错误码 2001/3140/3120/3104/1004/3001/5002。
  491. func (s *RefundService) ClearBalance(ctx context.Context, in ClearBalanceInput) (*ClearBalanceResult, error) {
  492. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "ClearBalance", true); err != nil {
  493. return nil, err
  494. }
  495. if s == nil || s.runner == nil || s.repo == nil {
  496. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
  497. }
  498. remark := strings.TrimSpace(in.Remark)
  499. if remark == "" {
  500. remark = defaultClearBalanceRemark
  501. }
  502. var out ClearBalanceResult
  503. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  504. txCtx := repository.WithTxContext(ctx, tx)
  505. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  506. return e
  507. }
  508. // ① 加锁:全局 → 校区(同 §7.2 基线)
  509. global, e := s.repo.GetForUpdate(txCtx, tx, in.UserID)
  510. if e != nil {
  511. if errors.Is(e, infraerrors.ErrNotFound) {
  512. return clearBalanceRejected("全局账户不存在(totalBalance 视为 0)")
  513. }
  514. return e
  515. }
  516. school, e := s.repo.GetCampusForUpdate(txCtx, tx, in.UserID, in.CampusID)
  517. if e != nil {
  518. if errors.Is(e, infraerrors.ErrNotFound) {
  519. return clearBalanceRejected("校区账户不存在(campusBalance 视为 0)")
  520. }
  521. return e
  522. }
  523. campusBalance, e := parseAccountAmount(school.Balance, "t_user_expense_school.balance")
  524. if e != nil {
  525. return e
  526. }
  527. totalBalance, e := parseAccountAmount(global.Balance, "t_user_expense.balance")
  528. if e != nil {
  529. return e
  530. }
  531. // ② 三条前置校验(失败 → 3120 + 原因,**不做 no-op**)
  532. if campusBalance <= 0 {
  533. return clearBalanceRejected("校区余额 ≤ 0,无需清零")
  534. }
  535. if totalBalance <= 0 {
  536. return clearBalanceRejected("全局余额 ≤ 0,无需清零")
  537. }
  538. if campusBalance > totalBalance {
  539. return clearBalanceRejected("校区余额大于全局余额(数据异常,拒绝清零)")
  540. }
  541. // ③ 双表同额扣减:balance−campusBalance、special_expenses+campusBalance、spend+campusBalance
  542. // ★ 全局表扣减基数同样是 campusBalance(漂移项,不得改成 totalBalance)
  543. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "ClearBalance", in.UserID,
  544. in.InstitutionID, in.CampusID,
  545. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: campusBalance.Neg()},
  546. repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: campusBalance},
  547. repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: campusBalance},
  548. ); e != nil {
  549. return e
  550. }
  551. after, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  552. if e != nil {
  553. return e
  554. }
  555. balanceAfter, e := parseAccountAmount(after.Balance, "t_user_expense_school.balance")
  556. if e != nil {
  557. return e
  558. }
  559. specialAfter, e := parseAccountAmount(after.SpecialExpenses, "t_user_expense_school.special_expenses")
  560. if e != nil {
  561. return e
  562. }
  563. // ④ 流水 type=208,money = −campusBalance(无请求号 → request_id/log_id 均为 NULL)
  564. lg := newExpenseLog(in.UserID, model.ExpenseLogTypeClearBalance, campusBalance.Neg(),
  565. "", remark, in.OperatorID, &balanceAfter)
  566. if e := s.insertLog(txCtx, tx, lg); e != nil {
  567. return e
  568. }
  569. // ⑤ 活动失效(同事务,与 SP9 同源)
  570. expired, e := s.expireRechargeActivities(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  571. if e != nil {
  572. return e
  573. }
  574. globalAfter, e := s.repo.GetGlobalAccount(txCtx, tx, in.UserID)
  575. if e != nil {
  576. return e
  577. }
  578. totalAfter, e := parseAccountAmount(globalAfter.Balance, "t_user_expense.balance")
  579. if e != nil {
  580. return e
  581. }
  582. out = ClearBalanceResult{
  583. Cleared: campusBalance,
  584. CampusBalanceBefore: campusBalance,
  585. TotalBalanceBefore: totalBalance,
  586. Balance: balanceAfter,
  587. TotalBalance: totalAfter,
  588. SpecialExpenses: specialAfter,
  589. LogID208: lg.ID,
  590. ExpiredActivityIDs: expired,
  591. Repeated: false,
  592. }
  593. return nil
  594. })
  595. if err != nil {
  596. return nil, err
  597. }
  598. return &out, nil
  599. }
  600. // clearBalanceRejected 构造 3120(前置条件不满足)+ 具体原因(设计 §3.1 #10 的显式化)。
  601. //
  602. // 入参:reason 人话原因(会进日志,不进响应体正文之外的结构)。
  603. // 出参:*infraerrors.AppError(3120)。
  604. func clearBalanceRejected(reason string) error {
  605. return infraerrors.WithCause(apperr.ErrClearBalanceRejected, errString("service: ClearBalance "+reason))
  606. }
  607. // ---------------------------------------------------------------- SP8 取消充值活动
  608. // CancelRechargeActivity 取消充值活动(SP8 `sp_user_expense_cancel_recharge_activity`)。
  609. //
  610. // dump 语义(:763935):
  611. //
  612. // ① `SELECT school_id FROM yuansiwei.t_school_campus WHERE id=inputCampusid`
  613. // → ISNULL 或 0:`msg='找不到学校信息'` + LEAVE(success=0)
  614. // ② 校区账户/全局账户不存在则建户
  615. // ③ UPDATE 校区:balance−money, total_income−money ;UPDATE 全局:同
  616. // ④ UPDATE t_courses_activity_signed SET status=**0** WHERE userid AND activityid(注意不是 4)
  617. // ⑤ INSERT log(type=209, money=**−money**)
  618. //
  619. // Go 的两处收敛:
  620. // - 跨库读 `yuansiwei.t_school_campus` → `port.CampusReader.GetSchoolID`(同事务只读,禁止跨库 SQL,§1.6);
  621. // 取不到 → **3104**(dump 返回 success=0 + 文案,无错误码);
  622. // - 幂等:`request_id + type(209)` 唯一键(dump 无幂等)。
  623. //
  624. // 归属说明:设计 §3.1 #8 把本方法挂在 `RecommendService.CancelRechargeActivity`(AC-08);
  625. // 本批(AC-05)先落在 RefundService 上——两者是同一个资金动作 + 同一句活动状态回写,
  626. // AC-08 落地时 RecommendService 可直接转调本方法(签名不变),避免同一逻辑写两份。
  627. //
  628. // 入参:ctx / in。出参:*CancelRechargeActivityResult;错误码 2001/2002/3140/3104/1004/3001/5002。
  629. func (s *RefundService) CancelRechargeActivity(
  630. ctx context.Context, in CancelRechargeActivityInput,
  631. ) (*CancelRechargeActivityResult, error) {
  632. if strings.TrimSpace(in.UserID) == "" {
  633. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  634. errString("service: CancelRechargeActivity 需要 user_id"))
  635. }
  636. if in.CampusID <= 0 {
  637. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  638. errString("service: CancelRechargeActivity 需要 campus_id > 0"))
  639. }
  640. if in.ActivityID <= 0 {
  641. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  642. errString("service: CancelRechargeActivity 需要 activity_id > 0"))
  643. }
  644. if err := validatePositiveAmount(in.Money, "CancelRechargeActivity"); err != nil {
  645. return nil, err
  646. }
  647. if s == nil || s.runner == nil || s.repo == nil || s.campus == nil {
  648. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: RefundService 未装配"))
  649. }
  650. remark := strings.TrimSpace(in.Remark)
  651. if remark == "" {
  652. remark = defaultCancelRechargeRemark
  653. }
  654. var out CancelRechargeActivityResult
  655. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  656. txCtx := repository.WithTxContext(ctx, tx)
  657. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct)
  658. if e != nil {
  659. return e
  660. }
  661. if hit != nil {
  662. bal, pe := balanceAfterOf(hit)
  663. if pe != nil {
  664. return pe
  665. }
  666. out = CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}
  667. if inst, ie := s.readCampus(txCtx, tx, in.CampusID); ie == nil {
  668. out.InstitutionID = inst.SchoolID
  669. }
  670. return nil
  671. }
  672. // ① 跨域读校区 → 机构 ID(失败 3104)
  673. info, e := s.readCampus(txCtx, tx, in.CampusID)
  674. if e != nil {
  675. return e
  676. }
  677. if info.SchoolID <= 0 {
  678. return infraerrors.WithCause(apperr.ErrCampusNotFound,
  679. errString("service: CancelRechargeActivity 校区未归属机构"))
  680. }
  681. if e := s.assertInScope(txCtx, in.UserID, info.SchoolID, in.CampusID); e != nil {
  682. return e
  683. }
  684. // ② 建户(dump 的 COUNT 判存在 → UPSERT)
  685. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  686. return e
  687. }
  688. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  689. UserID: in.UserID, InstitutionID: info.SchoolID, CampusID: in.CampusID,
  690. }); e != nil {
  691. return e
  692. }
  693. // ③ 余额−、总充值−
  694. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "CancelRechargeActivity", in.UserID,
  695. info.SchoolID, in.CampusID,
  696. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money.Neg()},
  697. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()},
  698. ); e != nil {
  699. return e
  700. }
  701. // ④ 报名状态归 0(dump:`SET status=0`,**不是 4**)
  702. if _, e := s.expirer.SetSignedStatus(
  703. txCtx, tx, in.UserID, []int64{in.ActivityID}, model.ActivitySignedCancelled,
  704. ); e != nil {
  705. return e
  706. }
  707. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, info.SchoolID, in.CampusID)
  708. if e != nil {
  709. return e
  710. }
  711. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  712. if e != nil {
  713. return e
  714. }
  715. // ⑤ 流水 type=209,money = −money
  716. lg := newExpenseLog(in.UserID, model.ExpenseLogTypeCancelRechargeAct, in.Money.Neg(),
  717. in.RequestID, remark, in.OperatorID, &bal)
  718. if e := s.insertLog(txCtx, tx, lg); e != nil {
  719. return e
  720. }
  721. out = CancelRechargeActivityResult{
  722. InstitutionID: info.SchoolID,
  723. LogID: lg.ID,
  724. Balance: bal,
  725. }
  726. return nil
  727. })
  728. if err != nil {
  729. if errors.Is(err, apperr.ErrRepeated) {
  730. hit, re := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeCancelRechargeAct)
  731. if re != nil {
  732. return nil, re
  733. }
  734. if hit == nil {
  735. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  736. errString("service: CancelRechargeActivity 幂等命中但首次流水已不可见"))
  737. }
  738. bal, pe := balanceAfterOf(hit)
  739. if pe != nil {
  740. return nil, pe
  741. }
  742. return &CancelRechargeActivityResult{LogID: hit.ID, Balance: bal, Repeated: true}, nil
  743. }
  744. return nil, err
  745. }
  746. return &out, nil
  747. }
  748. // ---------------------------------------------------------------- 内部助手
  749. // assertInScope 对象级越权断言(RefundService 版本,语义同 FundService)。
  750. func (s *RefundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
  751. if s == nil || s.member == nil {
  752. return nil // 系统任务/割接脚本模式
  753. }
  754. return s.member.AssertInScope(ctx, userID, institutionID, campusID)
  755. }
  756. // insertLog 写流水(撞 `uk_request_type` → 内部幂等哨兵,语义同 FundService.insertLog)。
  757. func (s *RefundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
  758. err := s.repo.InsertLog(txCtx, tx, lg)
  759. if err == nil {
  760. return nil
  761. }
  762. if errors.Is(err, repository.ErrDuplicateLog) {
  763. return apperr.ErrRepeated
  764. }
  765. return err
  766. }
  767. // readCampus 同事务读校区(优先 `TxCampusReader`,退化到连接池版 `CampusReader`)。
  768. //
  769. // 入参:ctx / tx / campusID(>0)。
  770. // 出参:*port.CampusInfo;3104(校区不存在)/ 3140(campusID<=0)/ 5002。
  771. //
  772. // 为什么优先 TxCampusReader:dump 是同库 JOIN(同一快照),目标态跨域读必须显式传 tx,
  773. // 否则读到的是连接池的默认隔离级别快照,与本次资金事务不一致(§1.6)。
  774. func (s *RefundService) readCampus(ctx context.Context, tx *gorm.DB, campusID int64) (*port.CampusInfo, error) {
  775. if s.campus == nil {
  776. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  777. errString("service: 未注入 port.CampusReader(无法判定自营/机构)"))
  778. }
  779. if txReader, ok := s.campus.(port.TxCampusReader); ok {
  780. return txReader.GetByIDTx(ctx, tx, campusID)
  781. }
  782. return s.campus.GetByID(ctx, campusID)
  783. }
  784. // expireRechargeActivities 判定自营/加盟并在同一事务内失效活动(SP9/SP10 共用)。
  785. //
  786. // 入参:txCtx / tx / userID / institutionID / campusID。
  787. // 出参:(失效活动 ID 列表, error);3104(校区不存在,R1 硬化)/ 5002。
  788. //
  789. // 分流(dump `IF(campusSelfSupport)`):
  790. // - 自营 true → `activity.campus_id IN (campusID, 0)`(含"全机构池"活动)
  791. // - 加盟 false → `activity.campus_id = campusID`(**不含** 0,避免误失效他校区活动)
  792. func (s *RefundService) expireRechargeActivities(
  793. ctx context.Context, tx *gorm.DB, userID string, institutionID, campusID int64,
  794. ) ([]int64, error) {
  795. info, err := s.readCampus(ctx, tx, campusID)
  796. if err != nil {
  797. return nil, err
  798. }
  799. if info.SchoolID > 0 && info.SchoolID != institutionID {
  800. // 校区归属机构与入参不一致 → 同码 3104(不泄露存在性,不误写)
  801. return nil, infraerrors.WithCause(apperr.ErrCampusNotFound,
  802. errString("service: 校区归属机构与入参 institution_id 不一致"))
  803. }
  804. return s.expirer.ExpireForUser(ctx, tx, userID, institutionID, campusID, info.SelfSupport)
  805. }