8-dedupe-pay-log.sql 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. -- ============================================================
  2. -- t_pay_log 重复商户订单号:诊断 + 去重(建唯一索引的前置步骤)
  3. --
  4. -- 【执行状态 · 2026-10-02】生产库残留的 10 组重复已由人工手工清理完毕。
  5. -- 本文件不再需要执行,仅保留备查;若日后 8.sql 的【执行前检查 1】再次返回结果,
  6. -- 可按本文件流程处理。
  7. -- 【3.定损】只用于评估历史多入账金额,业务已决定不追溯、不追回,跳过即可。
  8. -- 清理结果仍必须通过 8.sql 末尾的【上线后对账 1/2/3】验收(那属于数据正确性,与追款无关)。
  9. --
  10. -- 背景:8.sql 的执行前检查 1 返回了 10 条重复(同一 out_trade_no 有多行)。
  11. -- 同一个 out_trade_no 只可能来自同一笔微信支付,多行意味着该笔回调被处理了多次,
  12. -- 或有人工补录。必须先去重才能建 uk_pay_log_out_trade_no。
  13. --
  14. -- ⚠️ 顺序:先跑【1.诊断】→【2.判定】→【3.定损】→【4.备份并去重】→ 再回到 8.sql 建索引。
  15. -- ⚠️ 删除 pay_log 行 = 把"账本"改成唯一,**不等于**把多充进余额的钱收回;这两件事分开决策(见第 3 步)。
  16. -- ⚠️ 第 4 步只自动处理"同一用户 + 同一交易号 + 同一金额"的明确重复组,
  17. -- 其余组会列出来要求人工判断,避免误删别人真实收款的记录。
  18. -- ============================================================
  19. -- ============================================================
  20. -- 【1.诊断】10 组重复的画像
  21. -- ============================================================
  22. SELECT g.out_trade_no,
  23. g.cnt AS `行数`,
  24. g.tx_cnt AS `交易号种类`,
  25. g.user_cnt AS `用户数`,
  26. g.amount_cnt AS `金额种类`,
  27. g.first_create AS `首次记录时间`,
  28. g.last_create AS `末次记录时间`,
  29. TIMESTAMPDIFF(SECOND, g.first_create, g.last_create) AS `间隔秒`,
  30. g.ids,
  31. g.txs
  32. FROM (
  33. SELECT out_trade_no,
  34. COUNT(*) AS cnt,
  35. COUNT(DISTINCT transaction_id) AS tx_cnt,
  36. COUNT(DISTINCT user_id) AS user_cnt,
  37. COUNT(DISTINCT total) AS amount_cnt,
  38. MIN(create_time) AS first_create,
  39. MAX(create_time) AS last_create,
  40. GROUP_CONCAT(id ORDER BY id) AS ids,
  41. GROUP_CONCAT(transaction_id ORDER BY id) AS txs
  42. FROM charge_app.t_pay_log
  43. GROUP BY out_trade_no
  44. HAVING COUNT(*) > 1
  45. ) g
  46. ORDER BY g.first_create;
  47. -- ============================================================
  48. -- 【2.判定】逐行看全字段(人工确认每条重复的成因,别盲删)
  49. -- ============================================================
  50. SELECT p.*
  51. FROM charge_app.t_pay_log p
  52. JOIN (
  53. SELECT out_trade_no
  54. FROM charge_app.t_pay_log
  55. GROUP BY out_trade_no
  56. HAVING COUNT(*) > 1
  57. ) d ON d.out_trade_no = p.out_trade_no
  58. ORDER BY p.out_trade_no, p.id;
  59. -- 判定口径:
  60. -- 交易号种类 = 1 且 间隔秒很小(秒级) → 同一笔回调被并发/重试处理了两次:真实资金只收了一次,
  61. -- 多出的那行对应"多入账",保留 MIN(id),其余删除(第 3、4 步)
  62. -- 交易号种类 = 1 且 间隔很大(天/月) → 可能是事后人工补录(历史上的补单):
  63. -- 先确认当时余额是否已补过,再决定删哪行
  64. -- 交易号种类 > 1 → 异常数据,逐条人工判断,不要批量删
  65. -- 用户数 > 1 → 订单号撞号(不同用户同一订单号),必须单独定损
  66. -- 金额种类 > 1 → 数据不一致,逐条人工判断
  67. -- 安全校验:确认没有其他表引用这些 pay_log 行
  68. -- (充值场景 wallet_detail.transaction_id 实际未写入,这里仍做一次兜底检查;有结果先处理引用再删)
  69. SELECT w.id, w.user_id, w.order_no, w.transaction_id
  70. FROM charge_app.t_wallet_detail w
  71. JOIN (
  72. SELECT out_trade_no
  73. FROM charge_app.t_pay_log
  74. GROUP BY out_trade_no
  75. HAVING COUNT(*) > 1
  76. ) d ON d.out_trade_no = w.order_no
  77. WHERE w.transaction_id IS NOT NULL;
  78. -- ============================================================
  79. -- 【3.定损】多记了多少、涉及谁、有没有叠加影响退款
  80. -- ============================================================
  81. -- 3.1 疑似多入账金额("多出的行数 × 单笔金额"估算,单位:分)
  82. SELECT g.user_id,
  83. COUNT(*) AS `重复订单数`,
  84. SUM((g.cnt - 1) * g.total_each) AS `疑似多入账金额分`
  85. FROM (
  86. SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
  87. FROM charge_app.t_pay_log
  88. GROUP BY user_id, out_trade_no
  89. HAVING COUNT(*) > 1
  90. ) g
  91. GROUP BY g.user_id;
  92. -- 3.2 受影响用户的当前余额与充值口径(去重前后对照,用于和用户/财务核对)
  93. -- 为避免依赖窗口函数(兼容 MySQL 5.7 / MariaDB 10.2 以下),用 MIN(id) 自连接实现
  94. SELECT g.user_id,
  95. g.`重复订单数`,
  96. g.`疑似多入账金额分`,
  97. a.balance AS `当前余额分`,
  98. a.discount_amount AS `不可退金额分`,
  99. w.wallet_recharge_total AS `钱包已确认充值分`,
  100. p.pay_log_total_with_dup AS `pay_log合计_含重复分`,
  101. p.pay_log_total_dedup AS `pay_log合计_去重后分`
  102. FROM (
  103. SELECT user_id, COUNT(*) AS `重复订单数`, SUM((cnt - 1) * total_each) AS `疑似多入账金额分`
  104. FROM (
  105. SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
  106. FROM charge_app.t_pay_log
  107. GROUP BY user_id, out_trade_no
  108. HAVING COUNT(*) > 1
  109. ) x
  110. GROUP BY user_id
  111. ) g
  112. LEFT JOIN charge_app.t_account a ON a.user_id = g.user_id
  113. LEFT JOIN (
  114. SELECT user_id, SUM(amount) AS wallet_recharge_total
  115. FROM charge_app.t_wallet_detail
  116. WHERE type = 1 AND status = 1
  117. GROUP BY user_id
  118. ) w ON w.user_id = g.user_id
  119. LEFT JOIN (
  120. SELECT p.user_id,
  121. SUM(p.total) AS pay_log_total_with_dup,
  122. SUM(CASE WHEN p.id = k.keep_id THEN p.total ELSE 0 END) AS pay_log_total_dedup
  123. FROM charge_app.t_pay_log p
  124. LEFT JOIN (
  125. SELECT out_trade_no, MIN(id) AS keep_id
  126. FROM charge_app.t_pay_log
  127. GROUP BY out_trade_no
  128. ) k ON k.out_trade_no = p.out_trade_no
  129. WHERE p.trade_state = 'SUCCESS'
  130. GROUP BY p.user_id
  131. ) p ON p.user_id = g.user_id
  132. ORDER BY g.`疑似多入账金额分` DESC;
  133. -- 3.3 是否有人靠"虚高的充值合计"通过了退款校验(applyWxRefund 用 SUM(pay_log.total) 判断可退额度)
  134. -- 若有重复记录用户成功退款的,需单独评估是否超退
  135. SELECT r.user_id, r.out_trade_no, r.total, r.refund, r.status, r.create_time
  136. FROM charge_app.t_refund_log r
  137. WHERE r.user_id IN (
  138. SELECT user_id FROM charge_app.t_pay_log
  139. GROUP BY user_id, out_trade_no HAVING COUNT(*) > 1
  140. )
  141. ORDER BY r.user_id, r.create_time;
  142. -- 说明:第 3 步只做"定损"。是否向用户追回多入账金额属于业务/财务决策,
  143. -- 不要在未经确认的情况下直接扣减余额(余额被扣成负数会影响后续充电与退款判断)。
  144. -- ============================================================
  145. -- 【4.备份并去重】
  146. -- ============================================================
  147. -- 4.1 备份全部重复组(完整审计留痕;确认无误后再由人工决定是否清理该备份表)
  148. CREATE TABLE charge_app.t_pay_log_dup_bak_20261002 AS
  149. SELECT p.*
  150. FROM charge_app.t_pay_log p
  151. JOIN (
  152. SELECT out_trade_no
  153. FROM charge_app.t_pay_log
  154. GROUP BY out_trade_no
  155. HAVING COUNT(*) > 1
  156. ) d ON d.out_trade_no = p.out_trade_no;
  157. -- 4.2 需人工处理的"不安全组"(同一订单号跨用户/跨交易号/跨金额)——应为 0 行;
  158. -- 若有结果,必须逐条人工确认后再决定,不要交给下面的自动删除
  159. SELECT out_trade_no,
  160. COUNT(*) AS `行数`,
  161. COUNT(DISTINCT user_id) AS `用户数`,
  162. COUNT(DISTINCT transaction_id) AS `交易号种类`,
  163. COUNT(DISTINCT total) AS `金额种类`,
  164. GROUP_CONCAT(id ORDER BY id) AS ids
  165. FROM charge_app.t_pay_log
  166. GROUP BY out_trade_no
  167. HAVING COUNT(*) > 1
  168. AND (COUNT(DISTINCT user_id) > 1
  169. OR COUNT(DISTINCT transaction_id) > 1
  170. OR COUNT(DISTINCT total) > 1);
  171. -- 4.3 待删除行明细核对(仅"安全组":同一用户 + 同一交易号 + 同一金额,保留 MIN(id))
  172. SELECT p.id, p.out_trade_no, p.user_id, p.total, p.transaction_id, p.success_time, p.create_time
  173. FROM charge_app.t_pay_log p
  174. JOIN (
  175. SELECT out_trade_no, MIN(id) AS keep_id
  176. FROM charge_app.t_pay_log
  177. GROUP BY out_trade_no
  178. HAVING COUNT(*) > 1
  179. AND COUNT(DISTINCT user_id) = 1
  180. AND COUNT(DISTINCT transaction_id) = 1
  181. AND COUNT(DISTINCT total) = 1
  182. ) d ON d.out_trade_no = p.out_trade_no
  183. WHERE p.id <> d.keep_id
  184. ORDER BY p.out_trade_no, p.id;
  185. -- 4.4 正式删除(用临时表承接 keep_id,绕开 MySQL "不能在同一语句里对目标表做子查询" 的限制 1093)
  186. CREATE TEMPORARY TABLE tmp_pay_log_keep AS
  187. SELECT out_trade_no, MIN(id) AS keep_id
  188. FROM charge_app.t_pay_log
  189. GROUP BY out_trade_no
  190. HAVING COUNT(*) > 1
  191. AND COUNT(DISTINCT user_id) = 1
  192. AND COUNT(DISTINCT transaction_id) = 1
  193. AND COUNT(DISTINCT total) = 1;
  194. DELETE p
  195. FROM charge_app.t_pay_log p
  196. JOIN tmp_pay_log_keep k ON k.out_trade_no = p.out_trade_no
  197. WHERE p.id <> k.keep_id;
  198. DROP TEMPORARY TABLE tmp_pay_log_keep;
  199. -- 4.5 复核:应为 0 行(若仍有行,说明都属于 4.2 的不安全组,需人工处理)
  200. SELECT out_trade_no, COUNT(*) AS cnt
  201. FROM charge_app.t_pay_log
  202. GROUP BY out_trade_no
  203. HAVING COUNT(*) > 1;
  204. -- ============================================================
  205. -- 【5.回到 8.sql】
  206. -- ============================================================
  207. -- 4.5 返回 0 行后,再执行 database/8.sql 建唯一索引 uk_pay_log_out_trade_no。