activity_discount_repo.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. // 活动赠送金额池仓储(t_courses_activity_user_discount / _user_discount_log / _used_log)——AC-06。
  2. //
  3. // 依据:unified/03-migration/academic-domain-design.md §3.2 #14~#19(赠送金额池 4 SP)/ §1.5(金额)
  4. // / §1.7(scoping)。
  5. //
  6. // 覆盖 SP 映射:
  7. //
  8. // user_discount_usable → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid;
  9. // campusid 条件**嵌在 schoolid 分支内**且恒为等值)
  10. // user_discount_use → ListUsable(ASC) + IncUsed/IncUsedBatch + InsertDiscountLog(负值)
  11. // user_discount_refund → ListUsable(DESC) + IncUsed(负 delta) + InsertDiscountLog(正值)
  12. // user_discount_refund_user_activity → ListUsable(DESC)(同活动池优先退回)
  13. // use_activity_recommend → IncUsed + InsertUsedLog(老带新结算写负值)
  14. //
  15. // 金额口径(红线):库里 discount_amount / discount_amount_used 是 decimal(12,2) **字符串列**,
  16. // Go 侧一律转 money.Amount(int64 分)再运算;SQL 侧只用字符串字面量做加减(decimal 精确),
  17. // **绝不**把金额转 float64(sqlite 的 SUM() 返回 REAL,会让分位失真——见 SumUsable 实现说明)。
  18. package repository
  19. import (
  20. "context"
  21. "errors"
  22. "strings"
  23. "gorm.io/gorm"
  24. "gorm.io/gorm/clause"
  25. "yuansiwei/unified-backend/internal/academic/model"
  26. "yuansiwei/unified-backend/internal/academic/money"
  27. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  28. appscope "yuansiwei/unified-backend/internal/infra/scope"
  29. )
  30. // UsedDelta 批量累加「已使用赠送金额」的单元素(id + 增量)。
  31. //
  32. // 字段:
  33. // - DiscountID int64:优惠账户 ID(t_courses_activity_user_discount.id;<=0 的条目被跳过)。
  34. // - Delta money.Amount:discount_amount_used 的增量(正=消费/占用,负=退回冲正;单位:分)。
  35. type UsedDelta struct {
  36. // DiscountID 优惠账户 ID(DB 自增主键)。
  37. DiscountID int64
  38. // Delta 已使用金额增量(正=消费,负=退回;单位:分)。
  39. Delta money.Amount
  40. }
  41. // ActivityDiscountRepo 活动赠送金额池仓储。
  42. //
  43. // 表:
  44. // - t_courses_activity_user_discount(池:可用 = discount_amount − discount_amount_used);
  45. // - t_courses_activity_user_discount_log(使用/退回流水,只增,负=消费/正=退回);
  46. // - t_courses_activity_used_log(老带新获得/使用记录,结算写负值)。
  47. //
  48. // 作用域:三表均带 institution_id + campus_id(campus_id=0 = 机构所有自营校区)→ scopedCampusRows。
  49. type ActivityDiscountRepo struct {
  50. *BaseRepo
  51. }
  52. // NewActivityDiscountRepo 构造赠送金额池仓储。
  53. //
  54. // 入参:db *gorm.DB / sc *appscope.Scope(语义同 NewActivityRepo)。
  55. // 出参:*ActivityDiscountRepo(CampusScoped=false,校区维度走 IN (campus,0) 超集语义)。
  56. func NewActivityDiscountRepo(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountRepo {
  57. r := &ActivityDiscountRepo{BaseRepo: NewBaseRepo(db, sc)}
  58. r.CampusScoped = false
  59. return r
  60. }
  61. // db 取查询句柄(ctx 内含 tx 时复用同一事务)。
  62. func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) {
  63. if r == nil || r.DB == nil {
  64. return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityDiscountRepo.DB 为 nil"))
  65. }
  66. if tx := TxFromContext(ctx); tx != nil {
  67. return tx, nil
  68. }
  69. if ctx == nil {
  70. return r.DB, nil
  71. }
  72. return r.DB.WithContext(ctx), nil
  73. }
  74. // ListUsable 列出用户可用赠送金额池(**活动维度**,dump `yuansiwei_finance.sql:763216`)。
  75. //
  76. // 入参:
  77. // - ctx 请求上下文;
  78. // - userID 学生(canonical_user_id ULID;空 → ErrBadParam);
  79. // - activityID 活动 ID(**>0 时只取该活动的赠送池**,对齐 dump `WHERE userid=? AND activityid=?`;
  80. // **0 = 不限活动**,仅"汇总所有活动可用额"的场景使用——扣减路径必须传具体 activityID,
  81. // 否则会把 A 活动的消费扣到 B 活动的池上);
  82. // - order 游标方向(**顺序不可改**,源自 dump):
  83. // OrderAsc = user_discount_use 使用顺序(先入先出);
  84. // OrderDesc = user_discount_refund 退回顺序(后入先退)。
  85. //
  86. // 出参:
  87. // - []model.CoursesActivityUserDiscount:可用行(`discount_amount > discount_amount_used`),
  88. // 金额为字符串原文,调用方用 money.Parse 转 Amount;
  89. // - ErrBadParam / ErrDBError。
  90. //
  91. // 可用口径(dump user_discount_usable 一致):仅累加 discount_amount > discount_amount_used 的行
  92. // (负赠送/已用尽的行不参与),故本方法只返回正可用行。
  93. func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, activityID int64, order string) ([]model.CoursesActivityUserDiscount, error) {
  94. if strings.TrimSpace(userID) == "" {
  95. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable 需要 user_id"))
  96. }
  97. if activityID < 0 {
  98. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable activity_id 不得为负"))
  99. }
  100. dir, err := normalizeOrder(order)
  101. if err != nil {
  102. return nil, err
  103. }
  104. db, err := r.db(ctx)
  105. if err != nil {
  106. return nil, err
  107. }
  108. q := scopedCampusRows(db.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  109. q = q.Where("user_id = ?", userID).
  110. Where("discount_amount > discount_amount_used")
  111. if activityID > 0 {
  112. q = q.Where("activity_id = ?", activityID)
  113. }
  114. // dump 的 usable 无显式 ORDER BY;InnoDB 等值/主键扫描等价于主键升序,故 asc 口径与 dump 一致
  115. q = q.Order("id " + dir)
  116. var rows []model.CoursesActivityUserDiscount
  117. if err := q.Find(&rows).Error; err != nil {
  118. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  119. }
  120. return rows, nil
  121. }
  122. // SumUsable 汇总用户可用赠送金额(**对齐 dump `usable` 的 3 参口径**:机构 + 校区 + 用户)。
  123. //
  124. // 入参:
  125. // - ctx 请求上下文;
  126. // - userID 学生(ULID;空 → ErrBadParam);
  127. // - institutionID 机构 ID(原 schoolid;**>0 = 机构+校区两个等值条件一起生效**;
  128. // **<=0 = 平台视角:一条作用域条件都不加**,此时 campusID 被忽略);
  129. // - campusID 校区 ID(原 campusid;**仅在 institutionID>0 时生效**,且为**等值**——
  130. // `campusID=0` 命中"机构级池"(campus_id=0 的行),**不是**"不限校区");
  131. // 不得为负(→ ErrBadParam)。
  132. //
  133. // 出参:
  134. // - money.Amount:可用额度合计(单位:分;无可用行为 0);
  135. // - ErrBadParam / ErrDBError。
  136. //
  137. // 作用域(audit A7 二次修正,dump `yuansiwei_finance.sql:763181-763185` **逐字**):
  138. //
  139. // IF inputSchoolid>0 THEN
  140. // SET selectCondition = CONCAT(" AND `schoolid`=",schoolid," AND `campusid`=",campusid);
  141. // ELSE
  142. // SET selectCondition = '';
  143. // END IF;
  144. //
  145. // → 两条条件**都是等值**,且 campusid 条件**嵌套在 schoolid 分支内部**(不在外面):
  146. // - `institutionID > 0` → `institution_id = ? AND campus_id = ?`(**恒一起写**;此分支内
  147. // `campusID=0` 是"机构级池"这个**具体取值**,不是"不筛"——绝不能省略 campus 条件,
  148. // 否则会把全校区池一起加进来,可用额虚高);
  149. // - `institutionID<=0` → `selectCondition = ''` → **一条作用域条件都不写**,
  150. // 传入的 `campusID` 一律**忽略**(dump 里 campusid 只出现在 school 分支内,
  151. // 不存在"只按校区筛"的路径)。
  152. // 越权防线**不在本方法**:按裁定移到 Handler 角色白名单(平台视角只对 super_admin/academic 开放)。
  153. //
  154. // 实现说明(金额精度):**不用 SQL SUM()**。sqlite 把 decimal 存 NUMERIC 时 SUM 返回 REAL,
  155. // 会把分位带进浮点;MySQL 返回 DECIMAL(字符串) 虽精确,但两库行为不一致。
  156. // 因此这里只让 DB 做过滤,**逐行在 Go 侧用 money.Amount(int64 分)累加**(用户池行数极小,
  157. // 性能可忽略),换取"零浮点"的确定性。
  158. func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, institutionID, campusID int64) (money.Amount, error) {
  159. if strings.TrimSpace(userID) == "" {
  160. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 需要 user_id"))
  161. }
  162. if institutionID < 0 || campusID < 0 {
  163. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  164. errString("repository: SumUsable 机构/校区参数不得为负(0 的语义见方法注释:平台视角 / 机构级池)"))
  165. }
  166. db, err := r.db(ctx)
  167. if err != nil {
  168. return 0, err
  169. }
  170. q := db.Model(&model.CoursesActivityUserDiscount{}).
  171. Where("user_id = ?", userID).
  172. Where("discount_amount > discount_amount_used")
  173. // dump 763181-763185:两条作用域条件**都是等值**,且 campus 条件**嵌套在 school 分支内**——
  174. // 故只在 institutionID>0 时把两个条件一起写(campusID=0 → 只命中 campus_id=0 的机构级池);
  175. // institutionID<=0 → 不写任何作用域条件,campusID 被忽略。
  176. if institutionID > 0 {
  177. q = q.Where("institution_id = ? AND campus_id = ?", institutionID, campusID)
  178. }
  179. var rows []model.CoursesActivityUserDiscount
  180. if err := q.Find(&rows).Error; err != nil {
  181. return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  182. }
  183. total := money.Amount(0)
  184. for i := range rows {
  185. totalAmount, perr := money.Parse(rows[i].DiscountAmount)
  186. if perr != nil {
  187. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  188. }
  189. usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
  190. if perr != nil {
  191. return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  192. }
  193. total = total.Add(totalAmount.Sub(usedAmount))
  194. }
  195. return total, nil
  196. }
  197. // ListDiscountLogsByStudentCourse 按报课记录取**赠送金额退回对象**(audit A11,dump `:763057`)。
  198. //
  199. // 入参:
  200. // - ctx 请求上下文;
  201. // - coursesStudentID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam)。
  202. //
  203. // 出参:
  204. // - []model.CoursesActivityUserDiscountLog:该报课记录产生的**负值流水**(消费行,`discount_amount_used < 0`),
  205. // 按 id **降序**(最近使用优先,dump 退回路径固定顺序);空结果返回非 nil 切片;
  206. // - ErrBadParam / ErrDBError。
  207. //
  208. // SQL(dump 763057 原文口径 + 多租户作用域 JOIN):
  209. //
  210. // SELECT dlog.* FROM t_courses_activity_user_discount_log dlog
  211. // JOIN t_courses_activity_user_discount pool ON pool.id = dlog.activity_user_discount_id
  212. // WHERE dlog.courses_student_id = ? AND dlog.discount_amount_used < 0
  213. // AND pool.institution_id = ? AND pool.campus_id IN (?, 0) -- 作用域(普通角色)
  214. // ORDER BY dlog.id DESC
  215. //
  216. // 为什么只取负值行:退回时要按"当初扣了多少"逐笔等额回冲,正值行是退回动作本身(不能拿来回冲);
  217. // 排序固定 DESC(dump 无参数化方向)——**本方法不接受方向参数**,从根上消除 ORDER BY 注入面。
  218. //
  219. // 与旧 `ListSignedByStudentCourse` 的区别(audit A11):那条路径(signed ← pool ← log 三表 join)
  220. // **在 dump 中没有任何对应 SP 依据**,已删除;退回对象一律以本方法为准(流水是唯一事实来源)。
  221. func (r *ActivityDiscountRepo) ListDiscountLogsByStudentCourse(ctx context.Context, coursesStudentID int64) ([]model.CoursesActivityUserDiscountLog, error) {
  222. if coursesStudentID <= 0 {
  223. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  224. errString("repository: ListDiscountLogsByStudentCourse 需要 courses_student_id"))
  225. }
  226. db, err := r.db(ctx)
  227. if err != nil {
  228. return nil, err
  229. }
  230. rows := make([]model.CoursesActivityUserDiscountLog, 0, 8)
  231. // 流水表**自身无作用域列**(DDL:id/discount/pool_id/courses_student_id/…,无 institution_id),
  232. // 故作用域从"流水 → 池"JOIN 取得(池有 institution_id + campus_id),语义与活动域一致(campus 超集)。
  233. q := db.Table("t_courses_activity_user_discount_log AS dlog").
  234. Select("dlog.*").
  235. Joins("JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id").
  236. Where("dlog.courses_student_id = ?", coursesStudentID).
  237. Where("dlog.discount_amount_used < 0")
  238. q = scopedCampusRows(q, r.Sc, "pool")
  239. if err := q.Order("dlog.id DESC").Find(&rows).Error; err != nil {
  240. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  241. }
  242. if rows == nil {
  243. rows = make([]model.CoursesActivityUserDiscountLog, 0)
  244. }
  245. return rows, nil
  246. }
  247. // IncUsed 累加优惠账户的「已使用赠送金额」(单账户原子增减)。
  248. //
  249. // 入参:
  250. // - ctx 请求上下文;
  251. // - tx 事务句柄(**必传**);
  252. // - id 优惠账户 ID(<=0 → ErrBadParam);
  253. // - delta 增量(正=消费,负=退回冲正;单位:分;0 → 空操作)。
  254. //
  255. // 出参:
  256. // - int64:受影响行数(0 = 账户不存在/不在作用域内);
  257. // - ErrBadParam / ErrDBError。
  258. //
  259. // 红线:
  260. // - `discount_amount_used <= discount_amount` 的业务约束由 **Service** 在扣减前用可用额校验
  261. // (不足 → 3109),本方法**不做截断**——静默截断会破坏"池余额 = 流水累计"的对账恒等式;
  262. // - 与流水写入(InsertDiscountLog)必须同事务。
  263. func (r *ActivityDiscountRepo) IncUsed(ctx context.Context, tx *gorm.DB, id int64, delta money.Amount) (int64, error) {
  264. if err := requireTx(tx, "IncUsed"); err != nil {
  265. return 0, err
  266. }
  267. if id <= 0 {
  268. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncUsed 需要 discount id"))
  269. }
  270. if delta.IsZero() {
  271. return 0, nil
  272. }
  273. if err := delta.Validate(); err != nil {
  274. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  275. }
  276. q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  277. res := q.Where("id = ?", id).
  278. UpdateColumn("discount_amount_used", gorm.Expr("discount_amount_used + ?", delta.String()))
  279. if res.Error != nil {
  280. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  281. }
  282. return res.RowsAffected, nil
  283. }
  284. // IncUsedBatch 批量累加多个优惠账户的「已使用赠送金额」(**单语句 CASE WHEN**,禁止逐行 UPDATE)。
  285. //
  286. // 入参:
  287. // - ctx 请求上下文;
  288. // - tx 事务句柄(**必传**);
  289. // - items 增量集合(同一 id 出现多次时**后者覆盖前者**;<=0 的 id 与零增量条目被忽略)。
  290. //
  291. // 出参:
  292. // - int64:受影响行数;
  293. // - ErrBadParam / ErrDBError(空集合 → 0, nil,不发 SQL)。
  294. //
  295. // 为什么必须单语句(性能/正确性双约束):
  296. // - 一次跨多个池扣减若逐行 UPDATE,N 个账户 = N 次往返 + N 个独立执行计划;
  297. // - 单语句在事务内原子生效,减少锁持有时间(并发退费场景)。
  298. //
  299. // SQL:
  300. //
  301. // UPDATE t_courses_activity_user_discount
  302. // SET discount_amount_used = discount_amount_used + CASE id WHEN ? THEN ? ... ELSE 0 END
  303. // WHERE id IN (...) [AND institution_id = ? AND campus_id IN (?,0)]
  304. //
  305. // 红线:金额以**字符串**字面量入场(decimal 精确),禁止 float。
  306. func (r *ActivityDiscountRepo) IncUsedBatch(ctx context.Context, tx *gorm.DB, items []UsedDelta) (int64, error) {
  307. if err := requireTx(tx, "IncUsedBatch"); err != nil {
  308. return 0, err
  309. }
  310. // 归一:去重(后者覆盖)+ 过滤非法 id/零增量,保持入参顺序(CASE 分支顺序可预期,便于断言)
  311. order := make([]int64, 0, len(items))
  312. deltaByID := make(map[int64]money.Amount, len(items))
  313. for _, it := range items {
  314. if it.DiscountID <= 0 || it.Delta.IsZero() {
  315. continue
  316. }
  317. if err := it.Delta.Validate(); err != nil {
  318. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
  319. }
  320. if _, ok := deltaByID[it.DiscountID]; !ok {
  321. order = append(order, it.DiscountID)
  322. }
  323. deltaByID[it.DiscountID] = it.Delta
  324. }
  325. if len(order) == 0 {
  326. return 0, nil
  327. }
  328. var sqlText strings.Builder
  329. sqlText.WriteString("discount_amount_used + CASE id")
  330. caseArgs := make([]any, 0, len(order)*2)
  331. for _, id := range order {
  332. sqlText.WriteString(" WHEN ? THEN ?")
  333. caseArgs = append(caseArgs, id, deltaByID[id].String())
  334. }
  335. sqlText.WriteString(" ELSE 0 END")
  336. q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  337. res := q.Where("id IN ?", order).UpdateColumn("discount_amount_used", gorm.Expr(sqlText.String(), caseArgs...))
  338. if res.Error != nil {
  339. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  340. }
  341. return res.RowsAffected, nil
  342. }
  343. // InsertDiscountLog 写入赠送金额使用/退回流水(**只增**)。
  344. //
  345. // 入参:
  346. // - ctx 请求上下文;
  347. // - tx 事务句柄(**必传**);
  348. // - row 流水行(ActivityUserDiscountID>0;DiscountAmountUsed 负=消费/正=退回)。
  349. //
  350. // 出参:nil 成功;ErrBadParam / ErrDBError。
  351. //
  352. // 红线:流水与池余额变动(IncUsed)必须同事务;流水禁止 UPDATE/DELETE(审计底线)。
  353. func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUserDiscountLog) error {
  354. if err := requireTx(tx, "InsertDiscountLog"); err != nil {
  355. return err
  356. }
  357. if row == nil {
  358. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 为 nil"))
  359. }
  360. if row.ActivityUserDiscountID <= 0 {
  361. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 缺少 activity_user_discount_id"))
  362. }
  363. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  364. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  365. }
  366. return nil
  367. }
  368. // ---------------------------------------------------------------- AC-07 原语
  369. // ExistsUsedLog 判断用户在某活动上是否已有「老带新/充值赠送」记录(dump `signed_cancel` 762994 → **3108**)。
  370. //
  371. // SQL(dump 原文):
  372. //
  373. // SELECT (SELECT id FROM t_courses_activity_used_log WHERE activityid=? AND userid=?) INTO logid;
  374. // -- IF logid > 0 THEN LEAVE(不可取消)
  375. //
  376. // 语义:**判存在性**(不看金额正负、不看 order_no)——只要产生过消费/结算记录,
  377. // 老带新报名就永久不可撤销(奖励已发出)。
  378. //
  379. // 入参:ctx / activityID(<=0 → ErrBadParam)/ userID(空 → ErrBadParam)。
  380. // 出参:(是否存在, error);ErrBadParam / ErrDBError。
  381. func (r *ActivityDiscountRepo) ExistsUsedLog(ctx context.Context, activityID int64, userID string) (bool, error) {
  382. if activityID <= 0 || strings.TrimSpace(userID) == "" {
  383. return false, infraerrors.WithCause(infraerrors.ErrBadParam,
  384. errString("repository: ExistsUsedLog 需要 activity_id>0 且 user_id"))
  385. }
  386. db, err := r.db(ctx)
  387. if err != nil {
  388. return false, err
  389. }
  390. var count int64
  391. if err := db.Model(&model.CoursesActivityUsedLog{}).
  392. Where("activity_id = ? AND user_id = ?", activityID, userID).
  393. Count(&count).Error; err != nil {
  394. return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
  395. }
  396. return count > 0, nil
  397. }
  398. // GetUsedLog 取用户在某活动上的老带新/赠送记录(幂等回读用)。
  399. //
  400. // 与 `ExistsUsedLog` 的区别:本方法返回**整行**(含 id / activity_money_used / recommend_user_id),
  401. // 供幂等命中分支"从库回读"(§5.1 纪律 5:replay 不得复用首执行的中间变量)。
  402. //
  403. // 入参:ctx / activityID(<=0 → ErrBadParam)/ userID(空 → ErrBadParam)。
  404. // 出参:(*model.CoursesActivityUsedLog, nil) 命中;(**nil**, nil) 无记录;ErrBadParam / ErrDBError。
  405. func (r *ActivityDiscountRepo) GetUsedLog(
  406. ctx context.Context, activityID int64, userID string,
  407. ) (*model.CoursesActivityUsedLog, error) {
  408. if activityID <= 0 || strings.TrimSpace(userID) == "" {
  409. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  410. errString("repository: GetUsedLog 需要 activity_id>0 且 user_id"))
  411. }
  412. db, err := r.db(ctx)
  413. if err != nil {
  414. return nil, err
  415. }
  416. var out model.CoursesActivityUsedLog
  417. if err := db.Model(&model.CoursesActivityUsedLog{}).
  418. Where("activity_id = ? AND user_id = ?", activityID, userID).
  419. Order("id ASC").
  420. Take(&out).Error; err != nil {
  421. if errors.Is(err, gorm.ErrRecordNotFound) {
  422. return nil, nil
  423. }
  424. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  425. }
  426. return &out, nil
  427. }
  428. // ListUsedLogsByOrder 取某报课单产生的**消费方向**(负值)活动金额流水(dump 762854 游标)。
  429. //
  430. // SQL(dump 原文):
  431. //
  432. // SELECT activityid,userid,activity_money_used FROM t_courses_activity_used_log
  433. // WHERE orderid = ? AND activity_money_used < 0 ORDER BY id DESC;
  434. //
  435. // 入参:
  436. // - ctx 请求上下文;
  437. // - orderNo 报课单号(t_courses_student.id 的字符串形式;空 → ErrBadParam);
  438. // - order 方向(OrderAsc/OrderDesc;**退回路径必须 DESC**,非法值 → 2002)。
  439. //
  440. // 出参:[]model.CoursesActivityUsedLog(空 → 非 nil 切片);ErrBadParam / ErrDBError。
  441. //
  442. // 为什么只取负值行:正值行是**退回动作本身**(不能拿来回冲,否则同一笔可退两次)。
  443. func (r *ActivityDiscountRepo) ListUsedLogsByOrder(
  444. ctx context.Context, orderNo string, order string,
  445. ) ([]model.CoursesActivityUsedLog, error) {
  446. if strings.TrimSpace(orderNo) == "" {
  447. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  448. errString("repository: ListUsedLogsByOrder 需要 order_no"))
  449. }
  450. dir, err := normalizeOrder(order)
  451. if err != nil {
  452. return nil, err
  453. }
  454. db, err := r.db(ctx)
  455. if err != nil {
  456. return nil, err
  457. }
  458. rows := make([]model.CoursesActivityUsedLog, 0, 8)
  459. q := db.Model(&model.CoursesActivityUsedLog{}).
  460. Where("order_no = ?", orderNo).
  461. Where("activity_money_used < 0").
  462. Order("id " + dir)
  463. if err := q.Find(&rows).Error; err != nil {
  464. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  465. }
  466. return rows, nil
  467. }
  468. // DiscountLogWithPool 最近一条赠送流水 + 其所属池行(dump `refund_user_activity` 游标的展开,763122)。
  469. //
  470. // 字段:
  471. // - PoolID 池行 ID(更新目标);
  472. // - LogAmountUsed 流水金额原文(负=消费);
  473. // - CoursesStudentID 该流水关联的报课记录(退回时原样带回);
  474. // - DiscountAmount 池面额原文(type=3 代金券"一次全退"的取值来源);
  475. // - DiscountAmountUsed 池已用原文。
  476. type DiscountLogWithPool struct {
  477. // PoolID 优惠账户 ID。
  478. PoolID int64 `gorm:"column:pool_id"`
  479. // LogAmountUsed 流水金额(元字符串,负值)。
  480. LogAmountUsed string `gorm:"column:log_amount_used"`
  481. // CoursesStudentID 报课记录 ID(可为 NULL)。
  482. CoursesStudentID *int64 `gorm:"column:courses_student_id"`
  483. // DiscountAmount 池赠送面额(元字符串)。
  484. DiscountAmount string `gorm:"column:discount_amount"`
  485. // DiscountAmountUsed 池已使用(元字符串)。
  486. DiscountAmountUsed string `gorm:"column:discount_amount_used"`
  487. }
  488. // GetLastDiscountLogByUserActivity 取用户在**某活动**上最近一条消费流水(dump 763122 `LIMIT 0,1`)。
  489. //
  490. // SQL(dump 原文):
  491. //
  492. // SELECT t1.activity_user_discountid, t1.discount_amount_used, t1.courses_studentid,
  493. // t2.discount_amount, t2.discount_amount_used
  494. // FROM t_courses_activity_user_discount_log t1
  495. // LEFT JOIN t_courses_activity_user_discount t2 ON t1.activity_user_discountid = t2.id
  496. // WHERE t2.userid = ? AND t2.activityid = ? AND t1.discount_amount_used < 0
  497. // ORDER BY t1.id DESC LIMIT 0,1;
  498. //
  499. // 语义:`refund_user_activity` **只处理最近一条**(dump 的 LIMIT 1)——多张券时不会一次退完。
  500. //
  501. // 入参:ctx / userID(空 → ErrBadParam)/ activityID(<=0 → ErrBadParam)。
  502. // 出参:(*DiscountLogWithPool, nil) 命中;(**nil**, nil) 无消费流水(dump 游标空 → no-op);
  503. // ErrBadParam / ErrDBError。
  504. func (r *ActivityDiscountRepo) GetLastDiscountLogByUserActivity(
  505. ctx context.Context, userID string, activityID int64,
  506. ) (*DiscountLogWithPool, error) {
  507. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  508. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  509. errString("repository: GetLastDiscountLogByUserActivity 需要 user_id 且 activity_id>0"))
  510. }
  511. db, err := r.db(ctx)
  512. if err != nil {
  513. return nil, err
  514. }
  515. var out DiscountLogWithPool
  516. q := db.Table("t_courses_activity_user_discount_log AS dlog").
  517. Select("dlog.activity_user_discount_id AS pool_id, dlog.discount_amount_used AS log_amount_used, "+
  518. "dlog.courses_student_id, pool.discount_amount, pool.discount_amount_used").
  519. Joins("JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id").
  520. Where("pool.user_id = ? AND pool.activity_id = ?", userID, activityID).
  521. Where("dlog.discount_amount_used < 0").
  522. Order("dlog.id DESC").
  523. Limit(1)
  524. if err := q.Take(&out).Error; err != nil {
  525. if errors.Is(err, gorm.ErrRecordNotFound) {
  526. return nil, nil // dump:游标无行 → 整个 SP no-op(不是错误)
  527. }
  528. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  529. }
  530. return &out, nil
  531. }
  532. // ListUsableForUpdate 同 `ListUsable`,但对取出的池行加**行锁**(赠送金额扣减前必调)。
  533. //
  534. // 为什么需要独立方法而不是给 ListUsable 加锁:`ListUsable` 也被只读汇总路径使用(不该加锁)。
  535. // 加锁语义:先 SELECT ... FOR UPDATE 固定候选行的已用额,再据可用额判定 3109,
  536. // 避免"两笔并发使用同一张券 → used 超过 face"的超用(dump 无锁,属 Go 增强)。
  537. //
  538. // 入参:ctx / tx(**必传**)/ userID / activityID(**>0**,扣减必须单活动)/ order。
  539. // 出参:[]model.CoursesActivityUserDiscount(可用行,id 方向由 order 决定);ErrBadParam / ErrDBError。
  540. func (r *ActivityDiscountRepo) ListUsableForUpdate(
  541. ctx context.Context, tx *gorm.DB, userID string, activityID int64, order string,
  542. ) ([]model.CoursesActivityUserDiscount, error) {
  543. if err := requireTx(tx, "ListUsableForUpdate"); err != nil {
  544. return nil, err
  545. }
  546. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  547. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  548. errString("repository: ListUsableForUpdate 需要 user_id 且 activity_id>0"))
  549. }
  550. dir, err := normalizeOrder(order)
  551. if err != nil {
  552. return nil, err
  553. }
  554. rows := make([]model.CoursesActivityUserDiscount, 0, 8)
  555. q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  556. q = q.Where("user_id = ? AND activity_id = ?", userID, activityID).
  557. Where("discount_amount > discount_amount_used").
  558. Order("id " + dir).
  559. Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
  560. if err := q.Find(&rows).Error; err != nil {
  561. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  562. }
  563. return rows, nil
  564. }
  565. // GetDiscountForUpdate 按主键取池行并加行锁(退回路径更新前必调)。
  566. //
  567. // 入参:ctx / tx(必传)/ id(<=0 → ErrBadParam)。
  568. // 出参:*model.CoursesActivityUserDiscount;ErrNotFound / ErrBadParam / ErrDBError。
  569. func (r *ActivityDiscountRepo) GetDiscountForUpdate(
  570. ctx context.Context, tx *gorm.DB, id int64,
  571. ) (*model.CoursesActivityUserDiscount, error) {
  572. if err := requireTx(tx, "GetDiscountForUpdate"); err != nil {
  573. return nil, err
  574. }
  575. if id <= 0 {
  576. return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetDiscountForUpdate 需要 id>0"))
  577. }
  578. var out model.CoursesActivityUserDiscount
  579. q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
  580. if err := q.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
  581. Where("id = ?", id).Take(&out).Error; err != nil {
  582. return nil, wrapQueryErr(err, "GetDiscountForUpdate(t_courses_activity_user_discount)")
  583. }
  584. return &out, nil
  585. }
  586. // InsertUsedLog 写入老带新活动金额获得/使用记录(**只增**)。
  587. //
  588. // 入参:
  589. // - ctx 请求上下文;
  590. // - tx 事务句柄(**必传**);
  591. // - row 记录行(ActivityID>0、UserID 非空;老带新结算写**负值** activity_money_used)。
  592. //
  593. // 出参:nil 成功;ErrBadParam / ErrDBError。
  594. //
  595. // 红线:符号方向固定(结算负值);撤销报名前置校验按 (activity_id, user_id) 判存在(3108),
  596. // 故本表写入必须与 signed 状态推进同事务。
  597. func (r *ActivityDiscountRepo) InsertUsedLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUsedLog) error {
  598. if err := requireTx(tx, "InsertUsedLog"); err != nil {
  599. return err
  600. }
  601. if row == nil {
  602. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 为 nil"))
  603. }
  604. if row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" {
  605. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 缺少 activity_id/user_id"))
  606. }
  607. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  608. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  609. }
  610. return nil
  611. }