|
|
@@ -0,0 +1,307 @@
|
|
|
+package stage2
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "database/sql"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
+
|
|
|
+ "yuansiwei/unified-backend/internal/academic/dualrun/fixture"
|
|
|
+)
|
|
|
+
|
|
|
+// 本文件是 `RecommendSeed` 的两侧落库:把"两个用户各自处于哪种账户状态"造出来。
|
|
|
+//
|
|
|
+// 为什么用 `DELETE` + `INSERT` 绝对值、而不是 `UPDATE`:
|
|
|
+//
|
|
|
+// `UPDATE` 只能改"已经存在的行",而本 seed 要表达的第一种状态就是**行不存在**
|
|
|
+// (W4-B 的触发条件)。而且绝对值能给"NULL / 0 / 非 0"三种状态各自定位,
|
|
|
+// `+`/`-` 做不到。事务内 DELETE 随回滚恢复,不会污染真实数据。
|
|
|
+//
|
|
|
+// 为什么写完要**读回来比一次**:
|
|
|
+//
|
|
|
+// 三种状态下"写错"的方向都是"看起来种上了"(NULL 写成 0、0 写成 NULL、该删的没删),
|
|
|
+// 而它们**不会让任何业务断言红**,只会让 case 测出一个别的东西。
|
|
|
+// 所以必须在种子阶段就把"种出来的 == 声明的"钉死;不一致直接 FATAL(不是红一条断言)。
|
|
|
+
|
|
|
+// recommendAccountState 是"读回来的实际状态",用于和声明比对。
|
|
|
+type recommendAccountState struct {
|
|
|
+ GlobalExists bool
|
|
|
+ GlobalBalance *int64
|
|
|
+ GlobalRecommended *int64
|
|
|
+ CampusExists bool
|
|
|
+ CampusBalance *int64
|
|
|
+ CampusRecommended *int64
|
|
|
+}
|
|
|
+
|
|
|
+// seedLegacyRecommend 在 legacy 事务内造两侧账户(`t_user_expense` + `t_user_expense_school`)。
|
|
|
+func seedLegacyRecommend(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ if c.Recommend == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if err := c.Recommend.validate(c); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for _, u := range []struct {
|
|
|
+ legacyID int64
|
|
|
+ seed AccountSeed
|
|
|
+ role string
|
|
|
+ }{
|
|
|
+ {c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
|
|
|
+ {c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
|
|
|
+ } {
|
|
|
+ // 先无条件清干净:不能假设库里有/没有。target 库整库为空,legacy 侧则
|
|
|
+ // 可能有 `INSERT…SELECT FROM finance_bk` 搬进来的真实行 —— 不清就会
|
|
|
+ // 把"不建行"那条 case 种成"行还在",而它在报告上只表现为"断言不成立"。
|
|
|
+ if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE userid=? AND schoolid=? AND campusid=?`,
|
|
|
+ u.legacyID, c.InstitutionID, c.CampusID).Error; err != nil {
|
|
|
+ return fmt.Errorf("清 legacy %s 校区账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if err := tx.Exec(`DELETE FROM t_user_expense WHERE userid=?`, u.legacyID).Error; err != nil {
|
|
|
+ return fmt.Errorf("清 legacy %s 全局账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if u.seed.Absent {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ // `balance` 显式给 NULL 或值;`total_recommended_amount` 同理。
|
|
|
+ // 其余四列(spend/total_income/total_expenditure/special_expenses)本 case 不关心,
|
|
|
+ // 给 0(legacy 侧 t_user_expense 有 DEFAULT 0.000,_school 那四列无 DEFAULT ⇒ 必须显式写)。
|
|
|
+ if err := tx.Exec(`
|
|
|
+INSERT INTO t_user_expense (userid, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
|
|
|
+VALUES (?, ?, 0, 0, 0, ?, 0)`,
|
|
|
+ u.legacyID, nullableCents(u.seed.Balance), nullableCents(u.seed.Recommended)).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 legacy %s 全局账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if err := tx.Exec(`
|
|
|
+INSERT INTO t_user_expense_school (userid, schoolid, campusid, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
|
|
|
+VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0)`,
|
|
|
+ u.legacyID, c.InstitutionID, c.CampusID,
|
|
|
+ nullableCents(u.seed.Balance), nullableCents(u.seed.Recommended)).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 legacy %s 校区账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return verifyLegacyRecommendSeed(ctx, tx, c)
|
|
|
+}
|
|
|
+
|
|
|
+// seedTargetRecommend 同上(target 列名/ULID 不同)。
|
|
|
+func seedTargetRecommend(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ if c.Recommend == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if err := c.Recommend.validate(c); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ for _, u := range []struct {
|
|
|
+ legacyID int64
|
|
|
+ seed AccountSeed
|
|
|
+ role string
|
|
|
+ }{
|
|
|
+ {c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
|
|
|
+ {c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
|
|
|
+ } {
|
|
|
+ uid := fixture.FixtureCanonicalUserID(u.legacyID)
|
|
|
+ if err := tx.Exec(`DELETE FROM t_user_expense_school WHERE user_id=? AND institution_id=? AND campus_id=?`,
|
|
|
+ uid, c.InstitutionID, c.CampusID).Error; err != nil {
|
|
|
+ return fmt.Errorf("清 target %s 校区账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if err := tx.Exec(`DELETE FROM t_user_expense WHERE user_id=?`, uid).Error; err != nil {
|
|
|
+ return fmt.Errorf("清 target %s 全局账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if u.seed.Absent {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ // target 这几列是 `NOT NULL DEFAULT 0.00` —— **存不下 NULL**。
|
|
|
+ // 所以 target 侧只能是"有值"(`Balance`/`Recommended` 为 nil 时按 0 落)。
|
|
|
+ // 这一点必须显式说明:否则看代码的人会以为两侧种子同构,而实际上
|
|
|
+ // "行在列 NULL"这个状态在 target 侧**不可表达**(和 C2-7 的 `balance` 同理)。
|
|
|
+ if err := tx.Exec(`
|
|
|
+INSERT INTO t_user_expense (user_id, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
|
|
|
+VALUES (?, ?, 0, 0, 0, ?, 0)`,
|
|
|
+ uid, centsOrDefault(u.seed.Balance), centsOrDefault(u.seed.Recommended)).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 target %s 全局账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ if err := tx.Exec(`
|
|
|
+INSERT INTO t_user_expense_school (user_id, institution_id, campus_id, balance, spend, total_income, total_expenditure, total_recommended_amount, special_expenses)
|
|
|
+VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0)`,
|
|
|
+ uid, c.InstitutionID, c.CampusID,
|
|
|
+ centsOrDefault(u.seed.Balance), centsOrDefault(u.seed.Recommended)).Error; err != nil {
|
|
|
+ return fmt.Errorf("种 target %s 校区账户失败:%w", u.role, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return verifyTargetRecommendSeed(ctx, tx, c)
|
|
|
+}
|
|
|
+
|
|
|
+// nullableCents 把 `*int64` 变成 `any`:nil 落 SQL NULL。
|
|
|
+func nullableCents(p *int64) any {
|
|
|
+ if p == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return arrearsMoney(*p)
|
|
|
+}
|
|
|
+
|
|
|
+// centsOrDefault 用于 target(列 NOT NULL):nil 落 0。
|
|
|
+func centsOrDefault(p *int64) string {
|
|
|
+ if p == nil {
|
|
|
+ return arrearsMoney(0)
|
|
|
+ }
|
|
|
+ return arrearsMoney(*p)
|
|
|
+}
|
|
|
+
|
|
|
+// readLegacyRecommendState 读回 legacy 侧某用户的两张账户(不存在 ⇒ Exists=false)。
|
|
|
+func readLegacyRecommendState(ctx context.Context, tx *gorm.DB, c Case, legacyID int64) (recommendAccountState, error) {
|
|
|
+ var st recommendAccountState
|
|
|
+ var gb, gr, cb, cr *string
|
|
|
+ err := tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
|
|
|
+ FROM t_user_expense WHERE userid=?`, legacyID).Row().Scan(&gb, &gr)
|
|
|
+ if err == nil {
|
|
|
+ st.GlobalExists = true
|
|
|
+ st.GlobalBalance, st.GlobalRecommended = centsPtr(gb), centsPtr(gr)
|
|
|
+ } else if !isNoRows(err) {
|
|
|
+ return st, fmt.Errorf("读回 legacy 全局账户失败:%w", err)
|
|
|
+ }
|
|
|
+ err = tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
|
|
|
+ FROM t_user_expense_school WHERE userid=? AND schoolid=? AND campusid=?`,
|
|
|
+ legacyID, c.InstitutionID, c.CampusID).Row().Scan(&cb, &cr)
|
|
|
+ if err == nil {
|
|
|
+ st.CampusExists = true
|
|
|
+ st.CampusBalance, st.CampusRecommended = centsPtr(cb), centsPtr(cr)
|
|
|
+ } else if !isNoRows(err) {
|
|
|
+ return st, fmt.Errorf("读回 legacy 校区账户失败:%w", err)
|
|
|
+ }
|
|
|
+ return st, nil
|
|
|
+}
|
|
|
+
|
|
|
+// verifyLegacyRecommendSeed 断言"种出来的状态 == 声明的状态"。
|
|
|
+func verifyLegacyRecommendSeed(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ for _, u := range []struct {
|
|
|
+ legacyID int64
|
|
|
+ seed AccountSeed
|
|
|
+ role string
|
|
|
+ }{
|
|
|
+ {c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
|
|
|
+ {c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
|
|
|
+ } {
|
|
|
+ st, err := readLegacyRecommendState(ctx, tx, c, u.legacyID)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := compareSeedState(u.role, u.seed, st); err != nil {
|
|
|
+ return fmt.Errorf("case %q:%w", c.Name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// verifyTargetRecommendSeed 同上(target 侧 NULL 不可表达,按 0 比)。
|
|
|
+func verifyTargetRecommendSeed(ctx context.Context, tx *gorm.DB, c Case) error {
|
|
|
+ for _, u := range []struct {
|
|
|
+ legacyID int64
|
|
|
+ seed AccountSeed
|
|
|
+ role string
|
|
|
+ }{
|
|
|
+ {c.LegacyUserID, c.Recommend.Referred, "被推荐人"},
|
|
|
+ {c.RecommendUserID, c.Recommend.Referrer, "推荐人"},
|
|
|
+ } {
|
|
|
+ uid := fixture.FixtureCanonicalUserID(u.legacyID)
|
|
|
+ var gb, gr, cb, cr *string
|
|
|
+ var st recommendAccountState
|
|
|
+ err := tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
|
|
|
+ FROM t_user_expense WHERE user_id=?`, uid).Row().Scan(&gb, &gr)
|
|
|
+ if err == nil {
|
|
|
+ st.GlobalExists = true
|
|
|
+ st.GlobalBalance, st.GlobalRecommended = centsPtr(gb), centsPtr(gr)
|
|
|
+ } else if !isNoRows(err) {
|
|
|
+ return fmt.Errorf("读回 target 全局账户失败:%w", err)
|
|
|
+ }
|
|
|
+ err = tx.Raw(`SELECT CAST(balance AS DECIMAL(20,3)), CAST(total_recommended_amount AS DECIMAL(20,3))
|
|
|
+ FROM t_user_expense_school WHERE user_id=? AND institution_id=? AND campus_id=?`,
|
|
|
+ uid, c.InstitutionID, c.CampusID).Row().Scan(&cb, &cr)
|
|
|
+ if err == nil {
|
|
|
+ st.CampusExists = true
|
|
|
+ st.CampusBalance, st.CampusRecommended = centsPtr(cb), centsPtr(cr)
|
|
|
+ } else if !isNoRows(err) {
|
|
|
+ return fmt.Errorf("读回 target 校区账户失败:%w", err)
|
|
|
+ }
|
|
|
+ // target 的列 NOT NULL ⇒ 声明里的 nil("列为 NULL")在这一侧不适用,
|
|
|
+ // 按 0 参与比对;这一点在 `seedTargetRecommend` 里已写过原因。
|
|
|
+ normalized := u.seed
|
|
|
+ if !normalized.Absent {
|
|
|
+ if normalized.Balance == nil {
|
|
|
+ zero := int64(0)
|
|
|
+ normalized.Balance = &zero
|
|
|
+ }
|
|
|
+ if normalized.Recommended == nil {
|
|
|
+ zero := int64(0)
|
|
|
+ normalized.Recommended = &zero
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if err := compareSeedState(u.role+"(target)", normalized, st); err != nil {
|
|
|
+ return fmt.Errorf("case %q:%w", c.Name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// compareSeedState 把"声明"与"读回"逐列比。
|
|
|
+func compareSeedState(role string, seed AccountSeed, st recommendAccountState) error {
|
|
|
+ if seed.Absent {
|
|
|
+ if st.GlobalExists || st.CampusExists {
|
|
|
+ return fmt.Errorf("%s 声明「不建行」,但读回:全局行=%v 校区行=%v",
|
|
|
+ role, st.GlobalExists, st.CampusExists)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if !st.GlobalExists || !st.CampusExists {
|
|
|
+ return fmt.Errorf("%s 声明「行在」,但读回:全局行=%v 校区行=%v",
|
|
|
+ role, st.GlobalExists, st.CampusExists)
|
|
|
+ }
|
|
|
+ if !sameCents(seed.Balance, st.GlobalBalance) || !sameCents(seed.Balance, st.CampusBalance) {
|
|
|
+ return fmt.Errorf("%s 的 balance 与声明不符:声明 %s,全局读回 %s、校区读回 %s",
|
|
|
+ role, fmtCentsPtr(seed.Balance), fmtCentsPtr(st.GlobalBalance), fmtCentsPtr(st.CampusBalance))
|
|
|
+ }
|
|
|
+ if !sameCents(seed.Recommended, st.GlobalRecommended) || !sameCents(seed.Recommended, st.CampusRecommended) {
|
|
|
+ return fmt.Errorf("%s 的 total_recommended_amount 与声明不符:声明 %s,全局读回 %s、校区读回 %s",
|
|
|
+ role, fmtCentsPtr(seed.Recommended), fmtCentsPtr(st.GlobalRecommended), fmtCentsPtr(st.CampusRecommended))
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 三态小工具(读回校验用)----
|
|
|
+
|
|
|
+// centsPtr 把"可空十进制字符串"转成 `*int64`:NULL/空 ⇒ nil(**不是 0**)。
|
|
|
+func centsPtr(s *string) *int64 {
|
|
|
+ if s == nil || *s == "" {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ v, err := fixture.DecimalToCents(*s)
|
|
|
+ if err != nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return &v
|
|
|
+}
|
|
|
+
|
|
|
+// isNoRows 判断"没有行",用于把"行不存在"与"读失败"分开。
|
|
|
+//
|
|
|
+// 为什么要分开:`Row().Scan` 在没有行时返回 `sql.ErrNoRows`。
|
|
|
+// 把它和真错误混在一起,会让"账户不存在(本 case 故意造的)"被当成崩溃。
|
|
|
+func isNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) }
|
|
|
+
|
|
|
+// sameCents 比较"声明值"与"读回值",**nil 与 0 必须不同**。
|
|
|
+//
|
|
|
+// 这就是本文件存在的理由:W4-B 的缺陷是 NULL 不是 0,
|
|
|
+// 如果这里把 nil 当成 0,"种成 NULL"与"种成 0"就会得到同一个判定。
|
|
|
+func sameCents(want *int64, got *int64) bool {
|
|
|
+ if want == nil || got == nil {
|
|
|
+ return want == nil && got == nil
|
|
|
+ }
|
|
|
+ return *want == *got
|
|
|
+}
|
|
|
+
|
|
|
+// fmtCentsPtr 渲染三态金额:nil 显示 "NULL",不是 "0.00"。
|
|
|
+func fmtCentsPtr(p *int64) string {
|
|
|
+ if p == nil {
|
|
|
+ return "NULL"
|
|
|
+ }
|
|
|
+ return moneyStr(*p, false)
|
|
|
+}
|