| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- // 活动管理仓储(端点 22~25 / 28 / 37 的**数据访问原语**)。
- //
- // 依据:unified/03-migration/academic-frontend-contract-v1.md §2.2(活动 16 端点)/ §3.4 B(字段级定义);
- // unified/03-migration/academic-domain-design.md §4.2(端点清单)/ §3.2 #19-#20(报名状态机)/ §10.8(作用域)。
- //
- // ---------------------------------------------------------------------------
- // 为什么单开一个文件(而不写进 activity_repo.go)
- // ---------------------------------------------------------------------------
- //
- // `activity_repo.go` 的每个方法都**逐条对应某个 dump SP**(signed_add / signed_cancel / 资金侧原语),
- // 方法头的行号注释是可复核的对照依据。本文件的方法**在 dump 里没有任何 SP 对应**:
- //
- // 活动 CRUD(端点 22~25) 旧系统由 PHP 直接 INSERT/UPDATE `t_courses_activity`(无存储过程)
- // 报名列表(端点 28) 旧系统 PHP 直查
- // 回访登记(端点 37) `t_courses_activity_visit` 旧库基本空表(AUTO_INCREMENT=1,零 SP 引用)
- //
- // 把"有 dump 依据"与"无 dump 依据"混在同一文件里,正是"后人把自创实现当权威口径引用"的诱因
- // (设计 §3.8-c X1 就栽在这一点上)。故本文件做**物理隔离**:每个方法头显式写「dump: 无(PHP 直写)」,
- // 字段来源只标**目标态 DDL 行号**(`internal/academic/model/testdata/academic-model-ddl.sql`),
- // **不引用任何 SP 行号**——没有的东西不编。
- //
- // ---------------------------------------------------------------------------
- // 作用域(与 activity_repo.go 完全一致,不另立一套)
- // ---------------------------------------------------------------------------
- //
- // t_courses_activity / t_courses_activity_signed 都是 `institution_id + campus_id` 双列,
- // 且 `campus_id = 0` 表示「本机构所有**自营**校区」(DDL §2「7.」)⇒ 校区维度必须用
- // `campus_id IN (?, 0)` **超集**语义,统一经 `scopedCampusRows`(同包函数;机构强制过滤不放松)。
- //
- // 事务纪律(设计 §10.3):写方法一律接 `tx *gorm.DB`(**仓储不开事务**);读方法经 `db(ctx)`,
- // ctx 内已注入事务时自动复用同一 tx,保证事务内读一致。
- package repository
- import (
- "context"
- "sort"
- "strings"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- )
- // ActivityListFilter 活动列表过滤条件(端点 22 的 `type?` / `status?` / `keyword?`)。
- //
- // 字段语义(全部**可选**,nil/空串 = 不筛):
- // - Type:活动类型(2 充值赠送 / 3 代金券 / 4 老带新);白名单校验在 Handler(非法 → 2002);
- // - Status:活动状态(0 停用 / 1 开启 / 2 已结束);白名单校验在 Handler(非法 → 2002);
- // - Keyword:关键字,匹配 `t_courses_activity.title`(`LIKE %kw%`)。
- //
- // ⚠ `keyword` 只搜 `title` 的理由(字段准入纪律,契约 §3.4 ③):契约只写「关键字」,
- // 未指定列;本表的可读文本列只有 `title` / `memo` / `content`,其中 `content` 是 longtext HTML
- // (`LIKE` 会全表扫且命中 HTML 标签),`memo` 是内部备注(不是用户可见标题)。
- // 取 `title` 是**唯一能解释"用户在列表里看到什么就搜什么"的列**;扩展到 memo 属产品决策,不在本次。
- type ActivityListFilter struct {
- // Type 活动类型过滤(nil = 不筛)。
- Type *int8
- // Status 活动状态过滤(nil = 不筛)。
- Status *int8
- // Keyword 标题关键字(空 = 不筛)。
- Keyword string
- }
- // activitySignedCount 单活动的有效报名人数(`signed` 字段的批量来源)。
- type activitySignedCount struct {
- // ActivityID 活动 ID。
- ActivityID int64 `gorm:"column:activity_id"`
- // SignedTotal 有效报名数(`status <> 0`)。
- SignedTotal int32 `gorm:"column:signed_total"`
- }
- // ListActivities 活动分页列表(端点 22)。
- //
- // dump:**无**(旧系统 PHP 直查 `t_courses_activity`)。
- // 字段来源:目标态 DDL 第 7 表 `t_courses_activity`(第 242-268 行)。
- //
- // 入参:
- // - ctx 请求上下文;
- // - f 过滤条件(全可选);
- // - page/pageSize 分页(**内部再钳一次**,不信调用方:见 `PageQuery.Normalize` 纪律)。
- //
- // 出参:
- // - []model.CoursesActivity:当前页活动(**空 → 长度 0 的非 nil 切片**);
- // - int64:同过滤条件总数(与列表**同一 WHERE、同一快照**,不"先数后筛");
- // - ErrDBError(5002)。
- //
- // 排序:`toped DESC, id DESC`(置顶优先,其次新建在前)。**必须带第二排序键 `id`**:
- // 只按 `toped` 排时并列行的顺序由引擎决定,分页会出现"同一条出现两次/漏一条"(§3.8-R9 同类风险)。
- func (r *ActivityRepo) ListActivities(
- ctx context.Context, f ActivityListFilter, page, pageSize int,
- ) ([]model.CoursesActivity, int64, error) {
- db, err := r.db(ctx)
- if err != nil {
- return nil, 0, err
- }
- page, pageSize = ClampPage(page, pageSize)
- // build 每次返回**全新**链(Count 与 Find 各跑一条,不共用被 Count 污染过的语句)。
- build := func() *gorm.DB {
- q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
- if f.Type != nil {
- q = q.Where("type = ?", *f.Type)
- }
- if f.Status != nil {
- q = q.Where("status = ?", *f.Status)
- }
- if kw := strings.TrimSpace(f.Keyword); kw != "" {
- q = q.Where("title LIKE ?", "%"+kw+"%")
- }
- return q
- }
- var total int64
- if err := build().Count(&total).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- rows := make([]model.CoursesActivity, 0, pageSize)
- if err := build().Order("toped DESC, id DESC").
- Limit(pageSize).Offset(PageOffset(page, pageSize)).
- Find(&rows).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, total, nil
- }
- // CountSignedByActivityIDs 批量统计多个活动的**有效**报名人数(端点 22 的 `signed` 字段)。
- //
- // dump:**无**(PHP 直查)。判据与 dump `762928` 的 3105 查重同口径:`status <> 0`(取消的报名不算)。
- //
- // 入参:ctx;ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
- // 出参:map[activityID]报名人数(**缺失的 id 不在 map 中**,调用方按 0 处理);ErrDBError。
- //
- // 为什么不在 `ListActivities` 里 JOIN 聚合:`signed` 表的行数远大于活动表,
- // `LEFT JOIN + GROUP BY` 会让"只查 10 个活动"也扫一遍报名表;分开查可**按 id 集合精确裁剪**(防 N+1 也不放大)。
- //
- // 作用域:**不叠**。入参 id 全部来自已过作用域的活动查询,报名行的归属已由活动归属确定;
- // 叠 `campus_id IN (?,0)` 会在冗余列脏的历史行上漏计(同 `ExistsSigned` 的口径与理由)。
- func (r *ActivityRepo) CountSignedByActivityIDs(ctx context.Context, ids []int64) (map[int64]int32, error) {
- out := make(map[int64]int32, len(ids))
- clean := make([]int64, 0, len(ids))
- seen := make(map[int64]struct{}, len(ids))
- for _, id := range ids {
- if id <= 0 {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- clean = append(clean, id)
- }
- if len(clean) == 0 {
- return out, nil
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- rows := make([]activitySignedCount, 0, len(clean))
- if err := db.Model(&model.CoursesActivitySigned{}).
- Select("activity_id, COUNT(1) AS signed_total").
- Where("activity_id IN ?", clean).
- Where("status <> ?", model.ActivitySignedCancelled).
- Group("activity_id").
- Scan(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- for i := range rows {
- out[rows[i].ActivityID] = rows[i].SignedTotal
- }
- return out, nil
- }
- // CreateActivity 新建活动(端点 23)。
- //
- // dump:**无**(PHP 直写)。列清单来源:目标态 DDL 第 7 表(第 242-268 行)。
- //
- // 入参:ctx;tx(**必传**:机构/校区校验与写入同事务,避免"校验通过后机构被改");row 待插入行。
- // 出参:nil 成功(`row.ID` 被回填);ErrBadParam(row 非法)/ ErrInternal(漏传 tx)/ ErrDBError。
- //
- // 红线:
- // - `number_sign` **由本方法强制清零**:它是"报名人数"计数列,只能由 `IncreaseNumberSign` 增减;
- // 新建时带上外部传入值会造成"报名数凭空多出来"(契约 §3.4 B 行 22 注亦写「活动更新时不可写」);
- // - `institution_id` 由 Service 从请求作用域写入(不信任 body),`campus_id` 已由 Service 做
- // "校区属于本机构"校验(跨机构 → 1004)后传进来。
- func (r *ActivityRepo) CreateActivity(ctx context.Context, tx *gorm.DB, row *model.CoursesActivity) error {
- if err := requireTx(tx, "CreateActivity"); err != nil {
- return err
- }
- if row == nil {
- return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CreateActivity 需要 row"))
- }
- if row.InstitutionID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: CreateActivity 需要 institution_id>0(作用域列禁 0)"))
- }
- row.NumberSign = 0
- if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // UpdateActivity 更新活动可写列(端点 25)。
- //
- // dump:**无**(PHP 直写)。可写列白名单由**调用方**(Service)构造 `patch` 决定,
- // 本方法只负责"按 id + 作用域执行整列更新并校验影响行数"。
- //
- // 入参:
- // - ctx;tx(**必传**);
- // - id 活动 ID(<=0 → ErrBadParam);
- // - patch 列名 → 新值(**空 patch → ErrBadParam**:不是 no-op,是调用方 bug,静默成功会骗过前端)。
- //
- // 出参:(受影响行数, error);0 = 活动不存在或不在作用域内 ⇒ Service 返回 3001。
- //
- // 红线(与 §10.3「资金写路径必须校验影响行数」同源):
- // - **必须**把 `RowsAffected==0` 交给调用方判定 —— 静默 0 行会让"更新了一个不存在/越权的活动"
- // 表现成"成功",前端刷新后字段没变、用户只会反复点。
- // - `number_sign` **FREQ** 不在白名单:它在 Service 侧被显式拒绝(3140,契约 §3.4 B 行 25)。
- func (r *ActivityRepo) UpdateActivity(
- ctx context.Context, tx *gorm.DB, id int64, patch map[string]any,
- ) (int64, error) {
- if err := requireTx(tx, "UpdateActivity"); err != nil {
- return 0, err
- }
- if id <= 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateActivity 需要 id>0"))
- }
- if len(patch) == 0 {
- return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: UpdateActivity 的 patch 为空(无列可更新)"))
- }
- q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivity{}), r.Sc, "")
- res := q.Where("id = ?", id).UpdateColumns(patch)
- if res.Error != nil {
- return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
- }
- return res.RowsAffected, nil
- }
- // ListSignedByActivity 活动报名列表(端点 28,可按报名状态过滤)。
- //
- // dump:**无**(PHP 直查)。行结构来源:DDL 第 8 表 `t_courses_activity_signed`(第 277-300 行)。
- //
- // 入参:
- // - ctx;activityID 活动 ID(<=0 → ErrBadParam);
- // - status 报名状态过滤(nil = 不筛;**不做白名单校验**——状态枚举由 Handler 判 2002);
- // - page/pageSize 分页(内部再钳)。
- //
- // 出参:([]报名行, 总数, error);空 → 长度 0 的非 nil 切片。
- //
- // 排序:`id ASC`(报名先后,与"推荐人先来后到"的业务读法一致),第二排序键即主键、天然确定。
- //
- // 作用域:`scopedCampusRows` 叠在 **signed 行**上(报名行自带冗余机构/校区列)。
- // 注意活动归属**不由本方法校验**:Service 先 `GetByID`(带作用域)确认活动可见,
- // 再调本方法;否则"活动越权"会表现为"列表为空"(静默),比报 3001 更难排查。
- func (r *ActivityRepo) ListSignedByActivity(
- ctx context.Context, activityID int64, status *int32, page, pageSize int,
- ) ([]model.CoursesActivitySigned, int64, error) {
- if activityID <= 0 {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: ListSignedByActivity 需要 activity_id>0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, 0, err
- }
- page, pageSize = ClampPage(page, pageSize)
- build := func() *gorm.DB {
- q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
- q = q.Where("activity_id = ?", activityID)
- if status != nil {
- q = q.Where("status = ?", *status)
- }
- return q
- }
- var total int64
- if err := build().Count(&total).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- rows := make([]model.CoursesActivitySigned, 0, pageSize)
- if err := build().Order("id ASC").Limit(pageSize).Offset(PageOffset(page, pageSize)).
- Find(&rows).Error; err != nil {
- return nil, 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return rows, total, nil
- }
- // GetSignedByID 按报名行主键取单行(端点 37 回访登记的**前置存在性校验**)。
- //
- // dump:**无**(`t_courses_activity_visit` 旧库为零引用的空表)。
- //
- // 入参:ctx;signedID 报名行 ID(<=0 → ErrBadParam)。
- // 出参:*model.CoursesActivitySigned;ErrNotFound(3001:不存在或不在作用域内,不泄露存在性)/ ErrDBError。
- //
- // 为什么回访必须先取这一行:请求只给 `activitySignedId`,而响应要回 `activityId`
- // (契约 §3.4 B 行 37),且必须证明"这条报名行属于路径里的 `:id` 活动且在本机构"——
- // 否则跨机构/跨活动的回访会被静默写入(visit 表没有作用域列,写进去就再也筛不出来)。
- func (r *ActivityRepo) GetSignedByID(ctx context.Context, signedID int64) (*model.CoursesActivitySigned, error) {
- if signedID <= 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: GetSignedByID 需要 signed_id>0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- var out model.CoursesActivitySigned
- q := scopedCampusRows(db.Model(&model.CoursesActivitySigned{}), r.Sc, "")
- if err := q.Where("id = ?", signedID).Take(&out).Error; err != nil {
- return nil, wrapQueryErr(err, "GetSignedByID(t_courses_activity_signed)")
- }
- return &out, nil
- }
- // ExistsAnySigned 判断"该用户在该活动上是否留有**任意有效报名行**"(**不做活动 JOIN**)。
- //
- // dump:**无**(本方法只服务 §3.8-R8 的 3121 分支,dump 该分支是"读不到 type 就静默放行",Go 显式化)。
- //
- // 用途(**唯一用途**,不要拿它替代 `ExistsSigned`):
- // 取消报名取 `type` 失败时,用它区分两种"取不到":
- //
- // 无报名行 → 3001(§3.8-R7:查不到报名)
- // 有报名行但 JOIN 不到活动行 ⇒ type 读不到 ⇒ **3121**(§3.8-R8:状态机无法判定,
- // 不得"读不到就放行",否则跳过 3107/3108 的审核态保护)
- //
- // 入参:ctx;userID(空 → ErrBadParam);activityID(<=0 → ErrBadParam)。
- // 出参:(是否存在, error);ErrBadParam / ErrDBError。
- //
- // 判据与 `ExistsSigned`(3105)**逐字相同**(`user_id/activity_id/status<>0`)——两者只是
- // "有没有活动行"的处理不同;**不得**在这里改判据(改了就等于把 3105 与 3121 的边界挪了)。
- func (r *ActivityRepo) ExistsAnySigned(ctx context.Context, userID string, activityID int64) (bool, error) {
- if strings.TrimSpace(userID) == "" || activityID <= 0 {
- return false, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: ExistsAnySigned 需要 user_id 且 activity_id>0"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return false, err
- }
- var count int64
- if err := db.Model(&model.CoursesActivitySigned{}).
- Where("user_id = ? AND activity_id = ? AND status <> ?",
- userID, activityID, model.ActivitySignedCancelled).
- Count(&count).Error; err != nil {
- return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return count > 0, nil
- }
- // InsertVisit 写入一条活动回访记录(端点 37)。
- //
- // dump:**无**(`t_courses_activity_visit` 在 dump 中只有 DDL,无任何 SP/PHP 引用;
- // 来源见 dump 第 18413-18420 行)。列来源:目标态 DDL 第 13 表(第 404-416 行)。
- //
- // 入参:ctx;tx(**必传**:与报名行存在性校验同事务);row 待插入行(`activity_signed_id` 必 >0)。
- // 出参:nil 成功;ErrBadParam / ErrInternal / ErrDBError。
- //
- // 红线:`activity_signed_id` 为 NULL 的行(旧库遗留"无报名记录的回访")**不新造**——
- // 目标态该列可为空,但新写入一律要求有报名行(R8/契约 §3.4 都建立在"回访针对某条报名"之上)。
- func (r *ActivityRepo) InsertVisit(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityVisit) error {
- if err := requireTx(tx, "InsertVisit"); err != nil {
- return err
- }
- if row == nil || row.ActivitySignedID == nil || *row.ActivitySignedID <= 0 {
- return infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: InsertVisit 需要 activity_signed_id>0"))
- }
- if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
- return infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- return nil
- }
- // ActivityUsableItem 按活动拆分的可用赠送金额构成项(端点 31 的 `items[]` 单元)。
- //
- // 字段:
- // - ActivityID:活动 ID(`t_courses_activity_user_discount.activity_id`);
- // - Amount:该活动下可用额度合计(分)= Σ(`discount_amount − discount_amount_used`)。
- type ActivityUsableItem struct {
- // ActivityID 活动 ID。
- ActivityID int64
- // Amount 该活动可用额(分,严格 > 0)。
- Amount money.Amount
- }
- // SumUsableByActivity 按活动拆分用户可用赠送金额(端点 31 的 `items`,SP14 的**分活动视图**)。
- //
- // dump 对照(**只复刻过滤条件,不改口径**):SP14 `sp_courses_activity_user_discount_usable`
- // (dump 763176)只给出**总额**,没有"按活动拆分"的分支;本方法 = 同一 WHERE + `GROUP BY activity_id`,
- // 因此它**不引入新的筛选语义**,只是把已有行的构成显式化(契约 §3.4 B 行 31 要求 `items` 是"按活动的构成")。
- //
- // 过滤条件与 `SumUsable` **逐字一致**(同一 dump 段 763181-763185):
- //
- // WHERE user_id = ?
- // AND discount_amount > discount_amount_used -- 可用额 > 0 才算
- // [AND institution_id = ? AND campus_id = ?] -- 仅 institutionID>0 时两列一起写(等值)
- //
- // 金额精度:**不用 SQL SUM()**——逐行在 Go 侧用 `money.Amount`(int64 分)累加,理由同 `SumUsable`
- // (sqlite 的 `SUM(DECIMAL)` 返回 REAL,会把分位带进浮点;§10.7 禁 `SELECT SUM(decimal)`)。
- //
- // 入参:ctx;userID(空 → ErrBadParam);institutionID/campusID(负数 → ErrBadParam;0 的语义见 `SumUsable`)。
- // 出参:[]ActivityUsableItem(**按 activityID 升序**、**空 → 长度 0 的非 nil 切片**);ErrBadParam / ErrDBError。
- //
- // 为什么排序写死:map 遍历顺序随机,直接输出会让"同一请求两次不同顺序"(前端列表抖动、双跑噪声)。
- func (r *ActivityDiscountRepo) SumUsableByActivity(
- ctx context.Context, userID string, institutionID, campusID int64,
- ) ([]ActivityUsableItem, error) {
- if strings.TrimSpace(userID) == "" {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: SumUsableByActivity 需要 user_id"))
- }
- if institutionID < 0 || campusID < 0 {
- return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
- errString("repository: SumUsableByActivity 机构/校区参数不得为负(0 的语义见 SumUsable)"))
- }
- db, err := r.db(ctx)
- if err != nil {
- return nil, err
- }
- q := db.Model(&model.CoursesActivityUserDiscount{}).
- Where("user_id = ?", userID).
- Where("discount_amount > discount_amount_used")
- if institutionID > 0 {
- q = q.Where("institution_id = ? AND campus_id = ?", institutionID, campusID)
- }
- var rows []model.CoursesActivityUserDiscount
- if err := q.Find(&rows).Error; err != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
- }
- perActivity := make(map[int64]money.Amount, len(rows))
- for i := range rows {
- totalAmount, perr := money.Parse(rows[i].DiscountAmount)
- if perr != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
- }
- usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
- if perr != nil {
- return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr)
- }
- perActivity[rows[i].ActivityID] = perActivity[rows[i].ActivityID].Add(totalAmount.Sub(usedAmount))
- }
- out := make([]ActivityUsableItem, 0, len(perActivity))
- for activityID, amount := range perActivity {
- if amount <= 0 {
- continue // 多行相抵后仍不可用(如退回把已用额冲正)→ 不进 items(`usable` 合计也不含)
- }
- out = append(out, ActivityUsableItem{ActivityID: activityID, Amount: amount})
- }
- sort.Slice(out, func(i, j int) bool { return out[i].ActivityID < out[j].ActivityID })
- return out, nil
- }
|