fund_service.go 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303
  1. // 余额核心服务(8 个余额类 SP 的 Go 化落点)——AC-04。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.1(余额 11 SP 映射,本文件覆盖 #1~#7 + #11)
  4. // / §1.4(事务模板)/ §1.5(幂等键 uk_request_type)/ §7.1(充值时序)/ §10.2(type 编码与方向)
  5. // / §10.3(事务边界只在 Service)/ §10.7(金额溢出与量级上限)。
  6. //
  7. // SP 映射(dump 行号见 §3.1,**顺序与方向一律按 dump,不得"顺手修正"**):
  8. //
  9. // #1 sp_user_expense_recharge → Recharge(8 参、**双表更新**:全局 + 校区同事务)
  10. // #2 sp_user_expense_school_recharge → Recharge(同构合并,仅默认备注不同 → RechargeKind)
  11. // #3 sp_user_expense_school_expenditure → Expenditure(type 由调用方传,白名单校验)
  12. // #4 sp_user_expense_school_refund → Refund(**补 institution_id 参数**,BUG#2 修正)
  13. // #5 sp_user_expense_school_get_refundable_amount → RefundableAmount(只读,无事务)
  14. // #6 sp_user_expense_school_transfer_expenditure → Transfer(**不动 balance**;106=+m / 206=−m)
  15. // #7 sp_user_expense_baddebt → BadDebt(只加 special_expenses,不动 balance)
  16. // #11 sp_user_expense_school_spend → AccumulateSpend(只更新 spend,不动 balance;BUG#1)
  17. //
  18. // 两条不可违反的纪律(评审逐处检查):
  19. //
  20. // 1. **事务边界只在 Service**:写路径一律 `s.runner.WithinTx`;仓储方法不开事务,
  21. // 事务内下游调用一律经 `repository.WithTxContext(ctx, tx)` 注入的 ctx(§10.3)。
  22. // 2. **固定写序「全局 → 校区」**:所有双表写都收敛到
  23. // `ExpenseRepo.AddColumnsGlobalThenCampus`(一条表一条 UPDATE,全局在前),
  24. // 与 RefundService 的**加锁**顺序保持一致,否则与并发充值交叉即成环(§1.4 / §7.2)。
  25. //
  26. // 金额纪律:全链路 `money.Amount`(int64 分),入参先过 `validatePositiveAmount`
  27. // (业务量级 + `Validate()`),落库前再由仓储的 `amountLiteral` 二次 `Validate()`(§10.7 兜底网)。
  28. package service
  29. import (
  30. "context"
  31. "errors"
  32. "sort"
  33. "strings"
  34. "gorm.io/gorm"
  35. "yuansiwei/unified-backend/internal/academic/apperr"
  36. "yuansiwei/unified-backend/internal/academic/model"
  37. "yuansiwei/unified-backend/internal/academic/money"
  38. "yuansiwei/unified-backend/internal/academic/port"
  39. "yuansiwei/unified-backend/internal/academic/repository"
  40. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  41. appscope "yuansiwei/unified-backend/internal/infra/scope"
  42. )
  43. // 默认备注文案(dump 的 `IF inputRemark=”` 分支,**逐字对齐**,双跑会比对 log.remark)。
  44. const (
  45. // defaultRechargeRemark SP1 `sp_user_expense_recharge` 默认备注。
  46. defaultRechargeRemark = "余额充值"
  47. // defaultSchoolRechargeRemark SP2 `sp_user_expense_school_recharge` 默认备注(与 SP1 不同)。
  48. defaultSchoolRechargeRemark = "充值缴费"
  49. // defaultExpenditureRemark SP3 `sp_user_expense_school_expenditure` 默认备注。
  50. defaultExpenditureRemark = "历史扣费"
  51. // defaultRefundRemark SP4 `sp_user_expense_school_refund` 默认备注。
  52. defaultRefundRemark = "报课的费用退回"
  53. // defaultBadDebtRemark SP7 `sp_user_expense_baddebt` 默认备注。
  54. defaultBadDebtRemark = "坏账处理"
  55. )
  56. // maxSingleAmount 单笔金额业务上限(1,000,000.00 元)。
  57. //
  58. // 依据设计 §10.7「配套纪律 2」:Service 入口对 money 做业务量级断言,把"脏数据放大"挡在运算前
  59. // (超出 → 2002)。注意与 `money.MaxAmount`(decimal(12,2) 全域 ±99.99 亿元)不是一回事:
  60. // 前者是**业务**天花板,后者是**存储**天花板,两者都必须校验。
  61. const maxSingleAmount money.Amount = 100_000_000
  62. // RechargeKind 充值 SP 形态(#1 与 #2 的**唯一差异**是默认备注,合并后用它区分双跑映射)。
  63. type RechargeKind int
  64. const (
  65. // RechargeKindDefault 对应 SP1 `sp_user_expense_recharge`(默认备注"余额充值")。
  66. RechargeKindDefault RechargeKind = iota
  67. // RechargeKindSchool 对应 SP2 `sp_user_expense_school_recharge`(默认备注"充值缴费")。
  68. RechargeKindSchool
  69. )
  70. // FundService 余额核心服务(充值/消费/退费/转结/坏账/累计消费/可退金额)。
  71. //
  72. // 字段:
  73. // - repo:余额账户仓储(全局 + 校区 + 流水;校区账户走机构作用域);
  74. // - runner:事务运行器(顶层 BEGIN,事务内透传);
  75. // - member:成员关系校验器(可 nil:系统任务/割接脚本模式不做对象级越权断言)。
  76. type FundService struct {
  77. repo *repository.ExpenseRepo
  78. runner *repository.TxRunner
  79. member port.MembershipChecker
  80. }
  81. // NewFundService 构造余额核心服务(repo 与 runner 绑定同一 DB 句柄,避免跨连接池事务失效)。
  82. //
  83. // 入参:
  84. // - db *gorm.DB:数据库句柄(必填);
  85. // - sc *appscope.Scope:本次请求作用域(校区账户读写必需;系统任务可 nil,
  86. // 此时**只能**调用全局/流水方法,触达校区账户会 panic fail-fast);
  87. // - member port.MembershipChecker:成员校验器(可 nil)。
  88. //
  89. // 出参:*FundService。
  90. func NewFundService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FundService {
  91. return &FundService{
  92. repo: repository.NewExpenseRepo(db, sc),
  93. runner: repository.NewTxRunner(db),
  94. member: member,
  95. }
  96. }
  97. // ---------------------------------------------------------------- 入参 / 出参
  98. // RechargeInput 充值入参(SP1 `sp_user_expense_recharge` 8 参 / SP2 同构 7 参)。
  99. //
  100. // 入参纪律:`Money` 已由 Handler 用 `money.Parse` 解析(**拒绝 float**,§10.6),
  101. // 本层不再解析字符串,只校验量级与业务规则。
  102. type RechargeInput struct {
  103. // UserID 充值用户(canonical_user_id ULID;必填)。
  104. UserID string `json:"userId"`
  105. // InstitutionID 机构 ID(必填 >0;dump 的 inputSchoolid)。
  106. InstitutionID int64 `json:"institutionId"`
  107. // CampusID 校区 ID(必填 >0;dump 的 inputCampusid)。
  108. CampusID int64 `json:"campusId"`
  109. // Money 充值金额(>0;dump 的 inputMoney)。
  110. Money money.Amount `json:"money"`
  111. // Type 流水类型(dump 的 inputType;<100 一律归 100,对齐 dump `IF inputType<100`)。
  112. Type int32 `json:"type"`
  113. // RequestID 幂等请求号(→ request_id 幂等键 + log_id 业务单号;空 = 不享幂等)。
  114. RequestID string `json:"requestId"`
  115. // Remark 备注(空 → 按 Kind 取默认文案)。
  116. Remark string `json:"remark"`
  117. // OperatorID 操作人(→ create_user_id;空 → NULL)。
  118. OperatorID string `json:"-"`
  119. // Kind SP 形态(默认 RechargeKindDefault)。
  120. Kind RechargeKind `json:"-"`
  121. }
  122. // RechargeResult 充值结果(端点 3 响应 `{logId,balance,repeated}`)。
  123. type RechargeResult struct {
  124. // LogID 流水 ID(幂等命中时为**首次**流水 ID)。
  125. LogID int64 `json:"logId"`
  126. // Balance 充值后校区账户余额(balance_after 口径)。
  127. Balance money.Amount `json:"balance"`
  128. // Repeated true = 幂等命中(requestId 已处理),未重复入账。
  129. Repeated bool `json:"repeated"`
  130. }
  131. // ExpenditureInput 消费入参(SP3 `sp_user_expense_school_expenditure` 8 参)。
  132. type ExpenditureInput struct {
  133. // UserID 用户(必填)。
  134. UserID string `json:"userId"`
  135. // InstitutionID 机构 ID(必填 >0)。
  136. InstitutionID int64 `json:"institutionId"`
  137. // CampusID 校区 ID(必填 >0)。
  138. CampusID int64 `json:"campusId"`
  139. // Money 消费金额(>0;流水落 `-Money`)。
  140. Money money.Amount `json:"money"`
  141. // Type 消费类型(101/104/105/200/201/204/205,白名单;非法 → 2002)。
  142. Type int32 `json:"type"`
  143. // RequestID 幂等请求号(空 = 不享幂等)。
  144. RequestID string `json:"requestId"`
  145. // Remark 备注(空 → "历史扣费")。
  146. Remark string `json:"remark"`
  147. // OperatorID 操作人。
  148. OperatorID string `json:"-"`
  149. }
  150. // ExpenditureResult 消费结果(端点 4 响应 `{logId,balance}`)。
  151. type ExpenditureResult struct {
  152. // LogID 流水 ID。
  153. LogID int64 `json:"logId"`
  154. // Balance 消费后校区账户余额。
  155. Balance money.Amount `json:"balance"`
  156. // Repeated true = 幂等命中。
  157. Repeated bool `json:"repeated"`
  158. }
  159. // RefundInput 退费入参(SP4 `sp_user_expense_school_refund` 7 参 + **补 institution_id**)。
  160. type RefundInput struct {
  161. // UserID 用户(必填)。
  162. UserID string `json:"userId"`
  163. // InstitutionID 机构 ID(必填 >0;**dump 无此参数 = BUG#2**,见 Refund 方法注释)。
  164. InstitutionID int64 `json:"institutionId"`
  165. // CampusID 校区 ID(必填 >0)。
  166. CampusID int64 `json:"campusId"`
  167. // Money 退费金额(>0 才生效;<=0 → changed=false 的 no-op,对齐 dump `IF inputMoney>0`)。
  168. Money money.Amount `json:"money"`
  169. // Type 流水类型(dump 的 inputType;必须属 model.ExpenseLogType* 白名单)。
  170. Type int32 `json:"type"`
  171. // RequestID 幂等请求号(空 = 不享幂等)。
  172. RequestID string `json:"requestId"`
  173. // Remark 备注(空 → "报课的费用退回")。
  174. Remark string `json:"remark"`
  175. // OperatorID 操作人。
  176. OperatorID string `json:"-"`
  177. }
  178. // RefundResult 退费结果(端点 5 响应 `{logId,balance,changed}`)。
  179. type RefundResult struct {
  180. // LogID 流水 ID(no-op 时为 0)。
  181. LogID int64 `json:"logId"`
  182. // Balance 退费后校区账户余额。
  183. Balance money.Amount `json:"balance"`
  184. // Changed false = 未发生资金变动(money<=0 的 no-op,或幂等命中)。
  185. Changed bool `json:"changed"`
  186. // Repeated true = 幂等命中。
  187. Repeated bool `json:"repeated"`
  188. }
  189. // TransferInput 转结入参(SP6 `sp_user_expense_school_transfer_expenditure` 8 参)。
  190. type TransferInput struct {
  191. // UserID 用户(必填)。
  192. UserID string `json:"userId"`
  193. // FromInstitutionID 转出机构 ID(必填 >0)。
  194. FromInstitutionID int64 `json:"fromInstitutionId"`
  195. // FromCampusID 转出校区 ID(必填 >0)。
  196. FromCampusID int64 `json:"fromCampusId"`
  197. // ToInstitutionID 转入机构 ID(必填 >0)。
  198. ToInstitutionID int64 `json:"toInstitutionId"`
  199. // ToCampusID 转入校区 ID(必填 >0)。
  200. ToCampusID int64 `json:"toCampusId"`
  201. // Money 转结金额(>0)。
  202. Money money.Amount `json:"money"`
  203. // RequestID 幂等请求号(**两条流水共用**同一 request_id,type 不同故不撞键)。
  204. RequestID string `json:"requestId"`
  205. // Remark 备注(dump 无默认值)。
  206. Remark string `json:"remark"`
  207. // OperatorID 操作人。
  208. OperatorID string `json:"-"`
  209. }
  210. // TransferResult 转结结果(端点 7 响应 `{logId106,logId206}`)。
  211. type TransferResult struct {
  212. // LogID106 type=106 转结出流水 ID(dump: money = **+Money**)。
  213. LogID106 int64 `json:"logId106"`
  214. // LogID206 type=206 转结入流水 ID(dump: money = **−Money**)。
  215. LogID206 int64 `json:"logId206"`
  216. // FromBalance 转出校区账户余额(转结**不动 balance**,故与转结前相同)。
  217. FromBalance money.Amount `json:"fromBalance"`
  218. // ToBalance 转入校区账户余额(同上)。
  219. ToBalance money.Amount `json:"toBalance"`
  220. // Repeated true = 幂等命中。
  221. Repeated bool `json:"repeated"`
  222. }
  223. // BadDebtInput 坏账入参(SP7 `sp_user_expense_baddebt` 7 参)。
  224. type BadDebtInput struct {
  225. // UserID 用户(必填)。
  226. UserID string `json:"userId"`
  227. // InstitutionID 机构 ID(必填 >0)。
  228. InstitutionID int64 `json:"institutionId"`
  229. // CampusID 校区 ID(必填 >0)。
  230. CampusID int64 `json:"campusId"`
  231. // Money 坏账金额(>0)。
  232. Money money.Amount `json:"money"`
  233. // RequestID 幂等请求号(空 = 不享幂等)。
  234. RequestID string `json:"requestId"`
  235. // Remark 备注(空 → "坏账处理")。
  236. Remark string `json:"remark"`
  237. // OperatorID 操作人。
  238. OperatorID string `json:"-"`
  239. }
  240. // BadDebtResult 坏账结果(端点 8 响应 `{logId,specialExpenses}`)。
  241. type BadDebtResult struct {
  242. // LogID type=108 流水 ID。
  243. LogID int64 `json:"logId"`
  244. // SpecialExpenses 处理后校区账户特殊费用累计。
  245. SpecialExpenses money.Amount `json:"specialExpenses"`
  246. // Balance 处理后校区账户余额(本方法不动 balance,回显仅为对账方便)。
  247. Balance money.Amount `json:"balance"`
  248. // Repeated true = 幂等命中。
  249. Repeated bool `json:"repeated"`
  250. }
  251. // SpendInput 累计消费入参(SP11 `sp_user_expense_school_spend` 4 参)。
  252. type SpendInput struct {
  253. // UserID 用户(必填)。
  254. UserID string `json:"userId"`
  255. // InstitutionID 机构 ID(必填 >0)。
  256. InstitutionID int64 `json:"institutionId"`
  257. // CampusID 校区 ID(必填 >0)。
  258. CampusID int64 `json:"campusId"`
  259. // SpendMoney 本次累加的课费单价之和(元;**负数一律归 0**,dump INSERT 分支同口径)。
  260. SpendMoney money.Amount `json:"spendMoney"`
  261. }
  262. // SpendResult 累计消费结果(端点 10 响应 `{spend}`)。
  263. type SpendResult struct {
  264. // Spend 累加后校区账户 spend 列(**不是** balance)。
  265. Spend money.Amount `json:"spend"`
  266. }
  267. // ---------------------------------------------------------------- 校验助手
  268. // validateUserScope 校验"用户 + 机构 + 校区"三元组(资金方法入口统一形状)。
  269. //
  270. // 入参:userID / institutionID / campusID;op 操作名(错误文案定位);campusRequired 是否要求校区>0。
  271. // 出参:nil 合法;2001(缺 user_id)/ 3140(校区或机构缺失,对齐设计 §1.6「campusid<=0 → 3140」)。
  272. func validateUserScope(userID string, institutionID, campusID int64, op string, campusRequired bool) error {
  273. if strings.TrimSpace(userID) == "" {
  274. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+op+" 需要 user_id"))
  275. }
  276. if institutionID <= 0 {
  277. return infraerrors.WithCause(apperr.ErrBusinessParam,
  278. errString("service: "+op+" 需要 institution_id > 0"))
  279. }
  280. if campusRequired && campusID <= 0 {
  281. // dump:`IF (inputSchoolid<1 || inputCampusid<1) THEN LEAVE`(静默 no-op)
  282. // Go:显式 3140,避免"调用成功但什么都没做"
  283. return infraerrors.WithCause(apperr.ErrBusinessParam,
  284. errString("service: "+op+" 需要 campus_id > 0(dump:必须有校区信息)"))
  285. }
  286. if campusID < 0 {
  287. return infraerrors.WithCause(apperr.ErrBusinessParam,
  288. errString("service: "+op+" 的 campus_id 不得为负"))
  289. }
  290. return nil
  291. }
  292. // validatePositiveAmount 校验金额为正且不超业务/存储天花板(§10.7)。
  293. //
  294. // 入参:amount 金额(分);op 操作名。
  295. // 出参:nil 合法;2002(<=0 / 超业务上限 / 超 decimal(12,2) 全域)。
  296. //
  297. // 与 dump 的差异(登记为有意偏离):dump 对负数是 `IF inputMoney<0 THEN SET inputMoney=0`
  298. // **静默归零后继续记账**,会留下 money=0 的流水并占住幂等键;Go 显式拒绝(2002)。
  299. func validatePositiveAmount(amount money.Amount, op string) error {
  300. if amount <= 0 {
  301. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  302. errString("service: "+op+" 金额必须 > 0(dump 静默归零 → Go 显式拒绝)"))
  303. }
  304. if amount > maxSingleAmount {
  305. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  306. errString("service: "+op+" 金额超出单笔业务上限"))
  307. }
  308. if err := amount.Validate(); err != nil {
  309. return infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  310. }
  311. return nil
  312. }
  313. // parseAccountAmount 把账户 decimal 字符串列解析为 money.Amount。
  314. //
  315. // 入参:raw 列原文(如 "12.34");col 列名(错误文案定位)。
  316. // 出参:money.Amount;ErrDBError(历史脏值/格式非法——属数据问题,不是入参问题)。
  317. func parseAccountAmount(raw, col string) (money.Amount, error) {
  318. amount, err := money.Parse(raw)
  319. if err != nil {
  320. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  321. errString("service: 账户列 "+col+" 金额非法("+raw+")"))
  322. }
  323. return amount, nil
  324. }
  325. // optString 把空串转成 nil 指针(*string 列的 NULL 语义:request_id 为空时"不享幂等")。
  326. func optString(s string) *string {
  327. s = strings.TrimSpace(s)
  328. if s == "" {
  329. return nil
  330. }
  331. return &s
  332. }
  333. // amountPtr 返回金额两位小数字符串的指针(`balance_after` 等可空金额列用)。
  334. func amountPtr(a money.Amount) *string {
  335. s := a.String()
  336. return &s
  337. }
  338. // newExpenseLog 组装一条余额变更流水(字段口径收口,避免各方法重复拼装)。
  339. //
  340. // 入参:userID/typ/money 金额/requestID/remark/operatorID/balanceAfter(nil = 不写快照)。
  341. // 出参:*model.UserExpenseLog(未落库;Create 后 ID 由 GORM 回填)。
  342. func newExpenseLog(userID string, typ int32, amount money.Amount, requestID, remark, operatorID string,
  343. balanceAfter *money.Amount) *model.UserExpenseLog {
  344. lg := &model.UserExpenseLog{
  345. UserID: userID,
  346. Type: typ,
  347. Money: amount.String(),
  348. RequestID: optString(requestID),
  349. LogID: optString(requestID), // dump:logid = 外部单号(SP1 的 inputOrder / SP2 的 inputRequestid)
  350. CreateUserID: optString(operatorID),
  351. Remark: optString(remark),
  352. }
  353. if balanceAfter != nil {
  354. lg.BalanceAfter = amountPtr(*balanceAfter)
  355. }
  356. return lg
  357. }
  358. // assertInScope 对象级越权断言(全局钱包无作用域列,只能由 Service 显式校验,设计 §1.7 例外一)。
  359. //
  360. // 入参:ctx / userID / institutionID / campusID。
  361. // 出参:nil 通过(或未注入 checker 的系统任务模式);1004 越权;5002 DB 异常。
  362. func (s *FundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
  363. if s == nil || s.member == nil {
  364. return nil // 系统任务/割接脚本模式:不注入 checker 即不做对象级断言(装配层责任)
  365. }
  366. return s.member.AssertInScope(ctx, userID, institutionID, campusID)
  367. }
  368. // ---------------------------------------------------------------- SP1 / SP2 充值
  369. // Recharge 余额充值(SP1 `sp_user_expense_recharge` + SP2 `sp_user_expense_school_recharge`)。
  370. //
  371. // dump 语义(yuansiwei_finance.sql:764247,★8 参 + **双表更新**):
  372. //
  373. // ① inputMoney<0 → 0;inputType<100 → 100;inputRemark='' → 默认备注;
  374. // ② schoolid<1 || campusid<1 → LEAVE(静默 no-op);
  375. // ③ 校区账户不存在则 INSERT;全局账户不存在则 INSERT;
  376. // ④ UPDATE 校区:balance+, total_income+ ;UPDATE 全局:balance+, total_income+;
  377. // ⑤ INSERT log(type=inputType, logid=inputOrder, money=inputMoney)。
  378. //
  379. // Go 的三处**有意强化**(不改变资金结果):
  380. // - 建户合并为一次 UPSERT(`ON CONFLICT DO NOTHING`),替代 dump 的 `COUNT` 判存在(§1.5);
  381. // - 写序固定为 **全局 → 校区**(dump 是校区 → 全局;顺序不同但结果一致,
  382. // 统一顺序是**死锁预防**的硬要求,见 §1.4 与 §7.2 的加锁基线);
  383. // - 幂等:`request_id + type` 唯一键,并发重复提交只落 1 条流水(dump 无幂等,会重复入账)。
  384. //
  385. // 入参:ctx / in(见 RechargeInput;Kind 决定默认备注)。
  386. // 出参:*RechargeResult(logId/balance/repeated);错误码 2001/2002/3140/1004/5002。
  387. func (s *FundService) Recharge(ctx context.Context, in RechargeInput) (*RechargeResult, error) {
  388. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Recharge", true); err != nil {
  389. return nil, err
  390. }
  391. if err := validatePositiveAmount(in.Money, "Recharge"); err != nil {
  392. return nil, err
  393. }
  394. if in.Type < 100 {
  395. // dump:`IF inputType<100 THEN SET inputType=100`(充值流水类型下限)
  396. in.Type = model.ExpenseLogTypeRecharge
  397. }
  398. if s == nil || s.runner == nil || s.repo == nil {
  399. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  400. }
  401. remark := strings.TrimSpace(in.Remark)
  402. if remark == "" {
  403. if in.Kind == RechargeKindSchool {
  404. remark = defaultSchoolRechargeRemark
  405. } else {
  406. remark = defaultRechargeRemark
  407. }
  408. }
  409. var out RechargeResult
  410. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  411. txCtx := repository.WithTxContext(ctx, tx)
  412. // ① 幂等预检(`uk_request_type`):命中 → 返回首次结果,不重复入账
  413. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  414. if e != nil {
  415. return e
  416. }
  417. if hit != nil {
  418. bal, pe := balanceAfterOf(hit)
  419. if pe != nil {
  420. return pe
  421. }
  422. out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
  423. return nil
  424. }
  425. // ② 作用域断言(全局钱包无作用域列 → Service 层显式校验)
  426. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  427. return e
  428. }
  429. // ③ 建户(两个 UPSERT,**不动已有余额**)
  430. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  431. return e
  432. }
  433. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  434. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  435. }); e != nil {
  436. return e
  437. }
  438. // ④ 原子增减(固定「全局 → 校区」,两列合并为每表一条 UPDATE)
  439. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Recharge", in.UserID,
  440. in.InstitutionID, in.CampusID,
  441. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
  442. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
  443. ); e != nil {
  444. return e
  445. }
  446. // ⑤ 流水(balance_after = 充值后校区余额快照,供对账/双跑逐字段比对)
  447. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  448. if e != nil {
  449. return e
  450. }
  451. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  452. if e != nil {
  453. return e
  454. }
  455. lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
  456. if e := s.insertLog(txCtx, tx, lg); e != nil {
  457. return e
  458. }
  459. out = RechargeResult{LogID: lg.ID, Balance: bal}
  460. return nil
  461. })
  462. if err != nil {
  463. return nil, s.mapIdempotentHit(err, func() error {
  464. return s.replayRecharge(ctx, in, &out)
  465. })
  466. }
  467. return &out, nil
  468. }
  469. // ---------------------------------------------------------------- SP3 消费
  470. // Expenditure 余额消费扣费(SP3 `sp_user_expense_school_expenditure`)。
  471. //
  472. // dump 语义(:764364,**type 决定列组合,方向不可改**):
  473. //
  474. // inputType=200(历史扣费) → balance−, total_expenditure+, spend+
  475. // inputType=207(余额退费) → balance−, total_income−
  476. // 其余(101/104/105/201/204/205)→ balance−, total_expenditure+
  477. // 两条 UPDATE(校区 + 全局)+ log(money = **−inputMoney**)
  478. //
  479. // Go 的口径选择(**与 dump 的两处差异,均为设计 §3.1 #3 明确要求**):
  480. // - type 白名单校验:`101/104/105/200/201/204/205`(非法 → 2002)。dump 会把
  481. // `type<200 或 >299` **静默改成 200**,把"调用方传错类型"变成"记成历史扣费";
  482. // - dump 的 `type=207` 分支**不开放**给本方法(退费走 `Refund`/`CustomRefund`,
  483. // 否则同一语义有两条写入口,流水方向难以对账)。
  484. //
  485. // 入参:ctx / in。出参:*ExpenditureResult;错误码 2001/2002/3140/1004/5002。
  486. func (s *FundService) Expenditure(ctx context.Context, in ExpenditureInput) (*ExpenditureResult, error) {
  487. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Expenditure", true); err != nil {
  488. return nil, err
  489. }
  490. if err := validatePositiveAmount(in.Money, "Expenditure"); err != nil {
  491. return nil, err
  492. }
  493. if !isExpenditureType(in.Type) {
  494. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  495. errString("service: Expenditure 的 type 非法(白名单 101/104/105/200/201/204/205)"))
  496. }
  497. if s == nil || s.runner == nil || s.repo == nil {
  498. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  499. }
  500. remark := strings.TrimSpace(in.Remark)
  501. if remark == "" {
  502. remark = defaultExpenditureRemark
  503. }
  504. deltas := expenditureDeltas(in.Type, in.Money)
  505. var out ExpenditureResult
  506. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  507. txCtx := repository.WithTxContext(ctx, tx)
  508. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  509. if e != nil {
  510. return e
  511. }
  512. if hit != nil {
  513. bal, pe := balanceAfterOf(hit)
  514. if pe != nil {
  515. return pe
  516. }
  517. out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
  518. return nil
  519. }
  520. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  521. return e
  522. }
  523. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  524. return e
  525. }
  526. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  527. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  528. }); e != nil {
  529. return e
  530. }
  531. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Expenditure", in.UserID,
  532. in.InstitutionID, in.CampusID, deltas...); e != nil {
  533. return e
  534. }
  535. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  536. if e != nil {
  537. return e
  538. }
  539. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  540. if e != nil {
  541. return e
  542. }
  543. // dump:INSERT log(money = **−inputMoney**)
  544. lg := newExpenseLog(in.UserID, in.Type, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &bal)
  545. if e := s.insertLog(txCtx, tx, lg); e != nil {
  546. return e
  547. }
  548. out = ExpenditureResult{LogID: lg.ID, Balance: bal}
  549. return nil
  550. })
  551. if err != nil {
  552. return nil, s.mapIdempotentHit(err, func() error {
  553. return s.replayExpenditure(ctx, in, &out)
  554. })
  555. }
  556. return &out, nil
  557. }
  558. // expenditureDeltas 按 type 生成列增量(dump 三个分支的逐列对应,**顺序/方向不得改**)。
  559. //
  560. // 入参:typ 消费类型(已过白名单);amount 消费金额(>0)。
  561. // 出参:[]repository.ColumnDelta(balance 恒为 −amount)。
  562. func expenditureDeltas(typ int32, amount money.Amount) []repository.ColumnDelta {
  563. switch typ {
  564. case model.ExpenseLogTypeHistoryDeduct:
  565. // dump:`balance-`,`total_expenditure+`,`spend+`(历史扣费同时计入消费统计)
  566. return []repository.ColumnDelta{
  567. {Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
  568. {Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
  569. {Column: repository.ExpenseColumnSpend, Delta: amount},
  570. }
  571. default:
  572. // dump:`balance-`,`total_expenditure+`(报课学费 / 学校消费)
  573. return []repository.ColumnDelta{
  574. {Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
  575. {Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
  576. }
  577. }
  578. }
  579. // isExpenditureType 消费类型白名单(设计 §3.1 #3:101/104/105/200/201/204/205)。
  580. func isExpenditureType(typ int32) bool {
  581. switch typ {
  582. case model.ExpenseLogTypeCourseCampus, // 101
  583. model.ExpenseLogTypeCourseSchoolA, // 104
  584. model.ExpenseLogTypeCourseSchoolB, // 105
  585. model.ExpenseLogTypeHistoryDeduct, // 200
  586. model.ExpenseLogTypeCourseCampusB, // 201
  587. model.ExpenseLogTypeCourseSchoolC, // 204
  588. model.ExpenseLogTypeCourseSchoolD: // 205
  589. return true
  590. default:
  591. return false
  592. }
  593. }
  594. // ---------------------------------------------------------------- SP4 退费
  595. // Refund 余额退费(SP4 `sp_user_expense_school_refund`)。
  596. //
  597. // dump 语义(:764576):`IF inputMoney>0` 才动作(<=0 静默 no-op);
  598. // 校区账户已存在才 UPDATE(balance+, total_expenditure−);全局账户已存在才 UPDATE;
  599. // 任一更新成功才写 log(money = **+inputMoney**,type = inputType)。
  600. //
  601. // **BUG#2 修正(设计 §3.1 #4)**:dump 的定位条件是 `userid + campusid`,**没有 schoolid**——
  602. // 同一 campus_id 若在多机构复用(历史数据/跨库迁移期),会误更新别的机构的账户行。
  603. // Go 侧补 `institution_id` 参数并进 WHERE,定位三元组精确到 `(user, institution, campus)`。
  604. //
  605. // 入参:ctx / in。出参:*RefundResult(changed=false 表示无资金变动);
  606. // 错误码 2001/2002/3140/1004/3001/5002。
  607. func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) {
  608. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Refund", true); err != nil {
  609. return nil, err
  610. }
  611. if in.Type <= 0 {
  612. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Refund 需要 type"))
  613. }
  614. if !isKnownLogType(in.Type) {
  615. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  616. errString("service: Refund 的 type 不在流水类型白名单内"))
  617. }
  618. if s == nil || s.runner == nil || s.repo == nil {
  619. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  620. }
  621. remark := strings.TrimSpace(in.Remark)
  622. if remark == "" {
  623. remark = defaultRefundRemark
  624. }
  625. // dump:`IF inputMoney>0` 才动作 → Go 返回成功但 changed=false(显式语义,不静默)
  626. if in.Money <= 0 {
  627. return &RefundResult{Changed: false}, nil
  628. }
  629. if err := validatePositiveAmount(in.Money, "Refund"); err != nil {
  630. return nil, err
  631. }
  632. var out RefundResult
  633. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  634. txCtx := repository.WithTxContext(ctx, tx)
  635. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  636. if e != nil {
  637. return e
  638. }
  639. if hit != nil {
  640. bal, pe := balanceAfterOf(hit)
  641. if pe != nil {
  642. return pe
  643. }
  644. out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
  645. return nil
  646. }
  647. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  648. return e
  649. }
  650. // dump 不建户(account 不存在则什么都不做);Go 显式 3001,避免"调用成功但没退钱"
  651. if _, e := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  652. return e
  653. }
  654. // dump:`balance+`,`total_expenditure-`
  655. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Refund", in.UserID,
  656. in.InstitutionID, in.CampusID,
  657. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
  658. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
  659. ); e != nil {
  660. return e
  661. }
  662. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  663. if e != nil {
  664. return e
  665. }
  666. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  667. if e != nil {
  668. return e
  669. }
  670. lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &bal)
  671. if e := s.insertLog(txCtx, tx, lg); e != nil {
  672. return e
  673. }
  674. out = RefundResult{LogID: lg.ID, Balance: bal, Changed: true}
  675. return nil
  676. })
  677. if err != nil {
  678. return nil, s.mapIdempotentHit(err, func() error {
  679. return s.replayRefund(ctx, in, &out)
  680. })
  681. }
  682. return &out, nil
  683. }
  684. // ---------------------------------------------------------------- SP5 可退金额(只读)
  685. // RefundableAmount 查询校区账户可退金额(SP5 `sp_user_expense_school_get_refundable_amount`,**只读**)。
  686. //
  687. // 口径(设计 §3.1 #5 定稿):`max(校区余额, 0)`;账户不存在 → 0(旧行为);
  688. // 余额 <= 0(欠费)→ 0。
  689. //
  690. // ⚠ 与 dump 的差异(**已知偏离,登记于本批报告**):dump 在 `campusBalance - SUM(已使用赠送金额)`
  691. // 之后取 max(…,0)(`totalDiscountAmountUsed` 来自 t_user_balance_request × t_courses_activity_signed
  692. // 的关联聚合,yuansiwei_finance.sql:764472)。设计 §3.1 #5 把该方法**收敛为** `max(balance,0)`
  693. // (赠送扣减属 P2 的"赠送金额可退性"专题),本实现按设计执行;若后续要按 dump 补齐,
  694. // 只需扩展本方法内部(调用方签名不变)。
  695. //
  696. // 入参:ctx / userID / institutionID / campusID。
  697. // 出参:(可退金额, error);账户不存在 → (0, nil);错误码 2001/3140/1004/5002。
  698. func (s *FundService) RefundableAmount(
  699. ctx context.Context, userID string, institutionID, campusID int64,
  700. ) (money.Amount, error) {
  701. if err := validateUserScope(userID, institutionID, campusID, "RefundableAmount", true); err != nil {
  702. return 0, err
  703. }
  704. if s == nil || s.repo == nil {
  705. return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  706. }
  707. if err := s.assertInScope(ctx, userID, institutionID, campusID); err != nil {
  708. return 0, err
  709. }
  710. acc, err := s.repo.GetCampusAccount(ctx, nil, userID, institutionID, campusID)
  711. if err != nil {
  712. if errors.Is(err, infraerrors.ErrNotFound) {
  713. return 0, nil // dump:账户不存在 → 0
  714. }
  715. return 0, err
  716. }
  717. bal, err := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  718. if err != nil {
  719. return 0, err
  720. }
  721. if bal <= 0 {
  722. return 0, nil
  723. }
  724. return bal, nil
  725. }
  726. // ---------------------------------------------------------------- SP6 课费转结
  727. // Transfer 课费转结(SP6 `sp_user_expense_school_transfer_expenditure`)。
  728. //
  729. // dump 语义(:764661,★**与 P0-04 规格相反,以 dump 为准**,设计 §3.1 #6 / §10.2):
  730. //
  731. // IF inputMoney>0:
  732. // 源校区账户存在 →
  733. // 目标校区账户不存在则 INSERT;
  734. // UPDATE 源:total_expenditure−m, total_income−m
  735. // UPDATE 目标:total_expenditure+m, total_income+m
  736. // INSERT log(type=106, money=**+m**) ← 转结出
  737. // INSERT log(type=206, money=**−m**) ← 转结入
  738. //
  739. // **四个"不得改动"的点**:
  740. // 1. **不动 balance**(dump 全程没有 balance 赋值)——转结只调"总消费/总充值"两个统计口径;
  741. // 2. 106 记 **+m**、206 记 **−m**(与"出/入"的直觉相反,dump 原文如此,§11-6 #6);
  742. // 3. 两条流水的 `request_id` **相同**、`type` 不同 → 不撞 `uk_request_type`(唯一键含 type);
  743. // 4. 目标账户不存在时**建户**(dump 的 INSERT),源账户不存在时**不动**(dump 的 `IF campusLogid1>0`)。
  744. //
  745. // Go 的两处显式化:源账户不存在 → 3001(不静默);同校区自转 → 3140(净效应为零的无意义操作)。
  746. //
  747. // 入参:ctx / in。出参:*TransferResult{logId106,logId206};错误码 2001/2002/3140/1004/3001/5002。
  748. func (s *FundService) Transfer(ctx context.Context, in TransferInput) (*TransferResult, error) {
  749. if err := validateUserScope(in.UserID, in.FromInstitutionID, in.FromCampusID, "Transfer(from)", true); err != nil {
  750. return nil, err
  751. }
  752. if err := validateUserScope(in.UserID, in.ToInstitutionID, in.ToCampusID, "Transfer(to)", true); err != nil {
  753. return nil, err
  754. }
  755. if err := validatePositiveAmount(in.Money, "Transfer"); err != nil {
  756. return nil, err
  757. }
  758. if in.FromInstitutionID == in.ToInstitutionID && in.FromCampusID == in.ToCampusID {
  759. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  760. errString("service: Transfer 的转出与转入校区不能相同"))
  761. }
  762. if s == nil || s.runner == nil || s.repo == nil {
  763. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  764. }
  765. var out TransferResult
  766. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  767. txCtx := repository.WithTxContext(ctx, tx)
  768. // ① 幂等预检:106 是"转结出"主流水(必然存在),用它做命中判定
  769. hit106, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
  770. if e != nil {
  771. return e
  772. }
  773. if hit106 != nil {
  774. out = TransferResult{LogID106: hit106.ID, Repeated: true}
  775. if bal, pe := balanceAfterOf(hit106); pe == nil {
  776. out.FromBalance = bal
  777. }
  778. hit206, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
  779. if e != nil {
  780. return e
  781. }
  782. if hit206 != nil {
  783. out.LogID206 = hit206.ID
  784. if bal, pe := balanceAfterOf(hit206); pe == nil {
  785. out.ToBalance = bal
  786. }
  787. }
  788. return nil
  789. }
  790. // ② 作用域断言(两侧机构都必须可见)
  791. if e := s.assertInScope(txCtx, in.UserID, in.FromInstitutionID, in.FromCampusID); e != nil {
  792. return e
  793. }
  794. if in.ToInstitutionID != in.FromInstitutionID {
  795. if e := s.assertInScope(txCtx, in.UserID, in.ToInstitutionID, in.ToCampusID); e != nil {
  796. return e
  797. }
  798. }
  799. // ③ 加锁:两个校区账户**按 campus_id ASC 排序**后依次加锁(§1.4 多账户场景防死锁)
  800. refs := []campusRef{
  801. {InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID},
  802. {InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID},
  803. }
  804. sort.Slice(refs, func(i, j int) bool { return refs[i].CampusID < refs[j].CampusID })
  805. locked := make(map[campusRef]bool, len(refs))
  806. for _, ref := range refs {
  807. _, le := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, ref.InstitutionID, ref.CampusID)
  808. if le != nil {
  809. if errors.Is(le, infraerrors.ErrNotFound) {
  810. locked[ref] = false
  811. continue
  812. }
  813. return le
  814. }
  815. locked[ref] = true
  816. }
  817. fromRef := campusRef{InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID}
  818. toRef := campusRef{InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID}
  819. if !locked[fromRef] {
  820. // dump:源账户不存在 → `IF campusLogid1>0` 不成立 → 静默什么都不做
  821. return infraerrors.WithCause(infraerrors.ErrNotFound,
  822. errString("service: Transfer 转出校区账户不存在"))
  823. }
  824. if !locked[toRef] {
  825. // dump:目标账户不存在 → INSERT 建户
  826. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  827. UserID: in.UserID, InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID,
  828. }); e != nil {
  829. return e
  830. }
  831. }
  832. // ④ 4 次 UPDATE → 2 条语句(每表两列),**balance 一律不动**
  833. if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(out)", in.UserID,
  834. in.FromInstitutionID, in.FromCampusID,
  835. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
  836. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()},
  837. ); e != nil {
  838. return e
  839. }
  840. if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(in)", in.UserID,
  841. in.ToInstitutionID, in.ToCampusID,
  842. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money},
  843. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
  844. ); e != nil {
  845. return e
  846. }
  847. // ⑤ 两条流水(106 在前、206 在后;方向 ★dump 口径)
  848. fromAcc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.FromInstitutionID, in.FromCampusID)
  849. if e != nil {
  850. return e
  851. }
  852. fromBal, e := parseAccountAmount(fromAcc.Balance, "t_user_expense_school.balance")
  853. if e != nil {
  854. return e
  855. }
  856. toAcc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.ToInstitutionID, in.ToCampusID)
  857. if e != nil {
  858. return e
  859. }
  860. toBal, e := parseAccountAmount(toAcc.Balance, "t_user_expense_school.balance")
  861. if e != nil {
  862. return e
  863. }
  864. lg106 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferOut, in.Money,
  865. in.RequestID, in.Remark, in.OperatorID, &fromBal)
  866. if e := s.insertLog(txCtx, tx, lg106); e != nil {
  867. return e
  868. }
  869. lg206 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferIn, in.Money.Neg(),
  870. in.RequestID, in.Remark, in.OperatorID, &toBal)
  871. if e := s.insertLog(txCtx, tx, lg206); e != nil {
  872. return e
  873. }
  874. out = TransferResult{
  875. LogID106: lg106.ID, LogID206: lg206.ID,
  876. FromBalance: fromBal, ToBalance: toBal,
  877. }
  878. return nil
  879. })
  880. if err != nil {
  881. return nil, s.mapIdempotentHit(err, func() error {
  882. return s.replayTransfer(ctx, in, &out)
  883. })
  884. }
  885. return &out, nil
  886. }
  887. // campusRef 校区账户定位三元组的"机构 + 校区"部分(转结加锁排序用)。
  888. type campusRef struct {
  889. // InstitutionID 机构 ID。
  890. InstitutionID int64
  891. // CampusID 校区 ID。
  892. CampusID int64
  893. }
  894. // ---------------------------------------------------------------- SP7 坏账
  895. // BadDebt 坏账处理(SP7 `sp_user_expense_baddebt`)。
  896. //
  897. // 口径(设计 §3.1 #7):**只加 `special_expenses`,不动 balance**;log type=108、money=+Money。
  898. //
  899. // ⚠ 与 dump 的差异(**已知偏离,登记于本批报告**):dump(:763890)两条 UPDATE 都是
  900. // `balance = balance + m, special_expenses = special_expenses + m`——即**同时加余额**。
  901. // 设计与本实现按"坏账 = 只统计特殊费用"执行(不凭空给学员加可用余额);
  902. // 若按 dump 回改,只需在下面的 `AddColumnsGlobalThenCampus` 里补一个 balance 列增量,
  903. // 并把 `BadDebtResult.Balance` 的断言改为"增加后余额"。
  904. // 连带影响(已上报):dump 口径下 108 流水与账户余额同增,余额恒等式成立;
  905. // 本口径下 type=108 **不参与**余额恒等式(AC-17 ④ 需排除 108 或按 dump 回改)。
  906. //
  907. // 入参:ctx / in。出参:*BadDebtResult;错误码 2001/2002/3140/1004/5002。
  908. func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtResult, error) {
  909. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "BadDebt", true); err != nil {
  910. return nil, err
  911. }
  912. if err := validatePositiveAmount(in.Money, "BadDebt"); err != nil {
  913. return nil, err
  914. }
  915. if s == nil || s.runner == nil || s.repo == nil {
  916. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  917. }
  918. remark := strings.TrimSpace(in.Remark)
  919. if remark == "" {
  920. remark = defaultBadDebtRemark
  921. }
  922. var out BadDebtResult
  923. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  924. txCtx := repository.WithTxContext(ctx, tx)
  925. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
  926. if e != nil {
  927. return e
  928. }
  929. if hit != nil {
  930. out = BadDebtResult{LogID: hit.ID, Repeated: true}
  931. if acc, ae := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
  932. if spec, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
  933. out.SpecialExpenses = spec
  934. }
  935. if bal, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
  936. out.Balance = bal
  937. }
  938. }
  939. return nil
  940. }
  941. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  942. return e
  943. }
  944. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  945. return e
  946. }
  947. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  948. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  949. }); e != nil {
  950. return e
  951. }
  952. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "BadDebt", in.UserID,
  953. in.InstitutionID, in.CampusID,
  954. repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: in.Money},
  955. ); e != nil {
  956. return e
  957. }
  958. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  959. if e != nil {
  960. return e
  961. }
  962. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  963. if e != nil {
  964. return e
  965. }
  966. spec, e := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses")
  967. if e != nil {
  968. return e
  969. }
  970. lg := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, in.Money,
  971. in.RequestID, remark, in.OperatorID, &bal)
  972. if e := s.insertLog(txCtx, tx, lg); e != nil {
  973. return e
  974. }
  975. out = BadDebtResult{LogID: lg.ID, SpecialExpenses: spec, Balance: bal}
  976. return nil
  977. })
  978. if err != nil {
  979. return nil, s.mapIdempotentHit(err, func() error {
  980. return s.replayBadDebt(ctx, in, &out)
  981. })
  982. }
  983. return &out, nil
  984. }
  985. // ---------------------------------------------------------------- SP11 累计消费(spend)
  986. // AccumulateSpend 累计消费金额(SP11 `sp_user_expense_school_spend`)。
  987. //
  988. // 口径(设计 §3.1 #11):**只更新 `spend`,不动 `balance`**;无流水(dump 不写 log)。
  989. //
  990. // **BUG#1 修正**:dump 的全局分支 `INSERT INTO t_user_expense(userid, spend) VALUES(inputUserid,
  991. // inputMoney, inputSpendMoney)` 是 **3 个值对 2 列**(且 inputMoney 在该 SP 里根本未声明)→ 必报错。
  992. // Go 修正为"UPSERT 建户 + `spend = spend + ?`",列数与列名对齐 DDL。
  993. //
  994. // 负值处理:dump 仅在 **INSERT 分支**把负数归 0(UPDATE 分支会照减);本实现统一归 0
  995. // (`spend` 是"只增"的统计口径列,model 注释亦如此约定),并回显当前值。
  996. //
  997. // 幂等:dump 的 SP11 **没有请求号**(签名只有 4 参),因此本方法不参与 `uk_request_type` 幂等;
  998. // 重复调用会重复累加 `spend`——这是 dump 的既有语义(调用方为"结束上课"链路,每次课一条),
  999. // 不做"按 requestId 去重"的加法,以免与 dump 的双跑口径分叉。
  1000. //
  1001. // 入参:ctx / in。出参:*SpendResult{spend};错误码 2001/3140/5002。
  1002. func (s *FundService) AccumulateSpend(ctx context.Context, in SpendInput) (*SpendResult, error) {
  1003. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "AccumulateSpend", true); err != nil {
  1004. return nil, err
  1005. }
  1006. if err := in.SpendMoney.Validate(); err != nil {
  1007. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  1008. }
  1009. if in.SpendMoney < 0 {
  1010. in.SpendMoney = 0 // dump INSERT 分支:负数归 0(spend 只增)
  1011. }
  1012. if s == nil || s.runner == nil || s.repo == nil {
  1013. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  1014. }
  1015. var out SpendResult
  1016. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  1017. txCtx := repository.WithTxContext(ctx, tx)
  1018. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  1019. return e
  1020. }
  1021. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  1022. return e
  1023. }
  1024. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  1025. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  1026. }); e != nil {
  1027. return e
  1028. }
  1029. if !in.SpendMoney.IsZero() {
  1030. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "AccumulateSpend", in.UserID,
  1031. in.InstitutionID, in.CampusID,
  1032. repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: in.SpendMoney},
  1033. ); e != nil {
  1034. return e
  1035. }
  1036. }
  1037. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  1038. if e != nil {
  1039. return e
  1040. }
  1041. spend, e := parseAccountAmount(acc.Spend, "t_user_expense_school.spend")
  1042. if e != nil {
  1043. return e
  1044. }
  1045. out = SpendResult{Spend: spend}
  1046. return nil
  1047. })
  1048. if err != nil {
  1049. return nil, err
  1050. }
  1051. return &out, nil
  1052. }
  1053. // ---------------------------------------------------------------- 幂等公共形状
  1054. // insertLog 写流水并把"撞 uk_request_type"翻译成内部幂等哨兵(`apperr.ErrRepeated`)。
  1055. //
  1056. // 入参:txCtx(含 tx 的 ctx)/ tx / lg 流水行。
  1057. // 出参:nil 成功;apperr.ErrRepeated(**并发幂等命中**,调用方据此回放首次结果);
  1058. // 其余错误原样上抛(ErrBadParam/ErrDBError)。
  1059. //
  1060. // 为什么用 ErrRepeated 作为事务内哨兵:它是 *AppError,`TxRunner.wrapTxErr` 会原样透传
  1061. // (不降级成 5002),且它是设计里"仅内部日志用"的码(§10.1),不会泄漏给前端。
  1062. func (s *FundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
  1063. err := s.repo.InsertLog(txCtx, tx, lg)
  1064. if err == nil {
  1065. return nil
  1066. }
  1067. if errors.Is(err, repository.ErrDuplicateLog) {
  1068. return apperr.ErrRepeated
  1069. }
  1070. return err
  1071. }
  1072. // mapIdempotentHit 把"并发幂等命中"从错误路径转成"回放首次结果"的成功路径。
  1073. //
  1074. // 入参:
  1075. // - err:WithinTx 返回的错误;
  1076. // - replay:回放函数(重新查询首次流水并填充 out)。
  1077. //
  1078. // 出参:nil(回放成功)/ 回放错误 / 原错误(非幂等命中)。
  1079. //
  1080. // 语义(§1.5):`uk_request_type` 撞键 = 同一请求号 + 同一 type 的第二次写入,
  1081. // **不是错误**(code 仍 200,data.repeated=true)。实现上先整笔回滚(本次零残留),再回放。
  1082. func (s *FundService) mapIdempotentHit(err error, replay func() error) error {
  1083. if err == nil {
  1084. return nil
  1085. }
  1086. if errors.Is(err, apperr.ErrRepeated) {
  1087. return replay()
  1088. }
  1089. return err
  1090. }
  1091. // balanceAfterOf 从流水行取 balance_after 快照(NULL → 0,幂等回放用)。
  1092. //
  1093. // 入参:lg 流水行(可 nil)。出参:(金额, error);balance_after 为 NULL 时返回 0 且无错
  1094. // (历史行没有快照列,回放时余额字段退化为 0 并保持 repeated=true)。
  1095. func balanceAfterOf(lg *model.UserExpenseLog) (money.Amount, error) {
  1096. if lg == nil || lg.BalanceAfter == nil {
  1097. return 0, nil
  1098. }
  1099. return parseAccountAmount(*lg.BalanceAfter, "t_user_expense_log.balance_after")
  1100. }
  1101. // replayRecharge 幂等回放(充值)。
  1102. func (s *FundService) replayRecharge(ctx context.Context, in RechargeInput, out *RechargeResult) error {
  1103. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1104. if err != nil {
  1105. return err
  1106. }
  1107. if hit == nil {
  1108. return infraerrors.WithCause(infraerrors.ErrInternal,
  1109. errString("service: Recharge 幂等命中但首次流水已不可见"))
  1110. }
  1111. bal, err := balanceAfterOf(hit)
  1112. if err != nil {
  1113. return err
  1114. }
  1115. *out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
  1116. return nil
  1117. }
  1118. // replayExpenditure 幂等回放(消费)。
  1119. func (s *FundService) replayExpenditure(ctx context.Context, in ExpenditureInput, out *ExpenditureResult) error {
  1120. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1121. if err != nil {
  1122. return err
  1123. }
  1124. if hit == nil {
  1125. return infraerrors.WithCause(infraerrors.ErrInternal,
  1126. errString("service: Expenditure 幂等命中但首次流水已不可见"))
  1127. }
  1128. bal, err := balanceAfterOf(hit)
  1129. if err != nil {
  1130. return err
  1131. }
  1132. *out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
  1133. return nil
  1134. }
  1135. // replayRefund 幂等回放(退费)。
  1136. func (s *FundService) replayRefund(ctx context.Context, in RefundInput, out *RefundResult) error {
  1137. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1138. if err != nil {
  1139. return err
  1140. }
  1141. if hit == nil {
  1142. return infraerrors.WithCause(infraerrors.ErrInternal,
  1143. errString("service: Refund 幂等命中但首次流水已不可见"))
  1144. }
  1145. bal, err := balanceAfterOf(hit)
  1146. if err != nil {
  1147. return err
  1148. }
  1149. *out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
  1150. return nil
  1151. }
  1152. // replayBadDebt 幂等回放(坏账)。
  1153. func (s *FundService) replayBadDebt(ctx context.Context, in BadDebtInput, out *BadDebtResult) error {
  1154. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
  1155. if err != nil {
  1156. return err
  1157. }
  1158. if hit == nil {
  1159. return infraerrors.WithCause(infraerrors.ErrInternal,
  1160. errString("service: BadDebt 幂等命中但首次流水已不可见"))
  1161. }
  1162. var spec, bal money.Amount
  1163. if acc, ae := s.repo.GetCampusAccount(ctx, nil, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
  1164. if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
  1165. spec = v
  1166. }
  1167. if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
  1168. bal = v
  1169. }
  1170. }
  1171. *out = BadDebtResult{LogID: hit.ID, SpecialExpenses: spec, Balance: bal, Repeated: true}
  1172. return nil
  1173. }
  1174. // replayTransfer 幂等回放(转结)。
  1175. func (s *FundService) replayTransfer(ctx context.Context, in TransferInput, out *TransferResult) error {
  1176. hit106, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
  1177. if err != nil {
  1178. return err
  1179. }
  1180. if hit106 == nil {
  1181. return infraerrors.WithCause(infraerrors.ErrInternal,
  1182. errString("service: Transfer 幂等命中但首次流水已不可见"))
  1183. }
  1184. res := TransferResult{LogID106: hit106.ID, Repeated: true}
  1185. if bal, pe := balanceAfterOf(hit106); pe == nil {
  1186. res.FromBalance = bal
  1187. }
  1188. hit206, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
  1189. if err != nil {
  1190. return err
  1191. }
  1192. if hit206 != nil {
  1193. res.LogID206 = hit206.ID
  1194. if bal, pe := balanceAfterOf(hit206); pe == nil {
  1195. res.ToBalance = bal
  1196. }
  1197. }
  1198. *out = res
  1199. return nil
  1200. }
  1201. // isKnownLogType 流水类型白名单(`model.ExpenseLogType*` 全集,§10.2)。
  1202. //
  1203. // 用途:`Refund` 的 type 来自调用方(dump 透传),必须落在已知编码内,
  1204. // 否则会污染流水表并让"按 type 汇总"的对账口径失效。
  1205. func isKnownLogType(typ int32) bool {
  1206. switch typ {
  1207. case model.ExpenseLogTypeRecharge,
  1208. model.ExpenseLogTypeCourseCampus,
  1209. model.ExpenseLogTypeCourseSchoolA,
  1210. model.ExpenseLogTypeCourseSchoolB,
  1211. model.ExpenseLogTypeTransferOut,
  1212. model.ExpenseLogTypeRecommend,
  1213. model.ExpenseLogTypeBadDebt,
  1214. model.ExpenseLogTypeHistoryDeduct,
  1215. model.ExpenseLogTypeCourseCampusB,
  1216. model.ExpenseLogTypeCourseSchoolC,
  1217. model.ExpenseLogTypeCourseSchoolD,
  1218. model.ExpenseLogTypeTransferIn,
  1219. model.ExpenseLogTypeRefund,
  1220. model.ExpenseLogTypeClearBalance,
  1221. model.ExpenseLogTypeCancelRechargeAct:
  1222. return true
  1223. default:
  1224. return false
  1225. }
  1226. }