// 活动域 Handler 单测(AC-13b:端点 22~37,16 个)。 // // 断言纪律(与 AC-12 同源,只断 body 契约:HTTP 恒 200 + body.code): // // 每条用例都写成"**实现改错就变红**"的形状,判据写在每个 `Test` 的注释第一段。 // "用例全绿"不算验收——全绿只说明断言与实现同时存在,必须能指出"哪一行改掉它就会红"。 // // 覆盖结构: // // ① 端点覆盖 + 路由顺序 → TestActivityRouteCoverage // ② 列表:作用域/派生字段/枚举 → TestListActivitiesScopesAndDerives // ③ 新建:2002/3104/1004/成功 → TestCreateActivityValidationAndCrossCampus // ④ 详情:3001/字段映射/空数组 → TestActivityDetailScopeAndShape // ⑤ 报名列表:2002/空列表 → TestListSignedsInvalidStatusIs2002 // ⑥ 更新:2002(numberSign)/3001 → TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 // ⑦ 报名/取消:3105/跨表三列归零 → TestSignThenCancelCrossTableEffects // ⑧ 取消:3107 审核态零副作用 → TestCancelSignAuditedIs3107WithZeroSideEffects // ⑨ 取消:3121 type 读不到 → TestCancelSignUnresolvableTypeIs3121 // ⑩ 老带新:settled=false 零写入 → TestRecommendSettleNoopDoesNotWrite // ⑪ 取消充值:3104 → TestCancelRechargeUnknownCampusIs3104 // ⑫ 可用赠送:1004/1003/items → TestUsableBreakdownScopeAndShape // ⑬ 赠送使用:no-op 与 3109 → TestDiscountUseNoopAndInsufficient // ⑭ 充值赠送使用:status=2 前置 → TestRechargeGiftUseRequiresAttendedStatus // ⑮ 三条退回:refunded 语义 → TestRefundEndpointsReportRefundedFlag // ⑯ 回访:跨活动拒绝 + 落库 → TestVisitRejectsCrossActivitySigned package handler import ( "encoding/json" "strings" "testing" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // ---------------------------------------------------------------- 测试基础设施 // newActivityTestApp 构造只挂活动域 16 条端点的 app(含作用域注入中间件)。 // // 中间件按 `X-Test-Scope` 注入不同作用域(缺省 finance,与 `testScope` 一致): // // none / super / academic / student // // 路由注册顺序**有意**与 `activity_handler.go` 文件头的"静态优先"一致: // 若把 `PathActivityDetail` 注册在 `PathActivityDiscountsUsable` 之前, // `GET /admin/activities/discounts/usable` 会被 `:id="discounts"` 吃掉(见 `TestActivityRouteCoverage`)。 func newActivityTestApp(t *testing.T, db *gorm.DB) *fiber.App { t.Helper() h, err := New(Config{DB: db}) if err != nil { t.Fatalf("handler.New 失败: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Use(func(c *fiber.Ctx) error { switch c.Get("X-Test-Scope") { case "none": // 不写作用域 → 未登录语义(1001) case "super": appscope.Set(c, testSuperScope()) case "academic": appscope.Set(c, &appscope.Scope{ InstitutionID: 1, CampusID: 10, UserID: "01HACADEMIC0000000000001", Role: "academic", }) default: appscope.Set(c, testScope()) } return c.Next() }) // ① 静态段(顺序敏感,必须全在 `:id` 之前) app.Get(PathActivities, h.ListActivities) app.Post(PathActivities, h.CreateActivity) app.Get(PathActivityDiscountsUsable, h.GetActivityUsableDiscount) app.Post(PathActivityDiscountsUse, h.UseActivityDiscount) app.Post(PathActivityDiscountsRefund, h.RefundActivityDiscount) app.Post(PathActivityDiscountsRefundByActivity, h.RefundDiscountByActivity) app.Post(PathActivityRechargeGiftsUse, h.UseRechargeGift) app.Post(PathActivityRechargeGiftsRefund, h.RefundRechargeGift) // ② 带 :id 的段 app.Get(PathActivityDetail, h.GetActivity) app.Put(PathActivityDetail, h.UpdateActivity) app.Post(PathActivitySign, h.SignActivity) app.Post(PathActivityCancelSign, h.CancelActivitySign) app.Get(PathActivitySigneds, h.ListActivitySigneds) app.Post(PathActivityRecommendSettle, h.SettleActivityRecommend) app.Post(PathActivityCancelRecharge, h.CancelActivityRecharge) app.Post(PathActivityVisits, h.CreateActivityVisit) return app } // seedActivityRow 落一行活动(显式 ID,便于断言语义)。 func seedActivityRow(t *testing.T, db *gorm.DB, row model.CoursesActivity) { t.Helper() if row.NumberSign == 0 && row.NumberMax == 0 { row.NumberMax = 0 } if row.Title == nil { title := "活动" row.Title = &title } if err := db.Create(&row).Error; err != nil { t.Fatalf("seed 活动(%d) 失败: %v", row.ID, err) } } // seedSignedRow 落一行报名(status 显式传;responseUserID 非空表示已审核 → 3107 判据)。 func seedSignedRow( t *testing.T, db *gorm.DB, id, activityID int64, userID string, status int32, moneyStr string, responseUserID *string, ) { t.Helper() row := &model.CoursesActivitySigned{ ID: id, ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10, Status: status, ActivityMoney: moneyStr, ActivityMoneyUsed: "0.00", ResponseUserID: responseUserID, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 报名(id=%d) 失败: %v", id, err) } } // seedDiscountPool 落一行赠送池(amount/used 为元字符串)。 func seedDiscountPool( t *testing.T, db *gorm.DB, id, activityID int64, userID string, amount, used string, ) { t.Helper() row := &model.CoursesActivityUserDiscount{ ID: id, ActivityID: activityID, InstitutionID: 1, CampusID: 10, UserID: userID, Income: "0.00", DiscountAmount: amount, DiscountAmountUsed: used, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 赠送池(id=%d) 失败: %v", id, err) } } // readActivityByID 读活动行(断言库值用;不存在 → 失败)。 func readActivityByID(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 } // countRows 统计表内满足条件的行数(**跨表断言**用:响应体之外的唯一事实)。 func countRows(t *testing.T, db *gorm.DB, entity any, query string, args ...any) int64 { t.Helper() var n int64 if err := db.Model(entity).Where(query, args...).Count(&n).Error; err != nil { t.Fatalf("统计行数失败(%s): %v", query, err) } return n } // decodeData 把响应 data 解成目标结构(解不出 → 失败)。 func decodeData[T any](t *testing.T, raw json.RawMessage) T { t.Helper() var out T if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("data 解码失败(%s): %v", string(raw), err) } return out } // ---------------------------------------------------------------- ① 端点覆盖 + 路由顺序 // TestActivityRouteCoverage 16 条端点全注册、路径不含 `/api`、且**静态段优先**。 // // 会变红的条件: // - 任何一条 `PathActivity*` 改了名字/漏注册 → 对应 `registered[...]` 断言失败; // - 把 `GET /admin/activities/:id` 注册在 `discounts/usable` 之前 → // `/admin/activities/discounts/usable?userId=X` 会命中详情 handler 并因 id="discounts" 解析成 0 // ⇒ 返回 **2002**,本用例断言它必须是 **200**(真实业务码)→ 红。 func TestActivityRouteCoverage(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) expected := []struct{ method, path, name string }{ {"GET", PathActivities, "端点22 活动列表"}, {"POST", PathActivities, "端点23 新建"}, {"GET", PathActivityDetail, "端点24 详情"}, {"PUT", PathActivityDetail, "端点25 更新"}, {"POST", PathActivitySign, "端点26 报名"}, {"POST", PathActivityCancelSign, "端点27 取消报名"}, {"GET", PathActivitySigneds, "端点28 报名列表"}, {"POST", PathActivityRecommendSettle, "端点29 老带新结算"}, {"POST", PathActivityCancelRecharge, "端点30 取消充值"}, {"GET", PathActivityDiscountsUsable, "端点31 可用赠送"}, {"POST", PathActivityDiscountsUse, "端点32 使用赠送"}, {"POST", PathActivityDiscountsRefund, "端点33 赠送退回"}, {"POST", PathActivityDiscountsRefundByActivity, "端点34 按活动退回"}, {"POST", PathActivityRechargeGiftsUse, "端点35 充值赠送使用"}, {"POST", PathActivityRechargeGiftsRefund, "端点36 充值赠送退回"}, {"POST", PathActivityVisits, "端点37 回访"}, } registered := map[string]bool{} for _, r := range app.GetRoutes() { registered[r.Method+" "+r.Path] = true if strings.Contains(r.Path, "/api/") { t.Errorf("后端路由不得含 /api 前缀(§1.6):%s %s", r.Method, r.Path) } } for _, want := range expected { if !registered[want.method+" "+want.path] { t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path) } } if len(expected) != 16 { t.Fatalf("活动端点数应为 16,实为 %d(契约 §2.2)", len(expected)) } // 路由顺序的**行为断言**(比检查注册表更硬):静态路径必须走到自己的 handler。 code, msg, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U1", nil) if code != 200 { t.Fatalf("GET discounts/usable 期望 200,实得 %d(%s)——"+ "若为 2002 说明 :id 路由先注册吃掉了 discounts", code, msg) } } // ---------------------------------------------------------------- ② 列表 // TestListActivitiesScopesAndDerives 列表作用域 + `signed`/`numberSign`/`signable` 派生字段。 // // 会变红的条件(逐条): // - 去掉 `scopedCampusRows` ⇒ 列表里出现 institutionId=2 的活动 → 红; // - `CountSignedByActivityIDs` 少写 `status <> 0` ⇒ `signed` 变 3(期望 2)→ 红; // - `signable` 恒 true ⇒ status=0 的活动也 true → 红; // - `name`/`total` 没有从 `title`/`number_max` 映射 ⇒ 断言失败 → 红。 func TestListActivitiesScopesAndDerives(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) title1 := "充值赠送 A" seedActivityRow(t, db, model.CoursesActivity{ ID: 101, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Title: &title1, Money: "100.00", NumberMax: 5, NumberSign: 2, Status: 1, }) seedActivityRow(t, db, model.CoursesActivity{ ID: 102, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10, Money: "0.00", NumberMax: 0, Status: 0, // 停用 → signable=false }) seedActivityRow(t, db, model.CoursesActivity{ ID: 103, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20, Money: "1.00", NumberMax: 0, Status: 1, // 别的机构 → 不得出现 }) seedSignedRow(t, db, 1, 101, "U1", model.ActivitySignedSigned, "100.00", nil) seedSignedRow(t, db, 2, 101, "U2", model.ActivitySignedAttended, "100.00", nil) seedSignedRow(t, db, 3, 101, "U3", model.ActivitySignedCancelled, "0.00", nil) // 取消 → 不计 code, msg, data := doJSON(t, app, "GET", "/admin/activities?page=1&pageSize=10", nil) if code != 200 { t.Fatalf("列表期望 200,实得 %d(%s)", code, msg) } page := decodeData[struct { List []ActivityViewForTest `json:"list"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` Pages int64 `json:"pages"` }](t, data) if page.Total != 2 { t.Fatalf("列表总数应为 2(本机构 2 个),实得 %d(含跨机构活动则说明作用域失效)", page.Total) } if page.Pages != 1 { t.Fatalf("pages 应为 ceil(2/10)=1,实得 %d", page.Pages) } byID := map[int64]ActivityViewForTest{} for _, row := range page.List { byID[row.ID] = row } if _, ok := byID[103]; ok { t.Fatalf("跨机构活动 103 出现在列表里(作用域过滤失效,1004 类越权)") } first, ok := byID[101] if !ok { t.Fatalf("本机构活动 101 不在列表里") } if first.Name != "充值赠送 A" { t.Fatalf("name 应映射 title(\"充值赠送 A\"),实得 %q", first.Name) } if first.Total != 5 { t.Fatalf("total 应映射 number_max=5,实得 %d", first.Total) } if first.NumberSign != 2 { t.Fatalf("numberSign 应为库值 2,实得 %d", first.NumberSign) } if first.Signed != 2 { t.Fatalf("signed 应为 2(status<>0),实得 %d——"+ "若为 3 说明把 status=0(已取消)也统计进来了", first.Signed) } // 活动级 money(v1.6 契约补入):来自 `t_courses_activity.money`,两位小数字符串。 // 会变红的条件:① DTO 漏掉 Money 字段 ⇒ 得 "";② 用 float 输出 ⇒ 声明为 string 此处 unmarshal 失败; // ③ 拿 signeds[].money 顶上 ⇒ 值可能仍是 100.00,故另用活动 102(money=0.00、无报名)区分层级。 if first.Money != "100.00" { t.Fatalf("活动级 money 应为 \"100.00\"(← t_courses_activity.money),实得 %q", first.Money) } if stopped, ok := byID[102]; ok { if stopped.Money != "0.00" { t.Fatalf("无报名的停用活动 money 应为 \"0.00\"(活动级、与报名无关),实得 %q——"+ "若非 0.00 说明把 signeds[].money 当成了活动金额", stopped.Money) } } if !first.Signable { t.Fatalf("活动 101 应为 signable(status=1、未满员、无时间窗约束)") } disabled := byID[102] if disabled.Signable { t.Fatalf("活动 102 status=0(停用)应 signable=false,实得 true") } // 空数组而非 null:`list` 必须在 data 顶层且为数组 if !strings.Contains(string(data), `"list":[`) { t.Fatalf("data.list 必须是数组(不得为 null/缺键):%s", string(data)) } } // ActivityViewForTest 列表行(只声明本用例要断言的字段,避免抄一份 DTO 造成"两处定义")。 type ActivityViewForTest struct { ID int64 `json:"id"` Name string `json:"name"` Total int32 `json:"total"` NumberSign int32 `json:"numberSign"` Signed int32 `json:"signed"` // Money 声明为 **string**:若后端下发数值(number)会在此处 unmarshal 失败 ⇒ // "金额必须是两位小数字符串"这条不用额外断言也守住了。 Money string `json:"money"` Signable bool `json:"signable"` Type int8 `json:"type"` } // TestListActivitiesInvalidEnumsAre2002 type/status 过滤枚举越界 → **2002**(不是 2001、不是静默忽略)。 // // 会变红的条件:删掉 `parseEnumQuery` 的白名单判断 ⇒ 变成 200 且返回全量列表 → 红。 func TestListActivitiesInvalidEnumsAre2002(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) for _, q := range []string{"type=1", "type=9", "status=3", "status=abc"} { code, _, _ := doJSON(t, app, "GET", "/admin/activities?"+q, nil) if code != 2002 { t.Errorf("查询 %s 期望 2002(枚举/格式非法),实得 %d", q, code) } } } // ---------------------------------------------------------------- ③ 新建 // TestCreateActivityValidationAndCrossCampus 新建活动的四类拒绝 + 成功路径的库值断言。 // // 会变红的条件: // - 删除 `validateActivityFields` 的 type 白名单 ⇒ type=1 变 200 → 红; // - 删除时间窗倒挂判断 ⇒ start>end 变 200 → 红; // - 删除 `assertCampusInScope` ⇒ 传别家机构校区(20)变 200 → 红(**这是 1004 越权判据**); // - 删除校区端口调用 ⇒ 不存在的校区(777)变 5002/200 而不是 3104 → 红; // - 成功路径:`institution_id` 必须取作用域(1),且 `number_sign` 必须为 0。 func TestCreateActivityValidationAndCrossCampus(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) // 造一个"属于机构 2"的校区(跨机构写入判据;测试库默认只有 10 → 机构 1) if err := db.Exec( `INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(20,2,1,'active')`, ).Error; err != nil { t.Fatalf("seed t_campus(20) 失败: %v", err) } base := map[string]any{"type": 2, "title": "新活动", "money": "100.00"} cases := []struct { name string body map[string]any want int }{ {"类型 1(试听,旧遗留)", withFields(base, "type", 1), 2002}, {"类型 9(不存在)", withFields(base, "type", 9), 2002}, {"时间窗倒挂", withFields(base, "startTime", "2026-12-31", "endTime", "2026-01-01"), 2002}, {"校区不存在", withFields(base, "campusId", 777), 3104}, {"校区属别的机构", withFields(base, "campusId", 20), 1004}, {"机构参数跨作用域", withFields(base, "institutionId", 2), 1004}, {"金额格式非法", withFields(base, "money", "abc"), 2002}, } for _, tc := range cases { code, msg, _ := doJSON(t, app, "POST", "/admin/activities", tc.body) if code != tc.want { t.Errorf("%s:期望 %d,实得 %d(%s)", tc.name, tc.want, code, msg) } } code, msg, data := doJSON(t, app, "POST", "/admin/activities", base) if code != 200 { t.Fatalf("新建期望 200,实得 %d(%s)", code, msg) } created := decodeData[struct { ID int64 `json:"id"` BizNo string `json:"bizNo"` }](t, data) if created.ID <= 0 { t.Fatalf("新建应回 id>0,实得 %d", created.ID) } if created.BizNo != "" { t.Fatalf("bizNo 不应下发(t_courses_activity 无 biz_no 列):实得 %q", created.BizNo) } row := readActivityByID(t, db, created.ID) if row.InstitutionID != 1 { t.Fatalf("institution_id 应取作用域机构 1(不信任 body),实得 %d", row.InstitutionID) } if row.NumberSign != 0 { t.Fatalf("number_sign 必须为 0(计数列不得由新建写入),实得 %d", row.NumberSign) } if row.Title == nil || *row.Title != "新活动" { t.Fatalf("title 落库错误:%v", row.Title) } } // ---------------------------------------------------------------- ④ 详情 // TestActivityDetailScopeAndShape 详情的作用域(3001)与字段映射(name/total/signeds[].money)。 // // 会变红的条件: // - 去掉 `GetByID` 的作用域 ⇒ 别家机构活动(103)返回 200 → 红; // - `SignedView.Money` 用 float/未转 money.Amount ⇒ `"100.00"` 变 `100` → 红; // - `signeds` 用 nil 切片 ⇒ JSON 为 null(断言是 `"signeds":[]`)→ 红。 func TestActivityDetailScopeAndShape(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) title := "详情活动" seedActivityRow(t, db, model.CoursesActivity{ ID: 201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Title: &title, Money: "500.00", NumberMax: 10, Status: 1, }) seedSignedRow(t, db, 11, 201, "U1", model.ActivitySignedSigned, "500.00", nil) seedActivityRow(t, db, model.CoursesActivity{ ID: 202, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20, Money: "1.00", Status: 1, }) code, msg, data := doJSON(t, app, "GET", "/admin/activities/201", nil) if code != 200 { t.Fatalf("详情期望 200,实得 %d(%s)", code, msg) } detail := decodeData[struct { ID int64 `json:"id"` Name string `json:"name"` Total int32 `json:"total"` Signed int32 `json:"signed"` Signeds []struct { ID int64 `json:"id"` ActivityID int64 `json:"activityId"` UserID string `json:"userId"` Status int32 `json:"status"` Money string `json:"money"` CreateTime string `json:"createTime"` } `json:"signeds"` }](t, data) if detail.Name != "详情活动" || detail.Total != 10 || detail.Signed != 1 { t.Fatalf("详情字段映射错:%+v", detail) } if len(detail.Signeds) != 1 { t.Fatalf("详情应内嵌 1 条报名行,实得 %d", len(detail.Signeds)) } if detail.Signeds[0].Money != "500.00" { t.Fatalf("signeds[].money 必须是两位小数字符串(activity_money=500.00),实得 %q", detail.Signeds[0].Money) } if !strings.Contains(detail.Signeds[0].CreateTime, "T") { t.Fatalf("signeds[].createTime 必须是 RFC3339(含 T),实得 %q", detail.Signeds[0].CreateTime) } // 跨机构 → 3001(不泄露存在性;若变 200 说明作用域被绕过) if code, _, _ := doJSON(t, app, "GET", "/admin/activities/202", nil); code != 3001 { t.Fatalf("跨机构活动详情期望 3001,实得 %d", code) } // 空报名列表 → `[]`(不是 null) seedActivityRow(t, db, model.CoursesActivity{ ID: 203, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10, Money: "0.00", Status: 1, }) _, _, emptyData := doJSON(t, app, "GET", "/admin/activities/203", nil) if !strings.Contains(string(emptyData), `"signeds":[]`) { t.Fatalf("无报名时 signeds 必须是 [](不得 null):%s", string(emptyData)) } } // ---------------------------------------------------------------- ⑤ 报名列表 // TestListSignedsInvalidStatusIs2002 报名列表:status 越界 → 2002;合法空集 → `list:[]`。 // // 会变红的条件:删掉 0~4 白名单 ⇒ status=5 返回 200 → 红; // 若列表用 nil 切片 ⇒ `"list":[]` 断言失败 → 红。 func TestListSignedsInvalidStatusIs2002(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 301, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "10.00", Status: 1, }) if code, _, _ := doJSON(t, app, "GET", "/admin/activities/301/signeds?status=5", nil); code != 2002 { t.Fatalf("status=5 期望 2002,实得 %d", code) } code, msg, data := doJSON(t, app, "GET", "/admin/activities/301/signeds", nil) if code != 200 { t.Fatalf("报名列表期望 200,实得 %d(%s)", code, msg) } if !strings.Contains(string(data), `"list":[]`) { t.Fatalf("无报名时 list 必须是 []:%s", string(data)) } // 活动越权 → 3001(**不是** 空列表:两者语义不同) if code, _, _ := doJSON(t, app, "GET", "/admin/activities/999/signeds", nil); code != 3001 { t.Fatalf("不存在/越权活动的报名列表期望 3001,实得 %d", code) } } // ---------------------------------------------------------------- ⑥ 更新 // TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001 更新:`numberSign` 传即 2002;跨机构 3001。 // // 为什么是 2002 不是 3140(v1.6 契约更正):设计 §10.1 限定「3140 只收**参数之间**互相矛盾」; // "传了一个不可写字段"属**单一字段取值非法** ⇒ 2002。3140 若被塞这类判断,会退化成垃圾桶。 // // 会变红的条件: // - 把 `req.NumberSign` 判断删掉(或用值类型接)⇒ 2002 变 200,且 DB 里 number_sign 被改写 → 红; // - 把错误码改回 3140 ⇒ 实得 3140 ≠ 2002 → 红(守住"契约 v1.6 的这一处更正"); // - 去掉 `UpdateActivity` 的 `RowsAffected==0` 守卫 ⇒ 更新别家机构活动返回 200 → 红。 func TestUpdateActivityNumberSignIs2002AndCrossScopeIs3001(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 401, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "10.00", NumberMax: 3, NumberSign: 2, Status: 1, }) seedActivityRow(t, db, model.CoursesActivity{ ID: 402, Type: model.ActivityTypeRecharge, InstitutionID: 2, CampusID: 20, Money: "10.00", Status: 1, }) body := map[string]any{"type": 2, "title": "改标题", "money": "10.00", "status": 1, "numberSign": 7} code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body) if code != 2002 { t.Fatalf("numberSign 传即 2002(契约 §3.4 B 行 25,v1.6),实得 %d(%s)", code, msg) } if got := readActivityByID(t, db, 401).NumberSign; got != 2 { t.Fatalf("2002 被拒后 number_sign 必须一字不动(2),实得 %d", got) } // 去掉 numberSign 后正常更新 delete(body, "numberSign") if code, msg, _ := doJSON(t, app, "PUT", "/admin/activities/401", body); code != 200 { t.Fatalf("正常更新期望 200,实得 %d(%s)", code, msg) } updated := readActivityByID(t, db, 401) if updated.Title == nil || *updated.Title != "改标题" { t.Fatalf("title 未更新:%v", updated.Title) } if updated.NumberSign != 2 { t.Fatalf("更新不得改动 number_sign,实得 %d", updated.NumberSign) } // 跨机构 → 3001(RowsAffected==0 守卫;删掉守卫会变 200) if code, _, _ := doJSON(t, app, "PUT", "/admin/activities/402", body); code != 3001 { t.Fatalf("跨机构更新期望 3001,实得 %d", code) } } // ---------------------------------------------------------------- ⑦ 报名 / 取消报名 // TestSignThenCancelCrossTableEffects 报名 → 取消的**跨表**效果(端点 26/27)。 // // 会变红的条件: // - 去掉 3105 判据 ⇒ 第二次报名返回 200 → 红; // - 取消时只改 status 不归零金额(少写 activity_money/activity_money_used)⇒ 库值断言红; // - 取消时漏 `number_sign -1` ⇒ number_sign 仍为 1 → 红; // - 取消后再查 3001(无有效报名行)⇒ 若变 200 说明"已取消"被判成有效 → 红。 func TestSignThenCancelCrossTableEffects(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 501, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "100.00", NumberMax: 0, Status: 1, }) code, msg, data := doJSON(t, app, "POST", "/admin/activities/501/sign", map[string]any{"userId": "U100"}) if code != 200 { t.Fatalf("报名期望 200,实得 %d(%s)", code, msg) } sign := decodeData[struct { SignedID int64 `json:"signedId"` }](t, data) if sign.SignedID <= 0 { t.Fatalf("报名应回 signedId>0,实得 %d", sign.SignedID) } if n := countRows(t, db, &model.CoursesActivitySigned{}, "activity_id = ? AND user_id = ?", 501, "U100"); n != 1 { t.Fatalf("报名行应恰 1 条,实得 %d", n) } if got := readActivityByID(t, db, 501).NumberSign; got != 1 { t.Fatalf("报名后 number_sign 应为 1,实得 %d", got) } // 重复报名 → 3105(判**报名行** status) if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/sign", map[string]any{"userId": "U100"}); code != 3105 { t.Fatalf("重复报名期望 3105,实得 %d", code) } // 取消 → 三列归零 + number_sign-1(跨表断言,不看响应体) if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign", map[string]any{"userId": "U100"}); code != 200 { t.Fatalf("取消报名期望 200,实得 %d(%s)", code, msg) } var signed model.CoursesActivitySigned if err := db.Where("activity_id = ? AND user_id = ?", 501, "U100").Take(&signed).Error; err != nil { t.Fatalf("读报名行失败: %v", err) } if signed.Status != model.ActivitySignedCancelled { t.Fatalf("取消后 status 应为 0,实得 %d", signed.Status) } if !sameMoney(signed.ActivityMoney, "0.00") || !sameMoney(signed.ActivityMoneyUsed, "0.00") { t.Fatalf("取消后 activity_money/activity_money_used 必须同置 0.00(dump 763028),实得 %q/%q", signed.ActivityMoney, signed.ActivityMoneyUsed) } if got := readActivityByID(t, db, 501).NumberSign; got != 0 { t.Fatalf("取消后 number_sign 应为 0,实得 %d", got) } // 再次取消 → 3001(已无有效报名行) if code, _, _ := doJSON(t, app, "POST", "/admin/activities/501/cancel-sign", map[string]any{"userId": "U100"}); code != 3001 { t.Fatalf("重复取消期望 3001,实得 %d", code) } } // ---------------------------------------------------------------- ⑧ 3107 审核态 // TestCancelSignAuditedIs3107WithZeroSideEffects 已审核(type=2 且 response_user_id 有值)→ 3107,且**零副作用**。 // // 会变红的条件: // - 去掉 3107 判据 ⇒ 返回 200 且 status 变 0 → 红; // - 把 `number_sign-1` 挪到 3107 守卫之前 ⇒ number_sign 变 0(期望 1)→ 红("拒绝必须零副作用")。 func TestCancelSignAuditedIs3107WithZeroSideEffects(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 601, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "100.00", NumberSign: 1, Status: 1, }) auditor := "01HAUDITOR00000000000001" seedSignedRow(t, db, 61, 601, "U200", model.ActivitySignedSigned, "100.00", &auditor) if code, _, _ := doJSON(t, app, "POST", "/admin/activities/601/cancel-sign", map[string]any{"userId": "U200"}); code != 3107 { t.Fatalf("已审核报名期望 3107,实得 %d", code) } var signed model.CoursesActivitySigned if err := db.Where("id = ?", 61).Take(&signed).Error; err != nil { t.Fatalf("读报名行失败: %v", err) } if signed.Status != model.ActivitySignedSigned { t.Fatalf("3107 被拒后 status 必须仍为 1,实得 %d", signed.Status) } if !sameMoney(signed.ActivityMoney, "100.00") { t.Fatalf("3107 被拒后 activity_money 必须仍为 100.00,实得 %q", signed.ActivityMoney) } if got := readActivityByID(t, db, 601).NumberSign; got != 1 { t.Fatalf("3107 被拒后 number_sign 必须仍为 1(守卫在 UPDATE 之前),实得 %d", got) } } // ---------------------------------------------------------------- ⑨ 3121 type 读不到 // TestCancelSignUnresolvableTypeIs3121 报名行在、活动行没了 ⇒ `type` 读不到 ⇒ **3121**(§3.8-R8)。 // // 会变红的条件(**本用例是 R8 的唯一判据**): // - 把 `ExistsAnySigned` 分支删掉 ⇒ 落回 3001 → 红(那就是"读不到 type 就放行/当成不存在"); // - 反过来把分界写反(有报名行 → 3001)⇒ 期望 3121 实得 3001 → 红。 // // 为什么这种情况不是 3001:报名行**确实存在**(§3.8-R7 的 3001 是"查不到报名"); // 缺的是活动行 ⇒ 3107/3108 两条审核态保护无法判定 ⇒ 显式 3121,不得静默取消。 func TestCancelSignUnresolvableTypeIs3121(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) // 报名行存在(status=1),但活动行**不存在**(模拟活动被物理删除的历史脏数据) seedSignedRow(t, db, 71, 701, "U300", model.ActivitySignedSigned, "100.00", nil) if code, msg, _ := doJSON(t, app, "POST", "/admin/activities/701/cancel-sign", map[string]any{"userId": "U300"}); code != 3121 { t.Fatalf("取不到 type 期望 3121(§3.8-R8),实得 %d(%s)——"+ "若为 3001 说明把\"报名行存在但活动行缺失\"当成了\"报名不存在\"", code, msg) } var signed model.CoursesActivitySigned if err := db.Where("id = ?", 71).Take(&signed).Error; err != nil { t.Fatalf("读报名行失败: %v", err) } if signed.Status != model.ActivitySignedSigned { t.Fatalf("3121 被拒后 status 必须仍为 1,实得 %d", signed.Status) } } // ---------------------------------------------------------------- ⑩ 老带新结算 // TestRecommendSettleNoopDoesNotWrite 老带新结算:无 type=4 报名 ⇒ `settled=false` 且**零写入**。 // // 会变红的条件: // - handler 把 `settled` 恒写成 true ⇒ 断言 `false` 失败 → 红; // - 服务层漏掉"游标无行 → LEAVE"⇒ used_log 出现 1 行 → 跨表断言红。 func TestRecommendSettleNoopDoesNotWrite(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 801, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10, Money: "50.00", Status: 1, }) code, msg, data := doJSON(t, app, "POST", "/admin/activities/801/recommend-settle", map[string]any{"userId": "U400", "orderNo": "1001"}) if code != 200 { t.Fatalf("老带新结算期望 200,实得 %d(%s)", code, msg) } res := decodeData[struct { Settled bool `json:"settled"` ActivityID int64 `json:"activityId"` }](t, data) if res.Settled { t.Fatalf("无 type=4 报名时 settled 必须为 false(dump LEAVE 分支),实得 true") } if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ?", 801); n != 0 { t.Fatalf("settled=false 必须零写入,实得 used_log %d 行", n) } } // ---------------------------------------------------------------- ⑪ 取消充值活动 // TestCancelRechargeUnknownCampusIs3104 校区不存在/未归属 ⇒ **3104**(端点 30)。 // // 会变红的条件:删掉校区端口调用(或吞掉 3104)⇒ 变 200/5002 → 红。 func TestCancelRechargeUnknownCampusIs3104(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 901, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "100.00", Status: 1, }) code, msg, _ := doJSON(t, app, "POST", "/admin/activities/901/cancel-recharge", map[string]any{"userId": "U500", "campusId": 999, "money": "10.00"}) if code != 3104 { t.Fatalf("未知校区期望 3104,实得 %d(%s)", code, msg) } } // ---------------------------------------------------------------- ⑫ 可用赠送 // TestUsableBreakdownScopeAndShape 可用赠送:跨机构 1004 / 平台视角角色 1003 / items 构成与总量一致。 // // 会变红的条件: // - 删掉 `institutionId != sc.InstitutionID → 1004` ⇒ 能读到别的机构的池(200)→ 红; // - 删掉平台视角角色白名单 ⇒ academic 角色拿全平台池(200)→ 红; // - `usable` 不等于 Σ items 或 items 没按 activityId 升序 ⇒ 红; // - 加上 `memo` 键(无列支撑)⇒ `"memo"` 不得出现的断言红。 func TestUsableBreakdownScopeAndShape(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) // 同机构两块池(活动 1001 余 60.00、活动 1002 余 30.00)+ 别家机构一块(活动 1003 余 999.00) seedDiscountPool(t, db, 1, 1001, "U600", "100.00", "40.00") seedDiscountPool(t, db, 2, 1002, "U600", "30.00", "0.00") seedDiscountPool(t, db, 3, 1003, "U600", "999.00", "0.00") if err := db.Model(&model.CoursesActivityUserDiscount{}).Where("id = ?", 3). Update("institution_id", 2).Error; err != nil { t.Fatalf("改池机构失败: %v", err) } code, msg, data := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U600&institutionId=1&campusId=10", nil) if code != 200 { t.Fatalf("可用赠送期望 200,实得 %d(%s)", code, msg) } res := decodeData[struct { UserID string `json:"userId"` CampusID int64 `json:"campusId"` Usable string `json:"usable"` Items []struct { ActivityID int64 `json:"activityId"` Amount string `json:"amount"` } `json:"items"` }](t, data) if res.Usable != "90.00" { t.Fatalf("usable 应为 90.00(60+30,不含别家机构),实得 %q", res.Usable) } if len(res.Items) != 2 { t.Fatalf("items 应有 2 项(按活动),实得 %d", len(res.Items)) } if res.Items[0].ActivityID != 1001 || res.Items[1].ActivityID != 1002 { t.Fatalf("items 必须按 activityId 升序,实得 %+v", res.Items) } sum := money.Amount(0) for _, item := range res.Items { amount, err := money.Parse(item.Amount) if err != nil { t.Fatalf("items[].amount 不是合法金额:%q", item.Amount) } sum = sum.Add(amount) } usable, _ := money.Parse(res.Usable) if sum != usable { t.Fatalf("usable(%s) 必须等于 Σ items(%s)", res.Usable, sum.String()) } if strings.Contains(string(data), `"memo"`) { t.Fatalf("items 不得下发 memo(赠送池无 memo 列,契约缺口待裁决):%s", string(data)) } // 跨机构 → 1004 if code, _, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U600&institutionId=2&campusId=20", nil); code != 1004 { t.Fatalf("跨机构查询期望 1004,实得 %d", code) } // 平台视角(institutionId=0):finance 放行;academic → 1003 if code, _, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil); code != 200 { t.Fatalf("平台视角(finance)应放行,实得 %d", code) } if code, _, _ := doJSON(t, app, "GET", "/admin/activities/discounts/usable?userId=U600&institutionId=0&campusId=0", nil, "X-Test-Scope", "academic"); code != 1003 { t.Fatalf("平台视角 academic 角色期望 1003(设计 §3.2-A7),实得 %d", code) } } // ---------------------------------------------------------------- ⑬ 赠送使用 // TestDiscountUseNoopAndInsufficient 端点 32:金额 0 → `used=false` 且零写入;无池 → **3109**。 // // 会变红的条件: // - 把 `amount<=0` 改成报错/或照样扣 ⇒ used/库值断言红(§3.8-Z1 要求"跳过"); // - 删掉"可用不足 → 3109"⇒ 变 200 且池被扣成负 → 红。 func TestDiscountUseNoopAndInsufficient(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 1101, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10, Money: "100.00", Status: 1, }) seedDiscountPool(t, db, 11, 1101, "U700", "100.00", "0.00") // ① 金额 0 → no-op(不是错误) code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/use", map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "0.00"}) if code != 200 { t.Fatalf("金额 0 期望 200(dump no-op),实得 %d(%s)", code, msg) } if decodeData[struct { Used bool `json:"used"` }](t, data).Used { t.Fatalf("金额 0 时 used 必须为 false") } if n := countRows(t, db, &model.CoursesActivityUserDiscountLog{}, "activity_user_discount_id = ?", 11); n != 0 { t.Fatalf("金额 0 必须零写入,实得流水 %d 行", n) } // ② 超过可用 → 3109(Go 增强:dump 静默 LEAVE) if code, _, _ := doJSON(t, app, "POST", "/admin/activities/discounts/use", map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "200.00"}); code != 3109 { t.Fatalf("可用不足期望 3109,实得 %d", code) } // ③ 正常使用 → used=true + 池已用额 + 负值流水(跨表) // // ⚠ 活动 type=3(代金券)的口径(§3.2 #15,dump 763229):**实际占用额 = 券面额**, // 不是请求额("一次用完一张")。故请求 10.00、池却记 100.00 —— 这正是 dump 的既有行为, // 若有人"顺手修正"成请求额,本断言立刻红(这是**有意锁定**的偏差点,不是测试笔误)。 code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/use", map[string]any{"userId": "U700", "activityId": 1101, "coursesStudentId": 1, "amount": "10.00"}) if code != 200 { t.Fatalf("正常使用期望 200,实得 %d(%s)", code, msg) } if !decodeData[struct { Used bool `json:"used"` }](t, data).Used { t.Fatalf("正常使用 used 应为 true") } var pool model.CoursesActivityUserDiscount if err := db.Where("id = ?", 11).Take(&pool).Error; err != nil { t.Fatalf("读池失败: %v", err) } if !sameMoney(pool.DiscountAmountUsed, "100.00") { t.Fatalf("代金券(type=3) 已用额应为券面额 100.00(dump 763229),实得 %q", pool.DiscountAmountUsed) } var logRow model.CoursesActivityUserDiscountLog if err := db.Where("activity_user_discount_id = ?", 11).Take(&logRow).Error; err != nil { t.Fatalf("读流水失败: %v", err) } if !sameMoney(logRow.DiscountAmountUsed, "-100.00") { t.Fatalf("流水必须是负值(消费方向)且等于面额 -100.00,实得 %q", logRow.DiscountAmountUsed) } } // ---------------------------------------------------------------- ⑭ 充值赠送使用 // TestRechargeGiftUseRequiresAttendedStatus 端点 35:报名行必须 `status=2`(dump 游标 763262)。 // // 会变红的条件: // - 删掉 `status=2` 前置 ⇒ status=1 也扣(used=true)→ 红; // - 扣减后 `activity_money_used` 未更新 ⇒ 跨表断言红。 func TestRechargeGiftUseRequiresAttendedStatus(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 1201, Type: model.ActivityTypeRecharge, InstitutionID: 1, CampusID: 10, Money: "100.00", Status: 1, }) seedSignedRow(t, db, 121, 1201, "U800", model.ActivitySignedSigned, "100.00", nil) // status=1(仅报名) code, msg, data := doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use", map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"}) if code != 200 { t.Fatalf("期望 200(no-op),实得 %d(%s)", code, msg) } if decodeData[struct { Used bool `json:"used"` }](t, data).Used { t.Fatalf("status=1(未参加)时 used 必须为 false(dump 763262 前置)") } // 置为已参加 → 正常扣减 if err := db.Model(&model.CoursesActivitySigned{}).Where("id = ?", 121). Update("status", model.ActivitySignedAttended).Error; err != nil { t.Fatalf("改报名状态失败: %v", err) } code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/use", map[string]any{"userId": "U800", "activityId": 1201, "coursesStudentId": 1, "amount": "30.00"}) if code != 200 { t.Fatalf("期望 200,实得 %d(%s)", code, msg) } if !decodeData[struct { Used bool `json:"used"` }](t, data).Used { t.Fatalf("status=2 时 used 应为 true") } var signed model.CoursesActivitySigned if err := db.Where("id = ?", 121).Take(&signed).Error; err != nil { t.Fatalf("读报名行失败: %v", err) } if !sameMoney(signed.ActivityMoneyUsed, "30.00") { t.Fatalf("activity_money_used 应为 30.00,实得 %q", signed.ActivityMoneyUsed) } if n := countRows(t, db, &model.CoursesActivityUsedLog{}, "activity_id = ? AND user_id = ?", 1201, "U800"); n != 1 { t.Fatalf("used_log 应恰 1 行,实得 %d", n) } } // ---------------------------------------------------------------- ⑮ 三条退回 // TestRefundEndpointsReportRefundedFlag 端点 33/34/36:无可用流水 ⇒ `refunded=false`(不是恒 true)。 // // 会变红的条件:把响应的 `refunded` 写成常量 true("操作成功即 true")⇒ 三条断言全红。 // 此外 33 的正常路径做**跨表**断言(流水回冲 + 池已用额下降)。 func TestRefundEndpointsReportRefundedFlag(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) // 端点 33:无流水 → false code, msg, data := doJSON(t, app, "POST", "/admin/activities/discounts/refund", map[string]any{"coursesStudentId": 4242, "amount": "10.00"}) if code != 200 { t.Fatalf("赠送退回期望 200,实得 %d(%s)", code, msg) } if decodeData[struct { Refunded bool `json:"refunded"` }](t, data).Refunded { t.Fatalf("无可用流水时 refunded 必须为 false") } // 端点 34:无 log → false seedActivityRow(t, db, model.CoursesActivity{ ID: 1301, Type: model.ActivityTypeVoucher, InstitutionID: 1, CampusID: 10, Money: "100.00", Status: 1, }) code, msg, data = doJSON(t, app, "POST", "/admin/activities/discounts/refund-by-activity", map[string]any{"userId": "U900", "activityId": 1301, "amount": "10.00"}) if code != 200 { t.Fatalf("按活动退回期望 200,实得 %d(%s)", code, msg) } if decodeData[struct { Refunded bool `json:"refunded"` }](t, data).Refunded { t.Fatalf("无 log 时 refunded 必须为 false(dump no-op)") } // 端点 36:无 used_log → false code, msg, data = doJSON(t, app, "POST", "/admin/activities/recharge-gifts/refund", map[string]any{"coursesStudentId": 5555, "amount": "10.00"}) if code != 200 { t.Fatalf("充值赠送退回期望 200,实得 %d(%s)", code, msg) } if decodeData[struct { Refunded bool `json:"refunded"` }](t, data).Refunded { t.Fatalf("无 used_log 时 refunded 必须为 false") } } // ---------------------------------------------------------------- ⑯ 回访 // TestVisitRejectsCrossActivitySigned 回访:报名行不属于路径活动 ⇒ **3001**;正常 ⇒ 落库 1 行。 // // 会变红的条件: // - 删掉 `signed.ActivityID != in.ActivityID` 判断 ⇒ 跨活动回访 200 且写出一行(无法事后筛出)→ 红; // - `visited` 恒 false 或漏写库 ⇒ 跨表断言红。 func TestVisitRejectsCrossActivitySigned(t *testing.T) { db := newHandlerTestDB(t) app := newActivityTestApp(t, db) seedActivityRow(t, db, model.CoursesActivity{ ID: 1401, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10, Money: "0.00", Status: 1, }) seedActivityRow(t, db, model.CoursesActivity{ ID: 1402, Type: model.ActivityTypeRecommend, InstitutionID: 1, CampusID: 10, Money: "0.00", Status: 1, }) seedSignedRow(t, db, 141, 1402, "U950", model.ActivitySignedSigned, "0.00", nil) // 报名行 141 属于活动 1402,却提交到活动 1401 的回访端点 → 3001 code, msg, _ := doJSON(t, app, "POST", "/admin/activities/1401/visits", map[string]any{"activitySignedId": 141, "memo": "电话回访"}) if code != 3001 { t.Fatalf("跨活动回访期望 3001,实得 %d(%s)", code, msg) } if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 0 { t.Fatalf("被拒的跨活动回访不得写库,实得 %d 行", n) } // 正确活动 → 200 + 1 行 visit code, msg, data := doJSON(t, app, "POST", "/admin/activities/1402/visits", map[string]any{"activitySignedId": 141, "memo": "电话回访"}) if code != 200 { t.Fatalf("回访登记期望 200,实得 %d(%s)", code, msg) } res := decodeData[struct { Visited bool `json:"visited"` ActivityID int64 `json:"activityId"` }](t, data) if !res.Visited || res.ActivityID != 1402 { t.Fatalf("回访响应错:%+v", res) } if n := countRows(t, db, &model.CoursesActivityVisit{}, "activity_signed_id = ?", 141); n != 1 { t.Fatalf("回访应落库 1 行,实得 %d", n) } } // sameMoney 比较两个元字符串的**金额值**(不比较字面量)。 // // 为什么不能直接比字符串:sqlite 把 `DECIMAL(12,2)` 存成 NUMERIC,读回时 `"0.00"` 会变成 `"0"`、 // `"100.00"` 变成 `"100"`(MySQL 的 DECIMAL 会原样返回两位)。断言必须比"值",否则同一用例 // 在 sqlite 上红、在 MySQL 上绿 —— 那是"测试环境差异",不是实现差异。 // 生产语义(对外 JSON)由 `money.Amount.MarshalJSON` 保证恒两位小数,不在本助手范围。 func sameMoney(got, want string) bool { gotAmount, gerr := money.Parse(got) wantAmount, werr := money.Parse(want) if gerr != nil || werr != nil { return false } return gotAmount == wantAmount } // withFields 复制 base 并覆盖若干 k/v(k,v 交替),避免用例间互相污染 body。 func withFields(base map[string]any, kv ...any) map[string]any { out := make(map[string]any, len(base)+len(kv)/2) for k, v := range base { out[k] = v } for i := 0; i+1 < len(kv); i += 2 { key, _ := kv[i].(string) out[key] = kv[i+1] } return out }