数据迁移脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s

This commit is contained in:
Break
2026-05-27 11:10:56 +08:00
parent 0d96a94e5f
commit 58863e8ec5
20 changed files with 3343 additions and 0 deletions

View File

@@ -1348,6 +1348,9 @@ components:
type: string
device_realtime:
$ref: '#/components/schemas/DtoDeviceGatewayInfo'
gateway_card_imei:
description: 插拔卡业务 IMEI由 Gateway 卡状态接口同步,非设备自身 IMEI无业务含义仅供查看
type: string
gateway_extend:
description: Gateway 卡状态扩展字段,原样返回上游 extend用于展示运营商侧实际停机原因
type: string
@@ -1501,6 +1504,9 @@ components:
enable_virtual_data:
description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回)
type: boolean
gateway_card_imei:
description: 插拔卡业务 IMEI由 Gateway 卡状态接口同步,非设备自身 IMEI无业务含义仅供查看
type: string
gateway_extend:
description: Gateway 卡状态扩展字段,原样返回上游 extend用于展示运营商侧实际停机原因
type: string
@@ -3492,6 +3498,11 @@ components:
description: 退款原因
maxLength: 1000
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key通过/storage/upload-url上传图片后获得
maxLength: 500
minLength: 1
type: string
requested_refund_amount:
description: 申请退款金额(分)
minimum: 1
@@ -3500,6 +3511,7 @@ components:
- order_id
- actual_received_amount
- requested_refund_amount
- refund_voucher_key
type: object
DtoCreateRoleRequest:
properties:
@@ -7117,6 +7129,9 @@ components:
refund_reason:
description: 退款原因
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key
type: string
reject_reason:
description: 拒绝原因
type: string
@@ -7184,6 +7199,11 @@ components:
maxLength: 1000
nullable: true
type: string
refund_voucher_key:
description: 退款凭证对象存储file_key重新提交时可替换历史记录缺失时必填
maxLength: 500
nullable: true
type: string
requested_refund_amount:
description: 申请退款金额(分)
minimum: 1

View File

@@ -0,0 +1,611 @@
# 奇成数据迁移方案
> 目标:把奇成(旧 MySQL `kyhl` 库的存量卡和设备迁移到新系统PostgreSQL输出 SQL 脚本供线上手动执行。
> 范围:当前态资产 + 当前生效套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细**。
---
## 一、整体架构
```
业务方提供
┌──────────────────────┐
│ resources/cards.csv │
│ resources/devices.csv│
└──────────┬───────────┘
我们维护映射配置
┌──────────▼───────────┐
│ config/carrier.yaml │
│ config/series.yaml │
│ config/package.yaml │
│ config/shop.yaml │
└──────────┬───────────┘
┌──────────▼───────────┐
│ 脚本1资产导入 │ ← 纯离线,不查奇成
│ migrate_assets.py │
└──────────┬───────────┘
┌──────────────────────┐
│ output/step1_*.sql │ ← 手动在线上执行
└──────────┬───────────┘
│ (执行完,新库已有这些 iccid)
┌──────────▼───────────┐
│ 脚本2关联数据导入 │ ← 直连奇成只读查询
│ migrate_runtime.py │ ← 直连新库测试环境拿 id
└──────────┬───────────┘
┌──────────────────────┐
│ output/step2_*.sql │ ← 手动在线上执行
└──────────────────────┘
```
**关键约束**
- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件
- 脚本对奇成线上库**只 SELECT**DB 连接层强制只读事务(`SET TRANSACTION READ ONLY`
- 所有 INSERT 使用 `ON CONFLICT DO NOTHING`,支持重跑
---
## 二、业务方提供的资产清单
### 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` | `1` |
| `target_shop_code` | 否 | 目标店铺编码。**留空 = 进平台库存** | `SHOP20260428103424CZZQ` |
**注意**
- 设备上的卡,其 `cards.csv``target_shop_code` 必须留空(一致性校验由脚本检查)
- `cards.csv` 必须先到位,`devices.csv``sim_iccid_*` 才能被校验
- 业务方分别准备这两份是合理的,奇成 `tbl_card_relate`(主卡+副卡1+副卡2的关系业务方应已掌握
---
## 三、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`,业务方人工修复后重跑。
---
## 四、我们维护的映射配置
迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好以下映射。
### 4.1 `config/carrier_mapping.yaml`
```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.2 `config/series_mapping.yaml`
```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.3 `config/package_mapping.yaml`
```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.4 `config/shop_mapping.yaml`(可选别名)
```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 WHERE iccid_mark IN (...) AND status=1 ORDER BY expire_date DESC
→ 每张卡取最新生效的套餐meal_id, meal_name, start_date, expire_date
- tbl_card WHERE iccid_mark IN (...) → total_bytes_cnt (GB)
- tbl_agent_commission_account → 按 agent_id 聚合 can_draw_amount, total_commision_amount
- tbl_agent根据 cards.csv 中 agent 信息映射出 shop_id
3. 对每张卡:
a. 套餐映射legacy_meal_name → package_id从 yaml 或业务方 csv
b. 生成伪订单 SQLorder_no = "MIG-CARD-" + iccid 后8位 + 时间戳)
c. 生成 tb_package_usage SQL关联伪订单 + 计算 snapshot 字段)
d. 生成 UPDATE tb_iot_card SET data_usage_mb = ... (把 GB 转 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 # 迁移流水
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-CARD-' || RIGHT('89852000263338772439', 8) || '-' || TO_CHAR(NOW(), 'YYYYMMDDHH24MISS'),
'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 | 设备上的卡在 cards.csv 里又写了 target_shop_code | 一致性校验,进 errors.csv |
| 5 | 奇成 tbl_card_life 一张卡有多条 status=1 记录 | 取 expire_date 最大的那条warnings.csv 记录 |
| 6 | 套餐 ID 映射不全 | 脚本预扫描,缺的列出来,业务方/我们补全 yaml |
| 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-CARD-<iccid后8位>-<时间戳>` / `MIG-DEV-<vno>-<时间戳>` | 前缀就是迁移数据的唯一识别标记 |
| `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. 业务方提供数据 → 全流程联调一次 → 交付给运维上线