activity_usable_breakdown.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. // 可用赠送金额的**分活动构成**(端点 31 `GET /admin/activities/discounts/usable`)。
  2. //
  3. // 依据:
  4. // - 契约 academic-frontend-contract-v1.md §2.2 端点 31 / §3.4 B 行 31:
  5. // `userId`、`campusId`、`usable:金额`、`items:[{activityId:number, amount:金额, memo:string}]`;
  6. // - 设计 academic-domain-design.md §3.2 #14(SP14 `sp_courses_activity_user_discount_usable`,dump 763176)
  7. // 与 v0.1.5 裁定 **A7**(`schoolid>0` 时校区是**等值**;`schoolid<=0` 不加任何作用域条件)。
  8. //
  9. // ---------------------------------------------------------------------------
  10. // 为什么单开一个文件、且不放进 `activity_discount_service.go`
  11. // ---------------------------------------------------------------------------
  12. //
  13. // `activity_discount_service.go` 的方法与 SP12~SP17 **一一对应**(方法头都有 dump 行号)。
  14. // 端点 31 的 `items` 是**契约新增的视图**:SP14 只 `SELECT SUM(...)` 出总额,
  15. // 没有"按活动拆分"的分支。把"有 SP 对照"与"无 SP 对照"混写,会让后人误以为
  16. // `GROUP BY activity_id` 也是 dump 口径(它在 dump 里**不存在**)。
  17. //
  18. // 本文件只做一件事:**同一 WHERE 的行,换个聚合维度**。因此:
  19. // - 过滤条件**逐字复用** `SumUsable`(dump 763181-763185),一个新条件都不加;
  20. // - 金额**不用 SQL SUM**(§10.7 / E3-b:`SUM(DECIMAL)` 在 sqlite 返回 REAL,分位会漂);
  21. // - `usable` 由 items **逐项累加**得出(不是第二次查 SQL),保证"总额 = 构成之和"
  22. // 在**同一快照**里恒成立 —— 两次查询在并发下会给出互相矛盾的响应。
  23. //
  24. // ---------------------------------------------------------------------------
  25. // ⚠ 一处**契约缺口**(已上报,未擅自实现)
  26. // ---------------------------------------------------------------------------
  27. //
  28. // 契约要求 `items[].memo:string`,但 `t_courses_activity_user_discount`(赠送池行)**没有 memo 列**
  29. // (目标态 DDL 第 11 表逐列核对:id/activity_id/institution_id/campus_id/user_id/balance_request_id/
  30. // income/discount_amount/discount_amount_used/create_time)。按 §3.4 ③「字段准入条件」,
  31. // **找不到列的一律不下发**:本实现不输出 `memo`(而不是输出空串或"拿活动标题顶上"——
  32. // 后者会让前端把"活动名"当"备注"展示,属静默语义错位,比缺字段更难发现)。
  33. package service
  34. import (
  35. "context"
  36. "strings"
  37. "yuansiwei/unified-backend/internal/academic/money"
  38. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  39. )
  40. // UsableBreakdownItem 单活动的可用赠送构成项(契约 §3.4 B 行 31 的 `items[]`)。
  41. //
  42. // 字段 → 来源列:
  43. //
  44. // activityId ← t_courses_activity_user_discount.activity_id
  45. // amount ← Σ(discount_amount − discount_amount_used) GROUP BY activity_id(派生)
  46. //
  47. // ⚠ 契约里的 `memo` **不实现**:池表无 memo 列(见文件头)。
  48. type UsableBreakdownItem struct {
  49. // ActivityID 活动 ID。
  50. ActivityID int64 `json:"activityId"`
  51. // Amount 该活动可用额(分;严格 > 0)。
  52. Amount money.Amount `json:"amount"`
  53. }
  54. // UsableBreakdownResult 可用赠送金额(总额 + 分活动构成)。
  55. //
  56. // 字段 → 来源:
  57. //
  58. // userId ← 入参回显(作用域对象)
  59. // campusId ← 入参回显(**不是**数据行的校区列;见下方"回显语义")
  60. // usable ← Σ items[].amount(与 SP14 的 `SUM(discount_amount - used)` 同一批行)
  61. // items ← 按 activity_id 分组
  62. //
  63. // 回显语义:`campusId` 回显**请求参数**而不是"池行的 campus_id"——因为 `institutionID<=0`
  64. // 的平台视角下池行可能跨多个校区,回显数据行的校区会让前端误以为"这是某个校区的余额"。
  65. type UsableBreakdownResult struct {
  66. // UserID 学生(回显)。
  67. UserID string `json:"userId"`
  68. // CampusID 校区(回显入参)。
  69. CampusID int64 `json:"campusId"`
  70. // Usable 可用额度合计(分)。
  71. Usable money.Amount `json:"usable"`
  72. // Items 分活动构成(按 activityId 升序;空 → `[]`)。
  73. Items []UsableBreakdownItem `json:"items"`
  74. }
  75. // UsableBreakdown 汇总用户可用赠送金额并给出**按活动的构成**(端点 31,SP14 的分活动视图)。
  76. //
  77. // 口径(与 `UsableAmount` 完全一致,dump 763176 / 763181-763185):
  78. //
  79. // WHERE user_id = ? AND discount_amount > discount_amount_used
  80. // [AND institution_id = ? AND campus_id = ?] -- 仅 institutionID>0 时两列一起写(等值)
  81. //
  82. // 入参:ctx / in(`UserID` 必填;`InstitutionID`/`CampusID` 语义见 `UsableAmountInput`)。
  83. // 出参:*UsableBreakdownResult;错误码 2001(缺 userId)/ 2002(机构/校区为负)/ 5002。
  84. //
  85. // 越权防线(**不在本方法**,与 `SumUsable` 注释同源):`institutionID<=0` 的平台视角
  86. // 由 **Handler 的角色白名单**收口(设计 §3.2-A7 裁定),不在 SQL 里判。
  87. func (s *ActivityDiscountService) UsableBreakdown(
  88. ctx context.Context, in UsableAmountInput,
  89. ) (*UsableBreakdownResult, error) {
  90. if strings.TrimSpace(in.UserID) == "" {
  91. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  92. errString("service: UsableBreakdown 需要 user_id"))
  93. }
  94. if in.InstitutionID < 0 || in.CampusID < 0 {
  95. return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
  96. errString("service: UsableBreakdown 机构/校区参数不得为负(0 的语义见 repository.SumUsable)"))
  97. }
  98. if s == nil || s.disc == nil {
  99. return nil, infraerrors.WithCause(infraerrors.ErrInternal,
  100. errString("service: ActivityDiscountService 未装配"))
  101. }
  102. rows, err := s.disc.SumUsableByActivity(ctx, strings.TrimSpace(in.UserID), in.InstitutionID, in.CampusID)
  103. if err != nil {
  104. return nil, err
  105. }
  106. items := make([]UsableBreakdownItem, 0, len(rows))
  107. total := money.Amount(0)
  108. for i := range rows {
  109. items = append(items, UsableBreakdownItem{ActivityID: rows[i].ActivityID, Amount: rows[i].Amount})
  110. total = total.Add(rows[i].Amount)
  111. }
  112. return &UsableBreakdownResult{
  113. UserID: strings.TrimSpace(in.UserID),
  114. CampusID: in.CampusID,
  115. Usable: total,
  116. Items: items,
  117. }, nil
  118. }