fund_service.go 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555
  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(SP 头注释即「余额+,特殊费用+」→ **双加**)
  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. // - requests:余额工单仓储(**只读**用途:SP5 可退额需要按「充值工单 × 活动报名」汇总已用赠送额,
  75. // 设计 §3.1 #5 的第二步修正,`SumRechargeActivityUsedGift`);
  76. // - runner:事务运行器(顶层 BEGIN,事务内透传);
  77. // - campus:校区只读端口(**只读**:转结转入方建户需要"权威机构 ID",见 R4-bis);
  78. // - member:成员关系校验器(可 nil:系统任务/割接脚本模式不做对象级越权断言)。
  79. type FundService struct {
  80. repo *repository.ExpenseRepo
  81. requests *repository.BalanceRequestRepo
  82. runner *repository.TxRunner
  83. campus port.CampusReader
  84. member port.MembershipChecker
  85. }
  86. // NewFundService 构造余额核心服务(repo 与 runner 绑定同一 DB 句柄,避免跨连接池事务失效)。
  87. //
  88. // 入参:
  89. // - db *gorm.DB:数据库句柄(必填);
  90. // - sc *appscope.Scope:本次请求作用域(校区账户读写必需;系统任务可 nil,
  91. // 此时**只能**调用全局/流水方法,触达校区账户会 panic fail-fast);
  92. // - member port.MembershipChecker:成员校验器(可 nil)。
  93. //
  94. // 出参:*FundService。
  95. //
  96. // 说明:`campus` 端口由本构造函数内部用同一 db 装配(`port.NewOrgCampusReader`),
  97. // 因此**签名保持不变**(既有调用方无需改动);需要注入 mock 的用例可用 `WithCampusReader`。
  98. func NewFundService(db *gorm.DB, sc *appscope.Scope, member port.MembershipChecker) *FundService {
  99. return &FundService{
  100. repo: repository.NewExpenseRepo(db, sc),
  101. requests: repository.NewBalanceRequestRepo(db, sc),
  102. runner: repository.NewTxRunner(db),
  103. campus: port.NewOrgCampusReader(db),
  104. member: member,
  105. }
  106. }
  107. // WithCampusReader 替换校区只读端口(单测注入 mock 用;nil 时忽略)。
  108. //
  109. // 入参:reader port.CampusReader。出参:*FundService(链式,原地修改后返回自身)。
  110. func (s *FundService) WithCampusReader(reader port.CampusReader) *FundService {
  111. if s != nil && reader != nil {
  112. s.campus = reader
  113. }
  114. return s
  115. }
  116. // ---------------------------------------------------------------- 入参 / 出参
  117. // RechargeInput 充值入参(SP1 `sp_user_expense_recharge` 8 参 / SP2 同构 7 参)。
  118. //
  119. // 入参纪律:`Money` 已由 Handler 用 `money.Parse` 解析(**拒绝 float**,§10.6),
  120. // 本层不再解析字符串,只校验量级与业务规则。
  121. type RechargeInput struct {
  122. // UserID 充值用户(canonical_user_id ULID;必填)。
  123. UserID string `json:"userId"`
  124. // InstitutionID 机构 ID(必填 >0;dump 的 inputSchoolid)。
  125. InstitutionID int64 `json:"institutionId"`
  126. // CampusID 校区 ID(必填 >0;dump 的 inputCampusid)。
  127. CampusID int64 `json:"campusId"`
  128. // Money 充值金额(>0;dump 的 inputMoney)。
  129. Money money.Amount `json:"money"`
  130. // Type 流水类型(dump 的 inputType;<100 一律归 100,对齐 dump `IF inputType<100`)。
  131. Type int32 `json:"type"`
  132. // RequestID 幂等请求号(→ request_id 幂等键 + log_id 业务单号;空 = 不享幂等)。
  133. RequestID string `json:"requestId"`
  134. // Remark 备注(空 → 按 Kind 取默认文案)。
  135. Remark string `json:"remark"`
  136. // OperatorID 操作人(→ create_user_id;空 → NULL)。
  137. OperatorID string `json:"-"`
  138. // Kind SP 形态(默认 RechargeKindDefault)。
  139. Kind RechargeKind `json:"-"`
  140. }
  141. // RechargeResult 充值结果(端点 3 响应 `{logId,balance,repeated}`)。
  142. type RechargeResult struct {
  143. // LogID 流水 ID(幂等命中时为**首次**流水 ID)。
  144. LogID int64 `json:"logId"`
  145. // Balance 充值后校区账户余额(balance_after 口径)。
  146. Balance money.Amount `json:"balance"`
  147. // Repeated true = 幂等命中(requestId 已处理),未重复入账。
  148. Repeated bool `json:"repeated"`
  149. }
  150. // ExpenditureInput 消费入参(SP3 `sp_user_expense_school_expenditure` 8 参)。
  151. type ExpenditureInput 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 消费金额(>0;流水落 `-Money`)。
  159. Money money.Amount `json:"money"`
  160. // Type 消费类型(101/104/105/200/201/204/205,白名单;非法 → 2002)。
  161. Type int32 `json:"type"`
  162. // RequestID 幂等请求号(空 = 不享幂等)。
  163. RequestID string `json:"requestId"`
  164. // Remark 备注(空 → "历史扣费")。
  165. Remark string `json:"remark"`
  166. // OperatorID 操作人。
  167. OperatorID string `json:"-"`
  168. }
  169. // ExpenditureResult 消费结果(端点 4 响应 `{logId,balance}`)。
  170. type ExpenditureResult struct {
  171. // LogID 流水 ID。
  172. LogID int64 `json:"logId"`
  173. // Balance 消费后校区账户余额。
  174. Balance money.Amount `json:"balance"`
  175. // Repeated true = 幂等命中。
  176. Repeated bool `json:"repeated"`
  177. }
  178. // RefundInput 退费入参(SP4 `sp_user_expense_school_refund` 7 参 + **补 institution_id**)。
  179. type RefundInput struct {
  180. // UserID 用户(必填)。
  181. UserID string `json:"userId"`
  182. // InstitutionID 机构 ID(必填 >0;**dump 无此参数 = BUG#2**,见 Refund 方法注释)。
  183. InstitutionID int64 `json:"institutionId"`
  184. // CampusID 校区 ID(必填 >0)。
  185. CampusID int64 `json:"campusId"`
  186. // Money 退费金额(>0 才生效;<=0 → changed=false 的 no-op,对齐 dump `IF inputMoney>0`)。
  187. Money money.Amount `json:"money"`
  188. // Type 流水类型(dump 的 inputType;必须属 model.ExpenseLogType* 白名单)。
  189. Type int32 `json:"type"`
  190. // RequestID 幂等请求号(空 = 不享幂等)。
  191. RequestID string `json:"requestId"`
  192. // Remark 备注(空 → "报课的费用退回")。
  193. Remark string `json:"remark"`
  194. // OperatorID 操作人。
  195. OperatorID string `json:"-"`
  196. }
  197. // RefundResult 退费结果(端点 5 响应 `{logId,balance,changed}`)。
  198. type RefundResult struct {
  199. // LogID 流水 ID(恒 > 0:money<=0 已在入口按 R6 拒为 2002)。
  200. LogID int64 `json:"logId"`
  201. // Balance 退费后校区账户余额。
  202. Balance money.Amount `json:"balance"`
  203. // Changed 成功即发生账户写入 ⇒ **恒 true**(R6 定稿:金额<=0 一律入口 2002,不存在 no-op 分支)。
  204. // 字段仅为 DTO 契约兼容保留;**前端不得用 changed=false 作为正常分支**判断(不会有该取值)。
  205. // 幂等命中用 Repeated 表达。
  206. Changed bool `json:"changed"`
  207. // Repeated true = 幂等命中。
  208. Repeated bool `json:"repeated"`
  209. }
  210. // TransferInput 转结入参(SP6 `sp_user_expense_school_transfer_expenditure` 8 参)。
  211. type TransferInput struct {
  212. // UserID 用户(必填)。
  213. UserID string `json:"userId"`
  214. // FromInstitutionID 转出机构 ID(必填 >0)。
  215. FromInstitutionID int64 `json:"fromInstitutionId"`
  216. // FromCampusID 转出校区 ID(必填 >0)。
  217. FromCampusID int64 `json:"fromCampusId"`
  218. // ToInstitutionID 转入机构 ID(必填 >0)。
  219. ToInstitutionID int64 `json:"toInstitutionId"`
  220. // ToCampusID 转入校区 ID(必填 >0)。
  221. ToCampusID int64 `json:"toCampusId"`
  222. // Money 转结金额(>0)。
  223. Money money.Amount `json:"money"`
  224. // RequestID 幂等请求号(**两条流水共用**同一 request_id,type 不同故不撞键)。
  225. RequestID string `json:"requestId"`
  226. // Remark 备注(dump 无默认值)。
  227. Remark string `json:"remark"`
  228. // OperatorID 操作人。
  229. OperatorID string `json:"-"`
  230. }
  231. // TransferResult 转结结果(端点 7 响应 `{logId106,logId206}`)。
  232. type TransferResult struct {
  233. // LogID106 type=106 转结出流水 ID(dump: money = **+Money**)。
  234. LogID106 int64 `json:"logId106"`
  235. // LogID206 type=206 转结入流水 ID(dump: money = **−Money**)。
  236. LogID206 int64 `json:"logId206"`
  237. // FromBalance 转出校区账户余额(转结**不动 balance**,故与转结前相同)。
  238. FromBalance money.Amount `json:"fromBalance"`
  239. // ToBalance 转入校区账户余额(同上)。
  240. ToBalance money.Amount `json:"toBalance"`
  241. // Repeated true = 幂等命中。
  242. Repeated bool `json:"repeated"`
  243. }
  244. // BadDebtInput 坏账入参(SP7 `sp_user_expense_baddebt` 7 参)。
  245. type BadDebtInput struct {
  246. // UserID 用户(必填)。
  247. UserID string `json:"userId"`
  248. // InstitutionID 机构 ID(必填 >0)。
  249. InstitutionID int64 `json:"institutionId"`
  250. // CampusID 校区 ID(必填 >0)。
  251. CampusID int64 `json:"campusId"`
  252. // Money 坏账金额(>0)。
  253. Money money.Amount `json:"money"`
  254. // RequestID 幂等请求号(空 = 不享幂等)。
  255. RequestID string `json:"requestId"`
  256. // Remark 备注(空 → "坏账处理")。
  257. Remark string `json:"remark"`
  258. // OperatorID 操作人。
  259. OperatorID string `json:"-"`
  260. }
  261. // BadDebtResult 坏账结果(端点 8 响应 `{logId,specialExpenses}`)。
  262. type BadDebtResult struct {
  263. // LogID type=108 流水 ID。
  264. LogID int64 `json:"logId"`
  265. // SpecialExpenses 处理后校区账户特殊费用累计。
  266. SpecialExpenses money.Amount `json:"specialExpenses"`
  267. // Balance 处理后校区账户余额(**dump 口径:坏账双加,故已 +Money**)。
  268. Balance money.Amount `json:"balance"`
  269. // Repeated true = 幂等命中。
  270. Repeated bool `json:"repeated"`
  271. }
  272. // SpendInput 累计消费入参(SP11 `sp_user_expense_school_spend` 4 参)。
  273. type SpendInput struct {
  274. // UserID 用户(必填)。
  275. UserID string `json:"userId"`
  276. // InstitutionID 机构 ID(必填 >0)。
  277. InstitutionID int64 `json:"institutionId"`
  278. // CampusID 校区 ID(必填 >0)。
  279. CampusID int64 `json:"campusId"`
  280. // SpendMoney 本次累加的课费单价之和(元;**负数一律归 0**,dump INSERT 分支同口径)。
  281. SpendMoney money.Amount `json:"spendMoney"`
  282. }
  283. // SpendResult 累计消费结果(端点 10 响应 `{spend}`)。
  284. type SpendResult struct {
  285. // Spend 累加后校区账户 spend 列(**不是** balance)。
  286. Spend money.Amount `json:"spend"`
  287. }
  288. // ---------------------------------------------------------------- 校验助手
  289. // validateUserScope 校验"用户 + 机构 + 校区"三元组(资金方法入口统一形状)。
  290. //
  291. // 入参:userID / institutionID / campusID;op 操作名(错误文案定位);campusRequired 是否要求校区>0。
  292. // 出参:nil 合法;2001(缺 user_id)/ 3140(校区或机构缺失,对齐设计 §1.6「campusid<=0 → 3140」)。
  293. func validateUserScope(userID string, institutionID, campusID int64, op string, campusRequired bool) error {
  294. if strings.TrimSpace(userID) == "" {
  295. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+op+" 需要 user_id"))
  296. }
  297. if institutionID <= 0 {
  298. return infraerrors.WithCause(apperr.ErrBusinessParam,
  299. errString("service: "+op+" 需要 institution_id > 0"))
  300. }
  301. if campusRequired && campusID <= 0 {
  302. // dump:`IF (inputSchoolid<1 || inputCampusid<1) THEN LEAVE`(静默 no-op)
  303. // Go:显式 3140,避免"调用成功但什么都没做"
  304. return infraerrors.WithCause(apperr.ErrBusinessParam,
  305. errString("service: "+op+" 需要 campus_id > 0(dump:必须有校区信息)"))
  306. }
  307. if campusID < 0 {
  308. return infraerrors.WithCause(apperr.ErrBusinessParam,
  309. errString("service: "+op+" 的 campus_id 不得为负"))
  310. }
  311. return nil
  312. }
  313. // validatePositiveAmount 校验金额为正且不超业务/存储天花板(§10.7)。
  314. //
  315. // 入参:amount 金额(分);op 操作名。
  316. // 出参:nil 合法;2002(<=0 / 超业务上限 / 超 decimal(12,2) 全域)。
  317. //
  318. // 与 dump 的差异(登记为有意偏离):dump 对负数是 `IF inputMoney<0 THEN SET inputMoney=0`
  319. // **静默归零后继续记账**,会留下 money=0 的流水并占住幂等键;Go 显式拒绝(2002)。
  320. func validatePositiveAmount(amount money.Amount, op string) error {
  321. if amount <= 0 {
  322. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  323. errString("service: "+op+" 金额必须 > 0(dump 静默归零 → Go 显式拒绝)"))
  324. }
  325. if amount > maxSingleAmount {
  326. return infraerrors.WithCause(infraerrors.ErrBadFormat,
  327. errString("service: "+op+" 金额超出单笔业务上限"))
  328. }
  329. if err := amount.Validate(); err != nil {
  330. return infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  331. }
  332. return nil
  333. }
  334. // parseAccountAmount 把账户 decimal 字符串列解析为 money.Amount。
  335. //
  336. // 入参:raw 列原文(如 "12.34");col 列名(错误文案定位)。
  337. // 出参:money.Amount;ErrDBError(历史脏值/格式非法——属数据问题,不是入参问题)。
  338. func parseAccountAmount(raw, col string) (money.Amount, error) {
  339. amount, err := money.Parse(raw)
  340. if err != nil {
  341. return 0, infraerrors.WithCause(infraerrors.ErrDBError,
  342. errString("service: 账户列 "+col+" 金额非法("+raw+")"))
  343. }
  344. return amount, nil
  345. }
  346. // globalBalanceAfter 取「该笔操作完成后**全局账户**(t_user_expense)余额」快照。
  347. //
  348. // ⚠ **与 `campusBalanceAfter` 不得合并**(后人最容易"优化"成同一个函数):两者是**两列不同的列**、
  349. // 服务于**两个不同的消费者**,且在"多校区"场景下数值本来就不相等——
  350. // - 本函数 → 流水 `t_user_expense_log.balance_after`(对账链,全局口径);
  351. // - 另一个 → API 响应体 `Result.Balance`(用户体验,校区口径)。
  352. //
  353. // 用途(唯一):流水列 `t_user_expense_log.balance_after` 的取值口径(设计 §3.8-f ②-bis 裁定):
  354. // - 语义 = **全局账户余额**,不是校区余额(`t_user_expense_log` 上没有 `campus_id`,
  355. // 只有 user 级全局链才能做 `balance_after(op_n) = balance_after(op_{n−1}) + Σ(money of op_n)` 校验);
  356. // - 取值时机 = **写完账户、写第一条流水之前取一次**,同一操作的多行流水(转结 106/206、
  357. // 退费 108/207)**复用同一个值**;**禁止逐条重算**(并发下会取到不同值,链式校验必乱)。
  358. //
  359. // 入参:ctx / repo / tx(**必传**:必须与本操作的账户写入同事务同快照)/ userID。
  360. // 出参:(全局余额, error);ErrNotFound(账户不存在,属调用顺序错误)/ ErrDBError。
  361. func globalBalanceAfter(
  362. ctx context.Context, repo *repository.ExpenseRepo, tx *gorm.DB, userID string,
  363. ) (money.Amount, error) {
  364. global, err := repo.GetGlobalAccount(ctx, tx, userID)
  365. if err != nil {
  366. return 0, err
  367. }
  368. return parseAccountAmount(global.Balance, "t_user_expense.balance")
  369. }
  370. // campusBalanceAfter 取「该笔操作完成后**校区账户**余额」快照。
  371. //
  372. // ⚠ **与 `globalBalanceAfter` 不得合并**:见后者头注释(两个消费者、两列、多校区下数值本就不等)。
  373. //
  374. // 用途(唯一):**API 响应体的 `Balance` 字段**(各 Result.Balance 的契约是"校区账户余额",
  375. // 与流水的 `balance_after` 语义不同,**不得**混用同一个值)。
  376. //
  377. // 入参:ctx / repo / tx / userID / institutionID / campusID。
  378. // 出参:(校区余额, error);ErrNotFound / ErrDBError。
  379. func campusBalanceAfter(
  380. ctx context.Context, repo *repository.ExpenseRepo, tx *gorm.DB,
  381. userID string, institutionID, campusID int64,
  382. ) (money.Amount, error) {
  383. acc, err := repo.GetCampusAccount(ctx, tx, userID, institutionID, campusID)
  384. if err != nil {
  385. return 0, err
  386. }
  387. return parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  388. }
  389. // optString 把空串转成 nil 指针(*string 列的 NULL 语义:request_id 为空时"不享幂等")。
  390. func optString(s string) *string {
  391. s = strings.TrimSpace(s)
  392. if s == "" {
  393. return nil
  394. }
  395. return &s
  396. }
  397. // amountPtr 返回金额两位小数字符串的指针(`balance_after` 等可空金额列用)。
  398. func amountPtr(a money.Amount) *string {
  399. s := a.String()
  400. return &s
  401. }
  402. // newExpenseLog 组装一条余额变更流水(字段口径收口,避免各方法重复拼装)。
  403. //
  404. // 入参:userID/typ/money 金额/requestID/remark/operatorID/**balanceAfter = 该操作完成后全局账户余额快照**
  405. // (nil = 不写快照;同一操作的多行流水必须传**同一个值**,见 `globalBalanceAfter` 的口径说明)。
  406. // 出参:*model.UserExpenseLog(未落库;Create 后 ID 由 GORM 回填)。
  407. func newExpenseLog(userID string, typ int32, amount money.Amount, requestID, remark, operatorID string,
  408. balanceAfter *money.Amount) *model.UserExpenseLog {
  409. lg := &model.UserExpenseLog{
  410. UserID: userID,
  411. Type: typ,
  412. Money: amount.String(),
  413. RequestID: optString(requestID),
  414. LogID: optString(requestID), // dump:logid = 外部单号(SP1 的 inputOrder / SP2 的 inputRequestid)
  415. CreateUserID: optString(operatorID),
  416. Remark: optString(remark),
  417. }
  418. if balanceAfter != nil {
  419. lg.BalanceAfter = amountPtr(*balanceAfter)
  420. }
  421. return lg
  422. }
  423. // assertInScope 对象级越权断言(全局钱包无作用域列,只能由 Service 显式校验,设计 §1.7 例外一)。
  424. //
  425. // 入参:ctx / userID / institutionID / campusID。
  426. // 出参:nil 通过(或未注入 checker 的系统任务模式);1004 越权;5002 DB 异常。
  427. func (s *FundService) assertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
  428. if s == nil || s.member == nil {
  429. return nil // 系统任务/割接脚本模式:不注入 checker 即不做对象级断言(装配层责任)
  430. }
  431. return s.member.AssertInScope(ctx, userID, institutionID, campusID)
  432. }
  433. // ---------------------------------------------------------------- SP1 / SP2 充值
  434. // Recharge 余额充值(SP1 `sp_user_expense_recharge` + SP2 `sp_user_expense_school_recharge`)。
  435. //
  436. // dump 语义(yuansiwei_finance.sql:764247,★8 参 + **双表更新**):
  437. //
  438. // ① inputMoney<0 → 0;inputType<100 → 100;inputRemark='' → 默认备注;
  439. // ② schoolid<1 || campusid<1 → LEAVE(静默 no-op);
  440. // ③ 校区账户不存在则 INSERT;全局账户不存在则 INSERT;
  441. // ④ UPDATE 校区:balance+, total_income+ ;UPDATE 全局:balance+, total_income+;
  442. // ⑤ INSERT log(type=inputType, logid=inputOrder, money=inputMoney)。
  443. //
  444. // Go 的三处**有意强化**(不改变资金结果):
  445. // - 建户合并为一次 UPSERT(`ON CONFLICT DO NOTHING`),替代 dump 的 `COUNT` 判存在(§1.5);
  446. // - 写序固定为 **全局 → 校区**(dump 是校区 → 全局;顺序不同但结果一致,
  447. // 统一顺序是**死锁预防**的硬要求,见 §1.4 与 §7.2 的加锁基线);
  448. // - 幂等:`request_id + type` 唯一键,并发重复提交只落 1 条流水(dump 无幂等,会重复入账)。
  449. //
  450. // 入参:ctx / in(见 RechargeInput;Kind 决定默认备注)。
  451. // 出参:*RechargeResult(logId/balance/repeated);错误码 2001/2002/3140/1004/5002。
  452. func (s *FundService) Recharge(ctx context.Context, in RechargeInput) (*RechargeResult, error) {
  453. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Recharge", true); err != nil {
  454. return nil, err
  455. }
  456. if err := validatePositiveAmount(in.Money, "Recharge"); err != nil {
  457. return nil, err
  458. }
  459. if in.Type < 100 {
  460. // dump:`IF inputType<100 THEN SET inputType=100`(充值流水类型下限)
  461. in.Type = model.ExpenseLogTypeRecharge
  462. }
  463. if s == nil || s.runner == nil || s.repo == nil {
  464. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  465. }
  466. remark := strings.TrimSpace(in.Remark)
  467. if remark == "" {
  468. if in.Kind == RechargeKindSchool {
  469. remark = defaultSchoolRechargeRemark
  470. } else {
  471. remark = defaultRechargeRemark
  472. }
  473. }
  474. var out RechargeResult
  475. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  476. txCtx := repository.WithTxContext(ctx, tx)
  477. // ① 幂等预检(`uk_request_type`):命中 → 返回首次结果,不重复入账
  478. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  479. if e != nil {
  480. return e
  481. }
  482. if hit != nil {
  483. bal, pe := balanceAfterOf(hit)
  484. if pe != nil {
  485. return pe
  486. }
  487. out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
  488. return nil
  489. }
  490. // ② 作用域断言(全局钱包无作用域列 → Service 层显式校验)
  491. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  492. return e
  493. }
  494. // ③ 建户(两个 UPSERT,**不动已有余额**)
  495. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  496. return e
  497. }
  498. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  499. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  500. }); e != nil {
  501. return e
  502. }
  503. // ④ 原子增减(固定「全局 → 校区」,两列合并为每表一条 UPDATE)
  504. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Recharge", in.UserID,
  505. in.InstitutionID, in.CampusID,
  506. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
  507. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
  508. ); e != nil {
  509. return e
  510. }
  511. // ⑤ 流水(`balance_after` = **充值后全局账户余额**快照;响应体 Balance 仍是校区余额)
  512. globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
  513. if e != nil {
  514. return e
  515. }
  516. bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
  517. if e != nil {
  518. return e
  519. }
  520. lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &globalAfter)
  521. if e := s.insertLog(txCtx, tx, lg); e != nil {
  522. return e
  523. }
  524. out = RechargeResult{LogID: lg.ID, Balance: bal}
  525. return nil
  526. })
  527. if err != nil {
  528. return nil, s.mapIdempotentHit(err, func() error {
  529. return s.replayRecharge(ctx, in, &out)
  530. })
  531. }
  532. return &out, nil
  533. }
  534. // ---------------------------------------------------------------- SP3 消费
  535. // Expenditure 余额消费扣费(SP3 `sp_user_expense_school_expenditure`)。
  536. //
  537. // dump 语义(:764364,**type 决定列组合,方向不可改**):
  538. //
  539. // inputType=200(历史扣费) → balance−, total_expenditure+, spend+
  540. // inputType=207(余额退费) → balance−, total_income−
  541. // 其余(101/104/105/201/204/205)→ balance−, total_expenditure+
  542. // 两条 UPDATE(校区 + 全局)+ log(money = **−inputMoney**)
  543. //
  544. // Go 的口径选择(**与 dump 的两处差异,均为设计 §3.1 #3 明确要求**):
  545. // - type 白名单校验:`101/104/105/200/201/204/205`(非法 → 2002)。dump 会把
  546. // `type<200 或 >299` **静默改成 200**,把"调用方传错类型"变成"记成历史扣费";
  547. // - dump 的 `type=207` 分支**不开放**给本方法(退费走 `Refund`/`CustomRefund`,
  548. // 否则同一语义有两条写入口,流水方向难以对账)。
  549. //
  550. // 入参:ctx / in。出参:*ExpenditureResult;错误码 2001/2002/3140/1004/5002。
  551. func (s *FundService) Expenditure(ctx context.Context, in ExpenditureInput) (*ExpenditureResult, error) {
  552. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Expenditure", true); err != nil {
  553. return nil, err
  554. }
  555. if err := validatePositiveAmount(in.Money, "Expenditure"); err != nil {
  556. return nil, err
  557. }
  558. if !isExpenditureType(in.Type) {
  559. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  560. errString("service: Expenditure 的 type 非法(白名单 101/104/105/200/201/204/205)"))
  561. }
  562. if s == nil || s.runner == nil || s.repo == nil {
  563. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  564. }
  565. remark := strings.TrimSpace(in.Remark)
  566. if remark == "" {
  567. remark = defaultExpenditureRemark
  568. }
  569. deltas := expenditureDeltas(in.Type, in.Money)
  570. var out ExpenditureResult
  571. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  572. txCtx := repository.WithTxContext(ctx, tx)
  573. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  574. if e != nil {
  575. return e
  576. }
  577. if hit != nil {
  578. bal, pe := balanceAfterOf(hit)
  579. if pe != nil {
  580. return pe
  581. }
  582. out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
  583. return nil
  584. }
  585. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  586. return e
  587. }
  588. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  589. return e
  590. }
  591. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  592. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  593. }); e != nil {
  594. return e
  595. }
  596. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Expenditure", in.UserID,
  597. in.InstitutionID, in.CampusID, deltas...); e != nil {
  598. return e
  599. }
  600. // `balance_after` = 扣费后**全局账户余额**快照;响应体 Balance 仍是校区余额
  601. globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
  602. if e != nil {
  603. return e
  604. }
  605. bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
  606. if e != nil {
  607. return e
  608. }
  609. // dump:INSERT log(money = **−inputMoney**)
  610. lg := newExpenseLog(in.UserID, in.Type, in.Money.Neg(), in.RequestID, remark, in.OperatorID, &globalAfter)
  611. if e := s.insertLog(txCtx, tx, lg); e != nil {
  612. return e
  613. }
  614. out = ExpenditureResult{LogID: lg.ID, Balance: bal}
  615. return nil
  616. })
  617. if err != nil {
  618. return nil, s.mapIdempotentHit(err, func() error {
  619. return s.replayExpenditure(ctx, in, &out)
  620. })
  621. }
  622. return &out, nil
  623. }
  624. // expenditureDeltas 按 type 生成列增量(dump 三个分支的逐列对应,**顺序/方向不得改**)。
  625. //
  626. // 入参:typ 消费类型(已过白名单);amount 消费金额(>0)。
  627. // 出参:[]repository.ColumnDelta(balance 恒为 −amount)。
  628. func expenditureDeltas(typ int32, amount money.Amount) []repository.ColumnDelta {
  629. switch typ {
  630. case model.ExpenseLogTypeHistoryDeduct:
  631. // dump:`balance-`,`total_expenditure+`,`spend+`(历史扣费同时计入消费统计)
  632. return []repository.ColumnDelta{
  633. {Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
  634. {Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
  635. {Column: repository.ExpenseColumnSpend, Delta: amount},
  636. }
  637. default:
  638. // dump:`balance-`,`total_expenditure+`(报课学费 / 学校消费)
  639. return []repository.ColumnDelta{
  640. {Column: repository.ExpenseColumnBalance, Delta: amount.Neg()},
  641. {Column: repository.ExpenseColumnTotalExpenditure, Delta: amount},
  642. }
  643. }
  644. }
  645. // isExpenditureType 消费类型白名单(设计 §3.1 #3:101/104/105/200/201/204/205)。
  646. func isExpenditureType(typ int32) bool {
  647. switch typ {
  648. case model.ExpenseLogTypeCourseCampus, // 101
  649. model.ExpenseLogTypeCourseSchoolA, // 104
  650. model.ExpenseLogTypeCourseSchoolB, // 105
  651. model.ExpenseLogTypeHistoryDeduct, // 200
  652. model.ExpenseLogTypeCourseCampusB, // 201
  653. model.ExpenseLogTypeCourseSchoolC, // 204
  654. model.ExpenseLogTypeCourseSchoolD: // 205
  655. return true
  656. default:
  657. return false
  658. }
  659. }
  660. // ---------------------------------------------------------------- SP4 退费
  661. // Refund 余额退费(SP4 `sp_user_expense_school_refund`)。
  662. //
  663. // dump 语义(:764576):`IF inputMoney>0` 才动作(<=0 静默 no-op);
  664. // 校区账户已存在才 UPDATE(balance+, total_expenditure−);全局账户已存在才 UPDATE;
  665. // 任一更新成功才写 log(money = **+inputMoney**,type = inputType)。
  666. //
  667. // **BUG#2 修正(设计 §3.1 #4)**:dump 的定位条件是 `userid + campusid`,**没有 schoolid**——
  668. // 同一 campus_id 若在多机构复用(历史数据/跨库迁移期),会误更新别的机构的账户行。
  669. // Go 侧补 `institution_id` 参数并进 WHERE,定位三元组精确到 `(user, institution, campus)`。
  670. //
  671. // **R6(设计 §3.8-b)强化**:`money <= 0` → **2002**(与 recharge/expenditure/baddebt/transfer 五处统一)。
  672. // 与 dump 的差异是**有意偏离**:dump 对 <=0 是"静默不动作",前端会显示"成功但余额没变";
  673. // Go 显式拒绝。注:本处 dump 分支**不写流水**、不占幂等键,与另三处"归 0 仍写 0 元流水"不同,
  674. // 但对外统一成 2002 更不易被误用(前端不会把"没退钱"当成功)。
  675. //
  676. // 入参:ctx / in。出参:*RefundResult;
  677. // 错误码 2001/2002/3140/1004/3001/5002。
  678. func (s *FundService) Refund(ctx context.Context, in RefundInput) (*RefundResult, error) {
  679. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "Refund", true); err != nil {
  680. return nil, err
  681. }
  682. if in.Type <= 0 {
  683. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, errString("service: Refund 需要 type"))
  684. }
  685. if !isKnownLogType(in.Type) {
  686. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat,
  687. errString("service: Refund 的 type 不在流水类型白名单内"))
  688. }
  689. // R6:金额 <= 0 一律 2002(dump 是 `IF inputMoney>0` 静默跳过;Go 显式拒绝)
  690. if err := validatePositiveAmount(in.Money, "Refund"); err != nil {
  691. return nil, err
  692. }
  693. if s == nil || s.runner == nil || s.repo == nil {
  694. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  695. }
  696. remark := strings.TrimSpace(in.Remark)
  697. if remark == "" {
  698. remark = defaultRefundRemark
  699. }
  700. var out RefundResult
  701. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  702. txCtx := repository.WithTxContext(ctx, tx)
  703. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, in.Type)
  704. if e != nil {
  705. return e
  706. }
  707. if hit != nil {
  708. bal, pe := balanceAfterOf(hit)
  709. if pe != nil {
  710. return pe
  711. }
  712. out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
  713. return nil
  714. }
  715. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  716. return e
  717. }
  718. // dump 不建户(account 不存在则什么都不做);Go 显式 3001,避免"调用成功但没退钱"
  719. if _, e := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  720. return e
  721. }
  722. // dump:`balance+`,`total_expenditure-`
  723. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "Refund", in.UserID,
  724. in.InstitutionID, in.CampusID,
  725. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
  726. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
  727. ); e != nil {
  728. return e
  729. }
  730. globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
  731. if e != nil {
  732. return e
  733. }
  734. bal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.InstitutionID, in.CampusID)
  735. if e != nil {
  736. return e
  737. }
  738. lg := newExpenseLog(in.UserID, in.Type, in.Money, in.RequestID, remark, in.OperatorID, &globalAfter)
  739. if e := s.insertLog(txCtx, tx, lg); e != nil {
  740. return e
  741. }
  742. out = RefundResult{LogID: lg.ID, Balance: bal, Changed: true}
  743. return nil
  744. })
  745. if err != nil {
  746. return nil, s.mapIdempotentHit(err, func() error {
  747. return s.replayRefund(ctx, in, &out)
  748. })
  749. }
  750. return &out, nil
  751. }
  752. // ---------------------------------------------------------------- SP5 可退金额(只读)
  753. // RefundableAmount 查询校区账户可退金额(SP5 `sp_user_expense_school_get_refundable_amount`,**只读**)。
  754. //
  755. // dump 语义(:764421,**三步修正,缺一不可**;dump 里的游标段整段被 `/* */` 注释掉,实际只生效下面的 SUM):
  756. //
  757. // ① 读 campusBalance(t_user_expense_school.balance)与 totalBalance(t_user_expense.balance);
  758. // 任一 <= 0(含 NULL/账户不存在)→ 0;
  759. // ② **截断**:`IF campusBalance > totalBalance THEN campusBalance = totalBalance`,
  760. // 截断后再早退一次(`campusBalance <= 0 → 0`,dump 真有这句,最易漏);
  761. // ③ **减已使用赠送额**(`t_user_balance_request × t_courses_activity_signed` 关联汇总,
  762. // 口径见 `BalanceRequestRepo.SumRechargeActivityUsedGift`):
  763. // refundable = campusBalance − usedGift,**下限 0**(SUM 为 NULL → 视作 0)。
  764. //
  765. // 两个**别复原**的陷阱(设计 §3.8-b 明确登记,评审逐处检查):
  766. // - 同一 SP 里还把 `SUM(activity_money)` 存进 `totalDiscountAmount`,但**全文再未引用**(死变量)
  767. // → **不得**据此加"赠送额上限/赠送额不可超余额"之类的额外约束;
  768. // - 那段按 `response_time DESC` 逐笔累加的游标(`DECLARE cur` / `OPEN` / 循环)**整段被注释掉**
  769. // → **不得**照注释补回逐笔逻辑,实际口径就是一条聚合 SUM。
  770. //
  771. // Go 的显式化(不改变资金结果,只把 dump 的静默分支显式化):
  772. // - 校区账户不存在 → (0, nil)(dump:ISNULL → 0);
  773. // - `campusBalance <= 0`(欠费)或 `totalBalance <= 0` → 0(dump 下负数参与减法后取 max(…,0) 亦为 0,
  774. // 这里提前返回是同结果的短路,避免把"欠费"当成"可退");
  775. // - 全局账户不存在 → 0(totalBalance 视为 0 → 截断后必为 0)。
  776. //
  777. // 只读且**无事务**:本方法不写任何表,故不开事务、也不加行锁(加锁只属于资金写路径,§10.3)。
  778. // 与之配套的调用纪律:可退额是"查询口径",**不得**用它做写路径的扣减基数——
  779. // 退费扣减基数由 `CustomRefund` 在事务内用加锁读的校区余额决定(§7.2 R2)。
  780. //
  781. // 与 SP9 的关系(**勿互相复用**):`custom_refund` **不调用**本方法,它内部的坏账基数是
  782. // **校区 `balance` 本身**;两者数值天然不同(本方法扣赠送额与全局截断,SP9 不扣不截)。
  783. //
  784. // 入参:ctx / userID / institutionID / campusID。
  785. // 出参:(可退金额, error);账户不存在 / 余额非正 → (0, nil);错误码 2001/3140/1004/5002。
  786. func (s *FundService) RefundableAmount(
  787. ctx context.Context, userID string, institutionID, campusID int64,
  788. ) (money.Amount, error) {
  789. if err := validateUserScope(userID, institutionID, campusID, "RefundableAmount", true); err != nil {
  790. return 0, err
  791. }
  792. if s == nil || s.repo == nil || s.requests == nil {
  793. return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  794. }
  795. if err := s.assertInScope(ctx, userID, institutionID, campusID); err != nil {
  796. return 0, err
  797. }
  798. // ① 校区余额(读不到 = 账户不存在 → 0,dump 的 ISNULL 分支)
  799. acc, err := s.repo.GetCampusAccount(ctx, nil, userID, institutionID, campusID)
  800. if err != nil {
  801. if errors.Is(err, infraerrors.ErrNotFound) {
  802. return 0, nil // dump:账户不存在 → 0
  803. }
  804. return 0, err
  805. }
  806. campusBalance, err := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  807. if err != nil {
  808. return 0, err
  809. }
  810. if campusBalance <= 0 {
  811. return 0, nil
  812. }
  813. // ② 全局余额(跨校区总余额;这是 dump 的"截断上界")
  814. global, err := s.repo.GetGlobalAccount(ctx, nil, userID)
  815. if err != nil {
  816. if errors.Is(err, infraerrors.ErrNotFound) {
  817. return 0, nil // totalBalance 视为 0 → 截断后必为 0
  818. }
  819. return 0, err
  820. }
  821. totalBalance, err := parseAccountAmount(global.Balance, "t_user_expense.balance")
  822. if err != nil {
  823. return 0, err
  824. }
  825. if totalBalance <= 0 {
  826. return 0, nil
  827. }
  828. // ③ **第一步修正**:校区余额按全局余额截断(dump:campusBalance > totalBalance → 取 totalBalance)
  829. if campusBalance > totalBalance {
  830. campusBalance = totalBalance
  831. }
  832. // ③-b 截断后再早退一次(dump 里真有这句,最容易被"省掉"):截断后仍非正 → 0,
  833. // 不要在非正基数上继续做减项(否则 -- 与 max(…,0) 的结果虽同,语义却把"欠费/脏数据"当成可退基数)。
  834. if campusBalance <= 0 {
  835. return 0, nil
  836. }
  837. // ④ **第二步修正**:扣掉已使用的充值赠送额(赠送额不可退)
  838. usedGift, err := s.requests.SumRechargeActivityUsedGift(ctx, userID, institutionID, campusID)
  839. if err != nil {
  840. return 0, err
  841. }
  842. refundable := campusBalance.Sub(usedGift)
  843. if refundable <= 0 {
  844. return 0, nil // dump:max(…, 0)
  845. }
  846. return refundable, nil
  847. }
  848. // ---------------------------------------------------------------- SP6 课费转结
  849. // Transfer 课费转结(SP6 `sp_user_expense_school_transfer_expenditure`)。
  850. //
  851. // dump 语义(:764661,★**与 P0-04 规格相反,以 dump 为准**,设计 §3.1 #6 / §10.2):
  852. //
  853. // IF inputMoney>0:
  854. // 源校区账户存在 →
  855. // 目标校区账户不存在则 INSERT;
  856. // UPDATE 源:total_expenditure−m, total_income−m
  857. // UPDATE 目标:total_expenditure+m, total_income+m
  858. // INSERT log(type=106, money=**+m**) ← 转结出
  859. // INSERT log(type=206, money=**−m**) ← 转结入
  860. //
  861. // **四个"不得改动"的点**:
  862. // 1. **不动 balance**(dump 全程没有 balance 赋值)——转结只调"总消费/总充值"两个统计口径;
  863. // 2. 106 记 **+m**、206 记 **−m**(与"出/入"的直觉相反,dump 原文如此,§11-6 #6);
  864. // 3. 两条流水的 `request_id` **相同**、`type` 不同 → 不撞 `uk_request_type`(唯一键含 type);
  865. // 4. 目标账户不存在时**建户**(dump 的 INSERT),源账户不存在时**不动**(dump 的 `IF campusLogid1>0`)。
  866. //
  867. // **两处 dump 既有事实(已核全文,**不得"顺手补齐"**)**:
  868. // - 转结**只更新两张校区表**的 `total_expenditure` / `total_income`,**完全不碰全局表**
  869. // (整段没有 `UPDATE t_user_expense`)⇒ 全局的 `total_*` 与"各校区之和"**本来就不相等**,
  870. // 这是旧库既有口径,谁去"补齐全局"谁就把双跑对账搞乱;
  871. // - 转出/转入账户定位**只按 `userid + campusid`、没有 schoolid** ⇒ 同一 campus_id 在多机构复用时
  872. // 会误命中别家账户(BUG#2 同源)⇒ Go 一律按 `(user, institution, campus)` 三元组定位。
  873. //
  874. // 边界码(§3.8 R4 / R4-bis):
  875. //
  876. // refund 校区账户缺失 / refund 全局账户缺失 → **3001**(dump 只更单边 + 照写流水 = 双表撕裂)
  877. // transfer 转出方(源)账户缺失 → **3001**(dump `IF campusLogid1>0` 包整笔 → 静默 no-op)
  878. // transfer 转入方(目标)账户缺失 → **建户**(正常路径,见 `resolveTargetInstitution` 的 R4-bis)
  879. // transfer 转入校区查不到 / 未归属机构 → **3104**
  880. // transfer 入参机构 ≠ 校区归属机构 → **3140**(不建户、不落行,防机构错配僵尸户)
  881. // 同校区自转(净效应为零) → **3140**
  882. //
  883. // 入参:ctx / in。出参:*TransferResult{logId106,logId206};错误码 2001/2002/3104/3140/1004/3001/5002。
  884. func (s *FundService) Transfer(ctx context.Context, in TransferInput) (*TransferResult, error) {
  885. if err := validateUserScope(in.UserID, in.FromInstitutionID, in.FromCampusID, "Transfer(from)", true); err != nil {
  886. return nil, err
  887. }
  888. if err := validateUserScope(in.UserID, in.ToInstitutionID, in.ToCampusID, "Transfer(to)", true); err != nil {
  889. return nil, err
  890. }
  891. if err := validatePositiveAmount(in.Money, "Transfer"); err != nil {
  892. return nil, err
  893. }
  894. if in.FromInstitutionID == in.ToInstitutionID && in.FromCampusID == in.ToCampusID {
  895. return nil, infraerrors.WithCause(apperr.ErrBusinessParam,
  896. errString("service: Transfer 的转出与转入校区不能相同"))
  897. }
  898. if s == nil || s.runner == nil || s.repo == nil {
  899. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  900. }
  901. var out TransferResult
  902. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  903. txCtx := repository.WithTxContext(ctx, tx)
  904. // ① 幂等预检:106 是"转结出"主流水(必然存在),用它做命中判定
  905. hit106, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
  906. if e != nil {
  907. return e
  908. }
  909. if hit106 != nil {
  910. out = TransferResult{LogID106: hit106.ID, Repeated: true}
  911. if bal, pe := balanceAfterOf(hit106); pe == nil {
  912. out.FromBalance = bal
  913. }
  914. hit206, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
  915. if e != nil {
  916. return e
  917. }
  918. if hit206 != nil {
  919. out.LogID206 = hit206.ID
  920. if bal, pe := balanceAfterOf(hit206); pe == nil {
  921. out.ToBalance = bal
  922. }
  923. }
  924. return nil
  925. }
  926. // ② 作用域断言(两侧机构都必须可见)
  927. if e := s.assertInScope(txCtx, in.UserID, in.FromInstitutionID, in.FromCampusID); e != nil {
  928. return e
  929. }
  930. if in.ToInstitutionID != in.FromInstitutionID {
  931. if e := s.assertInScope(txCtx, in.UserID, in.ToInstitutionID, in.ToCampusID); e != nil {
  932. return e
  933. }
  934. }
  935. // ③ 加锁:两个校区账户**按 campus_id ASC 排序**后依次加锁(§1.4 多账户场景防死锁)
  936. refs := []campusRef{
  937. {InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID},
  938. {InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID},
  939. }
  940. sort.Slice(refs, func(i, j int) bool { return refs[i].CampusID < refs[j].CampusID })
  941. locked := make(map[campusRef]bool, len(refs))
  942. for _, ref := range refs {
  943. _, le := s.repo.GetCampusForUpdateExact(txCtx, tx, in.UserID, ref.InstitutionID, ref.CampusID)
  944. if le != nil {
  945. if errors.Is(le, infraerrors.ErrNotFound) {
  946. locked[ref] = false
  947. continue
  948. }
  949. return le
  950. }
  951. locked[ref] = true
  952. }
  953. fromRef := campusRef{InstitutionID: in.FromInstitutionID, CampusID: in.FromCampusID}
  954. toRef := campusRef{InstitutionID: in.ToInstitutionID, CampusID: in.ToCampusID}
  955. if !locked[fromRef] {
  956. // dump:源账户不存在 → `IF campusLogid1>0` 不成立 → 静默什么都不做
  957. return infraerrors.WithCause(infraerrors.ErrNotFound,
  958. errString("service: Transfer 转出校区账户不存在"))
  959. }
  960. if !locked[toRef] {
  961. // dump:目标账户不存在 → **INSERT 建户**(R4 ④:建户是**正常路径**,不是 3001——
  962. // 转结的语义就是"把钱挪到还没有账户的校区",改成 3001 会让首次跨校区转结全部失败)。
  963. //
  964. // **R4-bis(§3.8-R4-bis / §5.7 #18b、#19b)**:dump 建户用的是入参 `inputSchoolid2`,
  965. // 入参校区与机构不匹配时会建出**机构错配的僵尸户**;而 UK(user,institution,campus)
  966. // 一旦机构写错,后续按正确机构查不到 → 再建第二个 → 同校区双账户 → 差值冻结直接破。
  967. // 故建户的机构**只认校区表的权威值**,不用入参:
  968. // ① inst := 校区表里的 institution_id(同事务只读) ② 取不到 → 3104
  969. // ③ 入参 toInstitutionId ≠ inst → 3140(不建户、不落任何行) ④ 用 inst 建户
  970. // 正常数据下 ②③ 恒不触发(两值相同),只在非法入参时拒绝。
  971. inst, ie := s.resolveTargetInstitution(txCtx, tx, in.ToCampusID, in.ToInstitutionID)
  972. if ie != nil {
  973. return ie
  974. }
  975. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  976. UserID: in.UserID, InstitutionID: inst, CampusID: in.ToCampusID,
  977. }); e != nil {
  978. return e
  979. }
  980. }
  981. // ④ 4 次 UPDATE → 2 条语句(每表两列),**balance 一律不动**
  982. if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(out)", in.UserID,
  983. in.FromInstitutionID, in.FromCampusID,
  984. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money.Neg()},
  985. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money.Neg()},
  986. ); e != nil {
  987. return e
  988. }
  989. if _, e := s.repo.AddCampusColumns(txCtx, tx, "Transfer(in)", in.UserID,
  990. in.ToInstitutionID, in.ToCampusID,
  991. repository.ColumnDelta{Column: repository.ExpenseColumnTotalExpenditure, Delta: in.Money},
  992. repository.ColumnDelta{Column: repository.ExpenseColumnTotalIncome, Delta: in.Money},
  993. ); e != nil {
  994. return e
  995. }
  996. // ⑤ 两条流水(106 在前、206 在后;方向 ★dump 口径)
  997. //
  998. // `balance_after` 口径(§3.8-f ②-bis):转结**不动 balance**,故 106 与 206 **写同一个全局快照**;
  999. // 响应体的 FromBalance / ToBalance 才是两个校区各自的余额(两列语义不同,勿混用)。
  1000. globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
  1001. if e != nil {
  1002. return e
  1003. }
  1004. fromBal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.FromInstitutionID, in.FromCampusID)
  1005. if e != nil {
  1006. return e
  1007. }
  1008. toBal, e := campusBalanceAfter(txCtx, s.repo, tx, in.UserID, in.ToInstitutionID, in.ToCampusID)
  1009. if e != nil {
  1010. return e
  1011. }
  1012. lg106 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferOut, in.Money,
  1013. in.RequestID, in.Remark, in.OperatorID, &globalAfter)
  1014. if e := s.insertLog(txCtx, tx, lg106); e != nil {
  1015. return e
  1016. }
  1017. lg206 := newExpenseLog(in.UserID, model.ExpenseLogTypeTransferIn, in.Money.Neg(),
  1018. in.RequestID, in.Remark, in.OperatorID, &globalAfter)
  1019. if e := s.insertLog(txCtx, tx, lg206); e != nil {
  1020. return e
  1021. }
  1022. out = TransferResult{
  1023. LogID106: lg106.ID, LogID206: lg206.ID,
  1024. FromBalance: fromBal, ToBalance: toBal,
  1025. }
  1026. return nil
  1027. })
  1028. if err != nil {
  1029. return nil, s.mapIdempotentHit(err, func() error {
  1030. return s.replayTransfer(ctx, in, &out)
  1031. })
  1032. }
  1033. return &out, nil
  1034. }
  1035. // campusRef 校区账户定位三元组的"机构 + 校区"部分(转结加锁排序用)。
  1036. type campusRef struct {
  1037. // InstitutionID 机构 ID。
  1038. InstitutionID int64
  1039. // CampusID 校区 ID。
  1040. CampusID int64
  1041. }
  1042. // resolveTargetInstitution 解析转结**转入方**的权威机构 ID(R4-bis 的三道校验)。
  1043. //
  1044. // 入参:
  1045. // - ctx 请求上下文(含 tx);
  1046. // - tx 事务句柄(**必传**:与本次资金事务同一快照,禁止走连接池读到旧值);
  1047. // - campusID 转入校区(>0,已由 validateUserScope 校验);
  1048. // - inputInstitutionID 调用方声明的转入机构。
  1049. //
  1050. // 出参:
  1051. // - int64:**权威**机构 ID(= t_campus.institution_id),调用方必须用它建户;
  1052. // - 3104(校区不存在 / 校区未归属机构)/ 3140(入参与权威值矛盾)/ 5002。
  1053. //
  1054. // 为什么必须校验(§3.8-R4-bis):dump 建户用入参机构,入参错就会建出机构错配的僵尸账户,
  1055. // 之后按正确机构再也查不到它 → 同校区出现两个账户 → "差值冻结"恒等式破。
  1056. // 正常调用方两值一致,本校验零影响;只有入参矛盾时才拒绝(比"按解析值静默建户"安全:
  1057. // 后者管理员看不到任何提示,事故后极难归因)。
  1058. func (s *FundService) resolveTargetInstitution(
  1059. ctx context.Context, tx *gorm.DB, campusID, inputInstitutionID int64,
  1060. ) (int64, error) {
  1061. if s == nil || s.campus == nil {
  1062. return 0, infraerrors.WithCause(infraerrors.ErrInternal,
  1063. errString("service: 未注入 port.CampusReader(无法解析转入校区机构)"))
  1064. }
  1065. info, err := s.readCampusInfo(ctx, tx, campusID)
  1066. if err != nil {
  1067. return 0, err
  1068. }
  1069. if info == nil || info.SchoolID <= 0 {
  1070. return 0, infraerrors.WithCause(apperr.ErrCampusNotFound,
  1071. errString("service: Transfer 转入校区未归属机构,拒绝建户"))
  1072. }
  1073. if inputInstitutionID != info.SchoolID {
  1074. return 0, infraerrors.WithCause(apperr.ErrBusinessParam,
  1075. errString("service: Transfer 的转入机构与校区归属机构不一致(拒绝建户,防僵尸账户)"))
  1076. }
  1077. return info.SchoolID, nil
  1078. }
  1079. // readCampusInfo 同事务读校区(优先 TxCampusReader,退化到连接池版 CampusReader)。
  1080. //
  1081. // 入参:ctx / tx / campusID(>0)。出参:*port.CampusInfo;3104(校区不存在)/ 5002。
  1082. func (s *FundService) readCampusInfo(ctx context.Context, tx *gorm.DB, campusID int64) (*port.CampusInfo, error) {
  1083. if s == nil || s.campus == nil {
  1084. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  1085. errString("service: 未注入 port.CampusReader(无法判定校区归属)"))
  1086. }
  1087. if txReader, ok := s.campus.(port.TxCampusReader); ok {
  1088. return txReader.GetByIDTx(ctx, tx, campusID)
  1089. }
  1090. return s.campus.GetByID(ctx, campusID)
  1091. }
  1092. // ---------------------------------------------------------------- SP7 坏账
  1093. // BadDebt 坏账处理(SP7 `sp_user_expense_baddebt`)。
  1094. //
  1095. // dump 语义(:763890,SP 头注释即「用户坏账处理(余额+,特殊费用+)」,**双加**):
  1096. //
  1097. // UPDATE t_user_expense_school SET balance = balance + inputMoney,
  1098. // special_expenses = special_expenses + inputMoney
  1099. // WHERE userid AND schoolid AND campusid;
  1100. // UPDATE t_user_expense SET balance = balance + inputMoney,
  1101. // special_expenses = special_expenses + inputMoney
  1102. // WHERE userid;
  1103. // INSERT t_user_expense_log(..., type = 108, money = +inputMoney, ...);
  1104. //
  1105. // 即:**全局 + 校区都同时加 `balance` 与 `special_expenses`**(两列同增,金额相同),
  1106. // 流水 type=108、money=+Money。Go 侧严格照做(不做"只统计特殊费用"的收敛)。
  1107. //
  1108. // ★ type=108 的**两种来源**(对账/恒等式判别的唯一依据,AC-17 ④ 按此落地):
  1109. //
  1110. // ① **独立坏账**(本 SP):`balance += m` **且** `special_expenses += m`,108.money = +m;
  1111. // —— 108 上记的 +m **真实对应余额增加**,**参与**"balance 增量 = Σ流水"的归因。
  1112. // ② **退费差额**(`RefundService.CustomRefund` 的 `requested > 可退` 分支):
  1113. // 只 `special_expenses += m`(余额净减 = refunded),108.money = +badDebt,
  1114. // 而同一请求的 207.money = **−requested(全额)**——坏账部分已含在 207 里;
  1115. // —— 该 108 **不再构成一次独立的余额增加**,**不参与**"108 → balance 增量"的归因
  1116. // (否则会与 207 重复计入)。
  1117. //
  1118. // **判别式(唯一、可机械执行,设计 §3.8-b (f) A18)**:取该 108 流水的 `logid`
  1119. // (= `request_id`,`newExpenseLog` 对两者写同一值)——
  1120. // **同 `logid` 存在 type=207 且 `logid <> ''`(非 NULL/空)= 差额型(②)**;
  1121. // **不存在(含 logid 为空)= 独立坏账(①)**。可直接落地的判定 SQL(AC-17 ④ 可照抄):
  1122. //
  1123. // SELECT l.id, l.money,
  1124. // (l.log_id IS NOT NULL AND l.log_id <> ''
  1125. // AND EXISTS (SELECT 1 FROM t_user_expense_log r
  1126. // WHERE r.user_id = l.user_id AND r.type = 207 AND r.log_id = l.log_id)) AS differential
  1127. // FROM t_user_expense_log l
  1128. // WHERE l.user_id = ? AND l.type = 108;
  1129. //
  1130. // · 两条来源都存在时,"balance = Σ全部流水" 仍恒成立(②里 108 与 207 的代数和恰为净扣减
  1131. // −(requested−badDebt) = −refunded),但"逐条 108 是否代表一次余额增加"必须用上面的判别式区分;
  1132. // **禁止**用一条 `SUM(money) WHERE type=108` 把两种语义混算。
  1133. //
  1134. // 幂等:`request_id + type(108)` 唯一键;无 requestId 时不享幂等(dump 同口径)。
  1135. //
  1136. // 入参:ctx / in。出参:*BadDebtResult;错误码 2001/2002/3140/1004/5002。
  1137. func (s *FundService) BadDebt(ctx context.Context, in BadDebtInput) (*BadDebtResult, error) {
  1138. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "BadDebt", true); err != nil {
  1139. return nil, err
  1140. }
  1141. if err := validatePositiveAmount(in.Money, "BadDebt"); err != nil {
  1142. return nil, err
  1143. }
  1144. if s == nil || s.runner == nil || s.repo == nil {
  1145. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  1146. }
  1147. remark := strings.TrimSpace(in.Remark)
  1148. if remark == "" {
  1149. remark = defaultBadDebtRemark
  1150. }
  1151. var out BadDebtResult
  1152. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  1153. txCtx := repository.WithTxContext(ctx, tx)
  1154. hit, e := s.repo.FindLogByRequest(txCtx, tx, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
  1155. if e != nil {
  1156. return e
  1157. }
  1158. if hit != nil {
  1159. out = BadDebtResult{LogID: hit.ID, Repeated: true}
  1160. if acc, ae := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
  1161. if spec, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
  1162. out.SpecialExpenses = spec
  1163. }
  1164. if bal, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
  1165. out.Balance = bal
  1166. }
  1167. }
  1168. return nil
  1169. }
  1170. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  1171. return e
  1172. }
  1173. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  1174. return e
  1175. }
  1176. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  1177. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  1178. }); e != nil {
  1179. return e
  1180. }
  1181. // dump:两条 UPDATE 都是 `balance + inputMoney, special_expenses + inputMoney` → **双加**
  1182. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "BadDebt", in.UserID,
  1183. in.InstitutionID, in.CampusID,
  1184. repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: in.Money},
  1185. repository.ColumnDelta{Column: repository.ExpenseColumnSpecialExpenses, Delta: in.Money},
  1186. ); e != nil {
  1187. return e
  1188. }
  1189. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  1190. if e != nil {
  1191. return e
  1192. }
  1193. bal, e := parseAccountAmount(acc.Balance, "t_user_expense_school.balance")
  1194. if e != nil {
  1195. return e
  1196. }
  1197. spec, e := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses")
  1198. if e != nil {
  1199. return e
  1200. }
  1201. // `balance_after` = 坏账后**全局账户余额**快照(响应体 Balance 是校区余额,两者此时数值相同但口径不同)
  1202. globalAfter, e := globalBalanceAfter(txCtx, s.repo, tx, in.UserID)
  1203. if e != nil {
  1204. return e
  1205. }
  1206. lg := newExpenseLog(in.UserID, model.ExpenseLogTypeBadDebt, in.Money,
  1207. in.RequestID, remark, in.OperatorID, &globalAfter)
  1208. if e := s.insertLog(txCtx, tx, lg); e != nil {
  1209. return e
  1210. }
  1211. out = BadDebtResult{LogID: lg.ID, SpecialExpenses: spec, Balance: bal}
  1212. return nil
  1213. })
  1214. if err != nil {
  1215. return nil, s.mapIdempotentHit(err, func() error {
  1216. return s.replayBadDebt(ctx, in, &out)
  1217. })
  1218. }
  1219. return &out, nil
  1220. }
  1221. // ---------------------------------------------------------------- SP11 累计消费(spend)
  1222. // AccumulateSpend 累计消费金额(SP11 `sp_user_expense_school_spend`)。
  1223. //
  1224. // 口径(设计 §3.1 #11):**只更新 `spend`,不动 `balance`**;无流水(dump 不写 log)。
  1225. //
  1226. // **BUG#1 修正**:dump 的全局分支 `INSERT INTO t_user_expense(userid, spend) VALUES(inputUserid,
  1227. // inputMoney, inputSpendMoney)` 是 **3 个值对 2 列**(且 inputMoney 在该 SP 里根本未声明)→ 必报错。
  1228. // Go 修正为"UPSERT 建户 + `spend = spend + ?`",列数与列名对齐 DDL。
  1229. //
  1230. // 负值处理:dump 仅在 **INSERT 分支**把负数归 0(UPDATE 分支会照减);本实现统一归 0
  1231. // (`spend` 是"只增"的统计口径列,model 注释亦如此约定),并回显当前值。
  1232. //
  1233. // 幂等:dump 的 SP11 **没有请求号**(签名只有 4 参),因此本方法不参与 `uk_request_type` 幂等;
  1234. // 重复调用会重复累加 `spend`——这是 dump 的既有语义(调用方为"结束上课"链路,每次课一条),
  1235. // 不做"按 requestId 去重"的加法,以免与 dump 的双跑口径分叉。
  1236. //
  1237. // 入参:ctx / in。出参:*SpendResult{spend};错误码 2001/3140/5002。
  1238. func (s *FundService) AccumulateSpend(ctx context.Context, in SpendInput) (*SpendResult, error) {
  1239. if err := validateUserScope(in.UserID, in.InstitutionID, in.CampusID, "AccumulateSpend", true); err != nil {
  1240. return nil, err
  1241. }
  1242. if err := in.SpendMoney.Validate(); err != nil {
  1243. return nil, infraerrors.WithCause(infraerrors.ErrBadFormat, err)
  1244. }
  1245. if in.SpendMoney < 0 {
  1246. in.SpendMoney = 0 // dump INSERT 分支:负数归 0(spend 只增)
  1247. }
  1248. if s == nil || s.runner == nil || s.repo == nil {
  1249. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: FundService 未装配"))
  1250. }
  1251. var out SpendResult
  1252. err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
  1253. txCtx := repository.WithTxContext(ctx, tx)
  1254. if e := s.assertInScope(txCtx, in.UserID, in.InstitutionID, in.CampusID); e != nil {
  1255. return e
  1256. }
  1257. if e := s.repo.UpsertGlobal(txCtx, tx, &model.UserExpense{UserID: in.UserID}); e != nil {
  1258. return e
  1259. }
  1260. if e := s.repo.UpsertCampus(txCtx, tx, &model.UserExpenseSchool{
  1261. UserID: in.UserID, InstitutionID: in.InstitutionID, CampusID: in.CampusID,
  1262. }); e != nil {
  1263. return e
  1264. }
  1265. if !in.SpendMoney.IsZero() {
  1266. if _, e := s.repo.AddColumnsGlobalThenCampus(txCtx, tx, "AccumulateSpend", in.UserID,
  1267. in.InstitutionID, in.CampusID,
  1268. repository.ColumnDelta{Column: repository.ExpenseColumnSpend, Delta: in.SpendMoney},
  1269. ); e != nil {
  1270. return e
  1271. }
  1272. }
  1273. acc, e := s.repo.GetCampusAccount(txCtx, tx, in.UserID, in.InstitutionID, in.CampusID)
  1274. if e != nil {
  1275. return e
  1276. }
  1277. spend, e := parseAccountAmount(acc.Spend, "t_user_expense_school.spend")
  1278. if e != nil {
  1279. return e
  1280. }
  1281. out = SpendResult{Spend: spend}
  1282. return nil
  1283. })
  1284. if err != nil {
  1285. return nil, err
  1286. }
  1287. return &out, nil
  1288. }
  1289. // ---------------------------------------------------------------- 幂等公共形状
  1290. // insertLog 写流水并把"撞 uk_request_type"翻译成内部幂等哨兵(`apperr.ErrRepeated`)。
  1291. //
  1292. // 入参:txCtx(含 tx 的 ctx)/ tx / lg 流水行。
  1293. // 出参:nil 成功;apperr.ErrRepeated(**并发幂等命中**,调用方据此回放首次结果);
  1294. // 其余错误原样上抛(ErrBadParam/ErrDBError)。
  1295. //
  1296. // 为什么用 ErrRepeated 作为事务内哨兵:它是 *AppError,`TxRunner.wrapTxErr` 会原样透传
  1297. // (不降级成 5002),且它是设计里"仅内部日志用"的码(§10.1),不会泄漏给前端。
  1298. func (s *FundService) insertLog(txCtx context.Context, tx *gorm.DB, lg *model.UserExpenseLog) error {
  1299. err := s.repo.InsertLog(txCtx, tx, lg)
  1300. if err == nil {
  1301. return nil
  1302. }
  1303. if errors.Is(err, repository.ErrDuplicateLog) {
  1304. return apperr.ErrRepeated
  1305. }
  1306. return err
  1307. }
  1308. // mapIdempotentHit 把"并发幂等命中"从错误路径转成"回放首次结果"的成功路径。
  1309. //
  1310. // 入参:
  1311. // - err:WithinTx 返回的错误;
  1312. // - replay:回放函数(重新查询首次流水并填充 out)。
  1313. //
  1314. // 出参:nil(回放成功)/ 回放错误 / 原错误(非幂等命中)。
  1315. //
  1316. // 语义(§1.5):`uk_request_type` 撞键 = 同一请求号 + 同一 type 的第二次写入,
  1317. // **不是错误**(code 仍 200,data.repeated=true)。实现上先整笔回滚(本次零残留),再回放。
  1318. func (s *FundService) mapIdempotentHit(err error, replay func() error) error {
  1319. if err == nil {
  1320. return nil
  1321. }
  1322. if errors.Is(err, apperr.ErrRepeated) {
  1323. return replay()
  1324. }
  1325. return err
  1326. }
  1327. // balanceAfterOf 从流水行取 balance_after 快照(NULL → 0)。
  1328. //
  1329. // ⚠ 口径提醒:`balance_after` 自 v0.1.16 起是「该操作完成后**全局账户**余额」(§3.8-f ②-bis),
  1330. // 而各 `Result.Balance` 的契约是**校区**余额 —— 因此**响应体不得再用本函数填充**(会串口径),
  1331. // 回放一律走 `campusBalanceAfter` 读当前校区账户。本函数仅保留给"确实要读全局快照"的对账/排障用途。
  1332. //
  1333. // 入参:lg 流水行(可 nil)。出参:(金额, error);balance_after 为 NULL 时返回 0 且无错
  1334. // (历史行没有快照列,调用方需能接受 0)。
  1335. func balanceAfterOf(lg *model.UserExpenseLog) (money.Amount, error) {
  1336. if lg == nil || lg.BalanceAfter == nil {
  1337. return 0, nil
  1338. }
  1339. return parseAccountAmount(*lg.BalanceAfter, "t_user_expense_log.balance_after")
  1340. }
  1341. // replayRecharge 幂等回放(充值)。
  1342. func (s *FundService) replayRecharge(ctx context.Context, in RechargeInput, out *RechargeResult) error {
  1343. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1344. if err != nil {
  1345. return err
  1346. }
  1347. if hit == nil {
  1348. return infraerrors.WithCause(infraerrors.ErrInternal,
  1349. errString("service: Recharge 幂等命中但首次流水已不可见"))
  1350. }
  1351. // 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
  1352. bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
  1353. if err != nil {
  1354. return err
  1355. }
  1356. *out = RechargeResult{LogID: hit.ID, Balance: bal, Repeated: true}
  1357. return nil
  1358. }
  1359. // replayExpenditure 幂等回放(消费)。
  1360. func (s *FundService) replayExpenditure(ctx context.Context, in ExpenditureInput, out *ExpenditureResult) error {
  1361. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1362. if err != nil {
  1363. return err
  1364. }
  1365. if hit == nil {
  1366. return infraerrors.WithCause(infraerrors.ErrInternal,
  1367. errString("service: Expenditure 幂等命中但首次流水已不可见"))
  1368. }
  1369. // 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
  1370. bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
  1371. if err != nil {
  1372. return err
  1373. }
  1374. *out = ExpenditureResult{LogID: hit.ID, Balance: bal, Repeated: true}
  1375. return nil
  1376. }
  1377. // replayRefund 幂等回放(退费)。
  1378. func (s *FundService) replayRefund(ctx context.Context, in RefundInput, out *RefundResult) error {
  1379. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, in.Type)
  1380. if err != nil {
  1381. return err
  1382. }
  1383. if hit == nil {
  1384. return infraerrors.WithCause(infraerrors.ErrInternal,
  1385. errString("service: Refund 幂等命中但首次流水已不可见"))
  1386. }
  1387. // 响应体 Balance 是**校区**余额(与流水的 balance_after 全局口径不同),回放按当前校区账户取值
  1388. bal, err := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.InstitutionID, in.CampusID)
  1389. if err != nil {
  1390. return err
  1391. }
  1392. *out = RefundResult{LogID: hit.ID, Balance: bal, Changed: true, Repeated: true}
  1393. return nil
  1394. }
  1395. // replayBadDebt 幂等回放(坏账)。
  1396. func (s *FundService) replayBadDebt(ctx context.Context, in BadDebtInput, out *BadDebtResult) error {
  1397. hit, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeBadDebt)
  1398. if err != nil {
  1399. return err
  1400. }
  1401. if hit == nil {
  1402. return infraerrors.WithCause(infraerrors.ErrInternal,
  1403. errString("service: BadDebt 幂等命中但首次流水已不可见"))
  1404. }
  1405. var spec, bal money.Amount
  1406. if acc, ae := s.repo.GetCampusAccount(ctx, nil, in.UserID, in.InstitutionID, in.CampusID); ae == nil {
  1407. if v, pe := parseAccountAmount(acc.SpecialExpenses, "t_user_expense_school.special_expenses"); pe == nil {
  1408. spec = v
  1409. }
  1410. if v, pe := parseAccountAmount(acc.Balance, "t_user_expense_school.balance"); pe == nil {
  1411. bal = v
  1412. }
  1413. }
  1414. *out = BadDebtResult{LogID: hit.ID, SpecialExpenses: spec, Balance: bal, Repeated: true}
  1415. return nil
  1416. }
  1417. // replayTransfer 幂等回放(转结)。
  1418. func (s *FundService) replayTransfer(ctx context.Context, in TransferInput, out *TransferResult) error {
  1419. hit106, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferOut)
  1420. if err != nil {
  1421. return err
  1422. }
  1423. if hit106 == nil {
  1424. return infraerrors.WithCause(infraerrors.ErrInternal,
  1425. errString("service: Transfer 幂等命中但首次流水已不可见"))
  1426. }
  1427. // FromBalance / ToBalance 是两个**校区**各自余额(流水 balance_after 是全局口径,不能拿来充两个值)
  1428. res := TransferResult{LogID106: hit106.ID, Repeated: true}
  1429. if bal, pe := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.FromInstitutionID, in.FromCampusID); pe == nil {
  1430. res.FromBalance = bal
  1431. }
  1432. hit206, err := s.repo.FindLogByRequest(ctx, nil, in.UserID, in.RequestID, model.ExpenseLogTypeTransferIn)
  1433. if err != nil {
  1434. return err
  1435. }
  1436. if hit206 != nil {
  1437. res.LogID206 = hit206.ID
  1438. if bal, pe := campusBalanceAfter(ctx, s.repo, nil, in.UserID, in.ToInstitutionID, in.ToCampusID); pe == nil {
  1439. res.ToBalance = bal
  1440. }
  1441. }
  1442. *out = res
  1443. return nil
  1444. }
  1445. // isKnownLogType 流水类型白名单(`model.ExpenseLogType*` 全集,§10.2)。
  1446. //
  1447. // 用途:`Refund` 的 type 来自调用方(dump 透传),必须落在已知编码内,
  1448. // 否则会污染流水表并让"按 type 汇总"的对账口径失效。
  1449. func isKnownLogType(typ int32) bool {
  1450. switch typ {
  1451. case model.ExpenseLogTypeRecharge,
  1452. model.ExpenseLogTypeCourseCampus,
  1453. model.ExpenseLogTypeCourseSchoolA,
  1454. model.ExpenseLogTypeCourseSchoolB,
  1455. model.ExpenseLogTypeTransferOut,
  1456. model.ExpenseLogTypeRecommend,
  1457. model.ExpenseLogTypeBadDebt,
  1458. model.ExpenseLogTypeHistoryDeduct,
  1459. model.ExpenseLogTypeCourseCampusB,
  1460. model.ExpenseLogTypeCourseSchoolC,
  1461. model.ExpenseLogTypeCourseSchoolD,
  1462. model.ExpenseLogTypeTransferIn,
  1463. model.ExpenseLogTypeRefund,
  1464. model.ExpenseLogTypeClearBalance,
  1465. model.ExpenseLogTypeCancelRechargeAct:
  1466. return true
  1467. default:
  1468. return false
  1469. }
  1470. }