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