237 lines
12 KiB
Markdown
237 lines
12 KiB
Markdown
# 奇成数据迁移脚本
|
||
|
||
> 详细方案见 [`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**:`imei` / `virtual_no` 至少填一个;`device_name` / `device_model` / `device_type` / `max_sim_slots` 可选;`sim_iccid_*` 引用 `cards.csv` 中的 ICCID(可填 19 或 20 位;若 19 位前缀对应多张 20 位卡,必须填完整 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
|
||
psql -h $HOST -1 -f output/step2_06_asset_series_update.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: # 仅保留奇成套餐系列快照,生成器不要求填写
|
||
- 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` 与设备一致,设备分配时卡状态同步为已分销) |
|
||
|
||
如果 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` 的套餐使用记录。其他槽位绑定卡不重复生成主套餐,避免同一设备出现多条并列生效主套餐。
|
||
|
||
设备和卡的 `series_id` 以新系统目标套餐为准:脚本通过 `mapping.packages[].target_package_id` 对应的 `tb_package.series_id` 写入资产,并额外生成 `step2_06_asset_series_update.sql`,用于修复已经导入但因 `ON CONFLICT DO NOTHING` 没有被 step1 更新到的资产。
|
||
|
||
## 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 在奇成命中多条 `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` 指向的店铺在新库不存在,先建店铺 |
|