This commit is contained in:
@@ -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
|
||||
|
||||
611
docs/脚本/奇成数据迁移方案.md
Normal file
611
docs/脚本/奇成数据迁移方案.md
Normal 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星网专享年卡套餐每月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` | `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_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.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. 生成伪订单 SQL(order_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_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 # 迁移流水
|
||||
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. 我们这边:
|
||||
- 写脚本1(migrate_assets.py + lib + 模板)
|
||||
- 在测试库验证脚本1
|
||||
- 写脚本2(migrate_runtime.py)
|
||||
- 在测试库验证脚本2
|
||||
- 写 README.md 操作手册(含"如何在报表中排除迁移数据"提示)
|
||||
3. 业务方提供数据 → 全流程联调一次 → 交付给运维上线
|
||||
Reference in New Issue
Block a user