// 可用赠送金额的**分活动构成**(端点 31 `GET /admin/activities/discounts/usable`)。 // // 依据: // - 契约 academic-frontend-contract-v1.md §2.2 端点 31 / §3.4 B 行 31: // `userId`、`campusId`、`usable:金额`、`items:[{activityId:number, amount:金额, memo:string}]`; // - 设计 academic-domain-design.md §3.2 #14(SP14 `sp_courses_activity_user_discount_usable`,dump 763176) // 与 v0.1.5 裁定 **A7**(`schoolid>0` 时校区是**等值**;`schoolid<=0` 不加任何作用域条件)。 // // --------------------------------------------------------------------------- // 为什么单开一个文件、且不放进 `activity_discount_service.go` // --------------------------------------------------------------------------- // // `activity_discount_service.go` 的方法与 SP12~SP17 **一一对应**(方法头都有 dump 行号)。 // 端点 31 的 `items` 是**契约新增的视图**:SP14 只 `SELECT SUM(...)` 出总额, // 没有"按活动拆分"的分支。把"有 SP 对照"与"无 SP 对照"混写,会让后人误以为 // `GROUP BY activity_id` 也是 dump 口径(它在 dump 里**不存在**)。 // // 本文件只做一件事:**同一 WHERE 的行,换个聚合维度**。因此: // - 过滤条件**逐字复用** `SumUsable`(dump 763181-763185),一个新条件都不加; // - 金额**不用 SQL SUM**(§10.7 / E3-b:`SUM(DECIMAL)` 在 sqlite 返回 REAL,分位会漂); // - `usable` 由 items **逐项累加**得出(不是第二次查 SQL),保证"总额 = 构成之和" // 在**同一快照**里恒成立 —— 两次查询在并发下会给出互相矛盾的响应。 // // --------------------------------------------------------------------------- // ⚠ 一处**契约缺口**(已上报,未擅自实现) // --------------------------------------------------------------------------- // // 契约要求 `items[].memo:string`,但 `t_courses_activity_user_discount`(赠送池行)**没有 memo 列** // (目标态 DDL 第 11 表逐列核对:id/activity_id/institution_id/campus_id/user_id/balance_request_id/ // income/discount_amount/discount_amount_used/create_time)。按 §3.4 ③「字段准入条件」, // **找不到列的一律不下发**:本实现不输出 `memo`(而不是输出空串或"拿活动标题顶上"—— // 后者会让前端把"活动名"当"备注"展示,属静默语义错位,比缺字段更难发现)。 package service import ( "context" "strings" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" ) // UsableBreakdownItem 单活动的可用赠送构成项(契约 §3.4 B 行 31 的 `items[]`)。 // // 字段 → 来源列: // // activityId ← t_courses_activity_user_discount.activity_id // amount ← Σ(discount_amount − discount_amount_used) GROUP BY activity_id(派生) // // ⚠ 契约里的 `memo` **不实现**:池表无 memo 列(见文件头)。 type UsableBreakdownItem struct { // ActivityID 活动 ID。 ActivityID int64 `json:"activityId"` // Amount 该活动可用额(分;严格 > 0)。 Amount money.Amount `json:"amount"` } // UsableBreakdownResult 可用赠送金额(总额 + 分活动构成)。 // // 字段 → 来源: // // userId ← 入参回显(作用域对象) // campusId ← 入参回显(**不是**数据行的校区列;见下方"回显语义") // usable ← Σ items[].amount(与 SP14 的 `SUM(discount_amount - used)` 同一批行) // items ← 按 activity_id 分组 // // 回显语义:`campusId` 回显**请求参数**而不是"池行的 campus_id"——因为 `institutionID<=0` // 的平台视角下池行可能跨多个校区,回显数据行的校区会让前端误以为"这是某个校区的余额"。 type UsableBreakdownResult struct { // UserID 学生(回显)。 UserID string `json:"userId"` // CampusID 校区(回显入参)。 CampusID int64 `json:"campusId"` // Usable 可用额度合计(分)。 Usable money.Amount `json:"usable"` // Items 分活动构成(按 activityId 升序;空 → `[]`)。 Items []UsableBreakdownItem `json:"items"` } // UsableBreakdown 汇总用户可用赠送金额并给出**按活动的构成**(端点 31,SP14 的分活动视图)。 // // 口径(与 `UsableAmount` 完全一致,dump 763176 / 763181-763185): // // WHERE user_id = ? AND discount_amount > discount_amount_used // [AND institution_id = ? AND campus_id = ?] -- 仅 institutionID>0 时两列一起写(等值) // // 入参:ctx / in(`UserID` 必填;`InstitutionID`/`CampusID` 语义见 `UsableAmountInput`)。 // 出参:*UsableBreakdownResult;错误码 2001(缺 userId)/ 2002(机构/校区为负)/ 5002。 // // 越权防线(**不在本方法**,与 `SumUsable` 注释同源):`institutionID<=0` 的平台视角 // 由 **Handler 的角色白名单**收口(设计 §3.2-A7 裁定),不在 SQL 里判。 func (s *ActivityDiscountService) UsableBreakdown( ctx context.Context, in UsableAmountInput, ) (*UsableBreakdownResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UsableBreakdown 需要 user_id")) } if in.InstitutionID < 0 || in.CampusID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: UsableBreakdown 机构/校区参数不得为负(0 的语义见 repository.SumUsable)")) } if s == nil || s.disc == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("service: ActivityDiscountService 未装配")) } rows, err := s.disc.SumUsableByActivity(ctx, strings.TrimSpace(in.UserID), in.InstitutionID, in.CampusID) if err != nil { return nil, err } items := make([]UsableBreakdownItem, 0, len(rows)) total := money.Amount(0) for i := range rows { items = append(items, UsableBreakdownItem{ActivityID: rows[i].ActivityID, Amount: rows[i].Amount}) total = total.Add(rows[i].Amount) } return &UsableBreakdownResult{ UserID: strings.TrimSpace(in.UserID), CampusID: in.CampusID, Usable: total, Items: items, }, nil }