| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- // 可用赠送金额的**分活动构成**(端点 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
- }
|