| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- // RecommendService 单测(AC-08):老带新双边结算 + 取消充值活动 / 欠费核销的转调。
- //
- // 断言纪律(对照 §5.1 第 4/5 条):
- // 1. **双边各自入账**(不是"总额对了就行"):被推荐人与推荐人的
- // `balance` / `total_recommended_amount` **各加一次**、**各一条 107**。
- // 只断言"存在 107 流水"或"某一边余额增加"的用例,在"只给一边入账"的实现下依然全绿。
- // 2. **`balance_after` 各取本 user 全局快照**(两个值不要求相等,多校区/多历史下本来不等)。
- // 3. **幂等回放等价(纪律 5)**:造"首次结算后账户又被别的写入改过"的场景,
- // 再次结算必须 `repeated=true` 且响应体余额 == **当刻库值**(回放旧值即红)。
- // 4. **转调不重复实现**:取消充值活动与欠费核销分别只经 RefundService / MaintenanceService,
- // 用例同时证明其资金效果与幂等回读。
- package service
- import (
- "context"
- "testing"
- "time"
- "gorm.io/gorm"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/port"
- )
- // ---------------------------------------------------------------- 辅助
- // seedStudentCourse 落一行报课记录(老带新的机构/校区兜底来源)。
- func seedStudentCourse(t *testing.T, db *gorm.DB, id int64, studentID string,
- institutionID, campusID int64, remainderReceivable string) {
- t.Helper()
- confirmUser := "01HCONFIRM000000000000001"
- row := &model.CoursesStudent{
- ID: id, BizNo: "BIZ-" + time.Now().Format("150405.000000"), CoursesID: 1, ClassID: 1,
- InstitutionID: institutionID, CampusID: campusID, StudentID: studentID,
- RemainderReceivable: remainderReceivable, NetReceipts: "0.00",
- ConfirmDiscountUserID: &confirmUser,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报课记录(%d) 失败: %v", id, err)
- }
- }
- // seedCoursesClass 落一行排班规则(欠费核销候选集的 INNER JOIN 对象)。
- func seedCoursesClass(t *testing.T, db *gorm.DB, coursesID, classID int64, startTime time.Time) {
- t.Helper()
- row := &model.CoursesClass{
- CoursesID: coursesID, ClassID: classID, InstitutionID: 1, CampusID: 10,
- StartTime: &startTime,
- }
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 排班规则失败: %v", err)
- }
- }
- // usedLogsOfActivity 取某活动的 used_log 行(按 id ASC)。
- func usedLogsOfActivity(t *testing.T, db *gorm.DB, activityID int64) []model.CoursesActivityUsedLog {
- t.Helper()
- var rows []model.CoursesActivityUsedLog
- if err := db.Where("activity_id = ?", activityID).Order("id ASC").Find(&rows).Error; err != nil {
- t.Fatalf("读 used_log 失败: %v", err)
- }
- return rows
- }
- // ---------------------------------------------------------------- 老带新结算(SP18)
- // TestSettleRecommendDoubleSided 核心用例:双边各入一次账、各一条 107、used_log 负值、signed 结算态。
- func TestSettleRecommendDoubleSided(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- newUser := fundUserID(40)
- recUser := fundUserID(41)
- seedActivityFull(t, db, 500, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- rec := recUser
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 500, UserID: newUser, InstitutionID: 1, CampusID: 10,
- Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
- RecommendUserID: &rec,
- }).Error; err != nil {
- t.Fatalf("seed 老带新报名失败: %v", err)
- }
- res, err := svc.SettleRecommend(ctx, SettleRecommendInput{
- UserID: newUser, OrderNo: "7001", Remark: "报名成功",
- })
- if err != nil {
- t.Fatalf("老带新结算失败: %v", err)
- }
- if !res.Settled || res.Repeated {
- t.Fatalf("首次结算应 Settled=true/Repeated=false,实得 %+v", res)
- }
- if res.RecommendUserID != recUser || res.Amount.Cents() != 10000 {
- t.Fatalf("响应应回显推荐人与金额,实得 %+v", res)
- }
- // ① 双边 **各自** 加 balance + total_recommended_amount(不是"合计对就行")
- for _, uid := range []string{newUser, recUser} {
- global := readGlobal(t, db, uid)
- if cents(t, global.Balance) != 10000 {
- t.Fatalf("user %s 全局余额应 10000 分,实得 %d", uid, cents(t, global.Balance))
- }
- if cents(t, global.TotalRecommendedAmount) != 10000 {
- t.Fatalf("user %s 累计推荐额应 10000 分,实得 %d", uid, cents(t, global.TotalRecommendedAmount))
- }
- campus := readSchool(t, db, uid, 1, 10)
- if cents(t, campus.Balance) != 10000 {
- t.Fatalf("user %s 校区余额应 10000 分,实得 %d", uid, cents(t, campus.Balance))
- }
- if cents(t, campus.TotalRecommendedAmount) != 10000 {
- t.Fatalf("user %s 校区累计推荐额应 10000 分,实得 %d", uid, cents(t, campus.TotalRecommendedAmount))
- }
- }
- // ② **两条** 107,各属一个 user,金额都是 +m,logid = 报课单号
- for _, uid := range []string{newUser, recUser} {
- logs := logsByType(t, db, uid, model.ExpenseLogTypeRecommend)
- if len(logs) != 1 {
- t.Fatalf("user %s 应有 1 条 107,实得 %d 条", uid, len(logs))
- }
- if cents(t, logs[0].Money) != 10000 {
- t.Fatalf("user %s 的 107 金额应 10000 分,实得 %d", uid, cents(t, logs[0].Money))
- }
- if logs[0].LogID == nil || *logs[0].LogID != "7001" {
- t.Fatalf("user %s 的 107 logid 应为报课单号 7001,实得 %v", uid, logs[0].LogID)
- }
- if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
- t.Fatalf("user %s 的 balance_after 应为本 user 全局快照 10000 分,实得 %v", uid, logs[0].BalanceAfter)
- }
- }
- // ③ used_log:**负值** -m + 推荐人 + 单号
- used := usedLogsOfActivity(t, db, 500)
- if len(used) != 1 {
- t.Fatalf("used_log 应 1 条,实得 %d 条", len(used))
- }
- if cents(t, used[0].ActivityMoneyUsed) != -10000 {
- t.Fatalf("used_log 金额应为 -10000 分,实得 %d", cents(t, used[0].ActivityMoneyUsed))
- }
- if used[0].RecommendUserID == nil || *used[0].RecommendUserID != recUser {
- t.Fatalf("used_log 推荐人应为 %s,实得 %v", recUser, used[0].RecommendUserID)
- }
- // ④ signed:结算态(status=2、activity_money_used=+m)
- signed := readSignedRow(t, db, newUser, 500)
- if signed.Status != model.ActivitySignedAttended || cents(t, signed.ActivityMoneyUsed) != 10000 {
- t.Fatalf("报名行应结算为 status=2/used=10000,实得 status=%d used=%d",
- signed.Status, cents(t, signed.ActivityMoneyUsed))
- }
- }
- // TestSettleRecommendReplayReadsLiveState 幂等回放等价(纪律 5):
- // 首次结算后**别的写入改过账户**,再次结算的响应体必须是当刻库值(回放旧值即红)。
- func TestSettleRecommendReplayReadsLiveState(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- newUser := fundUserID(42)
- recUser := fundUserID(43)
- seedActivityFull(t, db, 501, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- rec := recUser
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 501, UserID: newUser, InstitutionID: 1, CampusID: 10,
- Status: model.ActivitySignedSigned, ActivityMoney: "100.00", ActivityMoneyUsed: "0.00",
- RecommendUserID: &rec,
- }).Error; err != nil {
- t.Fatalf("seed 老带新报名失败: %v", err)
- }
- first, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
- if err != nil {
- t.Fatalf("首次结算失败: %v", err)
- }
- // 造"首次执行后账户被其它写入改过":被推荐人又充了 55 元
- if err := db.Model(&model.UserExpense{}).Where("user_id = ?", newUser).
- UpdateColumn("balance", "155.00").Error; err != nil {
- t.Fatalf("模拟外部写入失败: %v", err)
- }
- if err := db.Model(&model.UserExpenseSchool{}).Where("user_id = ?", newUser).
- UpdateColumn("balance", "155.00").Error; err != nil {
- t.Fatalf("模拟外部写入失败: %v", err)
- }
- second, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7002"})
- if err != nil {
- t.Fatalf("二次结算失败: %v", err)
- }
- if !second.Repeated {
- t.Fatalf("二次结算应 repeated=true(used_log 已存在)")
- }
- // ★ 回读断言:响应体每个金额字段 == 当刻库值
- if second.UserBalance.Cents() != 15500 {
- t.Fatalf("回放必须回读当刻余额 15500 分(旧值 10000 → 红),实得 %d", second.UserBalance.Cents())
- }
- if second.UserCampusBalance.Cents() != 15500 {
- t.Fatalf("回放必须回读当刻校区余额 15500 分,实得 %d", second.UserCampusBalance.Cents())
- }
- if second.UserLogID != first.UserLogID || second.UsedLogID != first.UsedLogID {
- t.Fatalf("回放应命中同一条 107/used_log(first=%d/%d second=%d/%d)",
- first.UserLogID, first.UsedLogID, second.UserLogID, second.UsedLogID)
- }
- // 幂等不得再写一笔钱
- if global := readGlobal(t, db, newUser); cents(t, global.Balance) != 15500 {
- t.Fatalf("幂等命中不得再次入账,实得余额 %d", cents(t, global.Balance))
- }
- if logs := logsByType(t, db, newUser, model.ExpenseLogTypeRecommend); len(logs) != 1 {
- t.Fatalf("幂等命中不得再写 107,实得 %d 条", len(logs))
- }
- }
- // TestSettleRecommendSkipsWithoutRecommender 无推荐人 → 零写入(dump 763340 LEAVE)。
- func TestSettleRecommendSkipsWithoutRecommender(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(44)
- seedActivityFull(t, db, 502, model.ActivityTypeRecommend, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 502, UserID: user, InstitutionID: 1, CampusID: 10,
- Status: model.ActivitySignedSigned, ActivityMoney: "100.00",
- }).Error; err != nil {
- t.Fatalf("seed 报名失败: %v", err)
- }
- res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7003"})
- if err != nil {
- t.Fatalf("结算失败: %v", err)
- }
- if res.Settled {
- t.Fatalf("无推荐人时应零写入(Settled=false),实得 %+v", res)
- }
- if len(usedLogsOfActivity(t, db, 502)) != 0 {
- t.Fatalf("无推荐人时不得写 used_log")
- }
- if len(logsByType(t, db, user, model.ExpenseLogTypeRecommend)) != 0 {
- t.Fatalf("无推荐人时不得写 107")
- }
- }
- // TestSettleRecommendResolvesScopeFromStudentCourse 活动 schoolid/campusid=0 → 从报课记录回查(dump 763348)。
- func TestSettleRecommendResolvesScopeFromStudentCourse(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- newUser := fundUserID(45)
- recUser := fundUserID(46)
- // 活动为"全机构池"(campus_id=0)且 institution_id=0 → 必须回查报课记录
- seedActivityFull(t, db, 503, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
- rec := recUser
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 503, UserID: newUser, InstitutionID: 0, CampusID: 0,
- Status: model.ActivitySignedSigned, ActivityMoney: "80.00",
- RecommendUserID: &rec,
- }).Error; err != nil {
- t.Fatalf("seed 报名失败: %v", err)
- }
- seedStudentCourse(t, db, 7100, newUser, 1, 10, "0.00")
- res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: newUser, OrderNo: "7100"})
- if err != nil {
- t.Fatalf("结算失败: %v", err)
- }
- if !res.Settled || res.InstitutionID != 1 || res.CampusID != 10 {
- t.Fatalf("应从报课记录回查机构/校区 (1/10),实得 settled=%v %d/%d",
- res.Settled, res.InstitutionID, res.CampusID)
- }
- if got := cents(t, readGlobal(t, db, newUser).Balance); got != 8000 {
- t.Fatalf("被推荐人余额应 8000 分,实得 %d", got)
- }
- if got := cents(t, readSchool(t, db, recUser, 1, 10).Balance); got != 8000 {
- t.Fatalf("推荐人校区余额应 8000 分,实得 %d", got)
- }
- }
- // TestSettleRecommendSkipsWhenScopeUnresolvable 活动与报课记录都给不出机构/校区 → 零写入(dump 763355)。
- func TestSettleRecommendSkipsWhenScopeUnresolvable(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(47)
- seedActivityFull(t, db, 504, model.ActivityTypeRecommend, "80.00", 0, 0, 0, model.ActivityStatusEnabled)
- rec := fundUserID(48)
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 504, UserID: user, InstitutionID: 0, CampusID: 0,
- Status: model.ActivitySignedSigned, ActivityMoney: "80.00", RecommendUserID: &rec,
- }).Error; err != nil {
- t.Fatalf("seed 报名失败: %v", err)
- }
- res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "999999"})
- if err != nil {
- t.Fatalf("结算失败: %v", err)
- }
- if res.Settled || len(usedLogsOfActivity(t, db, 504)) != 0 {
- t.Fatalf("机构/校区无法解析时应零写入,实得 %+v", res)
- }
- }
- // TestSettleRecommendSkipsOtherActivityTypes 非 type=4 的报名不进入老带新结算。
- func TestSettleRecommendSkipsOtherActivityTypes(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- user := fundUserID(49)
- seedActivityFull(t, db, 505, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- rec := fundUserID(50)
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: 505, UserID: user, InstitutionID: 1, CampusID: 10,
- Status: model.ActivitySignedSigned, ActivityMoney: "100.00", RecommendUserID: &rec,
- }).Error; err != nil {
- t.Fatalf("seed 报名失败: %v", err)
- }
- res, err := svc.SettleRecommend(ctx, SettleRecommendInput{UserID: user, OrderNo: "7004"})
- if err != nil {
- t.Fatalf("结算失败: %v", err)
- }
- if res.Settled {
- t.Fatalf("非老带新活动不得结算,实得 %+v", res)
- }
- }
- // ---------------------------------------------------------------- 取消充值活动(SP8 转调)
- // TestRecommendCancelRechargeActivityDelegates 转调 RefundService:209 流水 -m、signed 归 0、余额减少。
- func TestRecommendCancelRechargeActivityDelegates(t *testing.T) {
- db := newActivityDomainDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
- ctx := context.Background()
- user := fundUserID(51)
- seedCampus(t, db, 10, 1, true)
- seedActivityFull(t, db, 506, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 506, model.ActivitySignedSigned, "100.00", "0.00")
- if _, err := fund.Recharge(ctx, RechargeInput{
- UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-1",
- }); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
- UserID: user, ActivityID: 506, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-1",
- })
- if err != nil {
- t.Fatalf("取消充值活动失败: %v", err)
- }
- if res.Repeated || res.InstitutionID != 1 || res.Balance.Cents() != 20000 {
- t.Fatalf("首次取消应 non-repeated/机构 1/余额 20000,实得 %+v", res)
- }
- if logs := logsByType(t, db, user, model.ExpenseLogTypeCancelRechargeAct); len(logs) != 1 ||
- cents(t, logs[0].Money) != -10000 {
- t.Fatalf("应写 1 条 209 且金额 -10000 分,实得 %+v", logs)
- }
- if signed := readSignedRow(t, db, user, 506); signed.Status != model.ActivitySignedCancelled {
- t.Fatalf("报名状态应归 0,实得 %d", signed.Status)
- }
- }
- // TestRecommendCancelRechargeReplayReadsLiveState 转调路径的幂等回放也必须回读当刻库值(纪律 5)。
- func TestRecommendCancelRechargeReplayReadsLiveState(t *testing.T) {
- db := newActivityDomainDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRecommendService(db, fundSuperScope()).WithCampusReader(port.NewOrgCampusReader(db))
- ctx := context.Background()
- user := fundUserID(52)
- seedCampus(t, db, 10, 1, true)
- seedActivityFull(t, db, 507, model.ActivityTypeRecharge, "100.00", 1, 10, 0, model.ActivityStatusEnabled)
- seedSignedFull(t, db, user, 507, model.ActivitySignedSigned, "100.00", "0.00")
- if _, err := fund.Recharge(ctx, RechargeInput{
- UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "300.00"), RequestID: "REQ-SEED-2",
- }); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- first, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
- UserID: user, ActivityID: 507, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
- })
- if err != nil {
- t.Fatalf("首次取消失败: %v", err)
- }
- if first.Balance.Cents() != 20000 {
- t.Fatalf("首次取消后校区余额应 20000 分,实得 %d", first.Balance.Cents())
- }
- // 首次执行后被别的写入改过(再充 50)
- if _, err := fund.Recharge(ctx, RechargeInput{
- UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "50.00"), RequestID: "REQ-SEED-3",
- }); err != nil {
- t.Fatalf("模拟外部写入失败: %v", err)
- }
- second, err := svc.CancelRechargeActivity(ctx, CancelRechargeActivityInput{
- UserID: user, ActivityID: 507, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-CANCEL-2",
- })
- if err != nil {
- t.Fatalf("二次取消失败: %v", err)
- }
- if !second.Repeated {
- t.Fatalf("二次取消应 repeated=true")
- }
- if second.Balance.Cents() != 25000 {
- t.Fatalf("回放必须回读当刻校区余额 25000 分(旧值 20000 → 红),实得 %d", second.Balance.Cents())
- }
- if second.LogID != first.LogID {
- t.Fatalf("回放应命中同一条 209(first=%d second=%d)", first.LogID, second.LogID)
- }
- }
- // ---------------------------------------------------------------- 欠费核销(SP27 转调)
- // TestRecommendCounteractArrearsDelegates 转调 MaintenanceService:按剩余应收核销并写回两列。
- func TestRecommendCounteractArrearsDelegates(t *testing.T) {
- db := newActivityDomainDB(t)
- svc := NewRecommendService(db, fundSuperScope())
- ctx := context.Background()
- student := fundUserID(53)
- startAt := time.Date(2026, 11, 1, 9, 0, 0, 0, time.UTC)
- seedCoursesClass(t, db, 1, 1, startAt)
- seedStudentCourse(t, db, 7200, student, 1, 10, "60.00")
- res, err := svc.CounteractArrears(ctx, CounteractArrearsInput{
- StudentID: student, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "100.00"),
- })
- if err != nil {
- t.Fatalf("欠费核销失败: %v", err)
- }
- if res.Applied.Cents() != 6000 {
- t.Fatalf("应核销 6000 分(截到剩余应收),实得 %d", res.Applied.Cents())
- }
- if res.Remaining.Cents() != 4000 {
- t.Fatalf("未用完部分应 4000 分留在账户,实得 %d", res.Remaining.Cents())
- }
- var row model.CoursesStudent
- if err := db.Where("id = ?", 7200).Take(&row).Error; err != nil {
- t.Fatalf("读报课记录失败: %v", err)
- }
- if cents(t, row.NetReceipts) != 6000 || cents(t, row.RemainderReceivable) != 0 {
- t.Fatalf("核销后 net_receipts 应 6000/remainder 0,实得 %d/%d",
- cents(t, row.NetReceipts), cents(t, row.RemainderReceivable))
- }
- }
|