28 KiB
奇成数据迁移方案
目标:把奇成(旧 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 归属、槽位和套餐规则
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
# 奇成的运营商名称(业务方在 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
# 奇成套餐系列名 → 新系统 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
# 奇成套餐名 → 新系统 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 店铺编码
# 如果业务方愿意填 shop_code,本文件可以省略
# 如果业务方只想填别名,这里维护 alias → shop_code 映射
aliases:
shop_a: "SHOP20260428103424CZZQ"
shop_b: "SHOP20260429150917OQKA"
五、脚本1:资产导入(migrate_assets.py)
5.1 输入
resources/cards.csvresources/devices.csvconfig/*.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:
-- 示例:插入一张卡,并用 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 模式:
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 的情况)
-- 在 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 同时插订单和 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 准备阶段(在我们的开发/测试机上)
# 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 线上执行(用户手动)
按编号顺序执行:
# 第一波:资产
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,由业务方维护:
# 奇成 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-300internal/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-%'过滤
十二、实施顺序(审查通过后)
- 业务方启动准备:明确
cards.csv/devices.csv/agent_shop_mapping.yaml的格式,开始整理 - 我们这边:
- 写脚本1(migrate_assets.py + lib + 模板)
- 在测试库验证脚本1
- 写脚本2(migrate_runtime.py)
- 在测试库验证脚本2
- 写 README.md 操作手册(含"如何在报表中排除迁移数据"提示)
- 业务方提供数据 → 全流程联调一次 → 交付给运维上线