|
|
@@ -0,0 +1,828 @@
|
|
|
+package stage2
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "database/sql"
|
|
|
+ "fmt"
|
|
|
+ "sort"
|
|
|
+ "strings"
|
|
|
+
|
|
|
+ "gorm.io/gorm"
|
|
|
+)
|
|
|
+
|
|
|
+// 本文件是批 3a 的**活动域快照**:六张表、逐列三态、行集多重集比对。
|
|
|
+//
|
|
|
+// # 为什么必须有它
|
|
|
+//
|
|
|
+// 活动链 9 支 SP 里 **只有 1 支碰资金表**(`use_activity_recommend`,14 处引用),
|
|
|
+// 其余 8 支的资金表引用数 = 0 —— 它们在 SP 层面根本不写资金。
|
|
|
+// 所以 C1/C2 那套 `Δbalance / Δspecial / Δincome / 流水 type` 比对机器对它们**完全不适用**,
|
|
|
+// 必须换成"读活动域的表,比活动域的行"。
|
|
|
+//
|
|
|
+// # 三条设计红线(都是踩过的坑,不要"优化"掉)
|
|
|
+//
|
|
|
+// 1. **逐列三态**:六张表的金额列大量 `DEFAULT NULL`(`_signed.activity_money`、
|
|
|
+// `_user_discount.discount_amount`、`used_log.activity_money_used` ……)。
|
|
|
+// 把 NULL 折成 0,"这行从没被写过"和"这列恰好是 0"就混成一个数 —— 那是 C1-11 那一整轮
|
|
|
+// 花代价修掉的东西,不能在新域重犯。
|
|
|
+//
|
|
|
+// 2. **`readRound2` 与 `readRound3` 不共用**:`t_courses_activity_user_discount` 同一张表里
|
|
|
+// `discount_amount` 是 `float(10,2)`、`discount_amount_used` 是 `float(10,3)`。
|
|
|
+// 抽一个"公共读金额"函数就会把 3 位那列悄悄降成 2 位,而且**不会报错**。
|
|
|
+// 所以两个函数分开写,函数名就是口径。
|
|
|
+//
|
|
|
+// 3. **行集比对用多重集**,不用"靠某个唯一键配对":
|
|
|
+// legacy 这五张表**只有 `PRIMARY(id)`**,没有任何业务唯一键(实测);
|
|
|
+// target 侧也只给 `t_courses_activity_signed` 补了 `uk_user_activity`,
|
|
|
+// `used_log` / `user_discount` / `discount_log` 仍然全是非唯一索引。
|
|
|
+// "靠键配对"必然要么撞键、要么漏行;而多重集对"少写一条 / 多写一条 / 写错一条"
|
|
|
+// 三种错法全部可检(抵消只可能发生在同一个 tuple 上,而那本身就是计数不同 = 红)。
|
|
|
+//
|
|
|
+// # 归一:哪些列不能直接进 tuple
|
|
|
+//
|
|
|
+// 自增 id(两侧起点不同) → 排除
|
|
|
+// create_time / response_time → 排除(NOW() 逐条不同)
|
|
|
+// `activity_user_discount_id`(父行自增 id) → 换成 (activity_id, user_id)
|
|
|
+// `courses_student_id`(bigint) → 换成 `courses_student_no`(旧 UUID)
|
|
|
+// `user_id`(ULID) → legacy 侧经 CanonicalUIDFor 换成 ULID,两侧同键
|
|
|
+
|
|
|
+// tupleSep / nullMark 是"把一行拼成一个可比较的字符串"用的分隔符。
|
|
|
+//
|
|
|
+// 用 `\x1f`(ASCII Unit Separator):这些字段的取值都是金额 / 主键 / 备注 / 单号,
|
|
|
+// 不会出现控制字符。NULL 用 `\x00` 单独标记 —— **不能**用空串代替,
|
|
|
+// 否则"列是 NULL"与"列是空字符串"会拼成同一个 tuple(又一次 NULL 折 0)。
|
|
|
+const (
|
|
|
+ tupleSep = "\x1f"
|
|
|
+ nullMark = "\x00"
|
|
|
+)
|
|
|
+
|
|
|
+// ---------------- 快照类型 ----------------
|
|
|
+
|
|
|
+// ActivitySnapshot 是一侧活动域的完整快照。
|
|
|
+//
|
|
|
+// `nil` 表示"本 case 不涉及活动域"(例如纯资金 case)。**nil 与 nil 不许当成"两侧相等"** ——
|
|
|
+// 那是"没测"而不是"测过且相等",所以所有活动域比对在 nil 时一律 `Skipped`。
|
|
|
+type ActivitySnapshot struct {
|
|
|
+ // Activities 是本 case plan 内每个活动的状态(key = 活动 id)。
|
|
|
+ Activities map[int64]ActivityState
|
|
|
+ // Signed 是本 case 用户在 plan 内活动上的报名整行(key = 活动 id + 用户 ULID)。
|
|
|
+ Signed map[ActivitySignedKey]SignedState
|
|
|
+ // UsedLog 是本 case plan 内活动上的用量流水**整表行集**。
|
|
|
+ UsedLog []UsedLogRow
|
|
|
+ // Discounts 是赠送额度整行(key = 活动 id + 用户 ULID)。
|
|
|
+ Discounts map[ActivitySignedKey]DiscountState
|
|
|
+ // DiscountLog 是赠送额度流水**整表行集**。
|
|
|
+ DiscountLog []DiscountLogRow
|
|
|
+ // Students 是报课欠费(key = `biz_no`,即旧 `t_courses_student.id`)。
|
|
|
+ Students map[string]StudentArrears
|
|
|
+}
|
|
|
+
|
|
|
+// ActivityState 是 `t_courses_activity` 里本 case 关心的列。
|
|
|
+//
|
|
|
+// 只有 `number_sign` 会被 SP 改(signed_add +1 / signed_cancel −1);
|
|
|
+// 其余列是种子写的,由「活动种子基线」那条断言覆盖。
|
|
|
+type ActivityState struct {
|
|
|
+ Type int64
|
|
|
+ Money int64 // readRound2:`float(10,2)`
|
|
|
+ InstitutionID int64
|
|
|
+ CampusID int64
|
|
|
+ NumberSign int64
|
|
|
+}
|
|
|
+
|
|
|
+// ActivitySignedKey 是报名行 / 赠送额度行的自然键。
|
|
|
+//
|
|
|
+// `UserUID` 两侧统一成 ULID 字符串(legacy 侧经 `CanonicalUIDFor`),
|
|
|
+// 这样同一个人的行在两侧落进同一个 key —— 而不是拿 int 与 ULID 硬比。
|
|
|
+type ActivitySignedKey struct {
|
|
|
+ ActivityID int64
|
|
|
+ UserUID string
|
|
|
+}
|
|
|
+
|
|
|
+// SignedState 是 `t_courses_activity_signed` 的一行。
|
|
|
+//
|
|
|
+// 三个金额列在 legacy 是 `decimal(10,3) NULL`、在 target 是 `decimal(12,2) NOT NULL`:
|
|
|
+// **NULL 在 target 不可表达**。所以这里三态照记,比对上由 `assertTriStateMapping` 按
|
|
|
+// "legacy NULL ⇒ target 必须恰好 0.00" 的映射去判(Q1:这是**真断言**,不是跳过)。
|
|
|
+type SignedState struct {
|
|
|
+ Status int64
|
|
|
+
|
|
|
+ ActivityMoney int64
|
|
|
+ ActivityMoneyNull bool
|
|
|
+ ActivityMoneyUsed int64
|
|
|
+ ActivityMoneyUsedNull bool
|
|
|
+ RechargeAmount int64
|
|
|
+ RechargeAmountNull bool
|
|
|
+
|
|
|
+ // RecommendUserUID `""` = 两侧都 NULL;`RecommendLegacyZero` 记"legacy 是 0 而非 NULL"。
|
|
|
+ //
|
|
|
+ // 为什么要这个标记:`0 ↔ NULL` 的等价**是我们定义的**,不能静默归一 ——
|
|
|
+ // 归一之后"legacy 写了 0、target 写了 NULL"这种真差异会被吃掉。
|
|
|
+ // 所以保留原始事实,等价性交给 `assertIdentityZeroNull` 单独断言并打两侧原值(Q2)。
|
|
|
+ RecommendUserUID string
|
|
|
+ RecommendLegacyZero bool
|
|
|
+ ResponseUserUID string
|
|
|
+ ResponseLegacyZero bool
|
|
|
+
|
|
|
+ RequestNo string
|
|
|
+ Memo string
|
|
|
+ // Rows 是同键行数。两侧都应恒为 1(target 有 `uk_user_activity`;legacy 没有,
|
|
|
+ // 靠 SP 的 DELETE+INSERT 保证)—— 由 `assertSingleSignedRow` 断言(补充③)。
|
|
|
+ Rows int
|
|
|
+}
|
|
|
+
|
|
|
+// UsedLogRow 是 `t_courses_activity_used_log` 的一行(进 tuple 的列)。
|
|
|
+type UsedLogRow struct {
|
|
|
+ ActivityID int64
|
|
|
+ UserUID string
|
|
|
+ RecommendUserUID string
|
|
|
+ RecommendLegacyZero bool
|
|
|
+ OrderNo string
|
|
|
+ Money int64 // readRound2(旧 `decimal(10,3) NULL`)
|
|
|
+ MoneyNull bool
|
|
|
+ Memo string
|
|
|
+}
|
|
|
+
|
|
|
+// DiscountState 是 `t_courses_activity_user_discount` 的一行。
|
|
|
+//
|
|
|
+// ⚠ 同一张表内两列精度不同:`DiscountAmount` 走 `readRound2`、
|
|
|
+// `DiscountAmountUsed` 走 `readRound3`→2。**不要合并成一个读函数。**
|
|
|
+type DiscountState struct {
|
|
|
+ DiscountAmount int64
|
|
|
+ DiscountAmountUsed int64
|
|
|
+ Income int64
|
|
|
+ // BalanceRequestIDNull 记"该列为 NULL"。3a 的 case 一律置 NULL 并**显式断言两侧都是 NULL**
|
|
|
+ // (Q3:将来有人改种子顺手带值,没有断言就会静默引入未验证的 id 重映射路径)。
|
|
|
+ BalanceRequestIDNull bool
|
|
|
+ Rows int
|
|
|
+}
|
|
|
+
|
|
|
+// DiscountLogRow 是 `t_courses_activity_user_discount_log` 的一行(进 tuple 的列)。
|
|
|
+//
|
|
|
+// 父行自增 id 两侧必然不同 ⇒ 归一成父行的 `(ActivityID, UserUID)` 再进 tuple。
|
|
|
+type DiscountLogRow struct {
|
|
|
+ ParentActivityID int64
|
|
|
+ ParentUserUID string
|
|
|
+ CoursesStudentNo string
|
|
|
+ Money int64 // readRound3 → 2
|
|
|
+ Memo string
|
|
|
+}
|
|
|
+
|
|
|
+// StudentArrears 是 `t_courses_student` 的欠费两列(key = `biz_no`)。
|
|
|
+//
|
|
|
+// ⚠ **只读 `net_receipts` 与 `remainder_receivable`**。
|
|
|
+// 不读 `unpaid`:它是 2018-05 起停更的僵尸列(`course_handler.go:647`、设计 §3.8-E4),
|
|
|
+// 禁展示禁下发;拿它当真值会比出一个不存在的差异。
|
|
|
+type StudentArrears struct {
|
|
|
+ NetReceipts int64
|
|
|
+ RemainderReceivable int64
|
|
|
+ // ConfirmDiscountSet = legacy `confirm_discount_userid > 0` / target `confirm_discount_user_id IS NOT NULL`。
|
|
|
+ //
|
|
|
+ // 它是**核销候选判据**的一半(另一半是 `remainder_receivable > 0`)。
|
|
|
+ // target 侧该列是 `varchar(26) NULL`,**NULL 语义 = 不可充值/不可核销**(模型注释原话:
|
|
|
+ // "NULL 语义=不可充值,勿用 0 替代")。所以种 target 侧必须给 ULID,
|
|
|
+ // 否则 target "不可核销"、legacy "可核销",红在种子而不是 SP。
|
|
|
+ ConfirmDiscountSet bool
|
|
|
+ Rows int
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 读取:legacy 侧 ----------------
|
|
|
+
|
|
|
+// readActivitySnapshotLegacy 读 legacy 侧的活动域快照。
|
|
|
+//
|
|
|
+// 只读 **本 case plan 内** 的行:活动表按 id、报名/额度按 (活动 id, 本 case 用户)、
|
|
|
+// 用量流水按活动 id、赠送流水按父行 join、报课欠费按 `Activity.StudentBizNos`。
|
|
|
+// 不做全表扫描 —— 一是慢,二是会把"别的用户的行"卷进比对,制造不可读的红。
|
|
|
+func readActivitySnapshotLegacy(ctx context.Context, tx *gorm.DB, c Case) (*ActivitySnapshot, error) {
|
|
|
+ p := c.ActivityPlan
|
|
|
+ if p == nil {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ ids := p.ActivityIDs()
|
|
|
+ if len(ids) == 0 {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ out := &ActivitySnapshot{
|
|
|
+ Activities: map[int64]ActivityState{},
|
|
|
+ Signed: map[ActivitySignedKey]SignedState{},
|
|
|
+ Discounts: map[ActivitySignedKey]DiscountState{},
|
|
|
+ Students: map[string]StudentArrears{},
|
|
|
+ }
|
|
|
+
|
|
|
+ // ① 活动行
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT id, IFNULL(type,0), `+readRound2("money")+`, IFNULL(schoolid,0), IFNULL(campusid,0), IFNULL(number_sign,0)
|
|
|
+ FROM t_courses_activity WHERE id IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 活动行失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var id int64
|
|
|
+ var st ActivityState
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&id, &st.Type, &money, &st.InstitutionID, &st.CampusID, &st.NumberSign); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.Money, _ = centsOrNull(money)
|
|
|
+ out.Activities[id] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ② 报名行(整行,含三态金额与两个 identity 列)
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT activityid,
|
|
|
+ IFNULL(status,0),
|
|
|
+ `+readRound2("activity_money")+`,
|
|
|
+ `+readRound2("activity_money_used")+`,
|
|
|
+ `+readRound2("recharge_amount")+`,
|
|
|
+ recommend_userid, response_userid,
|
|
|
+ IFNULL(requestid,''), IFNULL(memo,'')
|
|
|
+ FROM t_courses_activity_signed
|
|
|
+ WHERE activityid IN (?) AND userid = ?`, ids, c.LegacyUserID).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 报名行失败:%w", err)
|
|
|
+ }
|
|
|
+ uid := CanonicalUIDFor(c.LegacyUserID)
|
|
|
+ for rows.Next() {
|
|
|
+ var aid int64
|
|
|
+ var st SignedState
|
|
|
+ var money, used, recharge, rec, resp sql.NullString
|
|
|
+ if err := rows.Scan(&aid, &st.Status, &money, &used, &recharge, &rec, &resp,
|
|
|
+ &st.RequestNo, &st.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.ActivityMoney, st.ActivityMoneyNull = centsOrNull(money)
|
|
|
+ st.ActivityMoneyUsed, st.ActivityMoneyUsedNull = centsOrNull(used)
|
|
|
+ st.RechargeAmount, st.RechargeAmountNull = centsOrNull(recharge)
|
|
|
+ st.RecommendUserUID, st.RecommendLegacyZero = legacyIdentity(rec)
|
|
|
+ st.ResponseUserUID, st.ResponseLegacyZero = legacyIdentity(resp)
|
|
|
+ key := ActivitySignedKey{ActivityID: aid, UserUID: uid}
|
|
|
+ prev := out.Signed[key]
|
|
|
+ st.Rows = prev.Rows + 1
|
|
|
+ out.Signed[key] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ③ 用量流水(整表行集)
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT activityid, userid, IFNULL(recommend_userid,0),
|
|
|
+ IFNULL(orderid,''), `+readRound2("activity_money_used")+`, IFNULL(memo,'')
|
|
|
+ FROM t_courses_activity_used_log WHERE activityid IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 用量流水失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var r UsedLogRow
|
|
|
+ var uidInt, recInt int64
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&r.ActivityID, &uidInt, &recInt, &r.OrderNo, &money, &r.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.UserUID = CanonicalUIDFor(uidInt)
|
|
|
+ r.RecommendUserUID, r.RecommendLegacyZero = legacyIdentity(sql.NullString{String: fmt.Sprint(recInt), Valid: true})
|
|
|
+ r.Money, r.MoneyNull = centsOrNull(money)
|
|
|
+ out.UsedLog = append(out.UsedLog, r)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ④ 赠送额度行
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT d.activityid, d.userid,
|
|
|
+ `+readRound2("d.discount_amount")+`,
|
|
|
+ `+readRound3("d.discount_amount_used")+`,
|
|
|
+ `+readRound2("d.income")+`,
|
|
|
+ d.balance_requestid
|
|
|
+ FROM t_courses_activity_user_discount d
|
|
|
+ WHERE d.activityid IN (?) AND d.userid = ?`, ids, c.LegacyUserID).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 赠送额度失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var aid, uidInt int64
|
|
|
+ var amount, used, income, brid sql.NullString
|
|
|
+ if err := rows.Scan(&aid, &uidInt, &amount, &used, &income, &brid); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ var st DiscountState
|
|
|
+ st.DiscountAmount, _ = centsOrNull(amount)
|
|
|
+ st.DiscountAmountUsed, _ = centsOrNull(used)
|
|
|
+ st.Income, _ = centsOrNull(income)
|
|
|
+ st.BalanceRequestIDNull = !brid.Valid
|
|
|
+ key := ActivitySignedKey{ActivityID: aid, UserUID: CanonicalUIDFor(uidInt)}
|
|
|
+ prev := out.Discounts[key]
|
|
|
+ st.Rows = prev.Rows + 1
|
|
|
+ out.Discounts[key] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ⑤ 赠送额度流水(按父行 join,把父行自增 id 归一成 (activityid, userid))
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT d.activityid, d.userid, IFNULL(l.courses_studentid,''),
|
|
|
+ `+readRound3("l.discount_amount_used")+`, IFNULL(l.memo,'')
|
|
|
+ FROM t_courses_activity_user_discount_log l
|
|
|
+ JOIN t_courses_activity_user_discount d ON d.id = l.activity_user_discountid
|
|
|
+ WHERE d.activityid IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 赠送流水失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var r DiscountLogRow
|
|
|
+ var uidInt int64
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&r.ParentActivityID, &uidInt, &r.CoursesStudentNo, &money, &r.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.ParentUserUID = CanonicalUIDFor(uidInt)
|
|
|
+ r.Money, _ = centsOrNull(money)
|
|
|
+ out.DiscountLog = append(out.DiscountLog, r)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ⑥ 报课欠费(按 biz_no;legacy 的 `t_courses_student.id` 就是旧 UUID)
|
|
|
+ if len(c.Activity.StudentBizNos) > 0 {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT id, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
|
|
|
+ IF(IFNULL(confirm_discount_userid,0) > 0, 1, 0)
|
|
|
+ FROM t_courses_student WHERE id IN (?)`, c.Activity.StudentBizNos).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 legacy 报课欠费失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var biz string
|
|
|
+ var st StudentArrears
|
|
|
+ var net, rem sql.NullString
|
|
|
+ var confirm int64
|
|
|
+ if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.NetReceipts, _ = centsOrNull(net)
|
|
|
+ st.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
+ st.ConfirmDiscountSet = confirm == 1
|
|
|
+ st.Rows++
|
|
|
+ out.Students[biz] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+ return out, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 读取:target 侧 ----------------
|
|
|
+
|
|
|
+// readActivitySnapshotTarget 读 target 侧的活动域快照(列名是目标态命名)。
|
|
|
+//
|
|
|
+// ⚠ 三处必须记住的映射:
|
|
|
+// - `t_courses_activity.schoolid/campusid` → `institution_id/campus_id`
|
|
|
+// - `t_courses_activity_user_discount_log.courses_studentid` → **`courses_student_no`**
|
|
|
+// (不是 `courses_student_id`;后者是 bigint 自增,两侧必然不同)
|
|
|
+// - `t_courses_student.id`(bigint 自增)→ 定位用 **`biz_no`**(旧 UUID)
|
|
|
+func readActivitySnapshotTarget(ctx context.Context, tx *gorm.DB, c Case, uid string) (*ActivitySnapshot, error) {
|
|
|
+ p := c.ActivityPlan
|
|
|
+ if p == nil {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ ids := p.ActivityIDs()
|
|
|
+ if len(ids) == 0 {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ out := &ActivitySnapshot{
|
|
|
+ Activities: map[int64]ActivityState{},
|
|
|
+ Signed: map[ActivitySignedKey]SignedState{},
|
|
|
+ Discounts: map[ActivitySignedKey]DiscountState{},
|
|
|
+ Students: map[string]StudentArrears{},
|
|
|
+ }
|
|
|
+
|
|
|
+ // ① 活动行
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT id, type, `+readRound2("money")+`, institution_id, campus_id, number_sign
|
|
|
+ FROM t_courses_activity WHERE id IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 活动行失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var id int64
|
|
|
+ var st ActivityState
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&id, &st.Type, &money, &st.InstitutionID, &st.CampusID, &st.NumberSign); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.Money, _ = centsOrNull(money)
|
|
|
+ out.Activities[id] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ② 报名行
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT activity_id, status,
|
|
|
+ `+readRound2("activity_money")+`,
|
|
|
+ `+readRound2("activity_money_used")+`,
|
|
|
+ `+readRound2("recharge_amount")+`,
|
|
|
+ recommend_user_id, response_user_id,
|
|
|
+ IFNULL(request_no,''), IFNULL(memo,'')
|
|
|
+ FROM t_courses_activity_signed
|
|
|
+ WHERE activity_id IN (?) AND user_id = ?`, ids, uid).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 报名行失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var aid int64
|
|
|
+ var st SignedState
|
|
|
+ var money, used, recharge, rec, resp sql.NullString
|
|
|
+ if err := rows.Scan(&aid, &st.Status, &money, &used, &recharge, &rec, &resp,
|
|
|
+ &st.RequestNo, &st.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.ActivityMoney, st.ActivityMoneyNull = centsOrNull(money)
|
|
|
+ st.ActivityMoneyUsed, st.ActivityMoneyUsedNull = centsOrNull(used)
|
|
|
+ st.RechargeAmount, st.RechargeAmountNull = centsOrNull(recharge)
|
|
|
+ st.RecommendUserUID = strings.TrimSpace(rec.String)
|
|
|
+ st.ResponseUserUID = strings.TrimSpace(resp.String)
|
|
|
+ key := ActivitySignedKey{ActivityID: aid, UserUID: uid}
|
|
|
+ prev := out.Signed[key]
|
|
|
+ st.Rows = prev.Rows + 1
|
|
|
+ out.Signed[key] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ③ 用量流水
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT activity_id, user_id, recommend_user_id,
|
|
|
+ IFNULL(order_no,''), `+readRound2("activity_money_used")+`, IFNULL(memo,'')
|
|
|
+ FROM t_courses_activity_used_log WHERE activity_id IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 用量流水失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var r UsedLogRow
|
|
|
+ var rec sql.NullString
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&r.ActivityID, &r.UserUID, &rec, &r.OrderNo, &money, &r.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.RecommendUserUID = strings.TrimSpace(rec.String)
|
|
|
+ r.Money, r.MoneyNull = centsOrNull(money)
|
|
|
+ out.UsedLog = append(out.UsedLog, r)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ④ 赠送额度行
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT activity_id, user_id,
|
|
|
+ `+readRound2("discount_amount")+`,
|
|
|
+ `+readRound3("discount_amount_used")+`,
|
|
|
+ `+readRound2("income")+`,
|
|
|
+ balance_request_id
|
|
|
+ FROM t_courses_activity_user_discount
|
|
|
+ WHERE activity_id IN (?) AND user_id = ?`, ids, uid).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 赠送额度失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var aid int64
|
|
|
+ var uidStr string
|
|
|
+ var amount, used, income, brid sql.NullString
|
|
|
+ if err := rows.Scan(&aid, &uidStr, &amount, &used, &income, &brid); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ var st DiscountState
|
|
|
+ st.DiscountAmount, _ = centsOrNull(amount)
|
|
|
+ st.DiscountAmountUsed, _ = centsOrNull(used)
|
|
|
+ st.Income, _ = centsOrNull(income)
|
|
|
+ st.BalanceRequestIDNull = !brid.Valid
|
|
|
+ key := ActivitySignedKey{ActivityID: aid, UserUID: uidStr}
|
|
|
+ prev := out.Discounts[key]
|
|
|
+ st.Rows = prev.Rows + 1
|
|
|
+ out.Discounts[key] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ⑤ 赠送额度流水:父行 id 归一成 (activity_id, user_id);订单号用 `courses_student_no`
|
|
|
+ {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT d.activity_id, d.user_id, IFNULL(l.courses_student_no,''),
|
|
|
+ `+readRound3("l.discount_amount_used")+`, IFNULL(l.memo,'')
|
|
|
+ FROM t_courses_activity_user_discount_log l
|
|
|
+ JOIN t_courses_activity_user_discount d ON d.id = l.activity_user_discount_id
|
|
|
+ WHERE d.activity_id IN (?)`, ids).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 赠送流水失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var r DiscountLogRow
|
|
|
+ var money sql.NullString
|
|
|
+ if err := rows.Scan(&r.ParentActivityID, &r.ParentUserUID, &r.CoursesStudentNo, &money, &r.Memo); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ r.Money, _ = centsOrNull(money)
|
|
|
+ out.DiscountLog = append(out.DiscountLog, r)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+
|
|
|
+ // ⑥ 报课欠费(按 biz_no 定位)
|
|
|
+ if len(c.Activity.StudentBizNos) > 0 {
|
|
|
+ rows, err := tx.Raw(`
|
|
|
+SELECT biz_no, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+`,
|
|
|
+ IF(confirm_discount_user_id IS NULL OR confirm_discount_user_id = '', 0, 1)
|
|
|
+ FROM t_courses_student WHERE biz_no IN (?)`, c.Activity.StudentBizNos).Rows()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("读 target 报课欠费失败:%w", err)
|
|
|
+ }
|
|
|
+ for rows.Next() {
|
|
|
+ var biz string
|
|
|
+ var st StudentArrears
|
|
|
+ var net, rem sql.NullString
|
|
|
+ var confirm int64
|
|
|
+ if err := rows.Scan(&biz, &net, &rem, &confirm); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ st.NetReceipts, _ = centsOrNull(net)
|
|
|
+ st.RemainderReceivable, _ = centsOrNull(rem)
|
|
|
+ st.ConfirmDiscountSet = confirm == 1
|
|
|
+ st.Rows++
|
|
|
+ out.Students[biz] = st
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ _ = rows.Close()
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ _ = rows.Close()
|
|
|
+ }
|
|
|
+ return out, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 归一与比对 ----------------
|
|
|
+
|
|
|
+// legacyIdentity 把 legacy 的 int identity 列(`recommend_userid` / `response_userid` 等)
|
|
|
+// 转成 (ULID, 是否"恰好是 0")。
|
|
|
+//
|
|
|
+// 为什么保留 `isZero` 而不直接返回 `""`:`0 ↔ NULL` 的等价**是我们定义的**,
|
|
|
+// 静默归一会把"legacy 写了 0、target 写了 NULL"这种真差异吃掉(Q2)。
|
|
|
+// 所以这里只做**取值**,等价性交给 `assertIdentityZeroNull` 单独断言并打两侧原值。
|
|
|
+func legacyIdentity(v sql.NullString) (uid string, isZero bool) {
|
|
|
+ if !v.Valid {
|
|
|
+ return "", false // NULL
|
|
|
+ }
|
|
|
+ s := strings.TrimSpace(v.String)
|
|
|
+ if s == "" || s == "0" {
|
|
|
+ return "", s == "0"
|
|
|
+ }
|
|
|
+ if n, err := parseInt64(s); err == nil {
|
|
|
+ return CanonicalUIDFor(n), false
|
|
|
+ }
|
|
|
+ return s, false
|
|
|
+}
|
|
|
+
|
|
|
+// parseInt64 是只接受十进制整数的解析(不吞掉小数与符号异常)。
|
|
|
+func parseInt64(s string) (int64, error) {
|
|
|
+ var n int64
|
|
|
+ if _, err := fmt.Sscan(s, &n); err != nil {
|
|
|
+ return 0, err
|
|
|
+ }
|
|
|
+ return n, nil
|
|
|
+}
|
|
|
+
|
|
|
+// readRound2 是**2 位口径**的金额读法(迁移比较器:先 CAST 到 3 位定标,再 ROUND 到 2 位)。
|
|
|
+//
|
|
|
+// ⚠ 不要把它当成"通用金额读法":`t_courses_activity_user_discount.discount_amount_used`
|
|
|
+// 在旧库是 `float(10,3)`,必须走 `readRound3`。两列同表不同精度,
|
|
|
+// 抽一个公共函数就会把那列静默降成 2 位 —— 而且不会报错,只会让比对变松。
|
|
|
+func readRound2(col string) string {
|
|
|
+ return "CAST(ROUND(CAST(" + col + " AS DECIMAL(20,3)), 2) AS DECIMAL(20,2))"
|
|
|
+}
|
|
|
+
|
|
|
+// readRound3 是**3 位口径**的金额读法,用于 `discount_amount_used` 这类 `float(10,3)` 列。
|
|
|
+//
|
|
|
+// 3 位读出来后由 `centsOrNull` 转成分;与 2 位列相比**多一位可观测精度**,
|
|
|
+// 这正是 `user_discount_*` 那几支要的(它们的列宽是 3 位)。
|
|
|
+func readRound3(col string) string {
|
|
|
+ return "CAST(ROUND(CAST(" + col + " AS DECIMAL(20,3)), 3) AS DECIMAL(20,3))"
|
|
|
+}
|
|
|
+
|
|
|
+// usedLogTuple 把一行用量流水拼成可比字符串。
|
|
|
+//
|
|
|
+// 进 tuple 的列见文件头"归一"清单:**不含**自增 id、**不含** `create_time`。
|
|
|
+func usedLogTuple(r UsedLogRow) string {
|
|
|
+ return strings.Join([]string{
|
|
|
+ fmt.Sprint(r.ActivityID),
|
|
|
+ r.UserUID,
|
|
|
+ identityPart(r.RecommendUserUID),
|
|
|
+ orNull(r.OrderNo, r.OrderNo == ""),
|
|
|
+ moneyPart(r.Money, r.MoneyNull),
|
|
|
+ orNull(r.Memo, r.Memo == ""),
|
|
|
+ }, tupleSep)
|
|
|
+}
|
|
|
+
|
|
|
+// discountLogTuple 同上。父行自增 id 已归一成 (活动 id, 用户 ULID)。
|
|
|
+func discountLogTuple(r DiscountLogRow) string {
|
|
|
+ return strings.Join([]string{
|
|
|
+ fmt.Sprint(r.ParentActivityID),
|
|
|
+ r.ParentUserUID,
|
|
|
+ orNull(r.CoursesStudentNo, r.CoursesStudentNo == ""),
|
|
|
+ moneyPart(r.Money, false),
|
|
|
+ orNull(r.Memo, r.Memo == ""),
|
|
|
+ }, tupleSep)
|
|
|
+}
|
|
|
+
|
|
|
+// identityPart 渲染 identity 列:`""` 记成 NULL。
|
|
|
+//
|
|
|
+// ⚠ 这里把"legacy 0"与"target NULL"**都渲染成 NULL** —— 也就是说多重集比对里
|
|
|
+// `0 ↔ NULL` 被当作同一含义。这**是有意的**,但**不允许**它成为唯一的验证:
|
|
|
+// 若某一侧写了 0、另一侧写了 NULL,`assertIdentityZeroNull` 会单独把两侧原值打出来。
|
|
|
+// 也就是说:**多重集负责"行数/金额对不对",那条断言负责"等价判据本身是否仍成立"**,
|
|
|
+// 两者分工不同,缺一不可。
|
|
|
+func identityPart(uid string) string {
|
|
|
+ if uid == "" {
|
|
|
+ return nullMark
|
|
|
+ }
|
|
|
+ return uid
|
|
|
+}
|
|
|
+
|
|
|
+// moneyPart 渲染三态金额:NULL 用 `nullMark`,**不能渲染成 0**。
|
|
|
+func moneyPart(cents int64, isNull bool) string {
|
|
|
+ if isNull {
|
|
|
+ return nullMark
|
|
|
+ }
|
|
|
+ return fmt.Sprint(cents)
|
|
|
+}
|
|
|
+
|
|
|
+// orNull 把"空字符串"渲染成 NULL 标记(数据库里大部分文本列 NULL 与 ” 混用,
|
|
|
+// 本库里 SP 一律写 `IFNULL(x,”)` 读,所以比对时把两者视作同一含义并**在注释里写明**)。
|
|
|
+func orNull(s string, isNull bool) string {
|
|
|
+ if isNull {
|
|
|
+ return nullMark
|
|
|
+ }
|
|
|
+ return s
|
|
|
+}
|
|
|
+
|
|
|
+// buildMultiset 把行集渲染成"tuple → 出现次数"的多重集。
|
|
|
+func buildMultiset(rows []string) map[string]int {
|
|
|
+ m := make(map[string]int, len(rows))
|
|
|
+ for _, r := range rows {
|
|
|
+ m[r]++
|
|
|
+ }
|
|
|
+ return m
|
|
|
+}
|
|
|
+
|
|
|
+// multisetDiff 比较两个多重集,返回 (是否相等, 可读明细)。
|
|
|
+//
|
|
|
+// 为什么用多重集而不是"按键配对":
|
|
|
+// - 这三张 log 表**两侧都没有业务唯一键**(legacy 只有 `PRIMARY(id)`,target 也只有非唯一索引);
|
|
|
+// - "靠键配对"遇到重复键时只能取一条,于是"少写一条 + 多写一条"可能落在同一个键上互相抵消;
|
|
|
+// - 多重集下抵消只可能发生在**同一个 tuple** 上,而同一个 tuple 计数不同本身就是红。
|
|
|
+//
|
|
|
+// 明细按 tuple 排序输出(map 遍历无序,不排序的报告每次不一样,没法 diff)。
|
|
|
+func multisetDiff(lhs, rhs map[string]int, label string) (bool, string) {
|
|
|
+ keys := make([]string, 0, len(lhs)+len(rhs))
|
|
|
+ seen := make(map[string]struct{}, len(lhs)+len(rhs))
|
|
|
+ for k := range lhs {
|
|
|
+ if _, ok := seen[k]; !ok {
|
|
|
+ seen[k] = struct{}{}
|
|
|
+ keys = append(keys, k)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for k := range rhs {
|
|
|
+ if _, ok := seen[k]; !ok {
|
|
|
+ seen[k] = struct{}{}
|
|
|
+ keys = append(keys, k)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ sort.Strings(keys)
|
|
|
+
|
|
|
+ var parts []string
|
|
|
+ for _, k := range keys {
|
|
|
+ l, r := lhs[k], rhs[k]
|
|
|
+ if l == r {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ parts = append(parts, fmt.Sprintf("legacy<%s>×%d | target<%s>×%d", renderTuple(k), l, renderTuple(k), r))
|
|
|
+ }
|
|
|
+ if len(parts) == 0 {
|
|
|
+ return true, fmt.Sprintf("%s:%d 个 tuple 两侧多重集相同", label, len(keys))
|
|
|
+ }
|
|
|
+ return false, fmt.Sprintf("%s:%d 处计数不同 —— %s", label, len(parts), strings.Join(parts, ";"))
|
|
|
+}
|
|
|
+
|
|
|
+// renderTuple 把 tuple 字符串还原成可读形式(把分隔符换成 " | "、把 NULL 标记显示成 NULL)。
|
|
|
+func renderTuple(t string) string {
|
|
|
+ t = strings.ReplaceAll(t, nullMark, "NULL")
|
|
|
+ return strings.ReplaceAll(t, tupleSep, " | ")
|
|
|
+}
|
|
|
+
|
|
|
+// countOtherDiscountLogLegacy 统计"该 `courses_studentid` 下、除本 case 种的那几条之外"的负 log 条数。
|
|
|
+//
|
|
|
+// 为什么必须查这个:`sp_courses_activity_user_discount_refund` 的游标是
|
|
|
+//
|
|
|
+// WHERE courses_studentid = ? AND discount_amount_used < 0 ORDER BY id DESC
|
|
|
+//
|
|
|
+// —— **没有 userid 过滤**。而旧库真实有 162 行 log(target 0 行)。
|
|
|
+// 只要 case 用的 `courses_studentid` 撞上真实行,两侧游标看到的行集天然不同,
|
|
|
+// 那种红是【数据碰撞】,不是 SP 行为错。
|
|
|
+//
|
|
|
+// 与 `countOtherExpirableLegacy` 同一成例:**在 case 开始就把"污染源为 0"钉住**,
|
|
|
+// 而不是等它以一个看不懂的差异出现。
|
|
|
+func countOtherDiscountLogLegacy(
|
|
|
+ ctx context.Context, db *gorm.DB, coursesStudentNo string, seedIDs []int64,
|
|
|
+) (int, error) {
|
|
|
+ q := `SELECT COUNT(*) FROM t_courses_activity_user_discount_log
|
|
|
+ WHERE courses_studentid = ? AND discount_amount_used < 0`
|
|
|
+ args := []any{coursesStudentNo}
|
|
|
+ if len(seedIDs) > 0 {
|
|
|
+ q += ` AND id NOT IN (?)`
|
|
|
+ args = append(args, seedIDs)
|
|
|
+ }
|
|
|
+ var n int
|
|
|
+ if err := db.Raw(q, args...).Row().Scan(&n); err != nil {
|
|
|
+ return 0, fmt.Errorf("统计旧库其他负 log 失败:%w", err)
|
|
|
+ }
|
|
|
+ return n, nil
|
|
|
+}
|
|
|
+
|
|
|
+// activityDomainApplicable 判断"本 case 是否真的涉及活动域"。
|
|
|
+//
|
|
|
+// **两侧任一为 nil ⇒ 不适用**(所有活动域比对 Skipped)。
|
|
|
+// 绝不写成 `lg == tg`:`nil == nil` 会让"两侧都没读到"变成"两侧相等",
|
|
|
+// 而那正是"新字段对旧 case 为空被当成两侧不等/相等"的自伤点。
|
|
|
+func activityDomainApplicable(lg, tg *ActivitySnapshot) bool {
|
|
|
+ return lg != nil && tg != nil
|
|
|
+}
|
|
|
+
|
|
|
+// fmtSnapN 渲染"快照读到的活动条数",`-1` 表示整块 `Activity` 为 nil。
|
|
|
+func fmtSnapN(n int) string {
|
|
|
+ if n < 0 {
|
|
|
+ return "nil"
|
|
|
+ }
|
|
|
+ return fmt.Sprint(n)
|
|
|
+}
|