# 奇成数据迁移方案 > 目标:把奇成(旧 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', , 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--`) 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--` | 前缀就是迁移数据的唯一识别标记 | | `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=, agent_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. 业务方提供数据 → 全流程联调一次 → 交付给运维上线