Files
junhong_cmp_fiber/docs/脚本/奇成数据迁移方案.md
Break 1f634eb465
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
init
2026-06-16 15:15:50 +08:00

643 lines
28 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.
# 奇成数据迁移方案
> 目标:把奇成(旧 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星网专享年卡套餐每月300M12个月` |
| `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_idcards.csv 的 package_name 直接对照这里)
packages:
- legacy_package_name: "Y星网专享年卡套餐每月300M12个月"
target_package_id: 123
- legacy_package_name: "移动全国卡100G30天"
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. 生成伪订单 SQLorder_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_transactiontype=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. 我们这边:
- 写脚本1migrate_assets.py + lib + 模板)
- 在测试库验证脚本1
- 写脚本2migrate_runtime.py
- 在测试库验证脚本2
- 写 README.md 操作手册(含"如何在报表中排除迁移数据"提示)
3. 业务方提供数据 → 全流程联调一次 → 交付给运维上线