diff --git a/docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md b/docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md new file mode 100644 index 00000000..b6128a31 --- /dev/null +++ b/docs/data-fix/2026-11-04-换货迁移套餐双生效数据修正.md @@ -0,0 +1,133 @@ +# 2026-11-04 换货迁移导致卡上两个主套餐同时生效的数据修正 + +## 现象 + +卡 `89860624630055040571`(`tb_iot_card.id = 93937`)在后台的预计套餐到期时间显示"数据异常"(`expiry_estimate_status = invalid_data`)。 + +## 原因 + +### 1. 换货迁移把老卡的生效中套餐原样挂到了新卡上 + +2026-11-03 17:42,换货单 `EXC20261103174239616885`(`tb_exchange_order.id = 1094`)完成直接换货: + +- 老卡:`89860624630055027198`(`id = 93846`),停机、未实名,从未产生流量。 +- 新卡:`89860624630055040571`(`id = 93937`)。 +- 选了迁移数据。换货原因是"未实名卡,给老卡续费"。 + +`internal/service/exchange/migration.go` 的 `migratePackageUsageWithTx` 只把老卡套餐使用记录的 `iot_card_id` 改成新卡,状态原样保留,也不检查新卡是否已有生效中的主套餐。迁移后新卡上同时有两个生效中(`status = 1`)的主套餐: + +| 套餐使用记录 | 套餐 | 订单号 | 修正前状态 | 到期时间 | +|---|---|---|---|---| +| 47051 | Mifi-U省内版(12个月省内流量每月3000G),`package_id = 272` | `MIG-89860624630055040571-1` | 生效中 | 2026-12-14 | +| 46981 | 12个月省内流量每月1000G,`package_id = 230` | `MIG-89860624630055027198-1` | 生效中,`activated_at` 为空 | 2027-12-14 | + +46981 的到期时间正好是 47051 到期后再加一年,说明运营的本意是"续费,接在现有套餐后面"。 + +`internal/query/packageexpiry/query.go` 的 `Calculate` 要求一个资产只能有一个生效中或已用完的主套餐。出现两个时返回 `invalid_data`。 + +### 2. 流量扣到了续费的套餐上 + +换货后,新卡的流量全部扣到了 46981 上,47051 从 11-03 17:40 起没有再被扣过: + +| 日期 | 47051 日用量 | 46981 日用量 | +|---|---|---| +| 11-01 | 941 MB | | +| 11-02 | 255 MB | | +| 11-03 | 5,393 MB | 52 MB | +| 11-04 | | 2,707 MB | + +合计 9,348 MB,和新卡 11 月的 `current_month_usage_mb = 9348.33` 一致。老卡本月和上月的用量都是 0,所以 46981 上的用量全部来自新卡。 + +### 3. 第一次修正后仍显示数据异常:排序问题 + +46981 改成待生效后,页面仍然显示数据异常。原因是两条记录的 `priority` 都是 1,`created_at` 也完全相同(同一次迁移写入,都是 `2026-09-02 16:40:32.81965`),排序只能按 `id`,46981 就排在了 47051 前面。`Calculate` 不允许待生效套餐排在当前套餐之前,所以仍然判成 `invalid_data`。把 46981 的 `priority` 改成 2 后,它排到了 47051 后面。 + +## 已执行的修正(生产库,2026-11-04) + +两步都在单个事务中执行:先锁行,改完后自动校验,校验不通过就整体回滚。 + +### 第一步:用量转回 47051,46981 改为待生效 + +执行前:46981 的 `data_usage_mb = 2759`,47051 的 `data_usage_mb = 112102`。 + +```sql +BEGIN; +SELECT id, status, data_usage_mb, iot_card_id FROM tb_package_usage WHERE id IN (46981, 47051) ORDER BY id FOR UPDATE; + +-- 46981 的已用量转回 47051 +UPDATE tb_package_usage a +SET data_usage_mb = a.data_usage_mb + b.data_usage_mb, updated_at = now() +FROM tb_package_usage b +WHERE a.id = 47051 AND b.id = 46981 AND a.status = 1 AND b.status = 1 AND a.iot_card_id = 93937 AND b.iot_card_id = 93937; + +-- 日用量记录并到 47051 +INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at) +SELECT 47051, d.date, d.daily_usage_mb, 0, now(), now() +FROM tb_package_usage_daily_record d WHERE d.package_usage_id = 46981 +ON CONFLICT (package_usage_id, date) DO UPDATE +SET daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, updated_at = now(); + +-- 以 11-02 的累计值为起点,重算 47051 从 11-03 起的累计用量 +UPDATE tb_package_usage_daily_record r SET cumulative_usage_mb = x.cum, updated_at = now() +FROM ( + SELECT id, + (SELECT cumulative_usage_mb FROM tb_package_usage_daily_record WHERE package_usage_id = 47051 AND date = '2026-11-02') + + sum(daily_usage_mb) OVER (ORDER BY date) AS cum + FROM tb_package_usage_daily_record + WHERE package_usage_id = 47051 AND date >= '2026-11-03' +) x +WHERE r.id = x.id; + +DELETE FROM tb_package_usage_daily_record WHERE package_usage_id = 46981; + +-- 46981 改为待生效,和现有待生效套餐的数据形态一致;激活时不会把已用量清零,所以这里必须清零 +UPDATE tb_package_usage +SET status = 0, data_usage_mb = 0, activated_at = NULL, expires_at = NULL, + last_reset_at = NULL, next_reset_at = NULL, updated_at = now() +WHERE id = 46981 AND status = 1 AND iot_card_id = 93937; +-- 校验项:47051 的已用量等于两者之和;46981 是待生效、已用量为 0; +-- 47051 最后一天的累计用量等于它的已用量;46981 已经没有日用量记录 +COMMIT; +``` + +### 第二步:调整排队顺序 + +```sql +BEGIN; +UPDATE tb_package_usage SET priority = 2, updated_at = now() +WHERE id = 46981 AND status = 0 AND iot_card_id = 93937 AND priority = 1; +COMMIT; +``` + +## 修正后的状态 + +| 套餐使用记录 | 状态 | 优先级 | 已用量 | 生效时间 | 到期时间 | 下次重置 | +|---|---|---|---|---|---|---| +| 47051 | 生效中 | 1 | 114,861 MB | 2025-12-14 | 2026-12-14 | 2026-11-09 | +| 46981 | 待生效 | 2 | 0 | 空 | 空 | 空 | + +47051 的日用量记录:11-03 为 5,445 MB,累计 112,154 MB;11-04 为 2,707 MB,累计 114,861 MB。 + +预期效果: + +- 后续流量扣到 47051 上。 +- 预计到期时间推算为约 2027-12-14:47051 到期后,46981 由排队激活接上 365 天。 + +## 回滚 + +一般不需要回滚。如果必须恢复到修正前的状态: + +1. 46981 恢复为 `status = 1`、`priority = 1`、`data_usage_mb = 2759`、`expires_at = '2027-12-14 00:00:00'`、`last_reset_at = '2026-10-18 01:23:14.86183'`、`next_reset_at = '2026-11-17 00:00:00'`。 +2. 47051 的 `data_usage_mb` 减去 2759。 +3. 日用量记录拆回原样: + - 46981:11-03 为 52/52,11-04 为 2707/2759; + - 47051:11-03 为 5393/112102,并删除 47051 的 11-04 记录。 + +回滚前,要先确认修正之后又扣进 47051 的新用量。 + +## 遗留问题 + +- **代码漏洞未修。** 换货迁移不处理新资产已有生效中主套餐的情况,以后同类换货还会再出现两个主套餐同时生效。可选的修法: + - 迁移过来的主套餐改为待生效,排在新资产现有套餐之后(`priority` 递增); + - 或者在换货前检查,直接拒绝,提示运营先处理。 +- **同类问题未排查。** 生产中可能还有其他卡或设备同时挂着多个生效中的主套餐,尚未全量排查。