yeg
2026-09-17 91fede1466659416f522de286a2d9e772138ea4f
chore(scratch): 补账号错位排查探针与修复脚本

两个只读探针 + 一份待确认的修复脚本,接在 2026-09-16 两库合并的留档后面:

- AccountLookup.java:按登录账号 / 手机号在两个库里查同一个实体,并把它同主键的邻居打出来——
两库合并是按主键只插不改,撞车时「同 id 不同实体」就是这么被发现的。
- TenantDataDiff.java:同一归属 id(talent_id / merchant_id)在两个库各有多少行、差在哪些表,
用于判断「某个账号的数据是不是全在」。
- repair-onboardp-talent.sql:只针对 onboardp001 / 19934036931 这组错位的修复语句,尚未执行,
文件里写明事实、依据与验收语句,执行前需备份并按事务跑。
3个文件已添加
220 ■■■■■ 已修改文件
.scratch/db-merge/AccountLookup.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/db-merge/TenantDataDiff.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/db-merge/repair-onboardp-talent.sql 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/db-merge/AccountLookup.java
New file
@@ -0,0 +1,79 @@
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
 * Read-only: where do the merchant login `onboardp001` and the talent `19934036931` live, and what do
 * their neighbours at the same primary keys look like (the two-database merge was insert-only by primary
 * key, so a collision would leave an unrelated row in place). Never prints password hashes.
 */
public class AccountLookup {
    private static final String ACCOUNT = "onboardp001";
    private static final String MOBILE = "19934036931";
    public static void main(String[] args) throws Exception {
        for (String url : args) {
            System.out.println();
            System.out.println("================ " + url);
            try (Connection connection = DriverManager.getConnection(url, "root", "123456")) {
                System.out.println("server=" + one(connection, "select @@hostname, @@port, database()"));
                query(connection, """
                        select id, merchant_id, account, display_name, role_code, status, deleted, last_login_at, created_at,
                               length(password_hash) as pwd_len, left(password_hash, 8) as pwd_head
                        from merchant_operator where account like '%""" + ACCOUNT + "%'", "商户登录账号");
                query(connection, """
                        select id, merchant_name, contact_mobile, merchant_type_code, status, deleted, created_at
                        from merchant where id in (630, 631, 632, 633, 634)
                        order by id""", "merchant 表 id 630-634(看同 id 是不是同一实体)");
                query(connection, """
                        select merchant_id, count(*) rows_count, min(id) min_id, max(id) max_id
                        from merchant_operator where merchant_id in (630, 631, 632, 633, 634) group by merchant_id
                        order by merchant_id""", "这些商户各自有几个登录账号");
                query(connection, """
                        select id, name, mobile, status, deleted, created_at from talent_user where mobile = '""" + MOBILE + "'",
                        "人才账号(按手机号)");
                query(connection, """
                        select id, name, mobile, status, deleted, created_at from talent_user where id in (444, 445, 446, 447)
                        order by id""", "talent_user 表 id 444-447");
                query(connection, """
                        select id, order_no, order_type, order_status, verify_status, refund_status, pay_amount, merchant_id, talent_id, created_at
                        from benefit_order where id in (520, 521, 522, 523, 524) order by id""", "订单 id 520-524");
            } catch (SQLException exception) {
                System.out.println("查询失败: " + exception.getMessage());
            }
        }
    }
    private static String one(Connection connection, String sql) throws SQLException {
        try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) {
            if (!rows.next()) return "";
            StringBuilder text = new StringBuilder();
            for (int index = 1; index <= rows.getMetaData().getColumnCount(); index++) {
                text.append(rows.getString(index)).append(' ');
            }
            return text.toString().trim();
        }
    }
    private static void query(Connection connection, String sql, String title) throws SQLException {
        System.out.println();
        System.out.println("-- " + title);
        try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) {
            ResultSetMetaData meta = rows.getMetaData();
            List<String> header = new ArrayList<>();
            for (int index = 1; index <= meta.getColumnCount(); index++) header.add(meta.getColumnLabel(index));
            System.out.println(String.join(" | ", header));
            int count = 0;
            while (rows.next()) {
                List<String> line = new ArrayList<>();
                for (int index = 1; index <= meta.getColumnCount(); index++) {
                    Object value = rows.getObject(index);
                    line.add(value == null ? "NULL" : String.valueOf(value));
                }
                System.out.println(String.join(" | ", line));
                count++;
            }
            if (count == 0) System.out.println("(无行)");
        }
    }
}
.scratch/db-merge/TenantDataDiff.java
New file
@@ -0,0 +1,70 @@
import java.sql.*;
import java.util.*;
/**
 * Read-only: 同一个归属 id(talent_id / merchant_id)在两个库里各有多少行、差在哪些表。
 * 用于两库合并后核对「某个账号的数据是不是全在」,以及定位主键撞车影响的表。
 */
public class TenantDataDiff {
    public static void main(String[] args) throws Exception {
        String column = args[0];
        long id = Long.parseLong(args[1]);
        String left = args[2];
        String right = args[3];
        try (Connection a = DriverManager.getConnection(left, "root", "123456");
             Connection b = DriverManager.getConnection(right, "root", "123456")) {
            System.out.println("=== " + column + " = " + id);
            System.out.println("左 " + one(a, "select concat(@@hostname, '/', database())"));
            System.out.println("右 " + one(b, "select concat(@@hostname, '/', database())"));
            System.out.println();
            System.out.format("%-46s %8s %8s %8s%n", "表", "左", "右", "差");
            for (String table : referencingTables(a, column)) {
                if (!exists(b, table)) {
                    System.out.format("%-46s %8s %8s %8s%n", table, count(a, table, column, id), "-", "右库无此表");
                    continue;
                }
                long leftCount = count(a, table, column, id);
                long rightCount = count(b, table, column, id);
                if (leftCount == 0 && rightCount == 0) continue;
                System.out.format("%-46s %8d %8d %8d%s%n", table, leftCount, rightCount, leftCount - rightCount,
                        leftCount == rightCount ? "" : "   <-- 不一致");
            }
        }
    }
    private static List<String> referencingTables(Connection connection, String column) throws SQLException {
        List<String> tables = new ArrayList<>();
        try (PreparedStatement statement = connection.prepareStatement(
                "select table_name from information_schema.columns where table_schema = database() and column_name = ? order by table_name")) {
            statement.setString(1, column);
            try (ResultSet rows = statement.executeQuery()) {
                while (rows.next()) tables.add(rows.getString(1));
            }
        }
        return tables;
    }
    private static boolean exists(Connection connection, String table) throws SQLException {
        try (PreparedStatement statement = connection.prepareStatement(
                "select count(*) from information_schema.tables where table_schema = database() and table_name = ?")) {
            statement.setString(1, table);
            try (ResultSet rows = statement.executeQuery()) {
                return rows.next() && rows.getLong(1) > 0;
            }
        }
    }
    private static long count(Connection connection, String table, String column, long id) throws SQLException {
        // 列名与表名都来自 information_schema,不是外部输入。
        try (Statement statement = connection.createStatement();
             ResultSet rows = statement.executeQuery("select count(*) from `" + table + "` where `" + column + "` = " + id)) {
            return rows.next() ? rows.getLong(1) : 0;
        }
    }
    private static String one(Connection connection, String sql) throws SQLException {
        try (Statement statement = connection.createStatement(); ResultSet rows = statement.executeQuery(sql)) {
            return rows.next() ? rows.getString(1) : "";
        }
    }
}
.scratch/db-merge/repair-onboardp-talent.sql
New file
@@ -0,0 +1,71 @@
-- 修复 2026-09-16 两库合并留下的账号错位(只针对 onboardp001 / 19934036931,勿全表照搬)。
--
-- 事实(2026-09-17 核对,只读):
--   服务器库 talent_user id 446 = 「账号434 / 13540211634」(8-03 的种子账号),
--   本机开发库 talent_user id 446 = 「验证用户 / 19934036931」(本人的账号,9-13 注册)。
--   合并规则是「只插不改」,446 在服务器已被占用 → 本人那条**没有搬过去**;
--   合并后重新登录(9-16 19:02)由微信登录新建了 id 1031「普通游客」(openid 在它身上)。
--   本人的业务数据(13 笔订单 / 4 笔退款 / 12 条支付流水 / 5 条核销记录)当初按原主键搬过去了,
--   全部挂在 talent_id = 446 上——所以数据在,只是账号认不出来。账号434 自己没有任何业务数据
--   (它名下最早的订单是 9-14 10:57,全是本人的)。
--
-- 本脚本选择「把身份还原到数据所在的 446」,原因是改动最小:所有业务行已经指向 446,
-- 只需处理身份行、人才卡,以及被撞车的 card_id 引用。
--
-- 执行前请先备份:mysqldump 该库(.scratch/db-merge/README.md 里有 SchemaDump 的用法),
-- 然后在事务里跑,最后按文件末尾的验收语句核对。
start transaction;
-- 0) 先看清现状(可选,人工确认再往下)
select id, name, mobile, status, deleted from talent_user where id in (446, 1031);
-- 1) 撤掉重新登录时新建的那条游客账号:实测只有 1 条会话、0 笔订单、0 张卡。
--    openid 是唯一键,必须先从它身上挪走(见第 2 步)。
delete from talent_session where talent_id = 1031;
delete from talent_card    where talent_id = 1031;
delete from talent_user    where id = 1031;
-- 2) 把身份还原到 446:openid 从 1031 挪过来(人才端靠 openid 找人登录),手机号与姓名也还原。
--    账号434 是种子账号、无业务数据,其身份由此被本人身份取代。
update talent_user
set openid = 'oWVhp3cvkFLyILiXC4EyP7A3BFwo',
    name   = '验证用户',
    mobile = '19934036931',
    status = 1,
    deleted = 0
where id = 446;
-- 3) 人才卡:服务器上 446 名下那张是种子账号的遗留(card_no=0434、卡等级=普通游客)。
--    本机本人那张(card 240:SEEDCARD92348450290200 / EXCELLENT / 2026-09-12~2027-09-13)
--    因为 id 240 在服务器被别的人才占用,合并时整行没搬过来(服务器 card 240 属于 talent 240)。
--    这里把 446 名下这张的内容换成本人那张;card_no 已确认在服务器未被占用。
update talent_card
set card_no     = 'SEEDCARD92348450290200',
    card_type   = 'PERSONAL',
    card_level  = 'EXCELLENT',
    valid_start = '2026-09-12',
    valid_end   = '2027-09-13',
    status = 1,
    deleted = 0
where id = 446;
-- 4) 跨实体引用:本人订单原本指向本机 card 240,而服务器上 240 是**别的人才**的卡
--    (talent 240 的 0228/卓越人才卡)。不改指的话,本人订单会挂到别人的卡上。
update benefit_order                      set card_id = 446 where talent_id = 446 and card_id = 240;
update order_refund                       set card_id = 446 where talent_id = 446 and card_id = 240;
update verify_record                      set card_id = 446 where talent_id = 446 and card_id = 240;
update talent_dynamic_code                set card_id = 446 where talent_id = 446 and card_id = 240;
update order_dynamic_verification_code    set card_id = 446 where talent_id = 446 and card_id = 240;
-- 刻意不动的东西:talent_session / talent_dynamic_code / order_dynamic_verification_code 的
-- 历史行(本机有 17/121/55 行没搬过去)。这些是登录会话与 60 秒一换的核销码,运行时自动重建,
-- 补历史没有业务意义,也避免再引入一批 id 撞车。
commit;
-- 验收(应为:446 是本人身份;订单 13 笔;有效卡是 EXCELLENT):
--   select id, name, mobile from talent_user where id = 446;
--   select count(*) from benefit_order where talent_id = 446;
--   select card_no, card_level, valid_end from talent_card where talent_id = 446 and status = 1 and deleted = 0;
--   select count(*) from benefit_order where talent_id = 446 and card_id = 240;   -- 应为 0