| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- // 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)
- }
- }
|