| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- // MaintenanceService 单测(AC-11):3 个修复型 SP(幂等可重跑)+ 2 个迁移型 SP(BUG#3 修正)。
- //
- // 测试栈:glebarez/sqlite 文件库(真实事务)。每条"反直觉口径"都配一条反向断言。
- package service
- import (
- "context"
- "errors"
- "testing"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/apperr"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- )
- // int64Ptr 返回 int64 指针(可空列用)。
- func int64Ptr(v int64) *int64 { return &v }
- // TestMaintenanceRequiresSuperAdmin 维护端点(61~63)仅超管可执行(1004)。
- func TestMaintenanceRequiresSuperAdmin(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, campusScope())
- ctx := context.Background()
- _, err := svc.BackfillCoursesMaxPeople(ctx)
- requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
- _, err = svc.BackfillCoursesCampus(ctx)
- requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
- _, err = svc.RebuildActivityUsedLog(ctx)
- requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
- _, err = svc.ReplayActivitySigned(ctx)
- requireAppCode(t, err, infraerrors.ErrUnscoped.Code)
- // SP27 是业务动作(AC-08 链路),不做超管断言:普通校区角色可执行
- if _, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
- StudentID: "01HSTUDENT0000000000009", InstitutionID: 1, CampusID: 10, Money: money.FromCents(100),
- }); err != nil {
- t.Fatalf("SP27 不应要求超管,实际: %v", err)
- }
- }
- // TestBackfillCoursesMaxPeopleIdempotent SP28:max_people=0 行按分类回填;重跑不再命中(幂等)。
- func TestBackfillCoursesMaxPeopleIdempotent(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, superScope())
- ctx := context.Background()
- classify := &model.CoursesClassify{InstitutionID: 1, CampusID: 10, Name: "大班", ClassType: 1000, MaxPeople: 30, MinPeople: 1, UnitPrice: "100.00"}
- if err := db.Create(classify).Error; err != nil {
- t.Fatalf("seed 分类失败: %v", err)
- }
- zeroClassify := &model.CoursesClassify{InstitutionID: 1, CampusID: 10, Name: "未配置", ClassType: 1000, MinPeople: 1, UnitPrice: "100.00"}
- if err := db.Create(zeroClassify).Error; err != nil {
- t.Fatalf("seed 分类(0) 失败: %v", err)
- }
- // max_people 的 DDL 默认值是 1000,GORM 会忽略零值 → 显式 UPDATE 成 0(模拟"分类未配置人数")
- if err := db.Model(&model.CoursesClassify{}).Where("id = ?", zeroClassify.ID).
- UpdateColumn("max_people", 0).Error; err != nil {
- t.Fatalf("把分类 max_people 置 0 失败: %v", err)
- }
- c1 := &model.Courses{Name: "待回填1", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 0}
- c2 := &model.Courses{Name: "待回填2", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 0}
- c3 := &model.Courses{Name: "分类也0", InstitutionID: 1, CampusID: 10, ClassifyID: zeroClassify.ID, MaxPeople: 0}
- done := &model.Courses{Name: "已配置", InstitutionID: 1, CampusID: 10, ClassifyID: classify.ID, MaxPeople: 15}
- for _, row := range []*model.Courses{c1, c2, c3, done} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 课程(%s) 失败: %v", row.Name, err)
- }
- }
- first, err := svc.BackfillCoursesMaxPeople(ctx)
- if err != nil {
- t.Fatalf("SP28 首次执行失败: %v", err)
- }
- if first.Scanned != 3 || first.Filled != 2 || first.Skipped != 1 {
- t.Fatalf("首次 scanned/filled/skipped=(%d,%d,%d),期望 (3,2,1)", first.Scanned, first.Filled, first.Skipped)
- }
- if got := loadCourseMaxPeople(t, db, c1.ID); got != 30 {
- t.Fatalf("c1.max_people=%d,期望 30(从分类继承)", got)
- }
- if got := loadCourseMaxPeople(t, db, done.ID); got != 15 {
- t.Fatalf("已配置课程的 max_people 被改动:%d,期望 15", got)
- }
- if got := loadCourseMaxPeople(t, db, c3.ID); got != 0 {
- t.Fatalf("分类 max_people=0 时不应写非 0:%d", got)
- }
- // 反向断言:重跑不新增改动(幂等可重跑)
- second, err := svc.BackfillCoursesMaxPeople(ctx)
- if err != nil {
- t.Fatalf("SP28 重跑失败: %v", err)
- }
- if second.Scanned != 1 || second.Filled != 0 {
- t.Fatalf("重跑 scanned/filled=(%d,%d),期望 (1,0)(仅剩分类也为 0 的那条)", second.Scanned, second.Filled)
- }
- }
- // loadCourseMaxPeople 读课程 max_people。
- func loadCourseMaxPeople(t *testing.T, db *gorm.DB, id int64) int32 {
- t.Helper()
- var row model.Courses
- if err := db.Where("id = ?", id).First(&row).Error; err != nil {
- t.Fatalf("读取课程(%d) 失败: %v", id, err)
- }
- return row.MaxPeople
- }
- // TestBackfillCoursesCampusSingleCampusOnly SP29:只有"唯一校区"的课程才回填;
- // 多校区 / 无排班规则 → **保持 0**(反向断言)。
- func TestBackfillCoursesCampusSingleCampusOnly(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, superScope())
- ctx := context.Background()
- single := &model.Courses{Name: "单校区", InstitutionID: 1, CampusID: 0}
- multi := &model.Courses{Name: "多校区", InstitutionID: 1, CampusID: 0}
- none := &model.Courses{Name: "无规则", InstitutionID: 1, CampusID: 0}
- for _, row := range []*model.Courses{single, multi, none} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 课程(%s) 失败: %v", row.Name, err)
- }
- }
- // single:只有校区 20 的规则(两条同一校区也算 1 个校区)
- seedClassRule(t, db, single.ID, 20, "-1", hm(9, 0), hm(10, 0), "100.00")
- seedClassRule(t, db, single.ID, 20, "-1", hm(14, 0), hm(15, 0), "100.00")
- // multi:校区 20 + 30
- seedClassRule(t, db, multi.ID, 20, "-1", hm(9, 0), hm(10, 0), "100.00")
- seedClassRule(t, db, multi.ID, 30, "-1", hm(9, 0), hm(10, 0), "100.00")
- // none:不建规则
- out, err := svc.BackfillCoursesCampus(ctx)
- if err != nil {
- t.Fatalf("SP29 执行失败: %v", err)
- }
- if out.Scanned != 3 || out.Updated != 1 || out.SkippedMultiCampus != 1 || out.SkippedNoRule != 1 {
- t.Fatalf("scanned/updated/multi/norule=(%d,%d,%d,%d),期望 (3,1,1,1)",
- out.Scanned, out.Updated, out.SkippedMultiCampus, out.SkippedNoRule)
- }
- if got := loadCourseCampus(t, db, single.ID); got != 20 {
- t.Fatalf("单校区课程 campus_id=%d,期望 20", got)
- }
- // 反向断言:多校区课程不回填(保持 0,否则会把机构级课程锁进某一个校区)
- if got := loadCourseCampus(t, db, multi.ID); got != 0 {
- t.Fatalf("多校区课程 campus_id=%d,期望 0(不回填)", got)
- }
- if got := loadCourseCampus(t, db, none.ID); got != 0 {
- t.Fatalf("无规则课程 campus_id=%d,期望 0", got)
- }
- // 重跑:只剩 multi/none 仍为 0 被扫描,Updated=0(幂等)
- second, err := svc.BackfillCoursesCampus(ctx)
- if err != nil {
- t.Fatalf("SP29 重跑失败: %v", err)
- }
- if second.Scanned != 2 || second.Updated != 0 {
- t.Fatalf("重跑 scanned/updated=(%d,%d),期望 (2,0)", second.Scanned, second.Updated)
- }
- }
- // loadCourseCampus 读课程 campus_id。
- func loadCourseCampus(t *testing.T, db *gorm.DB, id int64) int64 {
- t.Helper()
- var row model.Courses
- if err := db.Where("id = ?", id).First(&row).Error; err != nil {
- t.Fatalf("读取课程(%d) 失败: %v", id, err)
- }
- return row.CampusID
- }
- // TestCounteractArrearsOrderCapAndDedup SP27:按课程开课时分升序核销、逐行截到剩余额度、
- // 金额用完即停、余额留在账户;同 (courses,class) 多规则不重复核销(反向断言)。
- func TestCounteractArrearsOrderCapAndDedup(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, campusScope())
- ctx := context.Background()
- student := "01HSTUDENT0000000000101"
- confirm := "01HOPERATOR0000000000001"
- // 三门课:开课时分 08:00 / 10:00 / 12:00(决定核销顺序)
- early := &model.Courses{Name: "早课", InstitutionID: 1, CampusID: 10}
- mid := &model.Courses{Name: "中课", InstitutionID: 1, CampusID: 10}
- late := &model.Courses{Name: "晚课", InstitutionID: 1, CampusID: 10}
- for _, row := range []*model.Courses{early, mid, late} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 课程失败: %v", err)
- }
- }
- seedClassRule(t, db, early.ID, 10, "-1", hm(8, 0), hm(9, 0), "100.00")
- seedClassRule(t, db, mid.ID, 10, "-1", hm(10, 0), hm(11, 0), "100.00")
- seedClassRule(t, db, late.ID, 10, "-1", hm(12, 0), hm(13, 0), "100.00")
- // mid 课**两条**排班规则(同 courses+class)→ dump 的 1:N JOIN 会重复取同一报课行(去重断言)
- seedClassRule(t, db, mid.ID, 10, "-1", hm(18, 0), hm(19, 0), "100.00")
- rows := []*model.CoursesStudent{
- {BizNo: "CS-A", CoursesID: early.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
- Status: model.CoursesStudentStatusNormal, RemainderReceivable: "100.00", NetReceipts: "0.00",
- LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
- {BizNo: "CS-B", CoursesID: mid.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
- Status: model.CoursesStudentStatusNormal, RemainderReceivable: "50.00", NetReceipts: "0.00",
- LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
- {BizNo: "CS-C", CoursesID: late.ID, ClassID: 1, InstitutionID: 1, CampusID: 10, StudentID: student,
- Status: model.CoursesStudentStatusNormal, RemainderReceivable: "30.00", NetReceipts: "0.00",
- LessonCost: "100.00", Number: 1, ConfirmDiscountUserID: &confirm},
- }
- for _, row := range rows {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报课记录(%s) 失败: %v", row.BizNo, err)
- }
- }
- out, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
- StudentID: student, InstitutionID: 1, CampusID: 10, Money: money.FromCents(12000), // 120.00
- })
- if err != nil {
- t.Fatalf("SP27 执行失败: %v", err)
- }
- if out.Applied.Cents() != 12000 || out.Remaining.Cents() != 0 {
- t.Fatalf("applied/remaining=(%d,%d),期望 (12000,0)", out.Applied.Cents(), out.Remaining.Cents())
- }
- if len(out.Items) != 2 {
- t.Fatalf("核销明细=%d 条,期望 2(100.00 + 20.00)", len(out.Items))
- }
- // 顺序:最早课先核销
- if out.Items[0].StudentCourseID != rows[0].ID || out.Items[0].Amount.Cents() != 10000 {
- t.Fatalf("首条核销=%+v,期望 CS-A 全额 100.00", out.Items[0])
- }
- if out.Items[1].StudentCourseID != rows[1].ID || out.Items[1].Amount.Cents() != 2000 {
- t.Fatalf("次条核销=%+v,期望 CS-B 截到 20.00", out.Items[1])
- }
- if got := cents(t, loadStudentCourse(t, db, rows[0].ID).NetReceipts); got != 10000 {
- t.Fatalf("CS-A net_receipts=%d 分,期望 10000", got)
- }
- if got := cents(t, loadStudentCourse(t, db, rows[0].ID).RemainderReceivable); got != 0 {
- t.Fatalf("CS-A remainder_receivable=%d 分,期望 0", got)
- }
- if got := cents(t, loadStudentCourse(t, db, rows[1].ID).RemainderReceivable); got != 3000 {
- t.Fatalf("CS-B remainder_receivable=%d 分,期望 3000(50-20)", got)
- }
- // 反向断言:第三条(晚课)没有被核销;CS-B 只因 1 条记录被核销(去重生效,未重复累加)
- if got := cents(t, loadStudentCourse(t, db, rows[2].ID).NetReceipts); got != 0 {
- t.Fatalf("CS-C 不应被核销,net_receipts=%d", got)
- }
- if got := cents(t, loadStudentCourse(t, db, rows[1].ID).NetReceipts); got != 2000 {
- t.Fatalf("CS-B net_receipts=%d 分,期望 2000(重复规则未导致重复核销)", got)
- }
- // money<=0 → no-op(dump: inputMoney<=0 直接 LEAVE)
- noop, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
- StudentID: student, InstitutionID: 1, CampusID: 10, Money: 0,
- })
- if err != nil {
- t.Fatalf("money=0 应 no-op,实际: %v", err)
- }
- if noop.Applied.Cents() != 0 || len(noop.Items) != 0 {
- t.Fatalf("money=0 时不应核销,实际 %+v", noop)
- }
- // 参数校验:campus<=0 → 3140
- _, err = svc.CounteractArrears(ctx, CounteractArrearsInput{StudentID: student, InstitutionID: 1, CampusID: 0, Money: 100})
- requireAppCode(t, err, apperr.CodeBusinessParam)
- }
- // TestRebuildActivityUsedLogMapping SP30:DELETE + INSERT 重建;orderid = courses_studentid 映射不可改。
- func TestRebuildActivityUsedLogMapping(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, superScope())
- ctx := context.Background()
- pool := &model.CoursesActivityUserDiscount{
- ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000001",
- Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
- }
- if err := db.Create(pool).Error; err != nil {
- t.Fatalf("seed 优惠池失败: %v", err)
- }
- businessTime := time.Date(2026, 3, 1, 10, 30, 0, 0, time.Local)
- logs := []*model.CoursesActivityUserDiscountLog{
- {ActivityUserDiscountID: pool.ID, CoursesStudentID: int64Ptr(77), CoursesStudentNo: stringPtr("CS-UUID-1"),
- DiscountAmountUsed: "-20.00", CreateTime: businessTime, Memo: stringPtr("使用充值赠送金额")},
- // 孤儿流水(池行不存在)→ 新模型 activity_id/user_id NOT NULL,无法表达 → 跳过
- {ActivityUserDiscountID: 999, CoursesStudentNo: stringPtr("CS-UUID-2"), DiscountAmountUsed: "-5.00", CreateTime: businessTime},
- }
- for _, row := range logs {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 优惠流水失败: %v", err)
- }
- }
- // 旧 used_log 脏行(应被清空)
- if err := db.Create(&model.CoursesActivityUsedLog{ActivityID: 9, UserID: "01HOLD000000000000000001", ActivityMoneyUsed: "1.00"}).Error; err != nil {
- t.Fatalf("seed 旧 used_log 失败: %v", err)
- }
- out, err := svc.RebuildActivityUsedLog(ctx)
- if err != nil {
- t.Fatalf("SP30 执行失败: %v", err)
- }
- if out.Scanned != 2 || out.Deleted != 1 || out.Inserted != 1 || out.Skipped != 1 {
- t.Fatalf("scanned/deleted/inserted/skipped=(%d,%d,%d,%d),期望 (2,1,1,1)",
- out.Scanned, out.Deleted, out.Inserted, out.Skipped)
- }
- var rebuilt []model.CoursesActivityUsedLog
- if err := db.Find(&rebuilt).Error; err != nil {
- t.Fatalf("读取重建结果失败: %v", err)
- }
- if len(rebuilt) != 1 {
- t.Fatalf("重建后 used_log=%d 行,期望 1(旧行已清空 + 孤儿跳过)", len(rebuilt))
- }
- row := rebuilt[0]
- if row.ActivityID != 5 || row.UserID != pool.UserID {
- t.Fatalf("重建行 activity/user=(%d,%s),期望 (5,%s)", row.ActivityID, row.UserID, pool.UserID)
- }
- // ★ 映射断言:order_no 取 courses_student_no(旧 UUID 单号),**不是** int64 id
- if row.OrderNo == nil || *row.OrderNo != "CS-UUID-1" {
- t.Fatalf("order_no=%v,期望 CS-UUID-1(orderid = courses_studentid 映射不可改)", row.OrderNo)
- }
- if got := cents(t, row.ActivityMoneyUsed); got != -2000 {
- t.Fatalf("activity_money_used=%d 分,期望 -2000(原值写入,不翻转符号)", got)
- }
- if !row.CreateTime.Equal(businessTime) {
- t.Fatalf("create_time=%v,期望 %v(业务时间逐值保留)", row.CreateTime, businessTime)
- }
- }
- // TestRebuildActivityUsedLogRollbackKeepsRows BUG#3 反向断言:中途失败必须**整体回滚**,
- // 旧 used_log 一行都不能少(dump 的 TRUNCATE 会隐式提交 → 数据全丢)。
- func TestRebuildActivityUsedLogRollbackKeepsRows(t *testing.T) {
- db := newLearningDB(t)
- pool := &model.CoursesActivityUserDiscount{
- ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000002",
- Income: "100.00", DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
- }
- if err := db.Create(pool).Error; err != nil {
- t.Fatalf("seed 优惠池失败: %v", err)
- }
- if err := db.Create(&model.CoursesActivityUserDiscountLog{
- ActivityUserDiscountID: pool.ID, CoursesStudentNo: stringPtr("CS-UUID-9"), DiscountAmountUsed: "-10.00",
- }).Error; err != nil {
- t.Fatalf("seed 优惠流水失败: %v", err)
- }
- old := &model.CoursesActivityUsedLog{ActivityID: 9, UserID: "01HOLD000000000000000002", ActivityMoneyUsed: "3.00"}
- if err := db.Create(old).Error; err != nil {
- t.Fatalf("seed 旧 used_log 失败: %v", err)
- }
- // 注入失败:所有 used_log 的 INSERT 一律报错(模拟"清空后重建失败")
- if err := db.Callback().Create().Before("gorm:create").Register("academic:fail_used_log_insert", func(tx *gorm.DB) {
- if tx.Statement != nil && tx.Statement.Schema != nil && tx.Statement.Schema.Table == "t_courses_activity_used_log" {
- _ = tx.AddError(errors.New("injected insert failure"))
- }
- }); err != nil {
- t.Fatalf("注册注入失败回调出错: %v", err)
- }
- svc := NewMaintenanceService(db, superScope())
- if _, err := svc.RebuildActivityUsedLog(context.Background()); err == nil {
- t.Fatal("注入失败后 SP30 应返回错误")
- }
- var rows []model.CoursesActivityUsedLog
- if err := db.Find(&rows).Error; err != nil {
- t.Fatalf("读取 used_log 失败: %v", err)
- }
- if len(rows) != 1 || rows[0].ActivityID != 9 {
- t.Fatalf("回滚后 used_log=%d 行(期望保留旧 1 行)—— DELETE+INSERT 必须同事务(BUG#3)", len(rows))
- }
- if got := cents(t, rows[0].ActivityMoneyUsed); got != 300 {
- t.Fatalf("回滚后旧行活动金额=%d 分,期望 300", got)
- }
- }
- // TestReplayActivitySigned SP31:只回写 status>0 的报名;已取消(status=0)不复活(反向断言)。
- func TestReplayActivitySigned(t *testing.T) {
- db := newLearningDB(t)
- svc := NewMaintenanceService(db, superScope())
- ctx := context.Background()
- responseUser := "01HRESPONSE000000000001"
- responseTime := time.Date(2026, 3, 2, 9, 0, 0, 0, time.Local)
- responseMsg := "审核通过"
- req := &model.UserBalanceRequest{
- BizNo: "BR-1", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER00000000000000003",
- Approved: model.BalanceRequestApprovedPassed, ResponseUserID: &responseUser,
- ResponseTime: &responseTime, ResponseMsg: &responseMsg, Money: "500.00",
- }
- if err := db.Create(req).Error; err != nil {
- t.Fatalf("seed 工单失败: %v", err)
- }
- pendingReq := &model.UserBalanceRequest{
- BizNo: "BR-2", Type: model.BalanceRequestTypeRecharge, UserID: "01HUSER00000000000000004",
- Approved: model.BalanceRequestApprovedPending, Money: "100.00",
- }
- if err := db.Create(pendingReq).Error; err != nil {
- t.Fatalf("seed 未审核工单失败: %v", err)
- }
- activePool := &model.CoursesActivityUserDiscount{
- ActivityID: 5, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000003",
- BalanceRequestID: int64Ptr(req.ID), Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
- }
- cancelledPool := &model.CoursesActivityUserDiscount{
- ActivityID: 6, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000004",
- BalanceRequestID: int64Ptr(req.ID), Income: "500.00", DiscountAmount: "100.00", DiscountAmountUsed: "20.00",
- }
- pendingPool := &model.CoursesActivityUserDiscount{
- ActivityID: 7, InstitutionID: 1, CampusID: 10, UserID: "01HUSER00000000000000005",
- BalanceRequestID: int64Ptr(pendingReq.ID), Income: "100.00", DiscountAmount: "10.00",
- }
- for _, row := range []*model.CoursesActivityUserDiscount{activePool, cancelledPool, pendingPool} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 优惠池失败: %v", err)
- }
- }
- signedActive := &model.CoursesActivitySigned{ActivityID: 5, UserID: activePool.UserID, Status: model.ActivitySignedSigned}
- signedCancelled := &model.CoursesActivitySigned{ActivityID: 6, UserID: cancelledPool.UserID, Status: model.ActivitySignedCancelled}
- for _, row := range []*model.CoursesActivitySigned{signedActive, signedCancelled} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报名失败: %v", err)
- }
- }
- out, err := svc.ReplayActivitySigned(ctx)
- if err != nil {
- t.Fatalf("SP31 执行失败: %v", err)
- }
- if out.Scanned != 2 {
- t.Fatalf("scanned=%d,期望 2(未审核工单的池行被 INNER JOIN 排除)", out.Scanned)
- }
- if out.Updated != 1 || out.SkippedCanceled != 1 {
- t.Fatalf("updated/skippedCanceled=(%d,%d),期望 (1,1)", out.Updated, out.SkippedCanceled)
- }
- var active model.CoursesActivitySigned
- if err := db.Where("id = ?", signedActive.ID).First(&active).Error; err != nil {
- t.Fatalf("读取报名失败: %v", err)
- }
- if cents(t, active.RechargeAmount) != 50000 {
- t.Fatalf("recharge_amount=%s,期望 500.00", active.RechargeAmount)
- }
- if cents(t, active.ActivityMoney) != 10000 {
- t.Fatalf("activity_money=%s,期望 100.00", active.ActivityMoney)
- }
- if cents(t, active.ActivityMoneyUsed) != 2000 {
- t.Fatalf("activity_money_used=%s,期望 20.00", active.ActivityMoneyUsed)
- }
- if active.RequestNo == nil || *active.RequestNo != "BR-1" {
- t.Fatalf("request_no=%v,期望 BR-1(工单 biz_no)", active.RequestNo)
- }
- if active.ResponseUserID == nil || *active.ResponseUserID != responseUser {
- t.Fatalf("response_user_id=%v,期望 %s", active.ResponseUserID, responseUser)
- }
- if active.ResponseTime == nil || !active.ResponseTime.Equal(responseTime) {
- t.Fatalf("response_time=%v,期望 %v", active.ResponseTime, responseTime)
- }
- // 反向断言:已取消报名(status=0)不被历史工单复活
- var cancelled model.CoursesActivitySigned
- if err := db.Where("id = ?", signedCancelled.ID).First(&cancelled).Error; err != nil {
- t.Fatalf("读取已取消报名失败: %v", err)
- }
- if cancelled.Status != model.ActivitySignedCancelled {
- t.Fatalf("已取消报名 status=%d,期望 0", cancelled.Status)
- }
- if cents(t, cancelled.RechargeAmount) != 0 || cancelled.RequestNo != nil {
- t.Fatalf("已取消报名被回写:recharge_amount=%s request_no=%v", cancelled.RechargeAmount, cancelled.RequestNo)
- }
- }
|