All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
643 lines
28 KiB
Markdown
643 lines
28 KiB
Markdown
# 奇成数据迁移方案
|
||
|
||
> 目标:把奇成(旧 MySQL `kyhl` 库)的存量卡和设备迁移到新系统(PostgreSQL),输出 SQL 脚本供线上手动执行。
|
||
> 范围:当前态资产 + 当前生效正式套餐 + 未生效待生效正式套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细、已过期套餐和加油包**。
|
||
|
||
---
|
||
|
||
## 一、整体架构
|
||
|
||
```
|
||
业务方提供
|
||
┌──────────────────────┐
|
||
│ resources/cards.csv │
|
||
│ resources/devices.csv│
|
||
└──────────┬───────────┘
|
||
│
|
||
我们维护唯一决策配置
|
||
┌──────────▼───────────┐
|
||
│ config/mapping.yaml │
|
||
│ 归属/槽位/套餐映射 │
|
||
└──────────┬───────────┘
|
||
│
|
||
┌──────────▼───────────┐
|
||
│ 脚本1:资产导入 │ ← 纯离线,不查奇成
|
||
│ migrate_assets.py │
|
||
└──────────┬───────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────┐
|
||
│ output/step1_*.sql │ ← 手动在线上执行
|
||
└──────────┬───────────┘
|
||
│ (执行完,新库已有这些 iccid)
|
||
│
|
||
┌──────────▼───────────┐
|
||
│ 脚本2:关联数据导入 │ ← 直连奇成只读查询
|
||
│ migrate_runtime.py │ ← 直连新库测试环境拿 id
|
||
└──────────┬───────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────┐
|
||
│ output/step2_*.sql │ ← 手动在线上执行
|
||
└──────────────────────┘
|
||
```
|
||
|
||
**关键约束**
|
||
- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件和审核 CSV
|
||
- `config/mapping.yaml` 是归属、设备当前槽位、套餐来源槽位和套餐迁移范围的决策源;奇成只提供历史事实
|
||
- 脚本对奇成线上库**只 SELECT**,DB 连接层强制只读事务(`SET TRANSACTION READ ONLY`)
|
||
- 所有 INSERT 使用 `ON CONFLICT DO NOTHING`,支持重跑
|
||
- `errors.csv` 非空时不执行 SQL,先修正配置/输入后重跑
|
||
|
||
---
|
||
|
||
## 二、业务方提供的资产清单
|
||
|
||
### 2.1 `resources/cards.csv` 卡资产清单
|
||
|
||
每张要迁移的卡一行(**包含独立卡和设备上的卡**)。
|
||
|
||
| 列名 | 必填 | 说明 | 示例 |
|
||
|------|------|------|------|
|
||
| `iccid` | 是 | ICCID。**19 位或 20 位**,业务方按奇成原值给(脚本根据 carrier_name 反查 carrier_type 判断长度规则) | `89852000263338772439` |
|
||
| `msisdn` | 否 | 手机号 / 接入号(奇成 `tbl_card.phone`) | `454006333877243` |
|
||
| `carrier_name` | 是 | 奇成的运营商名称,**必须在 `config/carrier.yaml` 中能匹配到** | `移动全国卡` |
|
||
| `package_name` | 否 | 当前生效套餐名(奇成 `tbl_card_life.meal_name`)。留空表示无套餐 | `Y星网专享年卡套餐每月300M(12个月)` |
|
||
| `package_expires_at` | 否 | 当前套餐到期时间(奇成 `tbl_card_life.expire_date`),格式 `YYYY-MM-DD HH:MM:SS`。无套餐留空 | `2027-04-28 16:00:00` |
|
||
| `is_industry` | 是 | 是否行业卡。`true`/`false` | `false` |
|
||
| `virtual_no` | 否 | 卡虚拟号(奇成 `tbl_virtual_number.vcode`),无则留空 | `8960066754` |
|
||
| `target_shop_code` | 否 | 目标店铺编码(`tb_shop.shop_code`)。**留空 = 进平台库存**;若该卡在设备上则**强制留空**(跟随设备) | `SHOP20260428103424CZZQ` |
|
||
|
||
### 2.2 `resources/devices.csv` 设备资产清单
|
||
|
||
每台要迁移的设备一行。
|
||
|
||
| 列名 | 必填 | 说明 | 示例 |
|
||
|------|------|------|------|
|
||
| `imei` | 否 | 设备 IMEI(非蜂窝设备可留空) | `860000000000001` |
|
||
| `virtual_no` | 是 | 设备虚拟号/别名(业务方自定义,全局唯一) | `DEV-202604-0001` |
|
||
| `device_name` | 否 | 设备名称 | `张三家充电宝` |
|
||
| `device_model` | 否 | 设备型号 | `CB-PRO-V2` |
|
||
| `manufacturer` | 否 | 制造商 | `深圳XX` |
|
||
| `max_sim_slots` | 否 | 插槽数,默认 4 | `4` |
|
||
| `sim_iccid_1` | 否 | 插槽1的 iccid,必须在 cards.csv 中存在 | `89852000...` |
|
||
| `sim_iccid_2` | 否 | 插槽2的 iccid | `89860623...` |
|
||
| `sim_iccid_3` | 否 | 插槽3的 iccid | |
|
||
| `sim_iccid_4` | 否 | 插槽4的 iccid | |
|
||
| `current_slot` | 否 | 当前使用的插槽位置(1-4),用于 `tb_device_sim_binding.is_current`。空值按 `mapping.yaml` 批量规则或覆盖项填充 | `2` |
|
||
| `package_source_slot` | 否 | 设备套餐来源槽位(1-4)。只迁该槽位卡的奇成正式套餐为设备套餐 | `2` |
|
||
|
||
**注意**
|
||
- `cards.csv` 必须先到位,`devices.csv` 的 `sim_iccid_*` 才能被校验
|
||
- 业务方分别准备这两份是合理的,奇成 `tbl_card_relate`(主卡+副卡1+副卡2)的关系业务方应已掌握
|
||
- 槽位解析优先级:`devices.csv` 行级配置 > `mapping.yaml.overrides.devices` > `mapping.yaml.ownership_rules.device` 批量默认值
|
||
- `current_slot` 或 `package_source_slot` 对应槽位无卡时写入 `errors.csv` 并阻断该设备相关 SQL
|
||
|
||
---
|
||
|
||
## 三、ICCID 19/20 位的处理规则(关键)
|
||
|
||
新系统设计:`tb_iot_card.iccid_19` 和 `iccid_20` 两个字段。
|
||
|
||
| 运营商类型 | 标准 ICCID 长度 | iccid_19 | iccid_20 |
|
||
|----------|---------------|----------|----------|
|
||
| **CTCC(电信)** | 19 位 | = iccid 全值 | NULL |
|
||
| **CMCC(移动)** | 20 位 | = iccid 前 19 位 | = iccid 全值 |
|
||
| **CUCC(联通)** | 20 位 | = iccid 前 19 位 | = iccid 全值 |
|
||
| **CBN(广电)** | 20 位 | = iccid 前 19 位 | = iccid 全值 |
|
||
|
||
**脚本判定流程**:
|
||
```
|
||
1. 读 carrier_name → 查 config/carrier.yaml 得到 carrier_type
|
||
2. 根据 carrier_type 决定标准长度(CTCC=19, 其他=20)
|
||
3. 校验 csv 中 iccid 实际长度:
|
||
- 长度匹配 → 正常拆分
|
||
- 长度=20 但 carrier_type=CTCC → 警告(电信卡居然是 20 位?写入 errors.csv 让业务方确认)
|
||
- 长度=19 但 carrier_type≠CTCC → 警告(缺校验位?写入 errors.csv)
|
||
- 长度 ∉ {19, 20} → 拒绝(奇成里有 18/21/22 位的脏数据,必须剔除)
|
||
4. 写 tb_iot_card 时同时填 iccid / iccid_19 / iccid_20
|
||
```
|
||
|
||
异常的 iccid 不进入 SQL 输出,统一写到 `output/errors.csv`,业务方人工修复后重跑。
|
||
|
||
---
|
||
|
||
## 四、我们维护的映射配置
|
||
|
||
迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好 `config/mapping.yaml`。该文件是本次迁移的唯一决策源。
|
||
|
||
### 4.1 归属、槽位和套餐规则
|
||
|
||
```yaml
|
||
ownership_rules:
|
||
default_target_shop_code: KWTX
|
||
device:
|
||
mode: default_shop
|
||
current_slot: 2
|
||
package_source_slot: 2
|
||
standalone_card:
|
||
mode: default_shop
|
||
|
||
package_rules:
|
||
migrate_statuses: [active, pending]
|
||
|
||
overrides:
|
||
devices:
|
||
- virtual_no: "862639073940258"
|
||
target_shop_code: OTHER
|
||
current_slot: 1
|
||
package_source_slot: 1
|
||
cards:
|
||
- iccid: "89861590172420360956"
|
||
target_shop_code: OTHER
|
||
```
|
||
|
||
归属规则:独立卡按 `overrides.cards` 或 `ownership_rules.standalone_card` 决定;设备按 `overrides.devices` 或 `ownership_rules.device` 决定;设备上的卡跟随设备。奇成 `agent_id` 不再作为默认归属来源。
|
||
|
||
### 4.2 `config/mapping.yaml` 中的 carriers
|
||
|
||
```yaml
|
||
# 奇成的运营商名称(业务方在 cards.csv 中填的)→ 新系统 carrier_id + type
|
||
carriers:
|
||
- legacy_name: "移动全国卡"
|
||
target_carrier_id: 1580
|
||
target_carrier_type: CMCC
|
||
target_carrier_name: "移动15-BE20030985258"
|
||
- legacy_name: "电信行业卡"
|
||
target_carrier_id: 1583
|
||
target_carrier_type: CTCC
|
||
target_carrier_name: "电信10-qiangukeji"
|
||
# ... 业务方提供的所有运营商都要列出
|
||
```
|
||
|
||
### 4.3 `config/mapping.yaml` 中的 series
|
||
|
||
```yaml
|
||
# 奇成套餐系列名 → 新系统 series_id
|
||
# (根据业务方提供的 package_name,反查 tbl_set_meal → meal_series_id → tbl_set_meal_series.name)
|
||
series:
|
||
- legacy_series_name: "SXKJ专享"
|
||
target_series_id: 5
|
||
- legacy_series_name: "移动全国卡月卡"
|
||
target_series_id: 6
|
||
```
|
||
|
||
### 4.4 `config/mapping.yaml` 中的 packages
|
||
|
||
```yaml
|
||
# 奇成套餐名 → 新系统 package_id(cards.csv 的 package_name 直接对照这里)
|
||
packages:
|
||
- legacy_package_name: "Y星网专享年卡套餐每月300M(12个月)"
|
||
target_package_id: 123
|
||
- legacy_package_name: "移动全国卡100G(30天)"
|
||
target_package_id: 124
|
||
```
|
||
|
||
> 注:奇成的 `tbl_set_meal` 套餐表非常大,**不需要全部映射**,只映射本次迁移涉及的套餐(脚本预扫描 cards.csv 收集 package_name 集合,缺失的列出来让我们补)。
|
||
|
||
### 4.5 店铺编码
|
||
|
||
```yaml
|
||
# 如果业务方愿意填 shop_code,本文件可以省略
|
||
# 如果业务方只想填别名,这里维护 alias → shop_code 映射
|
||
aliases:
|
||
shop_a: "SHOP20260428103424CZZQ"
|
||
shop_b: "SHOP20260429150917OQKA"
|
||
```
|
||
|
||
---
|
||
|
||
## 五、脚本1:资产导入(`migrate_assets.py`)
|
||
|
||
### 5.1 输入
|
||
- `resources/cards.csv`
|
||
- `resources/devices.csv`
|
||
- `config/*.yaml`
|
||
|
||
### 5.2 处理流程
|
||
|
||
```
|
||
1. 加载所有 yaml 映射 → 构建 (legacy_name → target_id) 字典
|
||
2. 读 cards.csv:
|
||
a. 行级校验:iccid 格式、carrier_name 是否在映射中、is_industry 合法
|
||
b. 19/20 位拆分(见第三节)
|
||
c. shop_code → shop_id(查新系统测试库 / 我们维护的映射)
|
||
d. 异常行写 errors.csv
|
||
3. 读 devices.csv:
|
||
a. 校验 sim_iccid_* 必须在 cards.csv 中存在
|
||
b. 校验设备上的卡其 cards.csv.target_shop_code 必须为空
|
||
c. virtual_no 重复性校验(同设备文件内 + 跨 cards.csv 的 virtual_no)
|
||
4. 生成 SQL(见 5.3)
|
||
```
|
||
|
||
### 5.3 输出文件
|
||
|
||
按表分块,便于逐表 review 和按需重跑:
|
||
|
||
```
|
||
output/
|
||
step1_01_iot_cards.sql # INSERT INTO tb_iot_card ...
|
||
step1_02_devices.sql # INSERT INTO tb_device ...
|
||
step1_03_asset_identifiers.sql # INSERT INTO tb_asset_identifier ...
|
||
step1_04_asset_wallets.sql # INSERT INTO tb_asset_wallet ...
|
||
step1_05_sim_bindings.sql # INSERT INTO tb_device_sim_binding ...
|
||
step1_06_shop_allocations.sql # INSERT INTO tb_asset_allocation_record(如有 shop_id)
|
||
errors.csv # 异常行清单
|
||
summary.txt # 统计:处理 X 张卡、Y 台设备、Z 条异常
|
||
```
|
||
|
||
### 5.4 SQL 生成规则
|
||
|
||
#### 关于 ID 生成
|
||
|
||
新系统的 PK 是 `bigint` 自增。脚本生成 SQL 时使用 CTE 模式,让 PostgreSQL 自己分配 id:
|
||
|
||
```sql
|
||
-- 示例:插入一张卡,并用 RETURNING 拿到 id,立刻写关联表
|
||
WITH inserted_card AS (
|
||
INSERT INTO tb_iot_card (
|
||
iccid, iccid_19, iccid_20, carrier_id, carrier_type, carrier_name,
|
||
card_category, batch_no, status, activation_status, real_name_status,
|
||
network_status, stop_reason, realname_policy, is_standalone,
|
||
msisdn, virtual_no, shop_id, creator, updater, created_at, updated_at
|
||
) VALUES (
|
||
'89852000263338772439', '8985200026333877243', '89852000263338772439',
|
||
1580, 'CMCC', '移动15-BE20030985258',
|
||
'normal', 'MIGRATION-20260525', 1, 0, 0,
|
||
0, '无套餐', 'after_order', true,
|
||
'454006333877243', '8960066754', NULL, 0, 0, NOW(), NOW()
|
||
)
|
||
ON CONFLICT (iccid) WHERE deleted_at IS NULL DO NOTHING
|
||
RETURNING id
|
||
)
|
||
-- 同步写 asset_identifier
|
||
, ins_ai_iccid AS (
|
||
INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)
|
||
SELECT '89852000263338772439', 'iot_card', id FROM inserted_card
|
||
ON CONFLICT (identifier) DO NOTHING
|
||
)
|
||
, ins_ai_vno AS (
|
||
INSERT INTO tb_asset_identifier (identifier, asset_type, asset_id)
|
||
SELECT '8960066754', 'iot_card', id FROM inserted_card
|
||
WHERE '8960066754' <> ''
|
||
ON CONFLICT (identifier) DO NOTHING
|
||
)
|
||
-- 同步建钱包
|
||
INSERT INTO tb_asset_wallet (
|
||
resource_type, resource_id, balance, frozen_balance,
|
||
currency, status, version, shop_id_tag, created_at, updated_at
|
||
)
|
||
SELECT 'iot_card', id, 0, 0, 'CNY', 1, 0, COALESCE(0, 0), NOW(), NOW()
|
||
FROM inserted_card
|
||
ON CONFLICT (resource_type, resource_id) WHERE deleted_at IS NULL DO NOTHING;
|
||
```
|
||
|
||
每张卡一个 CTE 块,脚本生成几万个块。**优势**:原子性 + 幂等 + 不用预先分配 id 段。
|
||
|
||
#### 关于设备-卡绑定
|
||
|
||
绑定 SQL 需要先拿到 device.id 和 card.id,用相同的 CTE 模式:
|
||
|
||
```sql
|
||
WITH d AS (
|
||
SELECT id FROM tb_device WHERE virtual_no = 'DEV-202604-0001' LIMIT 1
|
||
), c1 AS (
|
||
SELECT id FROM tb_iot_card WHERE iccid = '89852000263338772439' AND deleted_at IS NULL LIMIT 1
|
||
)
|
||
INSERT INTO tb_device_sim_binding (device_id, iot_card_id, slot_position, bind_status, is_current, bind_time, created_at, updated_at)
|
||
SELECT d.id, c1.id, 1, 1, true, NOW(), NOW(), NOW()
|
||
FROM d CROSS JOIN c1
|
||
ON CONFLICT (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL DO NOTHING;
|
||
-- 触发器自动更新 tb_iot_card.is_standalone = false
|
||
```
|
||
|
||
#### 关于分配记录(shop_id != NULL 的情况)
|
||
|
||
```sql
|
||
-- 在 iot_card 写入 shop_id 的同时,补一条分配记录
|
||
WITH c AS (
|
||
SELECT id FROM tb_iot_card WHERE iccid = '...' AND deleted_at IS NULL
|
||
)
|
||
INSERT INTO tb_asset_allocation_record (
|
||
allocation_no, allocation_type, asset_type, asset_id, asset_identifier,
|
||
from_owner_type, to_owner_type, to_owner_id, operator_id, created_at
|
||
)
|
||
SELECT 'MIG-' || TO_CHAR(NOW(), 'YYYYMMDDHH24MISS') || '-' || c.id,
|
||
'allocate', 'iot_card', c.id, '...',
|
||
'platform', 'shop', <shop_id>, 0, NOW()
|
||
FROM c;
|
||
```
|
||
|
||
---
|
||
|
||
## 六、脚本2:关联数据导入(`migrate_runtime.py`)
|
||
|
||
### 6.1 前提
|
||
- 脚本1已经执行,新库已有这批 iccid 的卡
|
||
- 业务方已确认每张卡当前对应的套餐 ID(在脚本1阶段维护好 `package_mapping.yaml`)
|
||
|
||
### 6.2 输入
|
||
- `resources/cards.csv`(同脚本1)
|
||
- `config/*.yaml`(同脚本1,额外加 `config/migration.yaml` 配置 migration_user_id、伪订单前缀等)
|
||
- 奇成只读 DSN(连接配置)
|
||
- 新系统**测试环境**只读 DSN(仅用于拿 iccid → id 的映射,验证脚本逻辑;线上 id 在 step2 SQL 里用 SELECT 子查询动态取,**不硬编码**)
|
||
|
||
### 6.3 处理流程
|
||
|
||
```
|
||
1. 读 cards.csv 得到 iccid 列表
|
||
2. 连奇成(只读事务)批量查:
|
||
- `tbl_card_life` 当前生效正式套餐和 `tbl_next_month_card_life` 次月待生效套餐,保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键
|
||
- `tbl_card.total_bytes_cnt` → 反算新系统真用量 MB
|
||
- `tbl_agent_commission_account` / `tbl_agent` → 仅用于代理钱包初始化
|
||
3. 对每个独立卡或设备套餐来源槽位:
|
||
a. 套餐映射:`legacy_meal_id` → `target_package_id`(来自 `mapping.yaml.packages`)
|
||
b. active 套餐写 `tb_package_usage.status=1`,pending 套餐写 `status=0` 并按稳定顺序写 `priority`
|
||
c. 生成伪订单 SQL(order_no = `MIG-<ICCID>-<priority>`)
|
||
d. 生成 `tb_package_usage` SQL(关联伪订单 + 计算 snapshot 字段)
|
||
e. 生成 `UPDATE tb_iot_card SET data_usage_mb = ...`(真用量 MB)
|
||
4. 对每个 shop(按代理映射出来的):
|
||
a. 生成 INSERT/UPDATE tb_agent_wallet(总值)
|
||
b. 生成一条 tb_agent_wallet_transaction(type=initial_migration, amount=总值, remark=源奇成代理ID)
|
||
5. 输出 SQL
|
||
```
|
||
|
||
### 6.4 输出文件
|
||
|
||
```
|
||
output/
|
||
step2_01_migration_orders.sql # 伪订单
|
||
step2_02_package_usages.sql # 套餐使用记录
|
||
step2_03_card_data_usage_update.sql # 卡累计流量 UPDATE
|
||
step2_04_agent_wallet_init.sql # 代理钱包总值
|
||
step2_05_agent_wallet_transactions.sql # 迁移流水
|
||
step2_06_asset_series_update.sql # 资产套餐系列回填
|
||
package_resolution.csv # 套餐迁移审核文件
|
||
errors.csv # 套餐映射缺失、多 active、槽位缺失等阻断错误
|
||
warnings.csv # 用量反算和代理钱包警告
|
||
summary.txt
|
||
```
|
||
|
||
### 6.5 伪订单 + 套餐使用记录的 SQL 形态
|
||
|
||
```sql
|
||
-- 伪订单(一条 SQL 同时插订单和 package_usage)
|
||
WITH new_order AS (
|
||
INSERT INTO tb_order (
|
||
order_no, order_type, buyer_type, buyer_id, iot_card_id,
|
||
total_amount, payment_method, payment_status, paid_at,
|
||
source, generation, creator, updater, created_at, updated_at
|
||
)
|
||
SELECT 'MIG-89852000263338772439-1',
|
||
'single_card', 'personal', 0, c.id,
|
||
0, 'offline', 2, NOW(),
|
||
'migration', 1, 0, 0, NOW(), NOW()
|
||
FROM tb_iot_card c WHERE c.iccid = '89852000263338772439' AND c.deleted_at IS NULL
|
||
ON CONFLICT (order_no) DO NOTHING
|
||
RETURNING id, order_no, iot_card_id
|
||
)
|
||
INSERT INTO tb_package_usage (
|
||
order_id, order_no, package_id, usage_type, iot_card_id,
|
||
data_limit_mb, virtual_total_mb_snapshot, display_gain_ratio_snapshot,
|
||
enable_virtual_data_snapshot, status, priority,
|
||
activated_at, expires_at,
|
||
package_name, package_price_config_status, package_is_gift,
|
||
data_reset_cycle, next_reset_at, generation,
|
||
creator, updater, created_at, updated_at
|
||
)
|
||
SELECT no.id, no.order_no, p.id, 'single_card', no.iot_card_id,
|
||
p.real_data_mb,
|
||
CASE WHEN p.enable_virtual_data THEN p.virtual_data_mb ELSE p.real_data_mb END,
|
||
p.virtual_ratio, p.enable_virtual_data,
|
||
1, 1,
|
||
NULL, -- activated_at 奇成多数为 NULL
|
||
'2027-04-28 16:00:00'::timestamp, -- 从奇成 tbl_card_life.expire_date 拿
|
||
p.package_name, p.price_config_status, p.is_gift,
|
||
p.data_reset_cycle, NULL,
|
||
1, 0, 0, NOW(), NOW()
|
||
FROM new_order no
|
||
JOIN tb_package p ON p.id = 123 AND p.deleted_at IS NULL -- package_id 来自映射
|
||
ON CONFLICT DO NOTHING;
|
||
```
|
||
|
||
---
|
||
|
||
## 七、运行流程
|
||
|
||
### 7.1 准备阶段(在我们的开发/测试机上)
|
||
|
||
```bash
|
||
# 1. 业务方提供
|
||
cp 业务方发的*.csv scripts/migration/resources/
|
||
|
||
# 2. 我们/业务方在新系统线上建好店铺/运营商/套餐系列/套餐
|
||
# (另行手动完成,本脚本不负责)
|
||
|
||
# 3. 我们维护映射 yaml
|
||
vim scripts/migration/config/*.yaml
|
||
|
||
# 4. 跑脚本1(离线,只读 csv + yaml)
|
||
cd scripts/migration
|
||
python migrate_assets.py
|
||
# 看 output/errors.csv,找业务方修复后重跑
|
||
|
||
# 5. 拿 step1 SQL 在测试环境跑一遍,确认无报错
|
||
psql -h 测试库 -f output/step1_01_iot_cards.sql
|
||
psql -h 测试库 -f output/step1_02_devices.sql
|
||
... (按编号顺序)
|
||
|
||
# 6. 跑脚本2(直连奇成 + 测试环境读 id)
|
||
python migrate_runtime.py
|
||
# 测试环境跑一遍 step2 SQL,验证最终态
|
||
|
||
# 7. 验证 OK,把所有 .sql 文件交给运维
|
||
```
|
||
|
||
### 7.2 线上执行(用户手动)
|
||
|
||
按编号顺序执行:
|
||
|
||
```bash
|
||
# 第一波:资产
|
||
psql -h 线上库 -1 -f step1_01_iot_cards.sql
|
||
psql -h 线上库 -1 -f step1_02_devices.sql
|
||
psql -h 线上库 -1 -f step1_03_asset_identifiers.sql
|
||
psql -h 线上库 -1 -f step1_04_asset_wallets.sql
|
||
psql -h 线上库 -1 -f step1_05_sim_bindings.sql
|
||
psql -h 线上库 -1 -f step1_06_shop_allocations.sql
|
||
|
||
# 第二波:关联数据
|
||
psql -h 线上库 -1 -f step2_01_migration_orders.sql
|
||
psql -h 线上库 -1 -f step2_02_package_usages.sql
|
||
psql -h 线上库 -1 -f step2_03_card_data_usage_update.sql
|
||
psql -h 线上库 -1 -f step2_04_agent_wallet_init.sql
|
||
psql -h 线上库 -1 -f step2_05_agent_wallet_transactions.sql
|
||
```
|
||
|
||
`-1` 单事务执行,失败自动回滚整文件。
|
||
|
||
---
|
||
|
||
## 八、目录结构(最终交付)
|
||
|
||
```
|
||
scripts/migration/
|
||
README.md # 操作手册
|
||
requirements.txt # python 依赖(pymysql, psycopg2, pyyaml, pandas)
|
||
migrate_assets.py # 脚本1
|
||
migrate_runtime.py # 脚本2
|
||
lib/
|
||
legacy_query.py # 奇成只读查询封装
|
||
csv_loader.py # csv 读取与校验
|
||
mapping_loader.py # yaml 映射加载
|
||
iccid_utils.py # 19/20 位拆分(移植 pkg/utils/iccid.go)
|
||
sql_builder.py # SQL 生成(CTE 模板)
|
||
resources/
|
||
cards.csv # 业务方提供
|
||
devices.csv # 业务方提供
|
||
config/
|
||
carrier_mapping.yaml
|
||
series_mapping.yaml
|
||
package_mapping.yaml
|
||
shop_mapping.yaml
|
||
migration.yaml # 全局配置(伪订单前缀、migration_user_id 等)
|
||
legacy_dsn.yaml # 奇成连接(.gitignore)
|
||
main_test_dsn.yaml # 新系统测试库连接(.gitignore)
|
||
output/
|
||
step1_*.sql
|
||
step2_*.sql
|
||
errors.csv
|
||
warnings.csv
|
||
summary.txt
|
||
```
|
||
|
||
---
|
||
|
||
## 九、风险与未决事项
|
||
|
||
| # | 风险 | 应对 |
|
||
|---|------|------|
|
||
| 1 | 业务方填的 carrier_name / package_name 与奇成不一致 → 映射失败 | 脚本预扫描,把所有未匹配项写到 unmatched.csv 让业务方修正 |
|
||
| 2 | 奇成的 iccid 有 18/21/22 位脏数据 | 脚本第三节规则剔除,进 errors.csv |
|
||
| 3 | 同一 iccid 被业务方填多次 | 脚本去重 + 警告 |
|
||
| 4 | 设备 `current_slot` 或 `package_source_slot` 指向空槽位 | 写入 errors.csv,阻断该设备资产或套餐 SQL |
|
||
| 5 | 同一资产存在多个 active 正式套餐 | 写入 errors.csv,阻断该资产套餐 SQL,人工裁决后修正 |
|
||
| 6 | 套餐 ID 映射不全 | `package_resolution.csv` 记录,errors.csv 阻断对应套餐 SQL |
|
||
| 7 | tb_iot_card.is_standalone 由触发器维护 | 脚本写入时设 true,触发器会因为后续 binding 自动转 false |
|
||
| 8 | 代理→shop 映射不明确 | 需要业务方提供 agent_id → shop_code 映射表,作为 config/agent_shop_mapping.yaml |
|
||
| 9 | 线上库 sequence 与测试库不同 | 用 CTE + RETURNING 模式,不硬编码 id |
|
||
| 10 | 重复执行 | ON CONFLICT DO NOTHING 保证幂等 |
|
||
|
||
---
|
||
|
||
## 十、待决事项的最终答案
|
||
|
||
### 10.1 代理→店铺映射(业务方提供)
|
||
|
||
新增 `config/agent_shop_mapping.yaml`,由业务方维护:
|
||
|
||
```yaml
|
||
# 奇成 agent_id → 新系统 shop_code 映射
|
||
mappings:
|
||
- legacy_agent_id: "5846ACF63BD94ED2A26072BA5411B197"
|
||
legacy_agent_name: "SX20250721101058"
|
||
target_shop_code: "SHOP20260428103424CZZQ"
|
||
# ...
|
||
```
|
||
|
||
脚本2 通过这份映射把奇成佣金归属到新系统的 shop_id 上。映射不全的奇成代理,其佣金进 `warnings.csv` 让业务方补全。
|
||
|
||
### 10.2 伪订单字段填法(全部复用现有常量,不动状态机)
|
||
|
||
`tb_order.buyer_id` 是 `NOT NULL bigint`,**平台自营/赠送场景代码里就是填 0**:
|
||
- `internal/service/order/service.go:251`(赠送场景)
|
||
- `internal/service/order/service.go:271`(平台自营)
|
||
|
||
迁移伪订单字段最终值:
|
||
|
||
| 字段 | 值 | 说明 |
|
||
|------|-----|------|
|
||
| `order_no` | `MIG-<ICCID>-<priority>` | 前缀就是迁移数据的唯一识别标记 |
|
||
| `source` | `'admin'`(复用现有) | 不新增 `migration` 取值 |
|
||
| `buyer_type` | `'personal'` | 与 buyer_id=0 配对 |
|
||
| `buyer_id` | `0` | 与赠送/平台自营一致 |
|
||
| `payment_method` | `'offline'`(复用现有 `model/order.go:99`) | 已有常量,只是字符串快照,不触发状态机 |
|
||
| `payment_status` | `2`(已支付) | 避免被支付回调扫到 |
|
||
| `paid_at` | `NOW()` | 已支付时间 |
|
||
| `total_amount` / `actual_paid_amount` | `0` | 避免汇总统计错乱 |
|
||
| `commission_status` | `2`(已完成) | 避免被佣金调度器扫描 |
|
||
| `commission_result` | `2`(无佣金) | 不进入佣金链路 |
|
||
| `operator_account_type` | `'platform'` | 操作者为平台 |
|
||
| `operator_account_name` | `'数据迁移'` | 备注 |
|
||
|
||
**识别迁移订单**:`WHERE order_no LIKE 'MIG-%'`(不依赖 source 字段)
|
||
|
||
### 10.3 shop_id_tag:平台库存填 0,有店铺填 shop_id
|
||
|
||
`tb_asset_wallet.shop_id_tag` 是 `NOT NULL bigint`,代码里平台库存场景就是填 0:
|
||
- `internal/service/exchange/service.go:297-300`
|
||
- `internal/service/client_order/service.go:723`:`shopTag := 0; if card.ShopID != nil { shopTag = *card.ShopID }`
|
||
|
||
迁移直接套这个逻辑。同理 `enterprise_id_tag` 可空,统一填 NULL(迁移阶段没有企业归属)。
|
||
|
||
### 10.4 代理佣金字段映射
|
||
|
||
新系统 `tb_agent_wallet` 设计是**双钱包**:每个店铺有两条记录,`wallet_type` 区分:
|
||
- `wallet_type='main'`:主钱包(充值消费的钱)
|
||
- `wallet_type='commission'`:分佣钱包(佣金)
|
||
|
||
映射关系:
|
||
|
||
| 新系统字段 | 奇成字段 | 说明 |
|
||
|-----------|---------|------|
|
||
| `main.balance` | `tbl_agent.balance_money` × 100 | 充值余额(奇成是元,新系统是分) |
|
||
| `commission.balance` | `tbl_agent_commission_account.can_draw_amount` × 100 | 当前可提现佣金 |
|
||
| `commission.frozen_balance` | `tbl_agent_commission_account.applying_draw_amount` × 100 | 申请中的佣金 |
|
||
|
||
`total_commision_amount`(累计总佣金)和 `total_draw_amount`(已提现累计)是**历史明细汇总值**,新系统的 `tb_agent_wallet` 没有对应字段;如果业务方需要这些历史数据展示,可以写到迁移流水的 `metadata` JSONB 里作为参考,但不影响余额计算。
|
||
|
||
每个 shop 生成 **2 条** `tb_agent_wallet`(main + commission)+ **2 条** `tb_agent_wallet_transaction`,全部复用现有常量:
|
||
|
||
| 字段 | 值 | 说明 |
|
||
|------|-----|------|
|
||
| `transaction_type` | `'recharge'`(复用) | 表示"初始化余额" |
|
||
| `transaction_subtype` | `NULL` | 字段允许 NULL,原本为 deduct 细分预留 |
|
||
| `reference_type` | `'topup'`(复用 `pkg/constants/wallet.go:115`) | 与 recharge 配对 |
|
||
| `reference_id` | `NULL` | 不关联具体业务,避免误关联 |
|
||
| `amount` | 奇成余额 × 100(元→分) | |
|
||
| `balance_before` | `0` | 迁移前为 0 |
|
||
| `balance_after` | 同 amount | |
|
||
| `status` | `1`(成功) | |
|
||
| `remark` | `"奇成数据迁移初始化,源 agent_id=<id>, agent_name=<name>"` | 文本识别 |
|
||
| `metadata` | `{"source":"qicheng","legacy_agent_id":"...","legacy_field":"can_draw_amount"}` | JSON 结构化识别 |
|
||
|
||
**识别迁移流水**:`WHERE remark LIKE '%奇成迁移%' OR metadata->>'source' = 'qicheng'`
|
||
|
||
---
|
||
|
||
## 十一、不破坏状态机的总体策略
|
||
|
||
**核心原则**:本次迁移**不新增任何常量**,全部复用现有取值。识别迁移数据靠 `order_no` 前缀 + `remark` / `metadata` 字段,而不是污染枚举字段。
|
||
|
||
**避免触发后续状态机的关键设置已散落在 10.2/10.4**,汇总如下:
|
||
- 伪订单:`payment_status=2`(已支付) + `commission_status=2`(已完成) + `commission_result=2`(无佣金) → 不被支付回调、佣金调度器扫到
|
||
- 钱包流水:`reference_id=NULL` → 不与真实业务关联,不被对账任务扫到
|
||
- 金额:迁移订单 `total_amount=0`、`actual_paid_amount=0` → 即使被汇总统计扫到也不污染收入数据
|
||
|
||
**遗留风险**:
|
||
- 后台订单列表若按 `source='admin'` 过滤,会看到迁移订单;建议提前做一个常用过滤条件(`order_no NOT LIKE 'MIG-%'`),或者前端订单列表加个"排除迁移数据"开关
|
||
- `payment_method='offline'` 在后台报表里会归类到"线下支付"统计中;如果运营对此敏感,需在报表 SQL 中加 `order_no NOT LIKE 'MIG-%'` 过滤
|
||
|
||
---
|
||
|
||
## 十二、实施顺序(审查通过后)
|
||
|
||
1. 业务方启动准备:明确 `cards.csv` / `devices.csv` / `agent_shop_mapping.yaml` 的格式,开始整理
|
||
2. 我们这边:
|
||
- 写脚本1(migrate_assets.py + lib + 模板)
|
||
- 在测试库验证脚本1
|
||
- 写脚本2(migrate_runtime.py)
|
||
- 在测试库验证脚本2
|
||
- 写 README.md 操作手册(含"如何在报表中排除迁移数据"提示)
|
||
3. 业务方提供数据 → 全流程联调一次 → 交付给运维上线
|