fund_service.go 58 KB

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