activity_admin_repo.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. // 活动管理仓储(端点 22~25 / 28 / 37 的**数据访问原语**)。
  2. //
  3. // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义);
  4. // unified/03-migration/academic-domain-design.md §4.2(端点清单)/ §3.2 #19-#20(报名状态机)/ §10.8(作用域)。
  5. //
  6. // ---------------------------------------------------------------------------
  7. // 为什么单开一个文件(而不写进 activity_repo.go)
  8. // ---------------------------------------------------------------------------
  9. //
  10. // `activity_repo.go` 的每个方法都**逐条对应某个 dump SP**(signed_add / signed_cancel / 资金侧原语),
  11. // 方法头的行号注释是可复核的对照依据。本文件的方法**在 dump 里没有任何 SP 对应**:
  12. //
  13. // 活动 CRUD(端点 22~25) 旧系统由 PHP 直接 INSERT/UPDATE `t_courses_activity`(无存储过程)
  14. // 报名列表(端点 28) 旧系统 PHP 直查
  15. // 回访登记(端点 37) `t_courses_activity_visit` 旧库基本空表(AUTO_INCREMENT=1,零 SP 引用)
  16. //
  17. // 把"有 dump 依据"与"无 dump 依据"混在同一文件里,正是"后人把自创实现当权威口径引用"的诱因
  18. // (设计 §3.8-c X1 就栽在这一点上)。故本文件做**物理隔离**:每个方法头显式写「dump: 无(PHP 直写)」,
  19. // 字段来源只标**目标态 DDL 行号**(`internal/academic/model/testdata/academic-model-ddl.sql`),
  20. // **不引用任何 SP 行号**——没有的东西不编。
  21. //
  22. // ---------------------------------------------------------------------------
  23. // 作用域(与 activity_repo.go 完全一致,不另立一套)
  24. // ---------------------------------------------------------------------------
  25. //
  26. // t_courses_activity / t_courses_activity_signed 都是 `institution_id + campus_id` 双列,
  27. // 且 `campus_id = 0` 表示「本机构所有**自营**校区」(DDL §2「7.」)⇒ 校区维度必须用
  28. // `campus_id IN (?, 0)` **超集**语义,统一经 `scopedCampusRows`(同包函数;机构强制过滤不放松)。
  29. //
  30. // 事务纪律(设计 §10.3):写方法一律接 `tx *gorm.DB`(**仓储不开事务**);读方法经 `db(ctx)`,
  31. // ctx 内已注入事务时自动复用同一 tx,保证事务内读一致。
  32. package repository
  33. import (
  34. "context"
  35. "sort"
  36. "strings"
  37. "gorm.io/gorm"
  38. "yuansiwei/unified-backend/internal/academic/model"
  39. "yuansiwei/unified-backend/internal/academic/money"
  40. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  41. )
  42. // ActivityListFilter 活动列表过滤条件(端点 22 的 `type?` / `status?` / `keyword?`)。
  43. //
  44. // 字段语义(全部**可选**,nil/空串 = 不筛):
  45. // - Type:活动类型(2 充值赠送 / 3 代金券 / 4 老带新);白名单校验在 Handler(非法 → 2002);
  46. // - Status:活动状态(0 停用 / 1 开启 / 2 已结束);白名单校验在 Handler(非法 → 2002);
  47. // - Keyword:关键字,匹配 `t_courses_activity.title`(`LIKE %kw%`)。
  48. //
  49. // ⚠ `keyword` 只搜 `title` 的理由(字段准入纪律,契约 §3.4 ③):契约只写「关键字」,
  50. // 未指定列;本表的可读文本列只有 `title` / `memo` / `content`,其中 `content` 是 longtext HTML
  51. // (`LIKE` 会全表扫且命中 HTML 标签),`memo` 是内部备注(不是用户可见标题)。
  52. // 取 `title` 是**唯一能解释"用户在列表里看到什么就搜什么"的列**;扩展到 memo 属产品决策,不在本次。
  53. type ActivityListFilter struct {
  54. // Type 活动类型过滤(nil = 不筛)。
  55. Type *int8
  56. // Status 活动状态过滤(nil = 不筛)。
  57. Status *int8
  58. // Keyword 标题关键字(空 = 不筛)。
  59. Keyword string
  60. }
  61. // activitySignedCount 单活动的有效报名人数(`signed` 字段的批量来源)。
  62. type activitySignedCount struct {
  63. // ActivityID 活动 ID。
  64. ActivityID int64 `gorm:"column:activity_id"`
  65. // SignedTotal 有效报名数(`status <> 0`)。
  66. SignedTotal int32 `gorm:"column:signed_total"`
  67. }
  68. // ListActivities 活动分页列表(端点 22)。
  69. //
  70. // dump:**无**(旧系统 PHP 直查 `t_courses_activity`)。
  71. // 字段来源:目标态 DDL 第 7 表 `t_courses_activity`(第 242-268 行)。
  72. //
  73. // 入参:
  74. // - ctx 请求上下文;
  75. // - f 过滤条件(全可选);
  76. // - page/pageSize 分页(**内部再钳一次**,不信调用方:见 `PageQuery.Normalize` 纪律)。
  77. //
  78. // 出参:
  79. // - []model.CoursesActivity:当前页活动(**空 → 长度 0 的非 nil 切片**);
  80. // - int64:同过滤条件总数(与列表**同一 WHERE、同一快照**,不"先数后筛");
  81. // - ErrDBError(5002)。
  82. //
  83. // 排序:`toped DESC, id DESC`(置顶优先,其次新建在前)。**必须带第二排序键 `id`**:
  84. // 只按 `toped` 排时并列行的顺序由引擎决定,分页会出现"同一条出现两次/漏一条"(§3.8-R9 同类风险)。
  85. func (r *ActivityRepo) ListActivities(
  86. ctx context.Context, f ActivityListFilter, page, pageSize int,
  87. ) ([]model.CoursesActivity, int64, error) {
  88. db, err := r.db(ctx)
  89. if err != nil {
  90. return nil, 0, err
  91. }
  92. page, pageSize = ClampPage(page, pageSize)
  93. // build 每次返回**全新**链(Count 与 Find 各跑一条,不共用被 Count 污染过的语句)。
  94. build := func() *gorm.DB {
  95. q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
  96. if f.Type != nil {
  97. q = q.Where("type = ?", *f.Type)
  98. }
  99. if f.Status != nil {
  100. q = q.Where("status = ?", *f.Status)
  101. }
  102. if kw := strings.TrimSpace(f.Keyword); kw != "" {
  103. q = q.Where("title LIKE ?", "%"+kw+"%")
  104. }
  105. return q
  106. }
  107. var total int64
  108. if err := build().Count(&total).Error; err != nil {
  109. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  110. }
  111. rows := make([]model.CoursesActivity, 0, pageSize)
  112. if err := build().Order("toped DESC, id DESC").
  113. Limit(pageSize).Offset(PageOffset(page, pageSize)).
  114. Find(&rows).Error; err != nil {
  115. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  116. }
  117. return rows, total, nil
  118. }
  119. // CountSignedByActivityIDs 批量统计多个活动的**有效**报名人数(端点 22 的 `signed` 字段)。
  120. //
  121. // dump:**无**(PHP 直查)。判据与 dump `762928` 的 3105 查重同口径:`status <> 0`(取消的报名不算)。
  122. //
  123. // 入参:ctx;ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
  124. // 出参:map[activityID]报名人数(**缺失的 id 不在 map 中**,调用方按 0 处理);ErrDBError。
  125. //
  126. // 为什么不在 `ListActivities` 里 JOIN 聚合:`signed` 表的行数远大于活动表,
  127. // `LEFT JOIN + GROUP BY` 会让"只查 10 个活动"也扫一遍报名表;分开查可**按 id 集合精确裁剪**(防 N+1 也不放大)。
  128. //
  129. // 作用域:**不叠**。入参 id 全部来自已过作用域的活动查询,报名行的归属已由活动归属确定;
  130. // 叠 `campus_id IN (?,0)` 会在冗余列脏的历史行上漏计(同 `ExistsSigned` 的口径与理由)。
  131. func (r *ActivityRepo) CountSignedByActivityIDs(ctx context.Context, ids []int64) (map[int64]int32, error) {
  132. out := make(map[int64]int32, len(ids))
  133. clean := make([]int64, 0, len(ids))
  134. seen := make(map[int64]struct{}, len(ids))
  135. for _, id := range ids {
  136. if id <= 0 {
  137. continue
  138. }
  139. if _, ok := seen[id]; ok {
  140. continue
  141. }
  142. seen[id] = struct{}{}
  143. clean = append(clean, id)
  144. }
  145. if len(clean) == 0 {
  146. return out, nil
  147. }
  148. db, err := r.db(ctx)
  149. if err != nil {
  150. return nil, err
  151. }
  152. rows := make([]activitySignedCount, 0, len(clean))
  153. if err := db.Model(&model.CoursesActivitySigned{}).
  154. Select("activity_id, COUNT(1) AS signed_total").
  155. Where("activity_id IN ?", clean).
  156. Where("status <> ?", model.ActivitySignedCancelled).
  157. Group("activity_id").
  158. Scan(&rows).Error; err != nil {
  159. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  160. }
  161. for i := range rows {
  162. out[rows[i].ActivityID] = rows[i].SignedTotal
  163. }
  164. return out, nil
  165. }
  166. // CreateActivity 新建活动(端点 23)。
  167. //
  168. // dump:**无**(PHP 直写)。列清单来源:目标态 DDL 第 7 表(第 242-268 行)。
  169. //
  170. // 入参:ctx;tx(**必传**:机构/校区校验与写入同事务,避免"校验通过后机构被改");row 待插入行。
  171. // 出参:nil 成功(`row.ID` 被回填);ErrBadParam(row 非法)/ ErrInternal(漏传 tx)/ ErrDBError。
  172. //
  173. // 红线:
  174. // - `number_sign` **由本方法强制清零**:它是"报名人数"计数列,只能由 `IncreaseNumberSign` 增减;
  175. // 新建时带上外部传入值会造成"报名数凭空多出来"(契约 §3.4 B 行 22 注亦写「活动更新时不可写」);
  176. // - `institution_id` 由 Service 从请求作用域写入(不信任 body),`campus_id` 已由 Service 做
  177. // "校区属于本机构"校验(跨机构 → 1004)后传进来。
  178. func (r *ActivityRepo) CreateActivity(ctx context.Context, tx *gorm.DB, row *model.CoursesActivity) error {
  179. if err := requireTx(tx, "CreateActivity"); err != nil {
  180. return err
  181. }
  182. if row == nil {
  183. return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateActivity 需要 row"))
  184. }
  185. if row.InstitutionID <= 0 {
  186. return infraerrors.WithCause(infraerrors.ErrBadParam,
  187. errString("repository: CreateActivity 需要 institution_id>0(作用域列禁 0)"))
  188. }
  189. row.NumberSign = 0
  190. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  191. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  192. }
  193. return nil
  194. }
  195. // UpdateActivity 更新活动可写列(端点 25)。
  196. //
  197. // dump:**无**(PHP 直写)。可写列白名单由**调用方**(Service)构造 `patch` 决定,
  198. // 本方法只负责"按 id + 作用域执行整列更新并校验影响行数"。
  199. //
  200. // 入参:
  201. // - ctx;tx(**必传**);
  202. // - id 活动 ID(<=0 → ErrBadParam);
  203. // - patch 列名 → 新值(**空 patch → ErrBadParam**:不是 no-op,是调用方 bug,静默成功会骗过前端)。
  204. //
  205. // 出参:(受影响行数, error);0 = 活动不存在或不在作用域内 ⇒ Service 返回 3001。
  206. //
  207. // 红线(与 §10.3「资金写路径必须校验影响行数」同源):
  208. // - **必须**把 `RowsAffected==0` 交给调用方判定 —— 静默 0 行会让"更新了一个不存在/越权的活动"
  209. // 表现成"成功",前端刷新后字段没变、用户只会反复点。
  210. // - `number_sign` **FREQ** 不在白名单:它在 Service 侧被显式拒绝(3140,契约 §3.4 B 行 25)。
  211. func (r *ActivityRepo) UpdateActivity(
  212. ctx context.Context, tx *gorm.DB, id int64, patch map[string]any,
  213. ) (int64, error) {
  214. if err := requireTx(tx, "UpdateActivity"); err != nil {
  215. return 0, err
  216. }
  217. if id <= 0 {
  218. return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateActivity 需要 id>0"))
  219. }
  220. if len(patch) == 0 {
  221. return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  222. errString("repository: UpdateActivity 的 patch 为空(无列可更新)"))
  223. }
  224. q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivity{}), r.Sc, "")
  225. res := q.Where("id = ?", id).UpdateColumns(patch)
  226. if res.Error != nil {
  227. return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
  228. }
  229. return res.RowsAffected, nil
  230. }
  231. // ListSignedByActivity 活动报名列表(端点 28,可按报名状态过滤)。
  232. //
  233. // dump:**无**(PHP 直查)。行结构来源:DDL 第 8 表 `t_courses_activity_signed`(第 277-300 行)。
  234. //
  235. // 入参:
  236. // - ctx;activityID 活动 ID(<=0 → ErrBadParam);
  237. // - status 报名状态过滤(nil = 不筛;**不做白名单校验**——状态枚举由 Handler 判 2002);
  238. // - page/pageSize 分页(内部再钳)。
  239. //
  240. // 出参:([]报名行, 总数, error);空 → 长度 0 的非 nil 切片。
  241. //
  242. // 排序:`id ASC`(报名先后,与"推荐人先来后到"的业务读法一致),第二排序键即主键、天然确定。
  243. //
  244. // 作用域:`scopedCampusRows` 叠在 **signed 行**上(报名行自带冗余机构/校区列)。
  245. // 注意活动归属**不由本方法校验**:Service 先 `GetByID`(带作用域)确认活动可见,
  246. // 再调本方法;否则"活动越权"会表现为"列表为空"(静默),比报 3001 更难排查。
  247. func (r *ActivityRepo) ListSignedByActivity(
  248. ctx context.Context, activityID int64, status *int32, page, pageSize int,
  249. ) ([]model.CoursesActivitySigned, int64, error) {
  250. if activityID <= 0 {
  251. return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
  252. errString("repository: ListSignedByActivity 需要 activity_id>0"))
  253. }
  254. db, err := r.db(ctx)
  255. if err != nil {
  256. return nil, 0, err
  257. }
  258. page, pageSize = ClampPage(page, pageSize)
  259. build := func() *gorm.DB {
  260. q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
  261. q = q.Where("activity_id = ?", activityID)
  262. if status != nil {
  263. q = q.Where("status = ?", *status)
  264. }
  265. return q
  266. }
  267. var total int64
  268. if err := build().Count(&total).Error; err != nil {
  269. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  270. }
  271. rows := make([]model.CoursesActivitySigned, 0, pageSize)
  272. if err := build().Order("id ASC").Limit(pageSize).Offset(PageOffset(page, pageSize)).
  273. Find(&rows).Error; err != nil {
  274. return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
  275. }
  276. return rows, total, nil
  277. }
  278. // GetSignedByID 按报名行主键取单行(端点 37 回访登记的**前置存在性校验**)。
  279. //
  280. // dump:**无**(`t_courses_activity_visit` 旧库为零引用的空表)。
  281. //
  282. // 入参:ctx;signedID 报名行 ID(<=0 → ErrBadParam)。
  283. // 出参:*model.CoursesActivitySigned;ErrNotFound(3001:不存在或不在作用域内,不泄露存在性)/ ErrDBError。
  284. //
  285. // 为什么回访必须先取这一行:请求只给 `activitySignedId`,而响应要回 `activityId`
  286. // (契约 §3.4 B 行 37),且必须证明"这条报名行属于路径里的 `:id` 活动且在本机构"——
  287. // 否则跨机构/跨活动的回访会被静默写入(visit 表没有作用域列,写进去就再也筛不出来)。
  288. func (r *ActivityRepo) GetSignedByID(ctx context.Context, signedID int64) (*model.CoursesActivitySigned, error) {
  289. if signedID <= 0 {
  290. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  291. errString("repository: GetSignedByID 需要 signed_id>0"))
  292. }
  293. db, err := r.db(ctx)
  294. if err != nil {
  295. return nil, err
  296. }
  297. var out model.CoursesActivitySigned
  298. q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
  299. if err := q.Where("id = ?", signedID).Take(&out).Error; err != nil {
  300. return nil, wrapQueryErr(err, "GetSignedByID(t_courses_activity_signed)")
  301. }
  302. return &out, nil
  303. }
  304. // ExistsAnySigned 判断"该用户在该活动上是否留有**任意有效报名行**"(**不做活动 JOIN**)。
  305. //
  306. // dump:**无**(本方法只服务 §3.8-R8 的 3121 分支,dump 该分支是"读不到 type 就静默放行",Go 显式化)。
  307. //
  308. // 用途(**唯一用途**,不要拿它替代 `ExistsSigned`):
  309. // 取消报名取 `type` 失败时,用它区分两种"取不到":
  310. //
  311. // 无报名行 → 3001(§3.8-R7:查不到报名)
  312. // 有报名行但 JOIN 不到活动行 ⇒ type 读不到 ⇒ **3121**(§3.8-R8:状态机无法判定,
  313. // 不得"读不到就放行",否则跳过 3107/3108 的审核态保护)
  314. //
  315. // 入参:ctx;userID(空 → ErrBadParam);activityID(<=0 → ErrBadParam)。
  316. // 出参:(是否存在, error);ErrBadParam / ErrDBError。
  317. //
  318. // 判据与 `ExistsSigned`(3105)**逐字相同**(`user_id/activity_id/status<>0`)——两者只是
  319. // "有没有活动行"的处理不同;**不得**在这里改判据(改了就等于把 3105 与 3121 的边界挪了)。
  320. func (r *ActivityRepo) ExistsAnySigned(ctx context.Context, userID string, activityID int64) (bool, error) {
  321. if strings.TrimSpace(userID) == "" || activityID <= 0 {
  322. return false, infraerrors.WithCause(infraerrors.ErrBadParam,
  323. errString("repository: ExistsAnySigned 需要 user_id 且 activity_id>0"))
  324. }
  325. db, err := r.db(ctx)
  326. if err != nil {
  327. return false, err
  328. }
  329. var count int64
  330. if err := db.Model(&model.CoursesActivitySigned{}).
  331. Where("user_id = ? AND activity_id = ? AND status <> ?",
  332. userID, activityID, model.ActivitySignedCancelled).
  333. Count(&count).Error; err != nil {
  334. return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
  335. }
  336. return count > 0, nil
  337. }
  338. // InsertVisit 写入一条活动回访记录(端点 37)。
  339. //
  340. // dump:**无**(`t_courses_activity_visit` 在 dump 中只有 DDL,无任何 SP/PHP 引用;
  341. // 来源见 dump 第 18413-18420 行)。列来源:目标态 DDL 第 13 表(第 404-416 行)。
  342. //
  343. // 入参:ctx;tx(**必传**:与报名行存在性校验同事务);row 待插入行(`activity_signed_id` 必 >0)。
  344. // 出参:nil 成功;ErrBadParam / ErrInternal / ErrDBError。
  345. //
  346. // 红线:`activity_signed_id` 为 NULL 的行(旧库遗留"无报名记录的回访")**不新造**——
  347. // 目标态该列可为空,但新写入一律要求有报名行(R8/契约 §3.4 都建立在"回访针对某条报名"之上)。
  348. func (r *ActivityRepo) InsertVisit(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityVisit) error {
  349. if err := requireTx(tx, "InsertVisit"); err != nil {
  350. return err
  351. }
  352. if row == nil || row.ActivitySignedID == nil || *row.ActivitySignedID <= 0 {
  353. return infraerrors.WithCause(infraerrors.ErrBadParam,
  354. errString("repository: InsertVisit 需要 activity_signed_id>0"))
  355. }
  356. if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
  357. return infraerrors.WithCause(infraerrors.ErrDBError, err)
  358. }
  359. return nil
  360. }
  361. // ActivityUsableItem 按活动拆分的可用赠送金额构成项(端点 31 的 `items[]` 单元)。
  362. //
  363. // 字段:
  364. // - ActivityID:活动 ID(`t_courses_activity_user_discount.activity_id`);
  365. // - Amount:该活动下可用额度合计(分)= Σ(`discount_amount − discount_amount_used`)。
  366. type ActivityUsableItem struct {
  367. // ActivityID 活动 ID。
  368. ActivityID int64
  369. // Amount 该活动可用额(分,严格 > 0)。
  370. Amount money.Amount
  371. }
  372. // SumUsableByActivity 按活动拆分用户可用赠送金额(端点 31 的 `items`,SP14 的**分活动视图**)。
  373. //
  374. // dump 对照(**只复刻过滤条件,不改口径**):SP14 `sp_courses_activity_user_discount_usable`
  375. // (dump 763176)只给出**总额**,没有"按活动拆分"的分支;本方法 = 同一 WHERE + `GROUP BY activity_id`,
  376. // 因此它**不引入新的筛选语义**,只是把已有行的构成显式化(契约 §3.4 B 行 31 要求 `items` 是"按活动的构成")。
  377. //
  378. // 过滤条件与 `SumUsable` **逐字一致**(同一 dump 段 763181-763185):
  379. //
  380. // WHERE user_id = ?
  381. // AND discount_amount > discount_amount_used -- 可用额 > 0 才算
  382. // [AND institution_id = ? AND campus_id = ?] -- 仅 institutionID>0 时两列一起写(等值)
  383. //
  384. // 金额精度:**不用 SQL SUM()**——逐行在 Go 侧用 `money.Amount`(int64 分)累加,理由同 `SumUsable`
  385. // (sqlite 的 `SUM(DECIMAL)` 返回 REAL,会把分位带进浮点;§10.7 禁 `SELECT SUM(decimal)`)。
  386. //
  387. // 入参:ctx;userID(空 → ErrBadParam);institutionID/campusID(负数 → ErrBadParam;0 的语义见 `SumUsable`)。
  388. // 出参:[]ActivityUsableItem(**按 activityID 升序**、**空 → 长度 0 的非 nil 切片**);ErrBadParam / ErrDBError。
  389. //
  390. // 为什么排序写死:map 遍历顺序随机,直接输出会让"同一请求两次不同顺序"(前端列表抖动、双跑噪声)。
  391. func (r *ActivityDiscountRepo) SumUsableByActivity(
  392. ctx context.Context, userID string, institutionID, campusID int64,
  393. ) ([]ActivityUsableItem, error) {
  394. if strings.TrimSpace(userID) == "" {
  395. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  396. errString("repository: SumUsableByActivity 需要 user_id"))
  397. }
  398. if institutionID < 0 || campusID < 0 {
  399. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  400. errString("repository: SumUsableByActivity 机构/校区参数不得为负(0 的语义见 SumUsable)"))
  401. }
  402. db, err := r.db(ctx)
  403. if err != nil {
  404. return nil, err
  405. }
  406. q := db.Model(&model.CoursesActivityUserDiscount{}).
  407. Where("user_id = ?", userID).
  408. Where("discount_amount > discount_amount_used")
  409. if institutionID > 0 {
  410. q = q.Where("institution_id = ? AND campus_id = ?", institutionID, campusID)
  411. }
  412. var rows []model.CoursesActivityUserDiscount
  413. if err := q.Find(&rows).Error; err != nil {
  414. return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
  415. }
  416. perActivity := make(map[int64]money.Amount, len(rows))
  417. for i := range rows {
  418. totalAmount, perr := money.Parse(rows[i].DiscountAmount)
  419. if perr != nil {
  420. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  421. }
  422. usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
  423. if perr != nil {
  424. return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
  425. }
  426. perActivity[rows[i].ActivityID] = perActivity[rows[i].ActivityID].Add(totalAmount.Sub(usedAmount))
  427. }
  428. out := make([]ActivityUsableItem, 0, len(perActivity))
  429. for activityID, amount := range perActivity {
  430. if amount <= 0 {
  431. continue // 多行相抵后仍不可用(如退回把已用额冲正)→ 不进 items(`usable` 合计也不含)
  432. }
  433. out = append(out, ActivityUsableItem{ActivityID: activityID, Amount: amount})
  434. }
  435. sort.Slice(out, func(i, j int) bool { return out[i].ActivityID < out[j].ActivityID })
  436. return out, nil
  437. }