-- ============================================================================ -- migration_v3.sql -- 提现迁移 + 佣金余额闭环修复 -- ============================================================================ -- -- 目的: -- 1. 把 ticket(提现工单) 表里 status=4 的 185 条记录全量重迁到 withdrawals 表 -- - 删除 5 条历史错迁的 status=3 (取消/拒绝) 单 -- - 补迁 8 条历史漏迁的 status=4 (已通过) 单 -- 2. 修正 13 个 user.commission 字段,使其等于 SUM(全部 type=33 日志) -- 3. content 写入 '历史提现 #',支持反查 ticket 源头 -- -- 执行方式(注意 --default-character-set 必须显式声明): -- docker exec -i ppanel-mysql mysql --default-character-set=utf8mb4 \ -- -uroot -pppanel_dev ppanel < migration_v3.sql -- -- 回滚方式(假设 COMMIT 之后想恢复): -- TRUNCATE TABLE withdrawals; -- INSERT INTO withdrawals SELECT * FROM withdrawals_backup_v3; -- UPDATE user u JOIN user_commission_backup_v3 b ON b.id=u.id SET u.commission = b.commission; -- ============================================================================ -- ---------------------------------------------------------------------------- -- 阶段 0: 备份(在事务外执行,即使后续 ROLLBACK 备份也保留) -- ---------------------------------------------------------------------------- DROP TABLE IF EXISTS withdrawals_backup_v3; CREATE TABLE withdrawals_backup_v3 AS SELECT * FROM withdrawals; DROP TABLE IF EXISTS user_commission_backup_v3; CREATE TABLE user_commission_backup_v3 AS SELECT id, commission, NOW() AS backup_at FROM user WHERE commission <> 0; SELECT 'backup_done' AS step, (SELECT COUNT(*) FROM withdrawals_backup_v3) AS withdrawals_rows, (SELECT COUNT(*) FROM user_commission_backup_v3) AS user_commission_rows; -- ---------------------------------------------------------------------------- -- 阶段 1: 事务开始 -- ---------------------------------------------------------------------------- START TRANSACTION; -- ---------------------------------------------------------------------------- -- 阶段 2: 清空 withdrawals 表 -- ---------------------------------------------------------------------------- TRUNCATE TABLE withdrawals; -- ---------------------------------------------------------------------------- -- 阶段 3: 从 ticket 表全量重迁(严格只迁 status=4) -- 中文字面量统一 COLLATE utf8mb4_general_ci 跟 ticket 表一致 -- ---------------------------------------------------------------------------- INSERT INTO withdrawals ( user_id, amount, content, status, reason, method, account, qr_code_url, created_at, updated_at ) SELECT t.user_id, ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) AS amount_cents, CONCAT(_utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, t.id) AS content, 1 AS status, '' AS reason, CASE WHEN t.description LIKE _utf8mb4'支付宝%' COLLATE utf8mb4_general_ci THEN 1 WHEN t.description LIKE _utf8mb4'微信%' COLLATE utf8mb4_general_ci THEN 2 WHEN UPPER(t.description) LIKE 'USDT%' OR UPPER(t.description) LIKE 'TRC20%' THEN 3 ELSE 0 END AS method, CASE WHEN UPPER(t.description) LIKE 'USDT(TRC20)-%' THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1)) WHEN UPPER(t.description) LIKE 'USDT%' AND LOCATE('-', t.description) > 0 THEN TRIM(SUBSTRING(t.description, LOCATE('-', t.description) + 1)) WHEN t.description LIKE _utf8mb4'支付宝-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'支付宝收款码' COLLATE utf8mb4_0900_ai_ci WHEN t.description LIKE _utf8mb4'微信-data:image%' COLLATE utf8mb4_general_ci THEN _utf8mb4'微信收款码' COLLATE utf8mb4_0900_ai_ci WHEN CHAR_LENGTH(t.description) > 255 THEN LEFT(t.description, 255) ELSE t.description END AS account, '' AS qr_code_url, t.created_at, t.updated_at FROM ticket t WHERE t.title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND t.status = 4 AND TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$' AND ROUND(CAST(TRIM(REPLACE(REPLACE(t.title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) AS DECIMAL(18,2)) * 100) > 0 ORDER BY t.id; -- ---------------------------------------------------------------------------- -- 阶段 4: 修正用户的 commission 字段,使其等于 SUM(type=33 日志) -- ---------------------------------------------------------------------------- UPDATE user u JOIN ( SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) AS log_sum FROM system_logs WHERE type = 33 GROUP BY object_id ) s ON s.object_id = u.id SET u.commission = s.log_sum WHERE u.commission <> s.log_sum; -- ---------------------------------------------------------------------------- -- 阶段 4b: 处理"前日志时代"账号 — 有 commission 余额但 system_logs 完全没记录 -- 补一条 type=335 admin_adjust 日志记录历史基准余额,让账目闭环 -- content 标记 source=pre_log_baseline 便于追溯 -- ---------------------------------------------------------------------------- INSERT INTO system_logs (type, date, object_id, content, created_at) SELECT 33, DATE_FORMAT(u.created_at, '%Y-%m-%d'), u.id, JSON_OBJECT( 'type', 335, 'amount', u.commission, 'order_no', '', 'timestamp', UNIX_TIMESTAMP(u.created_at)*1000, 'source', 'pre_log_baseline' ), u.created_at FROM user u WHERE u.commission <> 0 AND NOT EXISTS ( SELECT 1 FROM system_logs s WHERE s.type=33 AND s.object_id=u.id ); -- ---------------------------------------------------------------------------- -- 阶段 5: 验证(任何一项 verdict=FAIL 都应 ROLLBACK) -- ---------------------------------------------------------------------------- -- 5a. withdrawals 总数 = ticket status=4 数,且都 > 0 SELECT '5a_count_match' AS check_name, (SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4 AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$') AS expected, (SELECT COUNT(*) FROM withdrawals) AS actual, IF( (SELECT COUNT(*) FROM withdrawals) > 0 AND (SELECT COUNT(*) FROM ticket WHERE title LIKE _utf8mb4'提现%' COLLATE utf8mb4_general_ci AND status=4 AND TRIM(REPLACE(REPLACE(title, _utf8mb4'提现-' COLLATE utf8mb4_general_ci, ''), _utf8mb4'提现' COLLATE utf8mb4_general_ci, '')) REGEXP '^[0-9]+([.][0-9]+)?$') = (SELECT COUNT(*) FROM withdrawals), 'PASS', 'FAIL' ) AS verdict; -- 5b. 没有 amount<=0 SELECT '5b_amount_positive' AS check_name, 0 AS expected, (SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) AS actual, IF((SELECT COUNT(*) FROM withdrawals WHERE amount <= 0) = 0, 'PASS', 'FAIL') AS verdict; -- 5c. content 必须形如 '历史提现 #' SELECT '5c_content_format' AS check_name, 0 AS expected, (SELECT COUNT(*) FROM withdrawals WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) AS actual, IF((SELECT COUNT(*) FROM withdrawals WHERE content NOT REGEXP CONCAT('^', _utf8mb4'历史提现 #' COLLATE utf8mb4_0900_ai_ci, '[0-9]+$')) = 0, 'PASS', 'FAIL') AS verdict; -- 5d. user.commission == SUM(type=33 logs) 全用户闭环 SELECT '5d_balance_closure' AS check_name, 0 AS expected, (SELECT COUNT(*) FROM user u LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id WHERE u.commission <> COALESCE(s.amt, 0)) AS actual, IF((SELECT COUNT(*) FROM user u LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id WHERE u.commission <> COALESCE(s.amt, 0)) = 0, 'PASS', 'FAIL') AS verdict; -- 5e. 31742 闭环具体值核对 SELECT '5e_user_31742' AS check_name, (SELECT COALESCE(SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)),0) FROM system_logs WHERE type=33 AND object_id=31742) AS expected_from_logs, (SELECT commission FROM user WHERE id=31742) AS actual_balance, (SELECT COUNT(*) FROM withdrawals WHERE user_id=31742) AS withdrawals_count; -- ---------------------------------------------------------------------------- -- 阶段 6: 提交事务 -- 检查上面所有 verdict 是 PASS 后才 COMMIT,否则 ROLLBACK -- ---------------------------------------------------------------------------- COMMIT; -- ---------------------------------------------------------------------------- -- 阶段 7: 事后摘要 -- ---------------------------------------------------------------------------- SELECT 'final_summary' AS section, (SELECT COUNT(*) FROM withdrawals) AS withdrawals_count, (SELECT SUM(amount) FROM withdrawals WHERE status=1) AS total_amount_cents, (SELECT COUNT(*) FROM user u LEFT JOIN (SELECT object_id, SUM(CAST(JSON_EXTRACT(content,'$.amount') AS SIGNED)) amt FROM system_logs WHERE type=33 GROUP BY object_id) s ON s.object_id=u.id WHERE u.commission <> COALESCE(s.amt, 0)) AS imbalanced_users_after_fix;