// ActivityDiscountService 单测(AC-07):充值赠送使用/退回 + 代金券使用/退回 + 可用额。 // // 断言纪律(对照 §5.1 第 4 条「横切优先」——**不是**数 case 数量): // 1. **退回分配顺序(降序)是阻断级**:两次用券 100/50、退 120 ⇒ **先 50 再 70**。 // 若把 `id DESC` 写成 `ASC`,总额同样是 120,只有"逐条明细"能证明分配错误 ⇒ // 本文件断言 `Items` 的顺序与逐条金额,而不只断言合计。 // 2. **盖帽**:退 40 只能退 40(单行冲回额 = min(|行金额|, 剩余应退))。 // 3. **账务恒等式(自证失败式)**:`signed.activity_money_used == -Σ(used_log)`、 // `pool.discount_amount_used == -Σ(discount_log)`——任何"少写一条流水/金额算错"都会立刻破式。 // 4. **代金券一次全额退**:type=3 的退回额 = 券面额,再按最近一条消费额盖帽(A13 修正)。 package service import ( "context" "testing" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" ) // ---------------------------------------------------------------- 辅助 // seedSignedFull 落一行报名行(可指定状态与两列金额)。 func seedSignedFull(t *testing.T, db *gorm.DB, userID string, activityID int64, status int32, activityMoney, activityMoneyUsed string) { t.Helper() row := &model.CoursesActivitySigned{ ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10, Status: status, ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 报名行失败: %v", err) } } // seedPool 落一行赠送池(返回池 ID)。 func seedPool(t *testing.T, db *gorm.DB, userID string, activityID int64, discountAmount, discountAmountUsed string) int64 { t.Helper() row := &model.CoursesActivityUserDiscount{ ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID, DiscountAmount: discountAmount, DiscountAmountUsed: discountAmountUsed, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 赠送池失败: %v", err) } return row.ID } // readSignedUsed 读报名行已用额(分)。 func readSignedUsed(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 { t.Helper() row := readSignedRow(t, db, userID, activityID) if row == nil { t.Fatalf("报名行不存在(user=%s,activity=%d)", userID, activityID) } return cents(t, row.ActivityMoneyUsed) } // sumUsedLogAmount 汇总某活动某用户的 used_log 金额(分;消费为负、退回为正)。 func sumUsedLogAmount(t *testing.T, db *gorm.DB, activityID int64, userID string) int64 { t.Helper() var rows []model.CoursesActivityUsedLog if err := db.Where("activity_id = ? AND user_id = ?", activityID, userID).Find(&rows).Error; err != nil { t.Fatalf("读 used_log 失败: %v", err) } total := int64(0) for i := range rows { total += cents(t, rows[i].ActivityMoneyUsed) } return total } // readPoolUsed 读池已用额(分)。 func readPoolUsed(t *testing.T, db *gorm.DB, poolID int64) int64 { t.Helper() var row model.CoursesActivityUserDiscount if err := db.Where("id = ?", poolID).Take(&row).Error; err != nil { t.Fatalf("读赠送池(%d) 失败: %v", poolID, err) } return cents(t, row.DiscountAmountUsed) } // sumDiscountLogAmount 汇总某池的 discount_log 金额(分;消费为负、退回为正)。 func sumDiscountLogAmount(t *testing.T, db *gorm.DB, poolID int64) int64 { t.Helper() var rows []model.CoursesActivityUserDiscountLog if err := db.Where("activity_user_discount_id = ?", poolID).Find(&rows).Error; err != nil { t.Fatalf("读 discount_log 失败: %v", err) } total := int64(0) for i := range rows { total += cents(t, rows[i].DiscountAmountUsed) } return total } // ---------------------------------------------------------------- 充值赠送:使用(SP12) // TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity // 使用额不得超过活动剩余额(A9 修正),且 `signed.used == -Σ used_log` 恒成立。 func TestUseRechargeGiftCapsToRemainingAndKeepsLedgerIdentity(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(20) seedActivityFull(t, db, 400, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedSignedFull(t, db, user, 400, model.ActivitySignedAttended, "100.00", "0.00") // 第一次:用 30(小于剩余 100) first, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{ UserID: user, ActivityID: 400, StudentCourseID: 5001, Amount: mustAmount(t, "30.00"), }) if err != nil { t.Fatalf("使用充值赠送失败: %v", err) } if first.UsedAmount.Cents() != 3000 || first.Remaining.Cents() != 7000 { t.Fatalf("首用应 3000 分、剩余 7000 分,实得 %d/%d", first.UsedAmount.Cents(), first.Remaining.Cents()) } // 第二次:请求 90,但只剩 70 → 只能扣 70(A9:逐行递减到剩余额为止) second, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{ UserID: user, ActivityID: 400, StudentCourseID: 5002, Amount: mustAmount(t, "90.00"), }) if err != nil { t.Fatalf("第二次使用失败: %v", err) } if second.UsedAmount.Cents() != 7000 || second.Remaining.Cents() != 0 { t.Fatalf("第二次应被截到 7000 分、剩余 0,实得 %d/%d", second.UsedAmount.Cents(), second.Remaining.Cents()) } // 第三次:额度已用完 → no-op(不写流水) third, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{ UserID: user, ActivityID: 400, StudentCourseID: 5003, Amount: mustAmount(t, "10.00"), }) if err != nil { t.Fatalf("第三次使用失败: %v", err) } if third.UsedAmount.Cents() != 0 { t.Fatalf("额度用完后应为 no-op,实得 %d 分", third.UsedAmount.Cents()) } // 横切恒等式:报名行已用额 == -(Σ used_log) used := readSignedUsed(t, db, user, 400) if used != 10000 { t.Fatalf("报名行已用额应 10000 分,实得 %d", used) } if sum := sumUsedLogAmount(t, db, 400, user); sum != -used { t.Fatalf("恒等式破式:signed.used=%d 应等于 -Σ(used_log)=%d", used, -sum) } } // TestUseRechargeGiftRequiresAttendedStatus 报名行状态非 2(未参加)→ no-op(dump 游标条件)。 func TestUseRechargeGiftRequiresAttendedStatus(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(21) seedActivityFull(t, db, 401, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedSignedFull(t, db, user, 401, model.ActivitySignedSigned, "100.00", "0.00") res, err := svc.UseRechargeGift(ctx, UseRechargeGiftInput{ UserID: user, ActivityID: 401, StudentCourseID: 5004, Amount: mustAmount(t, "10.00"), }) if err != nil { t.Fatalf("使用充值赠送失败: %v", err) } if res.UsedAmount.Cents() != 0 { t.Fatalf("报名行 status!=2 时必须 no-op,实得 %d 分", res.UsedAmount.Cents()) } if used := readSignedUsed(t, db, user, 401); used != 0 { t.Fatalf("报名行已用额应保持 0,实得 %d", used) } if sum := sumUsedLogAmount(t, db, 401, user); sum != 0 { t.Fatalf("不得写 used_log,实得合计 %d", sum) } } // ---------------------------------------------------------------- 充值赠送:退回(SP13) // TestRefundRechargeGiftAllocatesDescending **降序分配**:先冲回最近一笔(50),再 70。 func TestRefundRechargeGiftAllocatesDescending(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(22) seedActivityFull(t, db, 402, model.ActivityTypeRecharge, "200.00", 1, 10, 0, model.ActivityStatusEnabled) seedSignedFull(t, db, user, 402, model.ActivitySignedAttended, "200.00", "150.00") // id 小的是**较早**的一笔(-100),id 大的是最近一笔(-50) seedUsedLogRow(t, db, 402, user, "6001", "-100.00") seedUsedLogRow(t, db, 402, user, "6001", "-50.00") res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{ StudentCourseID: 6001, Amount: mustAmount(t, "120.00"), }) if err != nil { t.Fatalf("退回充值赠送失败: %v", err) } if res.RefundedTotal.Cents() != 12000 { t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents()) } if len(res.Items) != 2 { t.Fatalf("应命中 2 笔,实得 %d 笔(%+v)", len(res.Items), res.Items) } // ★ 顺序断言:先 50(最近一笔),再 70(差额补到较早那笔) if res.Items[0].Amount.Cents() != 5000 { t.Fatalf("第一笔应为 5000 分(后入先退),实得 %d", res.Items[0].Amount.Cents()) } if res.Items[1].Amount.Cents() != 7000 { t.Fatalf("第二笔应为 7000 分,实得 %d", res.Items[1].Amount.Cents()) } // 横切恒等式:use(+150) 与 refund(-120) 之后 signed.used 应为 30 if used := readSignedUsed(t, db, user, 402); used != 3000 { t.Fatalf("退回后报名行已用额应 3000 分,实得 %d", used) } if sum := sumUsedLogAmount(t, db, 402, user); sum != -3000 { t.Fatalf("恒等式破式:signed.used=3000 应等于 -Σ(used_log)=%d", -sum) } } // TestRefundRechargeGiftCapsAtRequested 退 40 只退 40(盖上"请求额")。 func TestRefundRechargeGiftCapsAtRequested(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(23) seedActivityFull(t, db, 403, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedSignedFull(t, db, user, 403, model.ActivitySignedAttended, "100.00", "100.00") seedUsedLogRow(t, db, 403, user, "6002", "-100.00") res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{ StudentCourseID: 6002, Amount: mustAmount(t, "40.00"), }) if err != nil { t.Fatalf("退回充值赠送失败: %v", err) } if res.RefundedTotal.Cents() != 4000 { t.Fatalf("只应退 4000 分,实得 %d", res.RefundedTotal.Cents()) } if len(res.Items) != 1 || res.Items[0].Amount.Cents() != 4000 { t.Fatalf("应 1 笔 4000 分,实得 %+v", res.Items) } if used := readSignedUsed(t, db, user, 403); used != 6000 { t.Fatalf("退回 40 后已用额应 6000 分,实得 %d", used) } } // TestRefundRechargeGiftSkipsNonRechargeActivities 非 type=2 活动的流水不参与冲回(dump `IF type=2`)。 func TestRefundRechargeGiftSkipsNonRechargeActivities(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(24) seedActivityFull(t, db, 404, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedSignedFull(t, db, user, 404, model.ActivitySignedAttended, "100.00", "100.00") seedUsedLogRow(t, db, 404, user, "6003", "-100.00") res, err := svc.RefundRechargeGift(ctx, RefundRechargeGiftInput{ StudentCourseID: 6003, Amount: mustAmount(t, "100.00"), }) if err != nil { t.Fatalf("退回充值赠送失败: %v", err) } if res.RefundedTotal.Cents() != 0 || len(res.Items) != 0 { t.Fatalf("非充值赠送活动不得冲回,实得 %+v", res.Items) } if used := readSignedUsed(t, db, user, 404); used != 10000 { t.Fatalf("已用额应保持 10000 分,实得 %d", used) } } // ---------------------------------------------------------------- 代金券:使用(SP15) // TestUseVoucherType3UsesFaceValue 代金券按 dump 口径取**券面额**(不是请求额、也不是剩余额)。 func TestUseVoucherType3UsesFaceValue(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(25) seedActivityFull(t, db, 405, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 405, "100.00", "0.00") res, err := svc.UseVoucher(ctx, UseVoucherInput{ UserID: user, ActivityID: 405, StudentCourseID: 7001, Amount: mustAmount(t, "30.00"), }) if err != nil { t.Fatalf("使用代金券失败: %v", err) } if res.Applied.Cents() != 10000 { t.Fatalf("代金券应按面额 10000 分占用,实得 %d", res.Applied.Cents()) } if got := readPoolUsed(t, db, poolID); got != 10000 { t.Fatalf("池已用额应 10000 分,实得 %d", got) } // 横切恒等式:pool.used == -Σ(discount_log) if sum := sumDiscountLogAmount(t, db, poolID); sum != -10000 { t.Fatalf("恒等式破式:pool.used=10000 应等于 -Σ(discount_log)=%d", -sum) } } // TestUseVoucherRejectsInsufficientWith3109 可用额不足 → 3109(dump 静默 LEAVE,Go 显式)。 func TestUseVoucherRejectsInsufficientWith3109(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(26) seedActivityFull(t, db, 406, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 406, "100.00", "90.00") // 只剩 10 _, err := svc.UseVoucher(ctx, UseVoucherInput{ UserID: user, ActivityID: 406, StudentCourseID: 7002, Amount: mustAmount(t, "20.00"), }) wantAppErr(t, err, apperr.CodeGiftAmountShort, "可用赠送金额不足") if got := readPoolUsed(t, db, poolID); got != 9000 { t.Fatalf("被 3109 拒绝后池已用额不得变,实得 %d", got) } // 被拒后**零副作用**:池已用额与流水条数都不变(种子池本身没有流水行 → 合计应为 0) if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 { t.Fatalf("被 3109 拒绝后不得写 discount_log,实得合计 %d", sum) } } // ---------------------------------------------------------------- 代金券/赠送:按报课记录退回(SP16) // TestRefundByStuCourseDescendingWithCap 降序(50 → 70)+ 盖帽(退 40 只退 40)。 func TestRefundByStuCourseDescendingWithCap(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(27) seedActivityFull(t, db, 407, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 407, "150.00", "150.00") // 两次用券:先 100、后 50(同一报课记录)→ 池 used = 150 seedDiscountLogRow(t, db, poolID, 8001, "-100.00") seedDiscountLogRow(t, db, poolID, 8001, "-50.00") res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{ StudentCourseID: 8001, Amount: mustAmount(t, "120.00"), }) if err != nil { t.Fatalf("按报课记录退回失败: %v", err) } if res.RefundedTotal.Cents() != 12000 { t.Fatalf("应退 12000 分,实得 %d", res.RefundedTotal.Cents()) } if len(res.Items) != 2 || res.Items[0].Amount.Cents() != 5000 || res.Items[1].Amount.Cents() != 7000 { t.Fatalf("降序分配应为 5000→7000,实得 %+v", res.Items) } if got := readPoolUsed(t, db, poolID); got != 3000 { t.Fatalf("退回后池已用额应 3000 分,实得 %d", got) } if sum := sumDiscountLogAmount(t, db, poolID); sum != -3000 { t.Fatalf("恒等式破式:pool.used=3000 应等于 -Σ(discount_log)=%d", -sum) } } // TestRefundByStuCourseCapsAtRequested 退 40 只退 40。 func TestRefundByStuCourseCapsAtRequested(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(28) seedActivityFull(t, db, 408, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 408, "100.00", "100.00") seedDiscountLogRow(t, db, poolID, 8002, "-100.00") res, err := svc.RefundByStuCourse(ctx, RefundByStuCourseInput{ StudentCourseID: 8002, Amount: mustAmount(t, "40.00"), }) if err != nil { t.Fatalf("按报课记录退回失败: %v", err) } if res.RefundedTotal.Cents() != 4000 || len(res.Items) != 1 { t.Fatalf("应退 4000 分 1 笔,实得 %d 分 %d 笔", res.RefundedTotal.Cents(), len(res.Items)) } if got := readPoolUsed(t, db, poolID); got != 6000 { t.Fatalf("池已用额应 6000 分,实得 %d", got) } } // ---------------------------------------------------------------- 代金券:按活动退回(SP17) // TestRefundByActivityVoucherFullRefund 代金券一次全额退(amount 置为券面额)。 func TestRefundByActivityVoucherFullRefund(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(29) seedActivityFull(t, db, 409, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 409, "100.00", "100.00") seedDiscountLogRow(t, db, poolID, 9001, "-100.00") // 请求 30,但代金券一次全额退 → 实际退 100(再按最近一条消费额 100 盖帽) res, err := svc.RefundByActivity(ctx, RefundByActivityInput{ UserID: user, ActivityID: 409, Amount: mustAmount(t, "30.00"), }) if err != nil { t.Fatalf("按活动退回失败: %v", err) } if res.Refunded.Cents() != 10000 { t.Fatalf("代金券应一次全额退 10000 分,实得 %d", res.Refunded.Cents()) } if got := readPoolUsed(t, db, poolID); got != 0 { t.Fatalf("全额退后池已用额应 0,实得 %d", got) } if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 { t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum) } } // TestRefundByActivityCappedByLastLog A13 修正:非代金券按"最近一条消费额"盖帽,不得超退。 func TestRefundByActivityCappedByLastLog(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(30) seedActivityFull(t, db, 410, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 410, "100.00", "50.00") seedDiscountLogRow(t, db, poolID, 9002, "-50.00") // 请求 80 > 最近一条消费 50 → 只能退 50(dump 的 `SELECT x = y` 从未赋值 ⇒ 会退 80,A13 修正) res, err := svc.RefundByActivity(ctx, RefundByActivityInput{ UserID: user, ActivityID: 410, Amount: mustAmount(t, "80.00"), }) if err != nil { t.Fatalf("按活动退回失败: %v", err) } if res.Refunded.Cents() != 5000 { t.Fatalf("应盖帽到 5000 分,实得 %d", res.Refunded.Cents()) } if got := readPoolUsed(t, db, poolID); got != 0 { t.Fatalf("池已用额应 0,实得 %d", got) } if sum := sumDiscountLogAmount(t, db, poolID); sum != 0 { t.Fatalf("恒等式破式:pool.used=0 应等于 -Σ(discount_log)=%d", -sum) } } // TestRefundByActivityNoLogIsNoop 无消费流水 → no-op(dump LIMIT 1 空)。 func TestRefundByActivityNoLogIsNoop(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(31) seedActivityFull(t, db, 411, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) poolID := seedPool(t, db, user, 411, "100.00", "0.00") res, err := svc.RefundByActivity(ctx, RefundByActivityInput{ UserID: user, ActivityID: 411, Amount: mustAmount(t, "100.00"), }) if err != nil { t.Fatalf("按活动退回失败: %v", err) } if res.Refunded.Cents() != 0 { t.Fatalf("无消费流水应 no-op,实得 %d", res.Refunded.Cents()) } if got := readPoolUsed(t, db, poolID); got != 0 { t.Fatalf("池已用额应保持 0,实得 %d", got) } } // ---------------------------------------------------------------- 可用额汇总(SP14) // TestUsableAmountAggregatesAndRespectsScope 可用额 = Σ(face−used)(仅 face>used 的行), // 且 `institutionID>0` 时校区条件是**等值**(campus_id=0 的机构级池只在 campusID=0 时命中)。 func TestUsableAmountAggregatesAndRespectsScope(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityDiscountService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(32) seedActivityFull(t, db, 412, model.ActivityTypeVoucher, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedPool(t, db, user, 412, "100.00", "30.00") // 本校区:可用 70 // 机构级池(campus_id=0):可用 10 if err := db.Create(&model.CoursesActivityUserDiscount{ ActivityID: 412, InstitutionID: 1, CampusID: 0, UserID: user, DiscountAmount: "10.00", DiscountAmountUsed: "0.00", }).Error; err != nil { t.Fatalf("seed 机构级池失败: %v", err) } // 已用尽的行不参与(face == used) seedPool(t, db, user, 412, "50.00", "50.00") // campusID=10:只命中本校区池 → 70 res, err := svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 10}) if err != nil { t.Fatalf("汇总可用额失败: %v", err) } if res.Usable.Cents() != 7000 { t.Fatalf("本校区可用额应 7000 分(等值过滤,不含机构级池),实得 %d", res.Usable.Cents()) } // campusID=0:只命中机构级池 → 10 res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user, InstitutionID: 1, CampusID: 0}) if err != nil { t.Fatalf("汇总可用额失败: %v", err) } if res.Usable.Cents() != 1000 { t.Fatalf("机构级池可用额应 1000 分,实得 %d", res.Usable.Cents()) } // institutionID=0:平台视角,不加任何作用域条件 → 70+10=80 res, err = svc.UsableAmount(ctx, UsableAmountInput{UserID: user}) if err != nil { t.Fatalf("汇总可用额失败: %v", err) } if res.Usable.Cents() != 8000 { t.Fatalf("平台视角可用额应 8000 分,实得 %d", res.Usable.Cents()) } } // seedDiscountLogRow 落一行 discount_log(负=消费/正=退回)。 func seedDiscountLogRow(t *testing.T, db *gorm.DB, poolID int64, stuCourseID int64, amount string) { t.Helper() row := &model.CoursesActivityUserDiscountLog{ ActivityUserDiscountID: poolID, CoursesStudentID: &stuCourseID, DiscountAmountUsed: amount, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed discount_log 失败: %v", err) } } // 编译期断言:避免 money 包被"只用类型"时误删导入。 var _ = money.FromCents