// 活动管理仓储(端点 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 }