| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022 |
- // FundService 单测(AC-04):充值双表 / 幂等(含 20 并发)/ 消费三列组合 / 退费 BUG#2 /
- // 可退金额 / 坏账 / 累计消费 / 落库金额越界负例。
- //
- // 测试栈(与 AC-03 仓储测试同一套手法):
- // - 行为用例:glebarez/sqlite 文件库(真实事务、真实唯一键 uk_request_type);
- // - SQL 顺序用例:给 GORM 挂"抓 SQL"的 logger(sqlRecorder),断言仓储**真实生成**的语句顺序;
- // - 索引纪律:academic model 多表共用 `idx_inst_campus` 等索引名,SQLite 的索引名是**库级**唯一
- // (MySQL 表级),故 migrateFundTables 逐表迁移后**只删非唯一索引**——
- // `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 必须保留,
- // 否则幂等用例会静默变成"永远不撞键"的假绿。
- package service
- import (
- "context"
- "errors"
- "fmt"
- "path/filepath"
- "strings"
- "sync"
- "testing"
- "time"
- gormsqlite "github.com/glebarez/sqlite"
- "gorm.io/gorm"
- "gorm.io/gorm/logger"
- "yuansiwei/unified-backend/internal/academic/apperr"
- "yuansiwei/unified-backend/internal/academic/model"
- "yuansiwei/unified-backend/internal/academic/money"
- "yuansiwei/unified-backend/internal/academic/port"
- "yuansiwei/unified-backend/internal/academic/repository"
- infraerrors "yuansiwei/unified-backend/internal/infra/errors"
- appscope "yuansiwei/unified-backend/internal/infra/scope"
- )
- // ---------------------------------------------------------------- 测试基础设施
- // sqlRecorder GORM logger:按执行顺序收集 SQL(不改写、不过滤)。
- //
- // 用途:断言"全局账户锁先于校区账户锁"这类**顺序性**红约束(顺序错 = 与并发充值交叉即成环)。
- // 只实现 logger.Interface 的最小集合(Trace 收到 Explain 后的完整 SQL)。
- type sqlRecorder struct {
- mu sync.Mutex
- stmts []string
- }
- // Printf 收口(logger.Writer 兼容方法)。
- func (r *sqlRecorder) Printf(format string, args ...any) {
- msg := strings.TrimSpace(fmt.Sprintf(format, args...))
- if msg == "" {
- return
- }
- r.mu.Lock()
- defer r.mu.Unlock()
- r.stmts = append(r.stmts, msg)
- }
- // LogMode 实现 logger.Interface(不做级别过滤)。
- func (r *sqlRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
- // Info 实现 logger.Interface(单测不需要普通日志)。
- func (r *sqlRecorder) Info(context.Context, string, ...any) {}
- // Warn 实现 logger.Interface。
- func (r *sqlRecorder) Warn(context.Context, string, ...any) {}
- // Error 实现 logger.Interface。
- func (r *sqlRecorder) Error(context.Context, string, ...any) {}
- // Trace 实现 logger.Interface:把本次语句追加进 stmts。
- func (r *sqlRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
- sql, _ := fc()
- r.Printf("%s", sql)
- }
- // Snapshot 返回当前收集到的语句快照(调用方不得修改返回切片内部元素语义)。
- func (r *sqlRecorder) Snapshot() []string {
- r.mu.Lock()
- defer r.mu.Unlock()
- out := make([]string, len(r.stmts))
- copy(out, r.stmts)
- return out
- }
- // Reset 清空收集。
- func (r *sqlRecorder) Reset() {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.stmts = nil
- }
- // migrateFundTables 逐表 AutoMigrate,并**只删除非唯一索引**。
- //
- // 为什么只删非唯一:SQLite 索引名库级唯一(`idx_inst_campus` 同时出现在
- // t_user_expense_school 与 t_courses_activity_signed),一次性迁移会报 already exists;
- // 但 `uk_request_type` / `uk_user_activity` / `uk_user_inst_campus` 是**幂等与唯一性的实现载体**,
- // 删掉会让"并发重复提交只落 1 条流水"的用例变成永远通过的假绿。
- func migrateFundTables(t *testing.T, db *gorm.DB, tables ...any) {
- t.Helper()
- for _, tbl := range tables {
- if err := db.AutoMigrate(tbl); err != nil {
- t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
- }
- stmt := &gorm.Statement{DB: db}
- if err := stmt.Parse(tbl); err != nil {
- t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
- }
- type idxRow struct {
- Name string
- SQL *string
- }
- var rows []idxRow
- if err := db.Raw(
- "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
- stmt.Schema.Table).Scan(&rows).Error; err != nil {
- t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
- }
- for _, row := range rows {
- sqlText := ""
- if row.SQL != nil {
- sqlText = strings.ToUpper(*row.SQL)
- }
- if strings.Contains(sqlText, "UNIQUE") {
- continue // 唯一约束必须保留(幂等键/业务 UK)
- }
- if err := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
- t.Fatalf("删除索引(%s) 失败: %v", row.Name, err)
- }
- }
- }
- }
- // newFundServiceDB 余额域测试库(余额 3 表 + 活动 2 表 + 简化 t_campus)。
- //
- // t_campus 用原生 DDL 建(academic 侧只有 port 的只读 row struct,不参与 AutoMigrate,
- // 这正是设计 §1.3「跨域只读」的落地形态:academic 不建 org 的表,只读)。
- func newFundServiceDB(t *testing.T) *gorm.DB {
- t.Helper()
- db, _ := newFundServiceDBWithRecorder(t)
- return db
- }
- // newFundServiceDBWithRecorder 同 newFundServiceDB,并返回 SQL 记录器。
- func newFundServiceDBWithRecorder(t *testing.T) (*gorm.DB, *sqlRecorder) {
- t.Helper()
- dsn := filepath.Join(t.TempDir(), "academic_fund_service_test.db")
- rec := &sqlRecorder{}
- db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{Logger: rec})
- if err != nil {
- t.Fatalf("gorm.Open(余额域文件库) 失败: %v", err)
- }
- sqlDB, err := db.DB()
- if err != nil {
- t.Fatalf("取 sql.DB 失败: %v", err)
- }
- // 并发用例需要"多 goroutine 抢同一库":SQLite 写事务必须串行,
- // 单连接池即可让 20 个 goroutine 依次推进(生产 MySQL 是行锁串行,此处用连接池串行等价模拟)。
- sqlDB.SetMaxOpenConns(1)
- t.Cleanup(func() { _ = sqlDB.Close() })
- migrateFundTables(t, db,
- &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
- &model.CoursesActivity{}, &model.CoursesActivitySigned{},
- // t_user_balance_request:SP5 可退额第二步(已用赠送额)要按「充值工单 × 活动报名」汇总,
- // 缺表会让 SP5 用例退化成"永远查不到赠送额"的假绿。
- &model.UserBalanceRequest{},
- )
- // 跨域只读表(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, rec
- }
- // fundUserID 生成 26 位类 ULID 的测试用户 ID(列宽 varchar(26))。
- func fundUserID(i int) string { return fmt.Sprintf("01HUSER%019d", i) }
- // fundScope 构造普通校区作用域(机构 1 / 校区 10)。
- func fundScope() *appscope.Scope {
- return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "finance"}
- }
- // fundScopeFor 构造指定机构/校区的作用域(跨机构用例)。
- func fundScopeFor(institutionID, campusID int64) *appscope.Scope {
- return &appscope.Scope{InstitutionID: institutionID, CampusID: campusID, UserID: "01HOPERATOR0000000000001", Role: "finance"}
- }
- // fundSuperScope 平台超管作用域(跨机构视角,用于"必须能同时看到两个机构"的用例)。
- func fundSuperScope() *appscope.Scope {
- return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true}
- }
- // mustAmount 解析金额(测试内固定字面量)。
- func mustAmount(t *testing.T, s string) money.Amount {
- t.Helper()
- a, err := money.Parse(s)
- if err != nil {
- t.Fatalf("金额解析失败 %q: %v", s, err)
- }
- return a
- }
- // seedCampus 落一行 t_campus(self_support: true=自营 / false=加盟)。
- func seedCampus(t *testing.T, db *gorm.DB, campusID, institutionID int64, selfSupport bool) {
- t.Helper()
- if err := db.Exec(
- "INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(?,?,?,?)",
- campusID, institutionID, selfSupport, "active").Error; err != nil {
- t.Fatalf("seed t_campus(%d) 失败: %v", campusID, err)
- }
- }
- // readGlobal 读全局钱包。
- func readGlobal(t *testing.T, db *gorm.DB, userID string) model.UserExpense {
- t.Helper()
- var row model.UserExpense
- if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
- t.Fatalf("读全局账户(%s) 失败: %v", userID, err)
- }
- return row
- }
- // readSchool 读校区账户。
- func readSchool(t *testing.T, db *gorm.DB, userID string, institutionID, campusID int64) model.UserExpenseSchool {
- t.Helper()
- var row model.UserExpenseSchool
- if err := db.Where("user_id = ? AND institution_id = ? AND campus_id = ?", userID, institutionID, campusID).
- Take(&row).Error; err != nil {
- t.Fatalf("读校区账户(%s/%d/%d) 失败: %v", userID, institutionID, campusID, err)
- }
- return row
- }
- // logsByType 取某用户的指定 type 流水(按 id ASC)。
- func logsByType(t *testing.T, db *gorm.DB, userID string, typ int32) []model.UserExpenseLog {
- t.Helper()
- var rows []model.UserExpenseLog
- if err := db.Where("user_id = ? AND type = ?", userID, typ).Order("id ASC").Find(&rows).Error; err != nil {
- t.Fatalf("读流水(type=%d) 失败: %v", typ, err)
- }
- return rows
- }
- // countLogs 统计某用户指定 type 的流水条数。
- func countLogsByType(t *testing.T, db *gorm.DB, userID string, typ int32) int {
- t.Helper()
- return len(logsByType(t, db, userID, typ))
- }
- // ---------------------------------------------------------------- 充值(SP1 / SP2)
- // TestRechargeUpdatesBothTables 充值双表同事务:全局 + 校区 balance/total_income 同步,流水含 balance_after。
- func TestRechargeUpdatesBothTables(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(1)
- res, err := svc.Recharge(ctx, RechargeInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-RECHARGE-1",
- })
- if err != nil {
- t.Fatalf("Recharge 失败: %v", err)
- }
- if res.Repeated {
- t.Fatalf("首次充值不应 repeated=true")
- }
- if res.LogID <= 0 {
- t.Fatalf("logId 应 > 0,实得 %d", res.LogID)
- }
- if got := cents(t, res.Balance.String()); got != 10000 {
- t.Fatalf("返回余额=%d 分,期望 10000", got)
- }
- global := readGlobal(t, db, user)
- if got := cents(t, global.Balance); got != 10000 {
- t.Fatalf("全局 balance=%d 分,期望 10000", got)
- }
- if got := cents(t, global.TotalIncome); got != 10000 {
- t.Fatalf("全局 total_income=%d 分,期望 10000", got)
- }
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 10000 {
- t.Fatalf("校区 balance=%d 分,期望 10000", got)
- }
- if got := cents(t, school.TotalIncome); got != 10000 {
- t.Fatalf("校区 total_income=%d 分,期望 10000", got)
- }
- logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
- if len(logs) != 1 {
- t.Fatalf("充值流水应为 1 条,实得 %d", len(logs))
- }
- if got := cents(t, logs[0].Money); got != 10000 {
- t.Fatalf("流水 money=%d 分,期望 10000(方向为正)", got)
- }
- if logs[0].RequestID == nil || *logs[0].RequestID != "REQ-RECHARGE-1" {
- t.Fatalf("流水 request_id 未落:%+v", logs[0].RequestID)
- }
- if logs[0].BalanceAfter == nil || cents(t, *logs[0].BalanceAfter) != 10000 {
- t.Fatalf("流水 balance_after 快照错误:%+v", logs[0].BalanceAfter)
- }
- if logs[0].Remark == nil || *logs[0].Remark != defaultRechargeRemark {
- t.Fatalf("流水 remark 默认文案错误:%+v", logs[0].Remark)
- }
- }
- // TestRechargeKindSetsSchoolRemark SP1 / SP2 的唯一差异:默认备注文案。
- func TestRechargeKindSetsSchoolRemark(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(2)
- if _, err := svc.Recharge(ctx, RechargeInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), RequestID: "REQ-KIND-1", Kind: RechargeKindSchool,
- }); err != nil {
- t.Fatalf("Recharge(Kind=School) 失败: %v", err)
- }
- logs := logsByType(t, db, user, model.ExpenseLogTypeRecharge)
- if len(logs) != 1 || logs[0].Remark == nil || *logs[0].Remark != defaultSchoolRechargeRemark {
- t.Fatalf("SP2 默认备注应为 %q,实得 %+v", defaultSchoolRechargeRemark, logs)
- }
- }
- // TestRechargeIdempotentSameRequestID 同 requestId 第二次提交 → repeated=true、不重复入账。
- func TestRechargeIdempotentSameRequestID(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(3)
- in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "50.00"), RequestID: "REQ-IDEMPOTENT-1"}
- first, err := svc.Recharge(ctx, in)
- if err != nil {
- t.Fatalf("首次充值失败: %v", err)
- }
- second, err := svc.Recharge(ctx, in)
- if err != nil {
- t.Fatalf("重复充值应返回成功(幂等),实得错误: %v", err)
- }
- if !second.Repeated {
- t.Fatalf("重复提交应 repeated=true")
- }
- if second.LogID != first.LogID {
- t.Fatalf("重复提交应回放首次 logId=%d,实得 %d", first.LogID, second.LogID)
- }
- if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
- t.Fatalf("流水应只 1 条,实得 %d", count)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 5000 {
- t.Fatalf("余额应只变一次(5000 分),实得 %d", got)
- }
- }
- // TestRechargeConcurrentSameRequestID 20 goroutine 同 requestId → 1 条流水、余额只变一次。
- //
- // SQLite 无行锁,用单连接池把写事务串行化(等价于 MySQL 的行锁串行语义);
- // 断言点与生产一致:**流水条数**与**余额增量**(不是"是否报错")。
- func TestRechargeConcurrentSameRequestID(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(4)
- in := RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "30.00"), RequestID: "REQ-CONCURRENT-1"}
- const goroutines = 20
- var wg sync.WaitGroup
- results := make([]*RechargeResult, goroutines)
- errs := make([]error, goroutines)
- for i := 0; i < goroutines; i++ {
- wg.Add(1)
- go func(idx int) {
- defer wg.Done()
- results[idx], errs[idx] = svc.Recharge(ctx, in)
- }(i)
- }
- wg.Wait()
- for i, err := range errs {
- if err != nil {
- t.Fatalf("并发第 %d 次充值失败: %v", i, err)
- }
- }
- if count := countLogsByType(t, db, user, model.ExpenseLogTypeRecharge); count != 1 {
- t.Fatalf("并发同 requestId 应只 1 条流水,实得 %d", count)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 3000 {
- t.Fatalf("余额应只入账一次(3000 分),实得 %d", got)
- }
- repeated := 0
- for _, r := range results {
- if r != nil && r.Repeated {
- repeated++
- }
- }
- if repeated != goroutines-1 {
- t.Fatalf("应有 %d 次 repeated=true,实得 %d", goroutines-1, repeated)
- }
- }
- // TestRechargeValidation 入参校验:空用户 2001 / 校区缺失 3140 / 金额<=0 与超上限 2002。
- func TestRechargeValidation(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- cases := []struct {
- name string
- in RechargeInput
- want int
- }{
- {"空用户", RechargeInput{UserID: "", InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "1.00")}, 2001},
- {"校区缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 0, Money: mustAmount(t, "1.00")}, 3140},
- {"机构缺失", RechargeInput{UserID: fundUserID(5), InstitutionID: 0, CampusID: 10, Money: mustAmount(t, "1.00")}, 3140},
- {"金额为 0", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: 0}, 2002},
- {"金额为负", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "-1.00")}, 2002},
- {"超单笔上限", RechargeInput{UserID: fundUserID(5), InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "1000000.01")}, 2002},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- _, err := svc.Recharge(ctx, tc.in)
- requireAppCode(t, err, tc.want)
- })
- }
- }
- // ---------------------------------------------------------------- 消费(SP3)
- // TestExpenditureTypeBranches dump 的两个列组合分支 + 流水方向为负。
- func TestExpenditureTypeBranches(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(6)
- if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- // type=200(历史扣费):balance−、total_expenditure+、spend+
- if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "30.00"), Type: model.ExpenseLogTypeHistoryDeduct, RequestID: "REQ-EXP-200"}); err != nil {
- t.Fatalf("Expenditure(200) 失败: %v", err)
- }
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 7000 {
- t.Fatalf("200 后 balance 应 7000 分,实得 %d", got)
- }
- if got := cents(t, school.TotalExpenditure); got != 3000 {
- t.Fatalf("200 后 total_expenditure 应 3000 分,实得 %d", got)
- }
- if got := cents(t, school.Spend); got != 3000 {
- t.Fatalf("200 后 spend 应 3000 分,实得 %d", got)
- }
- // type=201(校区报课消费):balance−、total_expenditure+,**spend 不动**
- if _, err := svc.Expenditure(ctx, ExpenditureInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "20.00"), Type: model.ExpenseLogTypeCourseCampusB, RequestID: "REQ-EXP-201"}); err != nil {
- t.Fatalf("Expenditure(201) 失败: %v", err)
- }
- school = readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 5000 {
- t.Fatalf("201 后 balance 应 5000 分,实得 %d", got)
- }
- if got := cents(t, school.TotalExpenditure); got != 5000 {
- t.Fatalf("201 后 total_expenditure 应 5000 分,实得 %d", got)
- }
- if got := cents(t, school.Spend); got != 3000 {
- t.Fatalf("201 不应改 spend(应仍 3000 分),实得 %d", got)
- }
- // 全局表同列同步
- global := readGlobal(t, db, user)
- if got := cents(t, global.Balance); got != 5000 {
- t.Fatalf("全局 balance 应 5000 分,实得 %d", got)
- }
- if got := cents(t, global.TotalExpenditure); got != 5000 {
- t.Fatalf("全局 total_expenditure 应 5000 分,实得 %d", got)
- }
- // 流水金额恒为负(dump: `VALUES(...,-inputMoney,...)`)
- logs := logsByType(t, db, user, model.ExpenseLogTypeHistoryDeduct)
- if len(logs) != 1 {
- t.Fatalf("200 流水应 1 条,实得 %d", len(logs))
- }
- if got := cents(t, logs[0].Money); got != -3000 {
- t.Fatalf("200 流水 money 应 -3000 分,实得 %d", got)
- }
- }
- // TestExpenditureInvalidType 白名单外 type → 2002(dump 会静默改成 200,Go 显式拒绝)。
- func TestExpenditureInvalidType(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- _, err := svc.Expenditure(context.Background(), ExpenditureInput{
- UserID: fundUserID(7), InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "10.00"), Type: 999,
- })
- requireAppCode(t, err, 2002)
- }
- // ---------------------------------------------------------------- 退费(SP4,BUG#2)
- // TestRefundInstitutionFilterPreventsCrossInstitutionUpdate BUG#2 回归:
- // 同一 campus_id 在两个机构都有账户行时,只允许更新入参机构那一行。
- //
- // dump 的 WHERE 是 `userid + campusid`(无 schoolid)→ 两行都会被改;Go 补 institution_id。
- func TestRefundInstitutionFilterPreventsCrossInstitutionUpdate(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundSuperScope(), nil) // 超管视角:底层两行都可见
- ctx := context.Background()
- user := fundUserID(8)
- // 两个机构、同一个 campus_id=10
- for _, inst := range []int64{1, 2} {
- if err := db.Create(&model.UserExpenseSchool{
- UserID: user, InstitutionID: inst, CampusID: 10,
- Balance: "100.00", TotalExpenditure: "50.00",
- }).Error; err != nil {
- t.Fatalf("seed 校区账户(inst=%d) 失败: %v", inst, err)
- }
- }
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "200.00", TotalExpenditure: "50.00"}).Error; err != nil {
- t.Fatalf("seed 全局账户失败: %v", err)
- }
- res, err := svc.Refund(ctx, RefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "20.00"),
- Type: model.ExpenseLogTypeCourseCampus, RequestID: "REQ-REFUND-1",
- })
- if err != nil {
- t.Fatalf("Refund 失败: %v", err)
- }
- if !res.Changed {
- t.Fatalf("Refund 应 changed=true")
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 12000 {
- t.Fatalf("目标机构 balance 应 12000 分,实得 %d", got)
- }
- // 另一机构必须零变动(BUG#2 的核心断言)
- if got := cents(t, readSchool(t, db, user, 2, 10).Balance); got != 10000 {
- t.Fatalf("另一机构 balance 必须不变(10000 分),实得 %d", got)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).TotalExpenditure); got != 3000 {
- t.Fatalf("目标机构 total_expenditure 应 3000 分(退费回冲),实得 %d", got)
- }
- logs := logsByType(t, db, user, model.ExpenseLogTypeCourseCampus)
- if len(logs) != 1 || cents(t, logs[0].Money) != 2000 {
- t.Fatalf("退费流水应为 +2000 分一条,实得 %+v", logs)
- }
- }
- // TestRefundZeroMoneyIsNoop money<=0 → changed=false(对齐 dump `IF inputMoney>0`),不写流水。
- func TestRefundZeroMoneyIsNoop(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- user := fundUserID(9)
- res, err := svc.Refund(context.Background(), RefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10, Money: 0, Type: model.ExpenseLogTypeCourseCampus,
- })
- if err != nil {
- t.Fatalf("money=0 应返回成功(no-op),实得错误: %v", err)
- }
- if res.Changed || res.Repeated || res.LogID != 0 {
- t.Fatalf("money=0 应 changed=false 且无流水,实得 %+v", res)
- }
- if count := countLogsByType(t, db, user, model.ExpenseLogTypeCourseCampus); count != 0 {
- t.Fatalf("no-op 不应写流水,实得 %d 条", count)
- }
- }
- // TestRefundAccountMissing 账户未建户 → 3001(dump 静默无操作;Go 显式失败)。
- func TestRefundAccountMissing(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- _, err := svc.Refund(context.Background(), RefundInput{
- UserID: fundUserID(10), InstitutionID: 1, CampusID: 10, Money: mustAmount(t, "5.00"),
- Type: model.ExpenseLogTypeCourseCampus,
- })
- requireAppCode(t, err, 3001)
- }
- // ---------------------------------------------------------------- 可退金额(SP5,两步修正)
- // seedExpenseAccounts 落全局 + 校区两个账户行(SP5 第一步截断需要两张表同时有值)。
- func seedExpenseAccounts(t *testing.T, db *gorm.DB, userID, campusBalance, globalBalance string) {
- t.Helper()
- if err := db.Create(&model.UserExpense{UserID: userID, Balance: globalBalance}).Error; err != nil {
- t.Fatalf("seed 全局账户(%s) 失败: %v", userID, err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: userID, InstitutionID: 1, CampusID: 10,
- Balance: campusBalance}).Error; err != nil {
- t.Fatalf("seed 校区账户(%s) 失败: %v", userID, err)
- }
- }
- // seedGiftUsed 落一对「充值工单 + 活动报名」行(SP5 第二步「已用赠送额」的唯一数据来源)。
- //
- // 关键映射(照 dump 改写的依据):工单的 `id`(旧 UUID)→ 目标态 `biz_no`;
- // 报名的 `requestid` → 目标态 `request_no`;JOIN 走 `biz_no = request_no`。
- func seedGiftUsed(t *testing.T, db *gorm.DB, userID, bizNo string, activityID int64,
- approved int8, activityMoney, activityMoneyUsed string) {
- t.Helper()
- if err := db.Create(&model.UserBalanceRequest{
- BizNo: bizNo, Type: model.BalanceRequestTypeRecharge, UserID: userID,
- InstitutionID: 1, CampusID: 10, Approved: approved,
- }).Error; err != nil {
- t.Fatalf("seed 充值工单(%s) 失败: %v", bizNo, err)
- }
- reqNo := bizNo
- if err := db.Create(&model.CoursesActivitySigned{
- ActivityID: activityID, UserID: userID, InstitutionID: 1, CampusID: 10,
- ActivityMoney: activityMoney, ActivityMoneyUsed: activityMoneyUsed, RequestNo: &reqNo,
- }).Error; err != nil {
- t.Fatalf("seed 活动报名(%s) 失败: %v", bizNo, err)
- }
- }
- // TestRefundableAmount SP5 的全部边界:账户缺失 / 欠费 / 正常全额 /
- // **campus>total 截断** / **赠送已用小余额** / 赠送用尽不扣 / 未审批不扣 / 下限 0 / 3140。
- func TestRefundableAmount(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- // ① 账户不存在 → 0
- if got, err := svc.RefundableAmount(ctx, fundUserID(11), 1, 10); err != nil || got != 0 {
- t.Fatalf("账户不存在应返回 0 且无错,实得 (%d, %v)", got, err)
- }
- // ② 欠费(校区余额为负)→ 0(总额再大也不可退)
- seedExpenseAccounts(t, db, fundUserID(12), "-25.00", "100.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(12), 1, 10); err != nil || got != 0 {
- t.Fatalf("负余额应返回 0,实得 (%d, %v)", got, err)
- }
- // ③ 正常:校区 88.50 ≤ 全局 100.00,且无赠送 → 全额 88.50
- seedExpenseAccounts(t, db, fundUserID(13), "88.50", "100.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 10); err != nil || got.Cents() != 8850 {
- t.Fatalf("正余额应返回 8850 分,实得 (%d, %v)", got, err)
- }
- // ④ 校区缺失 → 3140
- if _, err := svc.RefundableAmount(ctx, fundUserID(13), 1, 0); err != nil {
- requireAppCode(t, err, 3140)
- }
- // ⑤ 全局账户缺失(totalBalance 视为 0)→ 0
- if err := db.Create(&model.UserExpenseSchool{UserID: fundUserID(18), InstitutionID: 1, CampusID: 10,
- Balance: "50.00"}).Error; err != nil {
- t.Fatalf("seed 校区账户失败: %v", err)
- }
- if got, err := svc.RefundableAmount(ctx, fundUserID(18), 1, 10); err != nil || got != 0 {
- t.Fatalf("全局账户缺失应返回 0,实得 (%d, %v)", got, err)
- }
- // ⑥ **第一步修正**:campusBalance > totalBalance → 按 totalBalance 截断(不得回 200)
- seedExpenseAccounts(t, db, fundUserID(19), "200.00", "120.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(19), 1, 10); err != nil || got.Cents() != 12000 {
- t.Fatalf("campus(200)>total(120) 应截断为 12000 分,实得 (%d, %v)", got, err)
- }
- // ⑦ **第二步修正**:赠送已用 → 可退额必须**小于**余额(100 − 20 = 80)
- seedExpenseAccounts(t, db, fundUserID(20), "100.00", "100.00")
- seedGiftUsed(t, db, fundUserID(20), "BR-GIFT-1", 1, model.BalanceRequestApprovedPassed, "50.00", "20.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(20), 1, 10); err != nil || got.Cents() != 8000 {
- t.Fatalf("赠送已用 20 应扣减(8000 分 < 余额 10000),实得 (%d, %v)", got, err)
- }
- // ⑧ 赠送**已用尽**(used == activity_money)→ 按 dump 的 `<` 条件不参与统计 → 不扣
- seedExpenseAccounts(t, db, fundUserID(21), "100.00", "100.00")
- seedGiftUsed(t, db, fundUserID(21), "BR-GIFT-2", 2, model.BalanceRequestApprovedPassed, "50.00", "50.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(21), 1, 10); err != nil || got.Cents() != 10000 {
- t.Fatalf("赠送用尽行不参与统计(应 10000),实得 (%d, %v)", got, err)
- }
- // ⑨ 工单**未审批**(approved != 1)→ 不参与统计
- seedExpenseAccounts(t, db, fundUserID(22), "100.00", "100.00")
- seedGiftUsed(t, db, fundUserID(22), "BR-GIFT-3", 3, model.BalanceRequestApprovedPending, "50.00", "20.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(22), 1, 10); err != nil || got.Cents() != 10000 {
- t.Fatalf("未审批工单不参与统计(应 10000),实得 (%d, %v)", got, err)
- }
- // ⑩ 截断后再扣赠送,仍**下限 0**(120 − 150 < 0 → 0)
- seedExpenseAccounts(t, db, fundUserID(23), "200.00", "120.00")
- seedGiftUsed(t, db, fundUserID(23), "BR-GIFT-4", 4, model.BalanceRequestApprovedPassed, "200.00", "150.00")
- if got, err := svc.RefundableAmount(ctx, fundUserID(23), 1, 10); err != nil || got != 0 {
- t.Fatalf("截断后扣赠送仍为负 → 应返回 0,实得 (%d, %v)", got, err)
- }
- }
- // ---------------------------------------------------------------- 坏账(SP7,双加)
- // TestBadDebtAddsBalanceAndSpecialExpenses dump 口径「余额+,特殊费用+」:
- // **两列同增**(全局 + 校区都是),108 流水为正。
- func TestBadDebtAddsBalanceAndSpecialExpenses(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(14)
- if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00")}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- res, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "30.00")})
- if err != nil {
- t.Fatalf("BadDebt 失败: %v", err)
- }
- // 校区:balance 100 → 130(双加),special_expenses 0 → 30
- school := readSchool(t, db, user, 1, 10)
- if got := cents(t, school.Balance); got != 13000 {
- t.Fatalf("校区 balance 应 10000+3000=13000 分(dump 双加),实得 %d", got)
- }
- if got := cents(t, school.SpecialExpenses); got != 3000 {
- t.Fatalf("校区 special_expenses 应 3000 分,实得 %d", got)
- }
- // 全局:同样双加
- global := readGlobal(t, db, user)
- if got := cents(t, global.Balance); got != 13000 {
- t.Fatalf("全局 balance 应 13000 分(双表同步双加),实得 %d", got)
- }
- if got := cents(t, global.SpecialExpenses); got != 3000 {
- t.Fatalf("全局 special_expenses 应 3000 分,实得 %d", got)
- }
- // 返回体:余额已是 +Money 之后的值
- if res.Balance.Cents() != 13000 || res.SpecialExpenses.Cents() != 3000 {
- t.Fatalf("返回体应 balance=13000/special=3000,实得 %d/%d",
- res.Balance.Cents(), res.SpecialExpenses.Cents())
- }
- // 流水:108 = +3000 一条
- logs := logsByType(t, db, user, model.ExpenseLogTypeBadDebt)
- if len(logs) != 1 || cents(t, logs[0].Money) != 3000 {
- t.Fatalf("108 流水应为 +3000 分一条,实得 %+v", logs)
- }
- }
- // ---------------------------------------------------------------- 账务恒等式(AC-17 ④ 口径)
- // balanceLogs 取某用户全部流水(按 id ASC)。
- func balanceLogs(t *testing.T, db *gorm.DB, userID string) []model.UserExpenseLog {
- t.Helper()
- var rows []model.UserExpenseLog
- if err := db.Where("user_id = ?", userID).Order("id ASC").Find(&rows).Error; err != nil {
- t.Fatalf("读全部流水(%s) 失败: %v", userID, err)
- }
- return rows
- }
- // sumLogMoney 流水金额**代数和**(Go 侧定点累加;禁 SQL SUM(decimal) 回 REAL)。
- func sumLogMoney(t *testing.T, rows []model.UserExpenseLog) money.Amount {
- t.Helper()
- total := money.Amount(0)
- for i := range rows {
- amount, err := money.Parse(rows[i].Money)
- if err != nil {
- t.Fatalf("流水(%d) 金额非法: %v", rows[i].ID, err)
- }
- total = total.Add(amount)
- }
- return total
- }
- // classifyBadDebtLogs 按 dump 兼容口径把 type=108 分成两类。
- //
- // 判别式(**唯一、可机械执行**):取 108 流水的 `logid`(= `request_id`)——
- // - **同 logid 存在 type=207 流水** → 差额型(`CustomRefund` 的退费差额):
- // 该 108 的金额已含在 207 的 −requested 全额里,**不构成**一次独立的余额增加;
- // - **不存在** → 独立坏账(`FundService.BadDebt`):balance 与 special_expenses 双加,
- // 该 108 **就是**一次余额增加。
- func classifyBadDebtLogs(t *testing.T, db *gorm.DB, userID string) (
- independent, differential []model.UserExpenseLog,
- ) {
- t.Helper()
- all := balanceLogs(t, db, userID)
- refundKeys := make(map[string]bool)
- for i := range all {
- if all[i].Type != model.ExpenseLogTypeRefund || all[i].LogID == nil {
- continue
- }
- refundKeys[*all[i].LogID] = true
- }
- for i := range all {
- if all[i].Type != model.ExpenseLogTypeBadDebt {
- continue
- }
- if all[i].LogID != nil && refundKeys[*all[i].LogID] {
- differential = append(differential, all[i])
- continue
- }
- independent = append(independent, all[i])
- }
- return independent, differential
- }
- // TestBalanceIdentityIndependentBadDebtCounted 独立坏账(108 无 207 同伴)**参与**恒等式:
- // 余额增量恰等于该 108 的金额,且「balance = Σ全部流水」成立(旧口径下这里是唯一破口)。
- func TestBalanceIdentityIndependentBadDebtCounted(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(24)
- if _, err := svc.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-BAL-RECHARGE"}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- before := cents(t, readSchool(t, db, user, 1, 10).Balance)
- if before != 10000 {
- t.Fatalf("准备余额应为 10000 分,实得 %d", before)
- }
- if _, err := svc.BadDebt(ctx, BadDebtInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "30.00"), RequestID: "REQ-ID-BAL-BADDEBT"}); err != nil {
- t.Fatalf("BadDebt 失败: %v", err)
- }
- after := cents(t, readSchool(t, db, user, 1, 10).Balance)
- independent, differential := classifyBadDebtLogs(t, db, user)
- if len(independent) != 1 || len(differential) != 0 {
- t.Fatalf("独立坏账应 1 条且无差额型,实得 independent=%d differential=%d",
- len(independent), len(differential))
- }
- // ① 108 参与:余额增量 == 108 金额(+3000)
- if after-before != cents(t, independent[0].Money) {
- t.Fatalf("独立坏账应使余额 +%d 分,实得增量 %d",
- cents(t, independent[0].Money), after-before)
- }
- // ② 全局口径「balance = Σ全部流水」
- all := balanceLogs(t, db, user)
- if sum := sumLogMoney(t, all).Cents(); sum != after {
- t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
- }
- }
- // TestBalanceIdentityDifferentialBadDebtExcluded 退费差额型(108 有 207 同伴)**不参与**
- // 「108 → 余额增量」的归因:坏账部分已含在 207 的 −requested 全额里,
- // 若把它再算一次独立余额增加,余额会凭空多出 badDebt(正是旧口径/新口径最容易混的一处)。
- func TestBalanceIdentityDifferentialBadDebtExcluded(t *testing.T) {
- db := newFundServiceDB(t)
- fund := NewFundService(db, fundSuperScope(), nil)
- svc := NewRefundService(db, fundSuperScope(), port.NewOrgCampusReader(db), nil)
- ctx := context.Background()
- user := fundUserID(25)
- seedCampus(t, db, 10, 1, true)
- if _, err := fund.Recharge(ctx, RechargeInput{UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "100.00"), RequestID: "REQ-ID-DIFF-RECHARGE"}); err != nil {
- t.Fatalf("准备余额失败: %v", err)
- }
- before := cents(t, readSchool(t, db, user, 1, 10).Balance)
- // 150 > 可退 100 → refunded 100、badDebt 50
- if _, err := svc.CustomRefund(ctx, CustomRefundInput{
- UserID: user, InstitutionID: 1, CampusID: 10,
- Money: mustAmount(t, "150.00"), RequestID: "REQ-ID-DIFF-REFUND",
- }); err != nil {
- t.Fatalf("CustomRefund 失败: %v", err)
- }
- after := cents(t, readSchool(t, db, user, 1, 10).Balance)
- independent, differential := classifyBadDebtLogs(t, db, user)
- if len(differential) != 1 || len(independent) != 0 {
- t.Fatalf("退费差额应 1 条差额型且无独立坏账,实得 independent=%d differential=%d",
- len(independent), len(differential))
- }
- logs207 := logsByType(t, db, user, model.ExpenseLogTypeRefund)
- if len(logs207) != 1 {
- t.Fatalf("207 应 1 条,实得 %d", len(logs207))
- }
- // ① 差额型 108 的 logid 必须与 207 相同(判别式的实现载体)
- if differential[0].LogID == nil || logs207[0].LogID == nil ||
- *differential[0].LogID != *logs207[0].LogID {
- t.Fatalf("差额型 108 应与 207 同 logid,实得 108=%+v 207=%+v",
- differential[0].LogID, logs207[0].LogID)
- }
- // ② 不参与:光看 207 就已经把这次余额变动记完了
- // before + 207 = after + badDebt > after → 差额恰是 badDebt,且必须靠 108 补平;
- // 但把该 108 再当成「一次独立余额增加」就会重复计入 → 合计比真实余额多一个 badDebt。
- onlyRefund := before + cents(t, logs207[0].Money)
- if onlyRefund == after {
- t.Fatalf("207 单独记账不应等于余额变化(差额型 108 必须存在):before=%d 207=%d after=%d",
- before, cents(t, logs207[0].Money), after)
- }
- pair := onlyRefund + cents(t, differential[0].Money)
- if pair != after {
- t.Fatalf("差额型 108 与 207 的代数和应等于净扣减:before(%d)+207(%d)+108(%d)=%d,after=%d",
- before, cents(t, logs207[0].Money), cents(t, differential[0].Money), pair, after)
- }
- if pair+cents(t, differential[0].Money) == after {
- t.Fatalf("差额型 108 不得再算一次独立的余额增加(重复计入会凭空多出 %d 分)",
- cents(t, differential[0].Money))
- }
- // ③ 但「balance = Σ全部流水」整体仍成立(108 与 207 成对出现,代数和 = 净扣减)
- all := balanceLogs(t, db, user)
- if sum := sumLogMoney(t, all).Cents(); sum != after {
- t.Fatalf("恒等式 balance=Σ流水 不成立:Σ=%d balance=%d", sum, after)
- }
- }
- // ---------------------------------------------------------------- 累计消费(SP11,BUG#1)
- // TestAccumulateSpendOnlySpend 只更新 spend(不动 balance),且**不写流水**;负数归 0。
- func TestAccumulateSpendOnlySpend(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(15)
- // 账户不存在时也应能建户并写 spend(dump 的 INSERT 分支;BUG#1 修正前该分支是 3 值对 2 列必报错)
- res, err := svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
- SpendMoney: mustAmount(t, "25.00")})
- if err != nil {
- t.Fatalf("AccumulateSpend 失败: %v", err)
- }
- if res.Spend.Cents() != 2500 {
- t.Fatalf("返回 spend 应 2500 分,实得 %d", res.Spend.Cents())
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Spend); got != 2500 {
- t.Fatalf("校区 spend 应 2500 分,实得 %d", got)
- }
- if got := cents(t, readGlobal(t, db, user).Spend); got != 2500 {
- t.Fatalf("全局 spend 应 2500 分,实得 %d", got)
- }
- if got := cents(t, readSchool(t, db, user, 1, 10).Balance); got != 0 {
- t.Fatalf("spend SP 不得改 balance(应 0),实得 %d", got)
- }
- var total int64
- if err := db.Model(&model.UserExpenseLog{}).Where("user_id = ?", user).Count(&total).Error; err != nil {
- t.Fatalf("统计流水失败: %v", err)
- }
- if total != 0 {
- t.Fatalf("spend SP 不写流水(dump 无 INSERT log),实得 %d 条", total)
- }
- // 负数一律归 0(dump INSERT 分支同口径;spend 只增)
- res, err = svc.AccumulateSpend(ctx, SpendInput{UserID: user, InstitutionID: 1, CampusID: 10,
- SpendMoney: mustAmount(t, "-10.00")})
- if err != nil {
- t.Fatalf("AccumulateSpend(负数) 失败: %v", err)
- }
- if res.Spend.Cents() != 2500 {
- t.Fatalf("负数应被归 0(spend 仍 2500 分),实得 %d", res.Spend.Cents())
- }
- }
- // ---------------------------------------------------------------- 幂等键与金额越界(负例)
- // TestInsertLogDuplicateMapsToRepeated 撞 uk_request_type → 内部哨兵 3114(不是 5002)。
- func TestInsertLogDuplicateMapsToRepeated(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(16)
- mk := func() *model.UserExpenseLog {
- return newExpenseLog(user, model.ExpenseLogTypeRecharge, mustAmount(t, "1.00"),
- "REQ-DUP-1", "dup", "", nil)
- }
- if err := db.Transaction(func(tx *gorm.DB) error {
- return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
- }); err != nil {
- t.Fatalf("首次写流水失败: %v", err)
- }
- err := db.Transaction(func(tx *gorm.DB) error {
- return svc.insertLog(repository.WithTxContext(ctx, tx), tx, mk())
- })
- if !errors.Is(err, apperr.ErrRepeated) {
- t.Fatalf("同 request_id+type 二次写入应映射为 3114 幂等哨兵,实得: %v", err)
- }
- }
- // TestAmountOverflowRejectedBeforeWrite AC-04 的落库负例:
- // 超出 decimal(12,2) 全域的金额必须在**写库前**被 `Amount.Validate()` 拦下(ErrOverflow),
- // 且账户零残留(不是"写了个截断值")。
- func TestAmountOverflowRejectedBeforeWrite(t *testing.T) {
- db := newFundServiceDB(t)
- svc := NewFundService(db, fundScope(), nil)
- ctx := context.Background()
- user := fundUserID(17)
- if err := db.Create(&model.UserExpense{UserID: user, Balance: "0.00"}).Error; err != nil {
- t.Fatalf("seed 全局账户失败: %v", err)
- }
- if err := db.Create(&model.UserExpenseSchool{UserID: user, InstitutionID: 1, CampusID: 10,
- Balance: "0.00"}).Error; err != nil {
- t.Fatalf("seed 校区账户失败: %v", err)
- }
- overflow := money.MaxAmount + 1
- err := db.Transaction(func(tx *gorm.DB) error {
- _, e := svc.repo.AddColumnsGlobalThenCampus(ctx, tx, "OverflowTest", user, 1, 10,
- repository.ColumnDelta{Column: repository.ExpenseColumnBalance, Delta: overflow})
- return e
- })
- if !errors.Is(err, money.ErrOverflow) {
- t.Fatalf("越界金额应返回 money.ErrOverflow,实得: %v", err)
- }
- var appErr *infraerrors.AppError
- if !errors.As(err, &appErr) {
- t.Fatalf("越界金额应返回 *AppError,实得: %v", err)
- }
- // 仓储层(AC-03 既有形状)把越界金额挂在 2001 哨兵上;Service 层对**入参**金额用 2002
- // (见 validatePositiveAmount)。此处断言的是"落库前被拦下"这一红约束,两层都属 2xxx 参数错。
- if appErr.Code != infraerrors.ErrBadParam.Code && appErr.Code != infraerrors.ErrBadFormat.Code {
- t.Fatalf("越界金额应映射为 2xxx 参数错,实得 %d: %v", appErr.Code, err)
- }
- if got := cents(t, readGlobal(t, db, user).Balance); got != 0 {
- t.Fatalf("越界金额不得落库(余额应仍 0),实得 %d", got)
- }
- }
|