// ActivityService 单测(AC-07):报名 / 取消报名。 // // 断言纪律(本文件的核心价值,对照 §5.1 五条纪律): // 1. **3105 与 3106 不得合并**(纪律 4「横切优先」): // `TestSignCancelResignMatrix` 一条用例同时证明两件事—— // 取消报名后**可以重报同一活动**(3105 判**报名行** status)、 // 但**不能再参加另一个老带新活动**(3106 判**活动** status、不看报名行 status)。 // 两个分支里任意一个被"按直觉"改成另一个,这条用例必红。 // 2. **守卫命中零副作用**:3107/3108 被拒后 `number_sign` 与三列金额**一字不动**。 // 3. **数值边界**:`number_sign` 不得减成负数(下限 0);`activity_money = max(money,0)`。 // 4. **人数上限 3141 是 Go 增强**:用例名与注释显式标注"dump 无此校验"。 package service import ( "context" "errors" "path/filepath" "testing" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ---------------------------------------------------------------- 测试基础设施 // newActivityDomainDB 活动域真实库(活动/报名/赠送池/两条流水 + 资金账户 + 报课/排班 + t_campus)。 // // 复用 `migrateFundTables`(与 fund_service_test.go 同一 package):逐表 AutoMigrate 后删除 // 该表索引——SQLite 的索引名是库级唯一,academic model 多表共用 `idx_inst_campus` 等名字, // 一次性迁移会报 "index already exists"。 func newActivityDomainDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_activity_test.db") db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("gorm.Open(活动域文件库) 失败: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("取 sql.DB 失败: %v", err) } // 单连接:让"并发"用例在 sqlite 上退化为串行(生产 MySQL 用行锁串行,语义等价) sqlDB.SetMaxOpenConns(1) t.Cleanup(func() { _ = sqlDB.Close() }) migrateFundTables(t, db, &model.CoursesActivity{}, &model.CoursesActivitySigned{}, &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{}, &model.CoursesActivityUsedLog{}, &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{}, &model.CoursesStudent{}, &model.CoursesClass{}, ) // 跨域只读表(org 域 t_campus 的最小列集;self_support 1=自营 / 0=加盟) if err := db.Exec(`CREATE TABLE t_campus ( campus_id INTEGER PRIMARY KEY, institution_id INTEGER NOT NULL DEFAULT 0, self_support BOOLEAN NOT NULL DEFAULT 1, status VARCHAR(32) NOT NULL DEFAULT 'active' )`).Error; err != nil { t.Fatalf("建 t_campus 失败: %v", err) } return db } // activityScopeFor 指定机构/校区的作用域(普通角色,非超管)。 func activityScopeFor(institutionID, campusID int64) *appscope.Scope { return &appscope.Scope{ InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "academic", } } // seedActivityFull 落一行活动(可指定类型/金额/人数上限/状态)。 func seedActivityFull(t *testing.T, db *gorm.DB, id int64, typ int8, money string, institutionID, campusID int64, numberMax int32, status int8) { t.Helper() row := &model.CoursesActivity{ ID: id, Type: typ, InstitutionID: institutionID, CampusID: campusID, Money: money, NumberMax: numberMax, Status: status, NumberSign: 0, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 活动(%d) 失败: %v", id, err) } } // readActivityRow 读活动行(断言 number_sign)。 func readActivityRow(t *testing.T, db *gorm.DB, id int64) model.CoursesActivity { t.Helper() var row model.CoursesActivity if err := db.Where("id = ?", id).Take(&row).Error; err != nil { t.Fatalf("读活动(%d) 失败: %v", id, err) } return row } // readSignedRow 读报名行(不存在 → 返回 nil)。 func readSignedRow(t *testing.T, db *gorm.DB, userID string, activityID int64) *model.CoursesActivitySigned { t.Helper() var rows []model.CoursesActivitySigned if err := db.Where("user_id = ? AND activity_id = ?", userID, activityID).Find(&rows).Error; err != nil { t.Fatalf("读报名行(user=%s,activity=%d) 失败: %v", userID, activityID, err) } if len(rows) == 0 { return nil } if len(rows) > 1 { t.Fatalf("报名行不唯一(%d 行)——UK(user_id,activity_id) 失效", len(rows)) } return &rows[0] } // countSignedRows 统计报名行条数(幂等断言:重复报名不得产生第二行)。 func countSignedRows(t *testing.T, db *gorm.DB, userID string, activityID int64) int64 { t.Helper() var n int64 if err := db.Model(&model.CoursesActivitySigned{}). Where("user_id = ? AND activity_id = ?", userID, activityID).Count(&n).Error; err != nil { t.Fatalf("统计报名行失败: %v", err) } return n } // seedUsedLogRow 落一行 used_log(`activity_money_used` 按方向传:使用/结算为负、退回为正)。 // // orderNo 是 dump 的 `orderid`(报课单号),退回游标(SP13)按它定位;不需要单号时传 ""。 func seedUsedLogRow(t *testing.T, db *gorm.DB, activityID int64, userID, orderNo, amount string) { t.Helper() row := &model.CoursesActivityUsedLog{ ActivityID: activityID, UserID: userID, ActivityMoneyUsed: amount, } if orderNo != "" { no := orderNo row.OrderNo = &no } if err := db.Create(row).Error; err != nil { t.Fatalf("seed used_log 失败: %v", err) } } // wantAppErr 断言错误码(不匹配 → 直接失败并打印实际值)。 func wantAppErr(t *testing.T, err error, code int, what string) { t.Helper() if err == nil { t.Fatalf("%s:期望错误码 %d,实得 nil(本应被拒)", what, code) } var appErr *infraerrors.AppError if !errors.As(err, &appErr) { t.Fatalf("%s:期望 *AppError(%d),实得 %T(%v)", what, code, err, err) } if appErr.Code != code { t.Fatalf("%s:期望错误码 %d,实得 %d(%s)", what, code, appErr.Code, appErr.Msg) } } // ---------------------------------------------------------------- 报名 // TestSignCreatesSignedAndIncrementsNumber 正常报名:写报名行 + 人数 +1 + 金额快照。 func TestSignCreatesSignedAndIncrementsNumber(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(1) seedActivityFull(t, db, 100, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) res, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 100, Status: 0}) if err != nil { t.Fatalf("Sign 失败: %v", err) } if res.Status != model.ActivitySignedSigned || res.SignedID <= 0 { t.Fatalf("报名状态应归一为 1 且回填 signedId,实得 status=%d signedId=%d", res.Status, res.SignedID) } if res.ActivityMoney.Cents() != 10000 { t.Fatalf("报名行金额应 10000 分,实得 %d", res.ActivityMoney.Cents()) } row := readSignedRow(t, db, user, 100) if row == nil { t.Fatalf("报名行未落库") } // 金额列在 sqlite 上按 NUMERIC 亲和回读("100" 而非 "100.00")→ 一律用 cents() 按分值比较 if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 10000 || cents(t, row.ActivityMoneyUsed) != 0 { t.Fatalf("报名行字段不符:status=%d money=%s used=%s", row.Status, row.ActivityMoney, row.ActivityMoneyUsed) } // 冗余作用域列必须是**活动上的**快照(campus_id=0 有"全机构自营校区"语义) if row.InstitutionID != 1 || row.CampusID != 10 { t.Fatalf("报名行作用域列应取活动值(1/10),实得 %d/%d", row.InstitutionID, row.CampusID) } if act := readActivityRow(t, db, 100); act.NumberSign != 1 { t.Fatalf("报名人数应 +1 到 1,实得 %d", act.NumberSign) } } // TestSignClampsNegativeActivityMoney 活动金额为负 → 报名行金额归 0(dump 762955)。 func TestSignClampsNegativeActivityMoney(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) user := fundUserID(2) seedActivityFull(t, db, 101, model.ActivityTypeRecharge, "-5.00", 1, 10, 0, model.ActivityStatusEnabled) res, err := svc.Sign(context.Background(), SignInput{UserID: user, ActivityID: 101}) if err != nil { t.Fatalf("Sign 失败: %v", err) } if res.ActivityMoney.Cents() != 0 { t.Fatalf("负金额应归 0,实得 %d 分", res.ActivityMoney.Cents()) } if row := readSignedRow(t, db, user, 101); row == nil || cents(t, row.ActivityMoney) != 0 { t.Fatalf("报名行金额应为 0,实得 %+v", row) } } // TestSignRejectsDuplicateWith3105 重复报名 → 3105,且**不产生第二行、人数不再 +1**。 func TestSignRejectsDuplicateWith3105(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(3) seedActivityFull(t, db, 102, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102}); err != nil { t.Fatalf("首次报名失败: %v", err) } _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 102}) wantAppErr(t, err, apperr.CodeActivityAlreadySigned, "重复报名") if n := countSignedRows(t, db, user, 102); n != 1 { t.Fatalf("重复报名不得产生第二行,实得 %d 行", n) } if act := readActivityRow(t, db, 102); act.NumberSign != 1 { t.Fatalf("被 3105 拒绝后人数不得再 +1,实得 %d", act.NumberSign) } } // TestSignRejectsMissingActivityWith3001 活动不存在 → 3001(dump 此处静默 success=0,Go 显式化)。 func TestSignRejectsMissingActivityWith3001(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) _, err := svc.Sign(context.Background(), SignInput{UserID: fundUserID(4), ActivityID: 999}) wantAppErr(t, err, 3001, "报名不存在活动") if n := countSignedRows(t, db, fundUserID(4), 999); n != 0 { t.Fatalf("活动不存在时不得写报名行,实得 %d 行", n) } } // TestSignRespectsNumberMax 人数上限 → 3141(**Go 增强:dump 无此校验**,见方法注释)。 func TestSignRespectsNumberMax(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() seedActivityFull(t, db, 103, model.ActivityTypeRecharge, "100.00", 1, 10, 1, model.ActivityStatusEnabled) if _, err := svc.Sign(ctx, SignInput{UserID: fundUserID(5), ActivityID: 103}); err != nil { t.Fatalf("第 1 个报名应成功: %v", err) } _, err := svc.Sign(ctx, SignInput{UserID: fundUserID(6), ActivityID: 103}) wantAppErr(t, err, apperr.CodeActivityFull, "超人数上限报名") if n := countSignedRows(t, db, fundUserID(6), 103); n != 0 { t.Fatalf("超上限被拒后不得写报名行,实得 %d 行", n) } if act := readActivityRow(t, db, 103); act.NumberSign != 1 { t.Fatalf("被 3141 拒绝后人数应保持 1,实得 %d", act.NumberSign) } } // TestSignCancelResignMatrix **横切断言(纪律 4)**:3105 与 3106 是两个不能合并的分支。 // // 步骤与判据: // // a) 报名**充值赠送** A(type=2)→ 成功;取消 A → 三列归零、人数回到 0 // b) **重报 A → 必须成功**(3105 判**报名行** status,status=0 不算已报名) // c) 用户已有一条**已取消**的老带新报名(type=4,status=0)→ 报名**另一个**老带新 B // → **必须被 3106 拒**(3106 判**活动** status、不看报名行 status ⇒ 取消也永久消耗次数) // // 若把两条判据互换口径: // - 3105 改成"判活动" → b) 会被误拒(A 的活动 status != 0); // - 3106 改成"判报名行 status" → c) 会被误放行(那条老带新报名 status=0)⇒ 可反复领奖励。 func TestSignCancelResignMatrix(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(7) seedActivityFull(t, db, 200, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedActivityFull(t, db, 201, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled) seedActivityFull(t, db, 202, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled) // a) 报名 + 取消(type=2,未审核 → 可取消) if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil { t.Fatalf("报名 A 失败: %v", err) } if _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 200}); err != nil { t.Fatalf("取消报名失败: %v", err) } row := readSignedRow(t, db, user, 200) if row == nil || row.Status != model.ActivitySignedCancelled { t.Fatalf("取消后报名行应保留且 status=0,实得 %+v", row) } if cents(t, row.ActivityMoney) != 0 || cents(t, row.ActivityMoneyUsed) != 0 { t.Fatalf("取消后两列金额应同置 0,实得 money=%s used=%s", row.ActivityMoney, row.ActivityMoneyUsed) } if act := readActivityRow(t, db, 200); act.NumberSign != 0 { t.Fatalf("取消后人数应回到 0(下限保护),实得 %d", act.NumberSign) } // b) 重报 A —— 3105 放行(判报名行 status) if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 200}); err != nil { t.Fatalf("取消后可重报同一活动(3105 判报名行 status),实得错误: %v", err) } if n := countSignedRows(t, db, user, 200); n != 1 { t.Fatalf("重报后仍应只有 1 行(DELETE+INSERT 幂等),实得 %d", n) } // c) 已取消的老带新报名(status=0)→ 报名另一个老带新 B 必须被 3106 拒 if err := db.Create(&model.CoursesActivitySigned{ ActivityID: 201, UserID: user, InstitutionID: 1, CampusID: 10, Status: model.ActivitySignedCancelled, ActivityMoney: "0", ActivityMoneyUsed: "0", }).Error; err != nil { t.Fatalf("seed 已取消的老带新报名失败: %v", err) } _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 202}) wantAppErr(t, err, apperr.CodeRecommendLimited, "老带新限一次(已取消也算参加过)") if n := countSignedRows(t, db, user, 202); n != 0 { t.Fatalf("被 3106 拒绝后不得写 B 的报名行,实得 %d 行", n) } } // ---------------------------------------------------------------- 取消报名 // TestCancelSignRejectsAuditedWith3107 type∈{2,3} 且已审核 → 3107,且**零副作用**。 func TestCancelSignRejectsAuditedWith3107(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(8) seedActivityFull(t, db, 300, model.ActivityTypeVoucher, "50.00", 1, 10, 0, model.ActivityStatusEnabled) auditor := "01HAUDITOR000000000000001" if err := db.Create(&model.CoursesActivitySigned{ ActivityID: 300, UserID: user, InstitutionID: 1, CampusID: 10, Status: model.ActivitySignedSigned, ActivityMoney: "50.00", ResponseUserID: &auditor, }).Error; err != nil { t.Fatalf("seed 已审核报名行失败: %v", err) } if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 300). UpdateColumn("number_sign", 1).Error; err != nil { t.Fatalf("seed 人数失败: %v", err) } _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 300}) wantAppErr(t, err, apperr.CodeActivitySignedAudited, "取消已审核报名") row := readSignedRow(t, db, user, 300) if row.Status != model.ActivitySignedSigned || cents(t, row.ActivityMoney) != 5000 { t.Fatalf("被 3107 拒绝后报名行必须一字不动,实得 status=%d money=%s", row.Status, row.ActivityMoney) } if act := readActivityRow(t, db, 300); act.NumberSign != 1 { t.Fatalf("被 3107 拒绝后人数不得减少,实得 %d", act.NumberSign) } } // TestCancelSignRejectsRecommendUsedWith3108 type=4 且 used_log 存在 → 3108,且**零副作用**。 func TestCancelSignRejectsRecommendUsedWith3108(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(9) seedActivityFull(t, db, 301, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled) if _, err := svc.Sign(ctx, SignInput{UserID: user, ActivityID: 301}); err != nil { t.Fatalf("报名失败: %v", err) } seedUsedLogRow(t, db, 301, user, "", "-100.00") // 已有结算记录(奖励已发出) _, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 301}) wantAppErr(t, err, apperr.CodeRecommendUsed, "取消已消费的老带新报名") row := readSignedRow(t, db, user, 301) if row.Status != model.ActivitySignedSigned { t.Fatalf("被 3108 拒绝后报名状态不得变,实得 %d", row.Status) } if act := readActivityRow(t, db, 301); act.NumberSign != 1 { t.Fatalf("被 3108 拒绝后人数不得减少,实得 %d", act.NumberSign) } } // TestCancelSignNumberSignFloorAtZero 人数已是 0 时取消 → 保持 0(不下穿)。 func TestCancelSignNumberSignFloorAtZero(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) ctx := context.Background() user := fundUserID(10) // type=1(试听):既不走 3107 也不走 3108 seedActivityFull(t, db, 302, model.ActivityTypeTrial, "0.00", 1, 10, 0, model.ActivityStatusEnabled) if err := db.Create(&model.CoursesActivitySigned{ ActivityID: 302, UserID: user, InstitutionID: 1, CampusID: 10, Status: model.ActivitySignedSigned, ActivityMoney: "0.00", }).Error; err != nil { t.Fatalf("seed 报名行失败: %v", err) } // number_sign 故意留 0(脏数据/并发取消后的状态) res, err := svc.CancelSign(ctx, CancelSignInput{UserID: user, ActivityID: 302}) if err != nil { t.Fatalf("取消报名失败: %v", err) } if res.NumberSign != 0 { t.Fatalf("人数下限应为 0,实得 %d", res.NumberSign) } if act := readActivityRow(t, db, 302); act.NumberSign != 0 { t.Fatalf("库中人数不得为负,实得 %d", act.NumberSign) } } // TestCancelSignMissingSignedWith3001 无有效报名行 → 3001(dump:游标空 → success=0)。 func TestCancelSignMissingSignedWith3001(t *testing.T) { db := newActivityDomainDB(t) svc := NewActivityService(db, fundSuperScope()) seedActivityFull(t, db, 303, model.ActivityTypeRecharge, "10.00", 1, 10, 0, model.ActivityStatusEnabled) _, err := svc.CancelSign(context.Background(), CancelSignInput{UserID: fundUserID(11), ActivityID: 303}) wantAppErr(t, err, 3001, "取消不存在的报名") if act := readActivityRow(t, db, 303); act.NumberSign != 0 { t.Fatalf("无报名行时人数不得变化,实得 %d", act.NumberSign) } }