// 活动赠送金额池仓储(t_courses_activity_user_discount / _user_discount_log / _used_log)——AC-06。 // // 依据:unified/03-migration/academic-domain-design.md §3.2 #14~#19(赠送金额池 4 SP)/ §1.5(金额) // / §1.7(scoping)。 // // 覆盖 SP 映射: // // user_discount_usable → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid) // user_discount_use → ListUsable(ASC) + IncUsed/IncUsedBatch + InsertDiscountLog(负值) // user_discount_refund → ListUsable(DESC) + IncUsed(负 delta) + InsertDiscountLog(正值) // user_discount_refund_user_activity → ListUsable(DESC)(同活动池优先退回) // use_activity_recommend → IncUsed + InsertUsedLog(老带新结算写负值) // // 金额口径(红线):库里 discount_amount / discount_amount_used 是 decimal(12,2) **字符串列**, // Go 侧一律转 money.Amount(int64 分)再运算;SQL 侧只用字符串字面量做加减(decimal 精确), // **绝不**把金额转 float64(sqlite 的 SUM() 返回 REAL,会让分位失真——见 SumUsable 实现说明)。 package repository import ( "context" "strings" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // UsedDelta 批量累加「已使用赠送金额」的单元素(id + 增量)。 // // 字段: // - DiscountID int64:优惠账户 ID(t_courses_activity_user_discount.id;<=0 的条目被跳过)。 // - Delta money.Amount:discount_amount_used 的增量(正=消费/占用,负=退回冲正;单位:分)。 type UsedDelta struct { // DiscountID 优惠账户 ID(DB 自增主键)。 DiscountID int64 // Delta 已使用金额增量(正=消费,负=退回;单位:分)。 Delta money.Amount } // ActivityDiscountRepo 活动赠送金额池仓储。 // // 表: // - t_courses_activity_user_discount(池:可用 = discount_amount − discount_amount_used); // - t_courses_activity_user_discount_log(使用/退回流水,只增,负=消费/正=退回); // - t_courses_activity_used_log(老带新获得/使用记录,结算写负值)。 // // 作用域:三表均带 institution_id + campus_id(campus_id=0 = 机构所有自营校区)→ scopedCampusRows。 type ActivityDiscountRepo struct { *BaseRepo } // NewActivityDiscountRepo 构造赠送金额池仓储。 // // 入参:db *gorm.DB / sc *appscope.Scope(语义同 NewActivityRepo)。 // 出参:*ActivityDiscountRepo(CampusScoped=false,校区维度走 IN (campus,0) 超集语义)。 func NewActivityDiscountRepo(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountRepo { r := &ActivityDiscountRepo{BaseRepo: NewBaseRepo(db, sc)} r.CampusScoped = false return r } // db 取查询句柄(ctx 内含 tx 时复用同一事务)。 func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) { if r == nil || r.DB == nil { return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityDiscountRepo.DB 为 nil")) } if tx := TxFromContext(ctx); tx != nil { return tx, nil } if ctx == nil { return r.DB, nil } return r.DB.WithContext(ctx), nil } // ListUsable 列出用户可用赠送金额池(**活动维度**,dump `yuansiwei_finance.sql:763216`)。 // // 入参: // - ctx 请求上下文; // - userID 学生(canonical_user_id ULID;空 → ErrBadParam); // - activityID 活动 ID(**>0 时只取该活动的赠送池**,对齐 dump `WHERE userid=? AND activityid=?`; // **0 = 不限活动**,仅"汇总所有活动可用额"的场景使用——扣减路径必须传具体 activityID, // 否则会把 A 活动的消费扣到 B 活动的池上); // - order 游标方向(**顺序不可改**,源自 dump): // OrderAsc = user_discount_use 使用顺序(先入先出); // OrderDesc = user_discount_refund 退回顺序(后入先退)。 // // 出参: // - []model.CoursesActivityUserDiscount:可用行(`discount_amount > discount_amount_used`), // 金额为字符串原文,调用方用 money.Parse 转 Amount; // - ErrBadParam / ErrDBError。 // // 可用口径(dump user_discount_usable 一致):仅累加 discount_amount > discount_amount_used 的行 // (负赠送/已用尽的行不参与),故本方法只返回正可用行。 func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, activityID int64, order string) ([]model.CoursesActivityUserDiscount, error) { if strings.TrimSpace(userID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable 需要 user_id")) } if activityID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable activity_id 不得为负")) } dir, err := normalizeOrder(order) if err != nil { return nil, err } db, err := r.db(ctx) if err != nil { return nil, err } q := scopedCampusRows(db.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "") q = q.Where("user_id = ?", userID). Where("discount_amount > discount_amount_used") if activityID > 0 { q = q.Where("activity_id = ?", activityID) } // dump 的 usable 无显式 ORDER BY;InnoDB 等值/主键扫描等价于主键升序,故 asc 口径与 dump 一致 q = q.Order("id " + dir) var rows []model.CoursesActivityUserDiscount if err := q.Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } return rows, nil } // SumUsable 汇总用户可用赠送金额(**对齐 dump `usable` 的 3 参口径**:机构 + 校区 + 用户)。 // // 入参: // - ctx 请求上下文; // - userID 学生(ULID;空 → ErrBadParam); // - institutionID 机构 ID(原 schoolid;**<=0 = 平台视角:不加任何机构过滤**); // - campusID 校区 ID(原 campusid;**>0 = 该校区等值**;0 = 不限定校区)。 // // 出参: // - money.Amount:可用额度合计(单位:分;无可用行为 0); // - ErrBadParam / ErrDBError。 // // 作用域(audit A7,dump `yuansiwei_finance.sql:763182-763183`): // // dump 的 usable(schoolid, campusid, userid) 三个条件都是**等值**:`schoolid=? AND campusid=? AND userid=?`, // 且 schoolid/campusid 传 0 时不参与过滤(平台/机构级汇总视角)。 // - `institutionID > 0` → `institution_id = ?`(等值,**不再用 `IN (?,0)` 超集**—— // 超集会把"机构级池(campus=0)"混进"指定校区可用额",与 dump 差一个池); // - `campusID > 0` → `campus_id = ?`(等值); // - `institutionID<=0` → **不加**机构/校区过滤(平台视角,字段语义见下); // 越权防线**不在本方法**:按裁定移到 Handler 角色白名单(平台视角只对 super_admin/academic 开放)。 // // 实现说明(金额精度):**不用 SQL SUM()**。sqlite 把 decimal 存 NUMERIC 时 SUM 返回 REAL, // 会把分位带进浮点;MySQL 返回 DECIMAL(字符串) 虽精确,但两库行为不一致。 // 因此这里只让 DB 做过滤,**逐行在 Go 侧用 money.Amount(int64 分)累加**(用户池行数极小, // 性能可忽略),换取"零浮点"的确定性。 func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, institutionID, campusID int64) (money.Amount, error) { if strings.TrimSpace(userID) == "" { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 需要 user_id")) } if institutionID < 0 || campusID < 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 机构/校区参数不得为负(0=不筛)")) } db, err := r.db(ctx) if err != nil { return 0, err } q := db.Model(&model.CoursesActivityUserDiscount{}). Where("user_id = ?", userID). Where("discount_amount > discount_amount_used") // dump 763182-763183:机构与校区都是**等值**条件;传 0 视为不筛(平台/机构级汇总视角) if institutionID > 0 { q = q.Where("institution_id = ?", institutionID) } if campusID > 0 { q = q.Where("campus_id = ?", campusID) } var rows []model.CoursesActivityUserDiscount if err := q.Find(&rows).Error; err != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } total := money.Amount(0) for i := range rows { totalAmount, perr := money.Parse(rows[i].DiscountAmount) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed) if perr != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr) } total = total.Add(totalAmount.Sub(usedAmount)) } return total, nil } // ListDiscountLogsByStudentCourse 按报课记录取**赠送金额退回对象**(audit A11,dump `:763057`)。 // // 入参: // - ctx 请求上下文; // - coursesStudentID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam)。 // // 出参: // - []model.CoursesActivityUserDiscountLog:该报课记录产生的**负值流水**(消费行,`discount_amount_used < 0`), // 按 id **降序**(最近使用优先,dump 退回路径固定顺序);空结果返回非 nil 切片; // - ErrBadParam / ErrDBError。 // // SQL(dump 763057 原文口径 + 多租户作用域 JOIN): // // SELECT dlog.* FROM t_courses_activity_user_discount_log dlog // JOIN t_courses_activity_user_discount pool ON pool.id = dlog.activity_user_discount_id // WHERE dlog.courses_student_id = ? AND dlog.discount_amount_used < 0 // AND pool.institution_id = ? AND pool.campus_id IN (?, 0) -- 作用域(普通角色) // ORDER BY dlog.id DESC // // 为什么只取负值行:退回时要按"当初扣了多少"逐笔等额回冲,正值行是退回动作本身(不能拿来回冲); // 排序固定 DESC(dump 无参数化方向)——**本方法不接受方向参数**,从根上消除 ORDER BY 注入面。 // // 与旧 `ListSignedByStudentCourse` 的区别(audit A11):那条路径(signed ← pool ← log 三表 join) // **在 dump 中没有任何对应 SP 依据**,已删除;退回对象一律以本方法为准(流水是唯一事实来源)。 func (r *ActivityDiscountRepo) ListDiscountLogsByStudentCourse(ctx context.Context, coursesStudentID int64) ([]model.CoursesActivityUserDiscountLog, error) { if coursesStudentID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListDiscountLogsByStudentCourse 需要 courses_student_id")) } db, err := r.db(ctx) if err != nil { return nil, err } rows := make([]model.CoursesActivityUserDiscountLog, 0, 8) // 流水表**自身无作用域列**(DDL:id/discount/pool_id/courses_student_id/…,无 institution_id), // 故作用域从"流水 → 池"JOIN 取得(池有 institution_id + campus_id),语义与活动域一致(campus 超集)。 q := db.Table("t_courses_activity_user_discount_log AS dlog"). Select("dlog.*"). Joins("JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id"). Where("dlog.courses_student_id = ?", coursesStudentID). Where("dlog.discount_amount_used < 0") q = scopedCampusRows(q, r.Sc, "pool") if err := q.Order("dlog.id DESC").Find(&rows).Error; err != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, err) } if rows == nil { rows = make([]model.CoursesActivityUserDiscountLog, 0) } return rows, nil } // IncUsed 累加优惠账户的「已使用赠送金额」(单账户原子增减)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - id 优惠账户 ID(<=0 → ErrBadParam); // - delta 增量(正=消费,负=退回冲正;单位:分;0 → 空操作)。 // // 出参: // - int64:受影响行数(0 = 账户不存在/不在作用域内); // - ErrBadParam / ErrDBError。 // // 红线: // - `discount_amount_used <= discount_amount` 的业务约束由 **Service** 在扣减前用可用额校验 // (不足 → 3109),本方法**不做截断**——静默截断会破坏"池余额 = 流水累计"的对账恒等式; // - 与流水写入(InsertDiscountLog)必须同事务。 func (r *ActivityDiscountRepo) IncUsed(ctx context.Context, tx *gorm.DB, id int64, delta money.Amount) (int64, error) { if err := requireTx(tx, "IncUsed"); err != nil { return 0, err } if id <= 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncUsed 需要 discount id")) } if delta.IsZero() { return 0, nil } if err := delta.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "") res := q.Where("id = ?", id). UpdateColumn("discount_amount_used", gorm.Expr("discount_amount_used + ?", delta.String())) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // IncUsedBatch 批量累加多个优惠账户的「已使用赠送金额」(**单语句 CASE WHEN**,禁止逐行 UPDATE)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - items 增量集合(同一 id 出现多次时**后者覆盖前者**;<=0 的 id 与零增量条目被忽略)。 // // 出参: // - int64:受影响行数; // - ErrBadParam / ErrDBError(空集合 → 0, nil,不发 SQL)。 // // 为什么必须单语句(性能/正确性双约束): // - 一次跨多个池扣减若逐行 UPDATE,N 个账户 = N 次往返 + N 个独立执行计划; // - 单语句在事务内原子生效,减少锁持有时间(并发退费场景)。 // // SQL: // // UPDATE t_courses_activity_user_discount // SET discount_amount_used = discount_amount_used + CASE id WHEN ? THEN ? ... ELSE 0 END // WHERE id IN (...) [AND institution_id = ? AND campus_id IN (?,0)] // // 红线:金额以**字符串**字面量入场(decimal 精确),禁止 float。 func (r *ActivityDiscountRepo) IncUsedBatch(ctx context.Context, tx *gorm.DB, items []UsedDelta) (int64, error) { if err := requireTx(tx, "IncUsedBatch"); err != nil { return 0, err } // 归一:去重(后者覆盖)+ 过滤非法 id/零增量,保持入参顺序(CASE 分支顺序可预期,便于断言) order := make([]int64, 0, len(items)) deltaByID := make(map[int64]money.Amount, len(items)) for _, it := range items { if it.DiscountID <= 0 || it.Delta.IsZero() { continue } if err := it.Delta.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } if _, ok := deltaByID[it.DiscountID]; !ok { order = append(order, it.DiscountID) } deltaByID[it.DiscountID] = it.Delta } if len(order) == 0 { return 0, nil } var sqlText strings.Builder sqlText.WriteString("discount_amount_used + CASE id") caseArgs := make([]any, 0, len(order)*2) for _, id := range order { sqlText.WriteString(" WHEN ? THEN ?") caseArgs = append(caseArgs, id, deltaByID[id].String()) } sqlText.WriteString(" ELSE 0 END") q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "") res := q.Where("id IN ?", order).UpdateColumn("discount_amount_used", gorm.Expr(sqlText.String(), caseArgs...)) if res.Error != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error) } return res.RowsAffected, nil } // InsertDiscountLog 写入赠送金额使用/退回流水(**只增**)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - row 流水行(ActivityUserDiscountID>0;DiscountAmountUsed 负=消费/正=退回)。 // // 出参:nil 成功;ErrBadParam / ErrDBError。 // // 红线:流水与池余额变动(IncUsed)必须同事务;流水禁止 UPDATE/DELETE(审计底线)。 func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUserDiscountLog) error { if err := requireTx(tx, "InsertDiscountLog"); err != nil { return err } if row == nil { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 为 nil")) } if row.ActivityUserDiscountID <= 0 { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 缺少 activity_user_discount_id")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil } // InsertUsedLog 写入老带新活动金额获得/使用记录(**只增**)。 // // 入参: // - ctx 请求上下文; // - tx 事务句柄(**必传**); // - row 记录行(ActivityID>0、UserID 非空;老带新结算写**负值** activity_money_used)。 // // 出参:nil 成功;ErrBadParam / ErrDBError。 // // 红线:符号方向固定(结算负值);撤销报名前置校验按 (activity_id, user_id) 判存在(3108), // 故本表写入必须与 signed 状态推进同事务。 func (r *ActivityDiscountRepo) InsertUsedLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUsedLog) error { if err := requireTx(tx, "InsertUsedLog"); err != nil { return err } if row == nil { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 为 nil")) } if row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" { return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 缺少 activity_id/user_id")) } if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil { return infraerrors.WithCause(infraerrors.ErrDBError, err) } return nil }