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

28 KiB
Raw Blame History

奇成数据迁移方案

目标:把奇成(旧 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 是归属、设备当前槽位、套餐来源槽位和套餐迁移范围的决策源;奇成只提供历史事实
  • 脚本对奇成线上库只 SELECTDB 连接层强制只读事务(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.csvsim_iccid_* 才能被校验
  • 业务方分别准备这两份是合理的,奇成 tbl_card_relate(主卡+副卡1+副卡2的关系业务方应已掌握
  • 槽位解析优先级:devices.csv 行级配置 > mapping.yaml.overrides.devices > mapping.yaml.ownership_rules.device 批量默认值
  • current_slotpackage_source_slot 对应槽位无卡时写入 errors.csv 并阻断该设备相关 SQL

三、ICCID 19/20 位的处理规则(关键)

新系统设计:tb_iot_card.iccid_19iccid_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.cardsownership_rules.standalone_card 决定;设备按 overrides.devicesownership_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_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 店铺编码

# 如果业务方愿意填 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

-- 示例:插入一张卡,并用 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. 生成伪订单 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 同时插订单和 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_slotpackage_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_idNOT 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_tagNOT NULL bigint,代码里平台库存场景就是填 0

  • internal/service/exchange/service.go:297-300
  • internal/service/client_order/service.go:723shopTag := 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_walletmain + 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=0actual_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. 业务方提供数据 → 全流程联调一次 → 交付给运维上线