Files
junhong_cmp_fiber/scripts/migration/README.md
2026-05-28 17:22:52 +08:00

234 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 奇成数据迁移脚本
> 详细方案见 [`docs/脚本/奇成数据迁移方案.md`](../../docs/脚本/奇成数据迁移方案.md)。
## 1. 四步走
```
业务方 我们 运维
│ │ │
│ cards.csv │ scan_legacy.py → mapping.yaml │
│ devices.csv │ ↓ 人工填 target_* │
├─────────────▶│ migrate_assets.py → step1_*.sql │
│ │ migrate_runtime.py → step2_*.sql │
│ ├────────────────────────────────────▶│ psql -1 -f step*.sql
│ │ │ migration-finalize
│ │ │ 重建轮询队列/缓存
```
## 2. 文件清单
```
scripts/migration/
├── README.md
├── requirements.txt
├── scan_legacy.py ← 第1步:扫描奇成,生成 mapping.yaml 模板
├── migrate_assets.py ← 第2步:生成资产 SQL
├── migrate_runtime.py ← 第3步:生成关联数据 SQL
├── lib/ ← 公共库
├── resources/
│ ├── README.md
│ ├── cards.csv.example ← 业务方提供:iccid + 可选覆盖列
│ └── devices.csv.example ← 业务方提供:设备 + 卡槽
├── config/
│ ├── mapping.yaml.example ← 唯一映射配置(全局 + carrier + package + series + agent)
│ └── legacy_dsn.yaml.example← 奇成 MySQL 只读 DSN
└── output/ ← 生成的 SQL 与日志
```
## 3. 详细流程
### 3.1 业务方提供数据
把两个 CSV 放到 `resources/` 下(命名为 `cards.csv` / `devices.csv`):
- **cards.csv**:`iccid_19`/`iccid_20` 至少填一个;仅填 `iccid_20` 时脚本自动派生前 19 位,但奇成主表只按 20 位精确查询;`is_industry` 可选
- **devices.csv**:`virtual_no` 必填,`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位)
字段说明详见 [`resources/README.md`](resources/README.md)。
### 3.2 我们扫描 + 填映射
```bash
pip install -r requirements.txt
cp config/legacy_dsn.yaml.example config/legacy_dsn.yaml # 填真实 DSN
cp config/mapping.yaml.example config/mapping.yaml # 第一次跑要有这个文件
# 扫描奇成,把发现的 carrier/package/agent 写入 mapping.yaml
python3 scan_legacy.py
# 看 config/mapping_todo.txt,把 target_* 全部填好
vim config/mapping.yaml
```
`scan_legacy.py` 可以反复跑(增量合并),已填的 `target_*` 不会被覆盖。
### 3.3 生成 SQL
```bash
python3 migrate_assets.py # → output/step1_*.sql + errors.csv
python3 migrate_runtime.py # → output/step2_*.sql + warnings.csv
```
如果 `errors.csv` 有内容,意味着 `mapping.yaml` 还没填完整、或者某些 ICCID 在奇成查不到,处理完再重跑。
### 3.4 测试环境 / 线上执行
```bash
# 按编号顺序执行
psql -h $HOST -1 -f output/step1_01_iot_cards.sql
psql -h $HOST -1 -f output/step1_02_devices.sql
psql -h $HOST -1 -f output/step1_03_asset_identifiers.sql
psql -h $HOST -1 -f output/step1_04_asset_wallets.sql
psql -h $HOST -1 -f output/step1_05_sim_bindings.sql
psql -h $HOST -1 -f output/step1_06_shop_allocations.sql
psql -h $HOST -1 -f output/step2_01_migration_orders.sql
psql -h $HOST -1 -f output/step2_02_package_usages.sql
psql -h $HOST -1 -f output/step2_03_card_data_usage_update.sql
psql -h $HOST -1 -f output/step2_04_agent_wallet_init.sql
psql -h $HOST -1 -f output/step2_05_agent_wallet_transactions.sql
```
`-1` = 单事务,失败回滚整文件;`ON CONFLICT DO NOTHING` 保证重跑安全。
SQL 导入完成后必须按批次执行迁移收尾。SQL 只负责落库,不会触发 Service 生命周期,
也不会自动写入 Redis 轮询队列或卡缓存:
```bash
# 先看本批次会处理多少卡、会写哪些轮询任务,不会写 Redis
go run ./cmd/migration-finalize --batch-no MIGRATION-QICHENG-YYYYMMDD
# dry-run 无误后再执行,会按 tb_polling_config 重建本批卡的轮询队列和卡缓存
go run ./cmd/migration-finalize --batch-no MIGRATION-QICHENG-YYYYMMDD --apply
```
## 4. 配置说明 (`mapping.yaml`)
唯一需要人工填写的文件:
```yaml
migration_user_id: 0 # 后台超级管理员 ID,作为所有 creator/updater
migration_batch_no: "MIGRATION-QICHENG-YYYYMMDD" # 本次迁移批次号,线上每批必须唯一
carriers: # 由 scan_legacy 自动发现 legacy_*,你只填 target_*
- legacy_account_id: "72493DB64A2A4F01B5E59E49D58A2A34"
legacy_account_name: "联通10-1"
legacy_category: 9 # 奇成运营商大类,仅作辅助判断
legacy_category_name: "中国联通"
target_carrier_id: 1585 ← 必填:新库 tb_carrier.id
target_carrier_type: CUCC ← 必填:CMCC/CUCC/CTCC/CBN
target_carrier_name: "联通22-..."
packages: # 同理
- legacy_meal_id: "D6554244711498752"
legacy_meal_name: "三网充电宝全国畅玩年卡套餐..."
target_package_id: 2 ← 必填:新库 tb_package.id
series: # 可选(留 null 表示不写入 tb_iot_card.series_id)
- legacy_series_id: "..."
legacy_series_name: "国内卡 无预存30天"
target_series_id: null
agents: # 决定所有卡/设备的店铺归属
- legacy_agent_id: "D15EC779767B412A82B8448AC2324F04"
legacy_agent_name: "叶发伟"
target_shop_code: "SHOP20260428103424CZZQ" # 留 null = 该代理的卡进平台库存
```
### 店铺归属是怎么决定的
CSV 里**没有** `target_shop_code` 列,所有归属都从 `mapping.agents` 推导:
| 资产 | 归属规则 |
|------|---------|
| 独立卡 | 奇成 `tbl_card.agent_id``mapping.agents[].target_shop_code` |
| 设备 | 主卡(`sim_iccid_1`)的 agent_id → 同上 |
| 设备上的卡 | 跟随设备(`shop_id=NULL`,由触发器+绑定关系维护) |
如果 agent 在 `mapping.agents``target_shop_code` 留空 → 该资产进平台库存。
## 5. 识别迁移数据(后台报表过滤用)
| 数据 | 识别条件 |
|------|---------|
| 迁移伪订单 | `WHERE order_no LIKE 'MIG-%'` |
| 迁移分配记录 | `WHERE allocation_no LIKE 'MIG-ALLOC-%'` |
| 迁移钱包流水 | `WHERE metadata->>'source' = 'qicheng'` |
迁移伪订单号固定为 `MIG-CARD-<ICCID>`:
- 20 位卡总长 29,19 位卡总长 28,符合新库 `order_no varchar(30)` 限制。
- 订单号不包含生成时间,`step2_01_migration_orders.sql``step2_02_package_usages.sql` 分开重跑也能稳定匹配。
## 6. 流量用量迁移规则(真用量 / 虚用量)
奇成与新系统的"虚流量"模型口径不同,必须做一次反算才能落对地方。
### 6.1 两边的口径差异
| | 奇成 | 新系统 |
|---|---|---|
| 用量存储 | `tbl_card.total_bytes_cnt`(varchar 小数 MB)= **虚用量**(真 × `(1 + flow_add_discount/100)`) | `tb_iot_card.data_usage_mb`(BIGINT MB)= **真用量**(网关返回的物理读数) |
| 加成配置 | `tbl_set_meal.flow_add_discount`(0~100 百分比,作用于"已用量") | `tb_package.enable_virtual_data` + `virtual_data_mb` + `virtual_ratio`(`= real_data_mb / virtual_data_mb`) |
| 用户看到的"已用" | = 数据库 `total_bytes_cnt`(已加成) | 前端用 `data_usage_mb × display_gain_ratio_snapshot` 现算 |
### 6.2 反算公式
迁移脚本对每张卡按下式得到真用量,再写入 `data_usage_mb` / `current_month_usage_mb` / `last_gateway_reading_mb`:
```
real_used_mb = total_bytes_cnt / (1 + flow_add_discount / 100)
```
其中 `flow_add_discount` 取自该卡在 `tbl_card_life`(status=1, expire_date 最大)对应的当前生效套餐。
### 6.3 精度与近似
- **跨多个套餐周期的累计**:`total_bytes_cnt` 是按各时段对应套餐虚比加权累加的,这里只用"当前生效套餐"的虚比反算,**有误差但可接受**(奇成没有按套餐分段的用量明细,无更好办法)。
- **无当前生效套餐却有 `total_bytes_cnt`**:按 `flow_add_discount=0` 处理(真=虚),并写入 `warnings.csv`(`kind=usage_without_active_package`)供人工核查。
- **整数取整规则**(`data_usage_mb` 是 BIGINT):
- `real_used_mb = 0` → 写 0
- `0 < real_used_mb < 1`**上取整为 1**,避免"明明用过的卡显示 0"
- `real_used_mb ≥ 1` → 向下取整
- **小数字段保留精度**:`current_month_usage_mb`(numeric)与 `last_gateway_reading_mb`(double)直接写 Decimal 真值,作为下次轮询增量计算的基线,**避免轮询第一次回写时产生跳变**。
### 6.4 套餐使用记录的虚流量快照
`tb_package_usage``virtual_total_mb_snapshot` / `display_gain_ratio_snapshot` / `enable_virtual_data_snapshot` 三个字段**全部从新系统 `tb_package` 当场快照**,与奇成无关:
| 快照字段 | 取值 |
|---|---|
| `virtual_total_mb_snapshot` | 启用虚:`virtual_data_mb`;否则:`real_data_mb` |
| `display_gain_ratio_snapshot` | 启用虚:`virtual_ratio`(即 `real_data_mb/virtual_data_mb`);否则:`1.0` |
| `enable_virtual_data_snapshot` | 启用虚:`TRUE`;否则:`FALSE` |
| `data_limit_mb` | 始终 `real_data_mb`(真总量) |
| `data_usage_mb` | `LEAST(real_used_mb_floor, virtual_total_mb_snapshot)`(防卫上限) |
### 6.5 设备绑定卡的套餐归属
设备上的卡在新系统运行态会按设备维度判断有效套餐和复机条件,因此迁移时只用设备当前槽位卡(默认 `sim_iccid_1`)作为奇成套餐来源,生成一条 `usage_type=device``device_id=设备ID` 的套餐使用记录。其他槽位绑定卡不重复生成主套餐,避免同一设备出现多条并列生效主套餐。
## 7. 重要约束
- ❗ 所有奇成查询走 `SET TRANSACTION READ ONLY`,严禁误写。
- ❗ 所有 INSERT 用 `ON CONFLICT DO NOTHING`,严禁更新已有数据。
- ❗ 伪订单的 `payment_status=2 commission_status=2 commission_result=2`,避免被支付回调、佣金调度器扫到。
- ❗ 迁移卡默认 `status=1(在库)` / `activation_status=0(未激活)`,激活由后续业务流程驱动。
## 8. 常见问题
| 现象 | 排查 |
|------|------|
| `scan_legacy.py``pymysql 缺失` | `pip install -r requirements.txt` |
| `errors.csv` 出现 `carrier_not_in_mapping` | `mapping.yaml.carriers` 没覆盖到这个 account_id/account_name,重跑 `scan_legacy.py` 自动补 |
| `errors.csv` 出现 `carrier_target_missing` | `mapping.yaml.carriers` 里有这一项但 `target_*` 还是 null,手动填 |
| `errors.csv` 出现 `not_in_legacy` | 该 ICCID 在奇成 `tbl_card` 查不到,业务方确认 |
| `errors.csv` 出现 `legacy_duplicate` | 同一 `iccid_19` 在奇成命中多条 `tbl_card`,业务方人工确认是哪张物理卡 |
| `errors.csv` 出现 `carrier_length_conflict` | 业务方填的 `iccid_20` 与奇成查到的运营商不匹配(CTCC 不该有 20 位 / 非 CTCC 必须有 20 位) |
| `errors.csv` 出现 `iccid_20_prefix_mismatch` | `iccid_20` 前 19 位 ≠ `iccid_19`,业务方校对 |
| `warnings.csv` 出现 `agent_no_legacy` | `mapping.agents` 配了但奇成没有 tbl_agent 记录,业务方确认 |
| `warnings.csv` 出现 `usage_without_active_package` | 该卡奇成 `total_bytes_cnt > 0``tbl_card_life` 中没有 status=1 的当前生效套餐,无法精确反算真用量(按真=虚处理),详见 §6.3 |
| 线上报 `null value in column "creator"` | `migration_user_id` 是 0,某些约束可能拒绝;改成真实管理员 ID |
| 线上报 `null shop_id` | `mapping.agents.target_shop_code` 指向的店铺在新库不存在,先建店铺 |