From 95fc0b0a1bbde3f129b0dd75097d34d9045d5b65 Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 12 May 2026 10:32:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98,=E4=B8=BB=E8=A6=81=E6=98=AF=E7=94=9F=E6=95=88?= =?UTF-8?q?=E5=A5=97=E9=A4=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .config/dbhub.toml | 23 + README.md | 4 + docs/admin-openapi.yaml | 1445 +++++++++++- docs/agent-open-api/功能总结.md | 265 +++ docs/asset-detail-refactor-api-changes.md | 1 + docs/deployment/生产数据库手工上线流程.md | 328 +++ .../series-binding-bulk-selection/功能总结.md | 85 + .../traffic-model-unification/字段替换矩阵.md | 1 + .../traffic-model-unification/接口回放样例.md | 1 + internal/model/dto/refund_dto.go | 2 +- internal/model/refund.go | 2 +- .../polling/package_activation_handler.go | 44 +- .../service/package/activation_service.go | 248 +- internal/service/refund/service.go | 208 +- ...e_refund_asset_processing_comment.down.sql | 1 + ...ate_refund_asset_processing_comment.up.sql | 1 + .../.openspec.yaml | 2 + .../2026-05-11-add-agent-open-api/design.md | 176 ++ .../2026-05-11-add-agent-open-api/proposal.md | 47 + .../specs/agent-open-api/spec.md | 250 ++ .../2026-05-11-add-agent-open-api/tasks.md | 51 + openspec/specs/agent-open-api/spec.md | 250 ++ openspec/specs/refund-api/spec.md | 6 +- openspec/specs/refund-asset-reset/spec.md | 89 +- openspec/specs/refund-request/spec.md | 2 +- scripts/legacy_device_export/README.md | 90 + scripts/legacy_device_export/devices.csv | 352 +++ scripts/legacy_device_export/main.go | 2050 +++++++++++++++++ scripts/refund_wallet_reset_audit.sql | 77 + scripts/refund_wallet_reset_repair.sql | 154 ++ tests/agent_open_api/main.go | 6 +- 草稿.md | 276 --- 32 files changed, 5984 insertions(+), 553 deletions(-) create mode 100644 docs/agent-open-api/功能总结.md create mode 100644 docs/deployment/生产数据库手工上线流程.md create mode 100644 docs/series-binding-bulk-selection/功能总结.md create mode 100644 migrations/000142_update_refund_asset_processing_comment.down.sql create mode 100644 migrations/000142_update_refund_asset_processing_comment.up.sql create mode 100644 openspec/changes/archive/2026-05-11-add-agent-open-api/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-11-add-agent-open-api/design.md create mode 100644 openspec/changes/archive/2026-05-11-add-agent-open-api/proposal.md create mode 100644 openspec/changes/archive/2026-05-11-add-agent-open-api/specs/agent-open-api/spec.md create mode 100644 openspec/changes/archive/2026-05-11-add-agent-open-api/tasks.md create mode 100644 openspec/specs/agent-open-api/spec.md create mode 100644 scripts/legacy_device_export/README.md create mode 100644 scripts/legacy_device_export/devices.csv create mode 100644 scripts/legacy_device_export/main.go create mode 100644 scripts/refund_wallet_reset_audit.sql create mode 100644 scripts/refund_wallet_reset_repair.sql delete mode 100644 草稿.md diff --git a/.config/dbhub.toml b/.config/dbhub.toml index 5cf957b..c670fab 100644 --- a/.config/dbhub.toml +++ b/.config/dbhub.toml @@ -1,7 +1,20 @@ [[sources]] id = "main" +description = "当前项目库" dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" +[[sources]] +id = "legacy_mysql" +description = "老系统 MySQL 库,仅用于 AI 查询和迁移脚本准备" +type = "mysql" +host = "120.79.89.216" +port = 3306 +database = "kyhl" +user = "root" +password = "XiWaJ9Eu%go9-2>g!xGp" +sslmode = "disable" +lazy = true + [[tools]] name = "search_objects" source = "main" @@ -11,3 +24,13 @@ name = "execute_sql" source = "main" readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN max_rows = 1000 # Limit query results + +[[tools]] +name = "search_objects" +source = "legacy_mysql" + +[[tools]] +name = "execute_sql" +source = "legacy_mysql" +readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN +max_rows = 5000 # Limit query results for migration exploration diff --git a/README.md b/README.md index e4dbc39..0b104b8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ 君鸿卡管系统是一个物联网卡和号卡的全生命周期管理平台,支持三种客户类型和两种组织实体的多租户管理。 +### 后台运营批量能力 + +后台卡/设备套餐系列绑定接口支持按显式列表、号段范围、筛选条件三种方式批量选择资源。运营人员可按完整筛选结果批量设置或清除套餐系列绑定,不受列表分页勾选限制。 + ### 三种客户类型 | 客户类型 | 业务特点 | 典型场景 | 钱包归属 | diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 4df0cdd..23e9d21 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -135,6 +135,224 @@ components: required: - operation_password type: object + DtoAgentOpenAPICardStatusResponse: + properties: + card_no: + description: 卡标识 + type: string + card_status: + description: 卡状态 (normal:正常, stopped:停机) + type: string + card_status_name: + description: 卡状态名称(中文) + type: string + stop_reason: + description: 停机原因 + type: string + stop_reason_name: + description: 停机原因名称(中文) + type: string + type: object + DtoAgentOpenAPICardTrafficResponse: + properties: + active_expires_at: + description: 当前生效套餐最晚过期时间 + format: date-time + nullable: true + type: string + active_packages: + description: 当前生效套餐列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIPackageTrafficItem' + nullable: true + type: array + active_remaining_flow_mb: + description: 当前生效套餐剩余流量(MB) + type: integer + active_total_flow_mb: + description: 当前生效套餐总流量(MB) + type: integer + active_used_flow_mb: + description: 当前生效套餐已用流量(MB) + type: integer + card_no: + description: 卡标识 + type: string + pending_packages: + description: 待生效套餐列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIPackageTrafficItem' + nullable: true + type: array + type: object + DtoAgentOpenAPIPackageItem: + properties: + cost_price: + description: 套餐结算价(分) + type: integer + package_code: + description: 套餐编码 + type: string + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称 + type: string + package_type: + description: 套餐类型 (formal:正式套餐, addon:附加套餐) + type: string + package_type_name: + description: 套餐类型名称(中文) + type: string + retail_price: + description: 套餐销售价(分) + type: integer + series_name: + description: 套餐系列名称 + type: string + total_flow_mb: + description: 总流量(MB) + type: integer + valid_days: + description: 有效天数 + type: integer + type: object + DtoAgentOpenAPIPackageTrafficItem: + properties: + expires_at: + description: 套餐过期时间 + format: date-time + nullable: true + type: string + package_code: + description: 套餐编码 + type: string + package_name: + description: 套餐名称 + type: string + package_type: + description: 套餐类型 (formal:正式套餐, addon:附加套餐) + type: string + package_type_name: + description: 套餐类型名称(中文) + type: string + priority: + description: 生效优先级(数字越小优先级越高) + type: integer + remaining_flow_mb: + description: 套餐剩余流量(MB,仅生效套餐返回) + type: integer + series_name: + description: 套餐系列名称 + type: string + start_at: + description: 套餐生效时间 + format: date-time + nullable: true + type: string + total_flow_mb: + description: 套餐总流量(MB) + type: integer + used_flow_mb: + description: 套餐已用流量(MB,仅生效套餐返回) + type: integer + valid_days: + description: 套餐有效天数(待生效套餐返回) + type: integer + type: object + DtoAgentOpenAPIRealnameStatusResponse: + properties: + card_no: + description: 卡标识 + type: string + is_realnamed: + description: 是否已实名 + type: boolean + type: object + DtoAgentOpenAPIWalletBalanceResponse: + properties: + available_balance: + description: 可用余额(分) + type: integer + balance: + description: 钱包余额(分) + type: integer + currency: + description: 币种 + type: string + frozen_balance: + description: 冻结余额(分) + type: integer + type: object + DtoAgentOpenAPIWalletPackageOrderFailure: + properties: + card_no: + description: 卡标识 + type: string + code: + description: 错误码 + type: integer + message: + description: 失败原因 + type: string + type: object + DtoAgentOpenAPIWalletPackageOrderItem: + properties: + card_no: + description: 卡标识 + type: string + order_no: + description: 订单号 + type: string + type: object + DtoAgentOpenAPIWalletPackageOrderRequest: + properties: + card_nos: + description: 卡标识列表(支持 ICCID、虚拟号、MSISDN) + items: + type: string + maxItems: 100 + minItems: 1 + nullable: true + type: array + package_code: + description: 套餐编码(一次只能购买一个套餐) + maxLength: 100 + minLength: 1 + type: string + required: + - card_nos + - package_code + type: object + DtoAgentOpenAPIWalletPackageOrderResponse: + properties: + batch_no: + description: 批次号 + type: string + failed_cards: + description: 失败卡列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIWalletPackageOrderFailure' + nullable: true + type: array + failed_count: + description: 失败数量 + type: integer + orders: + description: 成功订单列表 + items: + $ref: '#/components/schemas/DtoAgentOpenAPIWalletPackageOrderItem' + nullable: true + type: array + package_code: + description: 套餐编码 + type: string + success_count: + description: 成功数量 + type: integer + type: object DtoAgentRechargeListResponse: properties: items: @@ -687,6 +905,9 @@ components: device_type: description: 设备类型 type: string + enable_virtual_data: + description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回) + type: boolean generation: description: 世代 type: string @@ -1222,6 +1443,9 @@ components: enable_polling: description: 是否参与轮询 type: boolean + enable_virtual_data: + description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回) + type: boolean iccid: description: 卡ICCID(asset_type=card时有效) type: string @@ -1590,20 +1814,94 @@ components: type: object DtoBatchSetCardSeriesBindngRequest: properties: + batch_no: + description: 批次号(selection_type=filter时可选) + maxLength: 100 + type: string + carrier_id: + description: 运营商ID(selection_type=filter时可选) + minimum: 0 + nullable: true + type: integer + carrier_name: + description: 运营商名称模糊查询(selection_type=filter时可选) + maxLength: 100 + type: string + filter_series_id: + description: 当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id) + minimum: 0 + nullable: true + type: integer + iccid: + description: ICCID模糊查询(selection_type=filter时可选) + maxLength: 20 + type: string + iccid_end: + description: 结束ICCID(selection_type=range时使用) + maxLength: 20 + type: string + iccid_start: + description: 起始ICCID(selection_type=range时使用) + maxLength: 20 + type: string iccids: - description: ICCID列表 + description: ICCID列表(selection_type=list时使用,最多1000个) items: type: string - maxItems: 500 - minItems: 1 + maxItems: 1000 nullable: true type: array + is_distributed: + description: 是否已分销(selection_type=filter时可选) + nullable: true + type: boolean + is_replaced: + description: 是否有换卡记录(selection_type=filter时可选) + nullable: true + type: boolean + is_standalone: + description: 是否为独立卡(selection_type=filter时可选) + nullable: true + type: boolean + msisdn: + description: 卡接入号模糊查询(selection_type=filter时可选) + maxLength: 20 + type: string + package_id: + description: 套餐ID(selection_type=filter时可选) + minimum: 0 + nullable: true + type: integer + selection_type: + description: 选卡方式 (list:ICCID列表, range:号段范围, filter:筛选条件;不传时兼容旧版ICCID列表) + enum: + - list + - range + - filter + type: string series_id: - description: 套餐系列ID(0表示清除关联) + description: 目标套餐系列ID(0表示清除关联) minimum: 0 type: integer + shop_id: + description: 分销商ID(selection_type=filter时可选,兼容旧参数,单选) + minimum: 0 + nullable: true + type: integer + shop_ids: + description: 分销商ID列表(selection_type=filter时可选,多选) + items: + minimum: 0 + type: integer + nullable: true + type: array + status: + description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选) + maximum: 4 + minimum: 1 + nullable: true + type: integer required: - - iccids - series_id type: object DtoBatchSetCardSeriesBindngResponse: @@ -1623,21 +1921,87 @@ components: type: object DtoBatchSetDeviceSeriesBindngRequest: properties: + batch_no: + description: 批次号(selection_type=filter时可选) + maxLength: 100 + type: string + created_at_end: + description: 创建时间结束(selection_type=filter时可选) + format: date-time + nullable: true + type: string + created_at_start: + description: 创建时间起始(selection_type=filter时可选) + format: date-time + nullable: true + type: string device_ids: - description: 设备ID列表 + description: 设备ID列表(selection_type=list时使用,最多1000个) items: minimum: 0 type: integer - maxItems: 500 - minItems: 1 + maxItems: 1000 nullable: true type: array + device_name: + description: 设备名称模糊查询(selection_type=filter时可选) + maxLength: 255 + type: string + device_type: + description: 设备类型(selection_type=filter时可选) + maxLength: 50 + type: string + filter_series_id: + description: 当前绑定套餐系列ID(selection_type=filter时可选;目标套餐系列仍使用series_id) + minimum: 0 + nullable: true + type: integer + manufacturer: + description: 制造商模糊查询(selection_type=filter时可选) + maxLength: 255 + type: string + selection_type: + description: 选设备方式 (list:设备ID列表, range:设备虚拟号范围, filter:筛选条件;不传时兼容旧版设备ID列表) + enum: + - list + - range + - filter + type: string series_id: - description: 套餐系列ID(0表示清除关联) + description: 目标套餐系列ID(0表示清除关联) minimum: 0 type: integer + shop_id: + description: 店铺ID(selection_type=filter时可选,兼容旧参数,单选) + minimum: 0 + nullable: true + type: integer + shop_ids: + description: 店铺ID列表(selection_type=filter时可选,多选) + items: + minimum: 0 + type: integer + nullable: true + type: array + status: + description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选) + maximum: 4 + minimum: 1 + nullable: true + type: integer + virtual_no: + description: 设备虚拟号模糊查询(selection_type=filter时可选) + maxLength: 100 + type: string + virtual_no_end: + description: 结束设备虚拟号(selection_type=range时使用) + maxLength: 100 + type: string + virtual_no_start: + description: 起始设备虚拟号(selection_type=range时使用) + maxLength: 100 + type: string required: - - device_ids - series_id type: object DtoBatchSetDeviceSeriesBindngResponse: @@ -2175,7 +2539,7 @@ components: description: 生效零售价(分) type: integer validity_days: - description: 有效天数 + description: 有效天数(按天套餐为折算总天数,自然月套餐按当前日期推算) type: integer type: object DtoClientPayConfig: @@ -8798,6 +9162,31 @@ components: type: string type: object securitySchemes: + AgentOpenAPIAccount: + description: 代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001。 + in: header + name: X-Agent-Account + type: apiKey + AgentOpenAPINonce: + description: 调用方每次请求自行生成的随机串,不需要平台提前分配;同一账号在 5 分钟内不可重复。 + in: header + name: X-Agent-Nonce + type: apiKey + AgentOpenAPIPassword: + description: 代理账号当前登录密码,同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名。 + in: header + name: X-Agent-Password + type: apiKey + AgentOpenAPISign: + description: 调用方按签名规则实时计算的 HMAC-SHA256 小写十六进制签名,不是固定值。 + in: header + name: X-Agent-Sign + type: apiKey + AgentOpenAPITimestamp: + description: 调用方生成的请求发起时间,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,必须与签名原文 timestamp 行一致。 + in: header + name: X-Agent-Timestamp + type: apiKey BearerAuth: bearerFormat: JWT scheme: bearer @@ -12684,14 +13073,23 @@ paths: minimum: 1 nullable: true type: integer - - description: 店铺ID (NULL表示平台库存) + - description: 店铺ID(兼容旧参数,单选;NULL表示平台库存) in: query name: shop_id schema: - description: 店铺ID (NULL表示平台库存) + description: 店铺ID(兼容旧参数,单选;NULL表示平台库存) minimum: 0 nullable: true type: integer + - description: 店铺ID列表(多选) + in: query + name: shop_ids + schema: + description: 店铺ID列表(多选) + items: + minimum: 0 + type: integer + type: array - description: 套餐系列ID in: query name: series_id @@ -13927,7 +14325,7 @@ paths: - 设备管理 /api/admin/devices/series-binding: patch: - description: 批量设置或清除设备与套餐系列分配的关联关系。参数:series_id(套餐系列ID,0表示清除关联)。 + description: 批量设置或清除设备与套餐系列分配的关联关系。series_id 为目标套餐系列ID,0表示清除关联。支持 selection_type=list/range/filter:list 传 device_ids(兼容旧请求,不传 selection_type 时默认按 device_ids 处理),range 传 virtual_no_start/virtual_no_end,filter 按筛选条件选择完整结果,不受列表分页限制。 requestBody: content: application/json: @@ -15897,7 +16295,7 @@ paths: - IoT卡管理 /api/admin/iot-cards/series-binding: patch: - description: 批量设置或清除卡与套餐系列分配的关联关系。参数:series_id(套餐系列ID,0表示清除关联)。 + description: 批量设置或清除卡与套餐系列分配的关联关系。series_id 为目标套餐系列ID,0表示清除关联。支持 selection_type=list/range/filter:list 传 iccids(兼容旧请求,不传 selection_type 时默认按 iccids 处理),range 传 iccid_start/iccid_end,filter 按筛选条件选择完整结果,不受列表分页限制。 requestBody: content: application/json: @@ -15994,14 +16392,23 @@ paths: minimum: 0 nullable: true type: integer - - description: 分销商ID + - description: 分销商ID(兼容旧参数,单选) in: query name: shop_id schema: - description: 分销商ID + description: 分销商ID(兼容旧参数,单选) minimum: 0 nullable: true type: integer + - description: 分销商ID列表(多选) + in: query + name: shop_ids + schema: + description: 分销商ID列表(多选) + items: + minimum: 0 + type: integer + type: array - description: 套餐系列ID in: query name: series_id @@ -22184,6 +22591,12 @@ paths: minimum: 1 nullable: true type: integer + - description: 是否排除当前账号自身店铺,默认false + in: query + name: exclude_self + schema: + description: 是否排除当前账号自身店铺,默认false + type: boolean responses: "200": content: @@ -25643,6 +26056,1004 @@ paths: summary: 微信支付回调 tags: - 支付回调 + /api/open/v1/cards/realname-status: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 card_no 查询单卡实名状态。 + parameters: + - description: 卡标识(支持 ICCID、虚拟号、MSISDN) + in: query + name: card_no + required: true + schema: + description: 卡标识(支持 ICCID、虚拟号、MSISDN) + maxLength: 100 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPIRealnameStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询单卡实名状态 + tags: + - 代理开放接口 + /api/open/v1/cards/status: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 card_no 查询单卡状态,返回正常或停机以及停机原因。 + parameters: + - description: 卡标识(支持 ICCID、虚拟号、MSISDN) + in: query + name: card_no + required: true + schema: + description: 卡标识(支持 ICCID、虚拟号、MSISDN) + maxLength: 100 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPICardStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询单卡状态 + tags: + - 代理开放接口 + /api/open/v1/cards/traffic: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 card_no 查询单卡流量、当前套餐和待生效套餐。card_no 支持 ICCID、虚拟号、MSISDN。 + parameters: + - description: 卡标识(支持 ICCID、虚拟号、MSISDN) + in: query + name: card_no + required: true + schema: + description: 卡标识(支持 ICCID、虚拟号、MSISDN) + maxLength: 100 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPICardTrafficResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询单卡流量 + tags: + - 代理开放接口 + /api/open/v1/packages: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 分页查询当前账号可购买的套餐,返回套餐基础信息和价格。 + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 套餐类型 (formal:正式套餐, addon:附加套餐) + in: query + name: package_type + schema: + description: 套餐类型 (formal:正式套餐, addon:附加套餐) + nullable: true + type: string + - description: 套餐系列ID + in: query + name: series_id + schema: + description: 套餐系列ID + minimum: 0 + nullable: true + type: integer + - description: 套餐名称(模糊搜索) + in: query + name: package_name + schema: + description: 套餐名称(模糊搜索) + maxLength: 255 + nullable: true + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPIPackageItem' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询套餐列表 + tags: + - 代理开放接口 + /api/open/v1/wallet/balance: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 查询当前账号预充值钱包余额。未开通钱包时返回 0。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPIWalletBalanceResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询预充值钱包余额 + tags: + - 代理开放接口 + /api/open/v1/wallet/package-orders: + post: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 使用预充值钱包为多张卡购买同一个套餐编码。每张卡独立下单,允许部分成功。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOpenAPIWalletPackageOrderRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPIWalletPackageOrderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 钱包套餐购买 + tags: + - 代理开放接口 + /api/open/v1/wallet/transactions: + get: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 分页查询当前账号预充值钱包流水。 + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 交易类型过滤(recharge:充值入账, deduct:套餐扣款, refund:退款) + in: query + name: transaction_type + schema: + description: 交易类型过滤(recharge:充值入账, deduct:套餐扣款, refund:退款) + maxLength: 50 + type: string + - description: 开始日期(YYYY-MM-DD) + in: query + name: start_date + schema: + description: 开始日期(YYYY-MM-DD) + type: string + - description: 结束日期(YYYY-MM-DD) + in: query + name: end_date + schema: + description: 结束日期(YYYY-MM-DD) + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoMainWalletTransactionItem' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 查询预充值钱包流水 + tags: + - 代理开放接口 /health: get: responses: diff --git a/docs/agent-open-api/功能总结.md b/docs/agent-open-api/功能总结.md new file mode 100644 index 0000000..75f2d67 --- /dev/null +++ b/docs/agent-open-api/功能总结.md @@ -0,0 +1,265 @@ +# 代理开放接口对接说明 + +## 1. 接入说明 + +代理开放接口挂载在 `/api/open/v1`,面向代理店铺第三方系统调用。接入方使用代理账号登录信息进行签名认证,通过认证后即可调用卡查询、套餐查询、预充值钱包查询和套餐购买接口。 + +请通过 HTTPS 调用开放接口。接入方不要在客户端日志、浏览器控制台或异常信息中记录账号密码、签名、随机串等敏感信息。 + +## 2. 认证 Header + +每个请求必须携带以下 Header: + +| Header | 是什么 | 怎么来 | 示例 | 注意事项 | +| --- | --- | --- | --- | --- | +| `X-Agent-Account` | 代理账号标识 | 填写代理登录平台使用的用户名或手机号,由平台开通代理账号时提供 | `agent001` 或 `13800000000` | 必须与签名原文最后一行 `account` 完全一致 | +| `X-Agent-Password` | 代理账号当前登录密码 | 由代理账号持有人提供,和登录平台的密码一致 | `Passw0rd123` | 同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名;不要写入日志 | +| `X-Agent-Timestamp` | 请求发起时间 | 调用方在每次请求前生成 | `1715400000`、`1715400000000` 或 `2024-05-11T10:00:00+08:00` | 支持 Unix 秒、Unix 毫秒、RFC3339;必须与签名原文 `timestamp` 行完全一致;客户端服务器时间需同步 | +| `X-Agent-Nonce` | 请求随机串 | 调用方每次请求自行生成,不需要平台提前分配 | `n-1715400000-001` 或 UUID | 同一账号 5 分钟内不可重复,重复会被判定为重放请求 | +| `X-Agent-Sign` | 请求签名 | 调用方按下方签名规则实时计算 | `5f2d...` | 不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化 | + +Header 生成顺序建议: + +1. 先确定 `X-Agent-Account` 和 `X-Agent-Password`。 +2. 每次请求生成新的 `X-Agent-Timestamp` 和 `X-Agent-Nonce`。 +3. 按下方规则拼出签名原文。 +4. 用 `X-Agent-Password` 对签名原文做 HMAC-SHA256,得到 `X-Agent-Sign`。 +5. 带上 5 个 Header 发起请求。 + +签名原文: + +```text +METHOD +PATH +canonical_query +body_sha256 +timestamp +nonce +account +``` + +拼接规则: + +- 使用换行符 `\n` 连接 7 行,最后一行后不追加换行。 +- `METHOD` 使用大写 HTTP 方法,例如 `GET`、`POST`。 +- `PATH` 只取请求路径,例如 `/api/open/v1/cards/status`,不包含域名和 query。 +- `canonical_query` 为 query 参数规范化结果:排除 `sign` 字段;参数名按升序排列;同名多值按值升序排列;key 和 value 使用 URL QueryEscape 编码后按 `key=value` 拼接;多个参数用 `&` 连接;无 query 时为空字符串。 +- `body_sha256` 为原始请求 body 字节的 SHA256 小写十六进制值。GET 或空 body 使用空字符串的 SHA256:`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。 +- POST JSON 请求必须先确定最终发送的 body 字符串,再用同一个字符串计算 `body_sha256` 并发送请求;签名后不要再重新格式化 JSON、调整字段顺序或改变空格。 +- `timestamp`、`nonce`、`account` 必须与 Header 中的值完全一致。 + +签名值为: + +```text +lowercase_hex(HMAC-SHA256(password, sign_payload)) +``` + +其中 `password` 为 `X-Agent-Password` 的原始值,`sign_payload` 为上面 7 行拼接后的字符串。 + +### 2.1 GET 签名示例 + +请求: + +```text +GET /api/open/v1/cards/status?card_no=89860000000000000001 +X-Agent-Account: agent001 +X-Agent-Password: Passw0rd123 +X-Agent-Timestamp: 1715400000 +X-Agent-Nonce: n-1715400000-001 +``` + +签名原文: + +```text +GET +/api/open/v1/cards/status +card_no=89860000000000000001 +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1715400000 +n-1715400000-001 +agent001 +``` + +Node.js 示例: + +```javascript +const crypto = require("crypto"); + +function sha256Hex(input) { + return crypto.createHash("sha256").update(input).digest("hex"); +} + +function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); +} + +function canonicalQuery(params) { + return Object.entries(params) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => `${queryEscape(key)}=${queryEscape(value)}`) + .join("&"); +} + +function sign({ method, path, query, body, timestamp, nonce, account, password }) { + const payload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + sha256Hex(body || ""), + timestamp, + nonce, + account, + ].join("\n"); + + return crypto.createHmac("sha256", password).update(payload).digest("hex"); +} +``` + +完整 GET 调用示例: + +```javascript +const https = require("https"); + +const account = "agent001"; +const password = "Passw0rd123"; +const timestamp = Math.floor(Date.now() / 1000).toString(); +const nonce = `nonce-${Date.now()}`; +const method = "GET"; +const path = "/api/open/v1/cards/status"; +const query = { card_no: "89860000000000000001" }; +const body = ""; +const requestQuery = canonicalQuery(query); +const requestPath = `${path}?${requestQuery}`; +const signature = sign({ method, path, query, body, timestamp, nonce, account, password }); + +const req = https.request( + { + hostname: "open.example.com", + path: requestPath, + method, + headers: { + "X-Agent-Account": account, + "X-Agent-Password": password, + "X-Agent-Timestamp": timestamp, + "X-Agent-Nonce": nonce, + "X-Agent-Sign": signature, + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => console.log(data)); + } +); + +req.end(); +``` + +完整 POST 调用示例: + +```javascript +const https = require("https"); + +const account = "agent001"; +const password = "Passw0rd123"; +const timestamp = Math.floor(Date.now() / 1000).toString(); +const nonce = `nonce-${Date.now()}`; +const method = "POST"; +const path = "/api/open/v1/wallet/package-orders"; +const query = {}; + +const body = JSON.stringify({ + card_nos: ["89860000000000000001", "89860000000000000002"], + package_code: "PKG001", +}); + +const signature = sign({ method, path, query, body, timestamp, nonce, account, password }); + +const req = https.request( + { + hostname: "open.example.com", + path, + method, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "X-Agent-Account": account, + "X-Agent-Password": password, + "X-Agent-Timestamp": timestamp, + "X-Agent-Nonce": nonce, + "X-Agent-Sign": signature, + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => console.log(data)); + } +); + +req.write(body); +req.end(); +``` + +curl 调用示例: + +```bash +curl -G "https://open.example.com/api/open/v1/cards/status" \ + --data-urlencode "card_no=89860000000000000001" \ + -H "X-Agent-Account: agent001" \ + -H "X-Agent-Password: Passw0rd123" \ + -H "X-Agent-Timestamp: 1715400000" \ + -H "X-Agent-Nonce: n-1715400000-001" \ + -H "X-Agent-Sign: 上面代码计算出的签名" +``` + +## 3. 接口列表 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/open/v1/cards/traffic` | 按 `card_no` 查询单卡流量和套餐 | +| `GET` | `/api/open/v1/cards/status` | 按 `card_no` 查询单卡正常/停机状态 | +| `GET` | `/api/open/v1/cards/realname-status` | 按 `card_no` 查询实名状态 | +| `GET` | `/api/open/v1/packages` | 查询可购买套餐列表 | +| `POST` | `/api/open/v1/wallet/package-orders` | 使用预充值钱包给多张卡购买同一个套餐 | +| `GET` | `/api/open/v1/wallet/balance` | 查询预充值钱包余额 | +| `GET` | `/api/open/v1/wallet/transactions` | 查询预充值钱包流水 | + +`card_no` 支持 ICCID、虚拟号、MSISDN。开放接口只支持单卡,设备标识会返回参数错误。 + +## 4. 字段口径 + +卡流量字段: + +- `total_flow_mb`:套餐总流量,单位 MB。 +- `used_flow_mb`:已用流量,单位 MB。 +- `remaining_flow_mb`:剩余流量,单位 MB。 + +套餐列表字段: + +- `cost_price`:套餐结算价,单位分。 +- `retail_price`:套餐销售价,单位分。 +- `total_flow_mb`:套餐总流量,单位 MB。 +- `valid_days`:套餐有效天数。 + +钱包购买接口一次只允许一个 `package_code`,`card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。 + +## 5. 错误码 + +| 错误码 | 说明 | +| --- | --- | +| `1048` | 开放接口认证失败 | +| `1049` | 开放接口签名无效 | +| `1190` | 开放接口时间戳无效 | +| `1191` | 重复请求,请勿重放 | +| `1005` | 无权限操作该资源或资源不存在 | + +参数校验失败统一返回 `1001`。认证类失败只返回统一错误码和消息。 diff --git a/docs/asset-detail-refactor-api-changes.md b/docs/asset-detail-refactor-api-changes.md index aa2b0bc..15a5c32 100644 --- a/docs/asset-detail-refactor-api-changes.md +++ b/docs/asset-detail-refactor-api-changes.md @@ -79,6 +79,7 @@ | `virtual_total_mb` | int64 | 当前主套餐业务停机阈值 MB | | `virtual_used_mb` | float64 | 当前主套餐展示已用量 MB | | `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` | +| `enable_virtual_data` | bool | 当前主套餐是否启用虚流量(按套餐使用记录快照返回) | | `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device) | | `activated_at` | time | 激活时间(未来准备移除,请勿依赖) | | `created_at` | time | 创建时间 | diff --git a/docs/deployment/生产数据库手工上线流程.md b/docs/deployment/生产数据库手工上线流程.md new file mode 100644 index 0000000..206d4ed --- /dev/null +++ b/docs/deployment/生产数据库手工上线流程.md @@ -0,0 +1,328 @@ +# 生产数据库手工上线流程 + +## 1. 适用场景 + +本文档用于君鸿卡管系统**首次生产上线**时的数据库初始化,目标是: + +1. **不依赖应用启动自动建表** +2. **人工创建生产库和表结构** +3. **人工导入基础数据** +4. **人工对齐 `schema_migrations` 版本** +5. 后续继续沿用 `migrations/` 中的新增迁移 + +> 截至 **2026-05-06**,仓库内最新迁移版本为 **140**。 +> 当前仓库缺少可直接用于“全新环境从 0 建表”的 `000114_squash_baseline`,因此首次生产上线建议走“**手工 schema 基线 + 手工基础数据导入 + 手工迁移版本对齐**”。 + +--- + +## 2. 上线原则 + +### 2.1 本次建议做的事 + +1. 从你确认过的**源库**生成一份生产首发 schema 基线 SQL +2. 在生产环境手工创建空数据库 +3. 手工执行 schema 基线 SQL 建表 +4. 导入基础数据 +5. 手工写入 `schema_migrations=140` +6. 启动 API / Worker + +### 2.2 本次不建议做的事 + +1. 不依赖应用自动建表 +2. 不直接把当前活跃 `migrations/` 当作全新建库链路执行 +3. 不把业务数据表(订单、卡、设备、钱包流水等)从测试库整库导入生产 +4. 不直接复用测试环境里的密钥、回调地址、商户配置而不做复核 + +--- + +## 3. 基础数据范围 + +### 3.1 建议导入的非敏感基础数据 + +以下表建议从已确认的源库导出,再导入生产库: + +1. `tb_permission` +2. `tb_role` +3. `tb_role_permission` +4. `tb_carrier` +5. `tb_commission_withdrawal_setting` +6. `tb_polling_config` +7. `tb_polling_concurrency_config` +8. `tb_polling_alert_rule` +9. `tb_data_cleanup_config` + +### 3.2 建议单独处理的敏感基础数据 + +以下表包含支付、公众号、小程序、证书、密钥等敏感信息,建议单独导出或手工填充: + +1. `tb_wechat_config` + +### 3.3 不建议首发直接导入的数据 + +以下数据不属于“生产首发基础数据”,通常不要从测试库带入生产: + +1. `tb_account` +2. `tb_account_role` +3. `tb_shop` +4. `tb_enterprise` +5. `tb_iot_card` +6. `tb_device` +7. `tb_order` +8. 各类钱包、流水、日志、审计、导入任务表 + +--- + +## 4. 关于表结构基线 + +### 4.1 建议默认排除的确认遗留表 + +以下两张表已具备明确“遗留/保留历史”的语义,生成生产首发 schema 基线时建议默认排除: + +1. `tb_data_usage_record` +2. `tb_card_replacement_record_legacy` + +### 4.2 建议保守保留、后续再评估的表 + +以下表当前在代码里的活跃引用较少或暂无数据,但仍不建议在首次生产上线时贸然删除,先按兼容方式保留更稳: + +1. `tb_card_replacement_request` +2. `tb_number_card` +3. `tb_payment_merchant_setting` +4. `tb_dev_capability_config` + +--- + +## 5. 交付文件 + +本目录对应的手工上线辅助文件位于: + +`scripts/manual_db_release/` + +按执行顺序主要包括: + +1. `01_create_database.sql` +2. `02_generate_schema_baseline.sh` +3. `03_export_base_data_from_source.sh` +4. `04_import_base_data_into_target.sh` +5. `05_repair_base_data_sequences.sql` +6. `06_mark_schema_migrations.sql` +7. `07_verify_manual_release.sql` + +--- + +## 6. 手工上线步骤 + +## 6.1 第一步:确定源库 + +源库应满足: + +1. 结构版本已确认正确 +2. 基础配置完整 +3. 权限、角色、运营商、轮询配置经过业务验收 + +建议不要临时选一个“有人随手改过但未复核”的测试库。 + +--- + +## 6.2 第二步:手工创建生产数据库 + +先修改: + +`scripts/manual_db_release/01_create_database.sql` + +将其中的占位符替换为: + +1. `__DB_NAME__` +2. `__DB_USER__` +3. `__DB_PASSWORD__` + +然后执行: + +```bash +psql "postgres://postgres:你的密码@生产PG主机:5432/postgres?sslmode=disable" \ + -f scripts/manual_db_release/01_create_database.sql +``` + +--- + +## 6.3 第三步:生成 schema 基线 SQL + +使用脚本: + +```bash +SOURCE_DSN="postgres://源库账号:源库密码@源库主机:5432/源库名?sslmode=disable" \ +OUTPUT_SQL="/你的目录/00_schema_baseline.sql" \ +bash scripts/manual_db_release/02_generate_schema_baseline.sh +``` + +默认会排除: + +1. `schema_migrations` +2. `tb_data_usage_record` +3. `tb_card_replacement_record_legacy` + +生成后请人工复核一次: + +1. 是否包含你确认不需要的历史表 +2. 是否错误带入测试专用对象 +3. 是否包含意外的 `OWNER TO` +4. 是否包含不应该进入生产的注释性对象 + +--- + +## 6.4 第四步:在生产库执行 schema 基线 + +```bash +psql "postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \ + -v ON_ERROR_STOP=1 \ + -f /你的目录/00_schema_baseline.sql +``` + +执行后先不要急着启动应用,先导入基础数据。 + +--- + +## 6.5 第五步:导出基础数据 + +导出非敏感基础数据: + +```bash +SOURCE_DSN="postgres://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" \ +bash scripts/manual_db_release/03_export_base_data_from_source.sh +``` + +如需连同微信配置一起导出: + +```bash +SOURCE_DSN="postgres://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" \ +INCLUDE_SENSITIVE=1 \ +bash scripts/manual_db_release/03_export_base_data_from_source.sh +``` + +导出结果会放到: + +`scripts/manual_db_release/generated/` + +> 注意:`generated/` 下的 SQL 可能包含密钥和证书内容,不要提交到 Git。 + +--- + +## 6.6 第六步:导入基础数据 + +在生产库执行: + +```bash +TARGET_DSN="postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \ +bash scripts/manual_db_release/04_import_base_data_into_target.sh +``` + +如需导入敏感配置: + +```bash +TARGET_DSN="postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \ +INCLUDE_SENSITIVE=1 \ +bash scripts/manual_db_release/04_import_base_data_into_target.sh +``` + +--- + +## 6.7 第七步:修正序列值 + +```bash +psql "postgres://junhong_cmp:CtCom1zBzPQbpVNf3rCNxH@cxd.whcxd.cn:16159/junhong_cmp_prod?sslmode=disable" \ + -v ON_ERROR_STOP=1 \ + -f scripts/manual_db_release/05_repair_base_data_sequences.sql +``` + +这一步用于避免显式插入 ID 后,自增序列仍停留在旧值。 + +--- + +## 6.8 第八步:手工对齐迁移版本 + +```bash +psql "postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \ + -v ON_ERROR_STOP=1 \ + -f scripts/manual_db_release/06_mark_schema_migrations.sql +``` + +执行后,生产库会被视为已处于 `140` 版本。 +后续如果仓库新增 `141+` 迁移,就可以继续正常执行 `migrate up`。 + +> 如果你上线时仓库最新迁移版本已经不是 `140`,记得同步修改 `06_mark_schema_migrations.sql`。 + +--- + +## 6.9 第九步:上线前校验 + +```bash +psql "postgres://生产库账号:生产库密码@生产库主机:5432/生产库名?sslmode=disable" \ + -f scripts/manual_db_release/07_verify_manual_release.sql +``` + +重点关注: + +1. `schema_migrations.version=140` +2. 权限、角色、角色权限数量不为 0 +3. 运营商配置数量不为 0 +4. 轮询配置数量不为 0 +5. `tb_wechat_config` 是否按预期导入 + +--- + +## 6.10 第十步:启动应用 + +确认以下环境变量改为生产值: + +1. `JUNHONG_DATABASE_HOST` +2. `JUNHONG_DATABASE_PORT` +3. `JUNHONG_DATABASE_USER` +4. `JUNHONG_DATABASE_PASSWORD` +5. `JUNHONG_DATABASE_DBNAME` +6. `JUNHONG_REDIS_*` +7. `JUNHONG_JWT_SECRET_KEY` + +特别注意: + +1. 当前仓库里的 `docker-compose.prod.yml` 示例仍是测试库参数,不能原样直接用于生产 +2. 如果生产库里没有超级管理员账号,应用启动时会自动创建一个超级管理员账号 +3. 如不希望使用默认超管账号,请提前设置 `JUNHONG_DEFAULT_ADMIN_USERNAME`、`JUNHONG_DEFAULT_ADMIN_PASSWORD`、`JUNHONG_DEFAULT_ADMIN_PHONE` + +--- + +## 7. 推荐执行顺序汇总 + +```text +手工创建生产库 + -> 生成 schema 基线 SQL + -> 执行 schema 基线 SQL + -> 导出基础数据 + -> 导入基础数据 + -> 修正序列 + -> 写入 schema_migrations=140 + -> 校验 + -> 启动 API / Worker +``` + +--- + +## 8. 风险提醒 + +### 8.1 关于 `tb_wechat_config` + +该表包含敏感配置,建议: + +1. 单独导出 +2. 单独传输 +3. 单独审批 +4. 导入后立刻校验回调地址、商户号、证书内容 + +### 8.2 关于账号初始化 + +本次流程**不建议**直接从测试库导入 `tb_account`。 +首发生产环境建议只导入权限体系和系统配置,让生产超管账号在受控条件下独立创建。 + +### 8.3 关于后续迁移 + +本次只是首次生产上线的“人工基线落地”。 +后续版本发布仍应继续补齐正式的 `000114_squash_baseline`,避免长期依赖手工基线流程。 diff --git a/docs/series-binding-bulk-selection/功能总结.md b/docs/series-binding-bulk-selection/功能总结.md new file mode 100644 index 0000000..5abc307 --- /dev/null +++ b/docs/series-binding-bulk-selection/功能总结.md @@ -0,0 +1,85 @@ +# 套餐系列批量绑定选择方式优化 + +## 背景 + +运营人员为卡或设备设置套餐系列时,原接口只能提交当前页勾选的 `iccids` 或 `device_ids`。卡/设备列表存在分页限制,遇到按批次、店铺、运营商等条件批量处理时,需要跨页反复勾选,操作成本高。 + +## 涉及接口 + +- `PATCH /api/admin/iot-cards/series-binding` +- `PATCH /api/admin/devices/series-binding` + +## 请求方式 + +两个接口都保留旧请求兼容: + +```json +{ + "iccids": ["8986000000000000001"], + "series_id": 12 +} +``` + +```json +{ + "device_ids": [1001], + "series_id": 12 +} +``` + +新增 `selection_type` 后支持三种选择方式: + +| selection_type | 卡接口 | 设备接口 | 用途 | +| --- | --- | --- | --- | +| `list` | `iccids` | `device_ids` | 显式列表选择,兼容旧勾选逻辑 | +| `range` | `iccid_start` + `iccid_end` | `virtual_no_start` + `virtual_no_end` | 按连续号段/虚拟号范围批量处理 | +| `filter` | 卡列表筛选条件 | 设备列表筛选条件 | 按完整筛选结果批量处理,不受分页限制 | + +## 示例 + +按卡批次和运营商批量绑定: + +```json +{ + "selection_type": "filter", + "batch_no": "BATCH-2026-05", + "carrier_id": 1, + "series_id": 12 +} +``` + +按 ICCID 号段清除绑定: + +```json +{ + "selection_type": "range", + "iccid_start": "8986000000000000001", + "iccid_end": "8986000000000000999", + "series_id": 0 +} +``` + +按设备筛选条件批量绑定: + +```json +{ + "selection_type": "filter", + "shop_id": 10, + "batch_no": "DEVICE-2026-05", + "series_id": 12 +} +``` + +## 权限与兼容性 + +- `series_id=0` 仍表示清除套餐系列关联。 +- 代理用户仍只能操作自己店铺名下的卡/设备,保持旧接口权限语义不变。 +- 代理用户设置非零 `series_id` 时,仍会校验当前店铺是否拥有该套餐系列授权。 +- 旧请求不传 `selection_type` 时,只要传了 `iccids` 或 `device_ids`,后端仍按列表选择处理。 + +## 验证 + +- `go build ./...` +- `go vet ./...` +- `go run ./cmd/gendocs` +- `ccc index` diff --git a/docs/traffic-model-unification/字段替换矩阵.md b/docs/traffic-model-unification/字段替换矩阵.md index 6d1e9b9..305ff4b 100644 --- a/docs/traffic-model-unification/字段替换矩阵.md +++ b/docs/traffic-model-unification/字段替换矩阵.md @@ -66,6 +66,7 @@ | `package_remain_mb` | 来自后台资产解析结果 | `virtual_total_mb` / `virtual_used_mb` | 不再返回 remain | | 无 | 无 | `current_package_usage_id` | 当前主套餐使用记录 ID | | 无 | 无 | `reduction_pct` | 展示增幅比例 | +| 无 | `tb_package_usage.enable_virtual_data_snapshot` | `enable_virtual_data` | 返回当前主套餐快照,供前端区分是否启用虚流量 | ### 3.5 C 端 `GET /api/c/v1/asset/package-history` diff --git a/docs/traffic-model-unification/接口回放样例.md b/docs/traffic-model-unification/接口回放样例.md index 8aa241f..42eeef9 100644 --- a/docs/traffic-model-unification/接口回放样例.md +++ b/docs/traffic-model-unification/接口回放样例.md @@ -97,6 +97,7 @@ curl -X GET "http://localhost:8080/api/c/v1/asset/info?identifier=" - 仅返回当前主套餐摘要 - 无主套餐时五字段为 `0`,`current_package_usage_id = null` +- 当前主套餐摘要包含 `enable_virtual_data` - 不再出现 `package_total_mb` / `package_used_mb` / `package_remain_mb` ### 4.2 套餐历史 diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index dab6ecd..ac79fb7 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -75,7 +75,7 @@ type RefundResponse struct { RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"` Remark string `json:"remark,omitempty" description:"审批备注"` CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"` - AssetReset bool `json:"asset_reset" description:"资产是否已重置"` + AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"` Creator uint `json:"creator" description:"创建人ID"` Updater uint `json:"updater" description:"更新人ID"` CreatedAt string `json:"created_at" description:"创建时间"` diff --git a/internal/model/refund.go b/internal/model/refund.go index 56e58f2..6df65f4 100644 --- a/internal/model/refund.go +++ b/internal/model/refund.go @@ -42,7 +42,7 @@ type RefundRequest struct { // 后处理标记 CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"` - AssetReset bool `gorm:"column:asset_reset;not null;default:false;comment:资产是否已重置(套餐停用+停机+世代重置)" json:"asset_reset"` + AssetReset bool `gorm:"column:asset_reset;not null;default:false;comment:退款后资产处理是否完成" json:"asset_reset"` } // TableName 指定表名 diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index 1e39e1b..fb11593 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -72,28 +72,26 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont return err } - if len(expiredPackages) == 0 { - return nil - } + if len(expiredPackages) > 0 { + h.logger.Info("发现过期主套餐", + zap.Int("count", len(expiredPackages)), + zap.Duration("check_duration", time.Since(startTime))) - h.logger.Info("发现过期主套餐", - zap.Int("count", len(expiredPackages)), - zap.Duration("check_duration", time.Since(startTime))) - - // 处理每个过期的主套餐 - for _, pkg := range expiredPackages { - if err := h.processExpiredPackage(ctx, pkg); err != nil { - h.logger.Error("处理过期套餐失败", - zap.Uint("package_usage_id", pkg.ID), - zap.Error(err)) - // 继续处理下一个,不中断 - continue + // 处理每个过期的主套餐 + for _, pkg := range expiredPackages { + if err := h.processExpiredPackage(ctx, pkg); err != nil { + h.logger.Error("处理过期套餐失败", + zap.Uint("package_usage_id", pkg.ID), + zap.Error(err)) + // 继续处理下一个,不中断 + continue + } } - } - h.logger.Info("套餐激活检查完成", - zap.Int("processed", len(expiredPackages)), - zap.Duration("total_duration", time.Since(startTime))) + h.logger.Info("套餐激活检查完成", + zap.Int("processed", len(expiredPackages)), + zap.Duration("total_duration", time.Since(startTime))) + } // 任务 6: 孤儿套餐扫描 orphanCount, err := h.findAndActivateOrphanPackages(ctx) @@ -109,15 +107,15 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont } // findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活 -// 孤儿定义:存在 status=0 的主套餐(pending_realname_activation=false),但不存在 status=1 的主套餐 +// 孤儿定义:存在 status=0 的主套餐,但不存在 status=1 的主套餐。 func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Context) (int, error) { // 查询孤儿待生效主套餐(无生效主套餐但有待生效主套餐) var orphanUsages []*model.PackageUsage err := h.db.WithContext(ctx). Where("status = ?", constants.PackageUsageStatusPending). Where("master_usage_id IS NULL"). - Where("pending_realname_activation = ?", false). Where("deleted_at IS NULL"). + Order("priority ASC, created_at ASC"). Limit(100). Find(&orphanUsages).Error if err != nil { @@ -178,9 +176,9 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con continue } - // 保留 priority 最低的套餐 + // 保留购买顺序最靠前的套餐,队首未满足实名条件时不跳过。 if existing, ok := carrierMap[key]; ok { - if usage.Priority < existing.Priority { + if usage.Priority < existing.Priority || (usage.Priority == existing.Priority && usage.CreatedAt.Before(existing.CreatedAt)) { carrierMap[key] = usage } } else { diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index dd9b479..b744c15 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -290,38 +290,34 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package return nil } - // 按 ExpiryBase 选择激活时间基准 - now := time.Now() - var activatedAt time.Time - if pkg.ExpiryBase == "from_purchase" { - activatedAt = usage.CreatedAt - } else { - activatedAt = now - } - expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) - nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + if currentUsage.MasterUsageID == nil { + hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) + if err != nil { + return err + } + if hasActive { + s.logger.Info("已有生效主套餐,跳过指定套餐激活", + zap.Uint("usage_id", currentUsage.ID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) + return nil + } - // 更新套餐状态 - updates := map[string]interface{}{ - "status": constants.PackageUsageStatusActive, - "activated_at": activatedAt, - "expires_at": expiresAt, - } - if nextResetAt != nil { - updates["next_reset_at"] = *nextResetAt - } - if err := tx.Model(&usage).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败") + canActivate, err := s.canActivatePendingUsage(ctx, tx, ¤tUsage, carrierType, carrierID) + if err != nil { + return err + } + if !canActivate { + s.logger.Info("指定套餐暂不满足激活条件", + zap.Uint("usage_id", currentUsage.ID), + zap.Bool("pending_realname_activation", currentUsage.PendingRealnameActivation)) + return nil + } } - // 同步载体状态 - s.syncCarrierStatusActivated(ctx, tx, &usage, carrierType, carrierID) - - s.logger.Info("指定套餐已激活", - zap.Uint("usage_id", usage.ID), - zap.Uint("package_id", usage.PackageID), - zap.String("carrier_type", carrierType), - zap.Uint("carrier_id", carrierID)) + if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil { + return err + } // 异步复机 if s.resumeCallback != nil { @@ -339,6 +335,84 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package }) } +// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。 +// 返回 true 表示本次成功激活了一个主套餐;队首套餐未满足实名条件时返回 false。 +func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) { + if carrierType != constants.AssetTypeIotCard && carrierType != constants.AssetTypeDevice { + return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") + } + if carrierID == 0 { + return false, errors.New(errors.CodeInvalidParam, "无效的载体ID") + } + + lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) + lockValue := time.Now().String() + locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() + if err != nil { + return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") + } + if !locked { + s.logger.Warn("套餐激活正在进行中,跳过", + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) + return false, errors.New(errors.CodePackageActivationConflict) + } + defer s.redis.Del(ctx, lockKey) + + activated := false + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) + if err != nil { + return err + } + if hasActive { + return nil + } + + nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) + if err == gorm.ErrRecordNotFound { + return nil + } + if err != nil { + return err + } + + canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) + if err != nil { + return err + } + if !canActivate { + s.logger.Info("队首待生效套餐暂不满足激活条件", + zap.Uint("usage_id", nextMain.ID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID), + zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) + return nil + } + + var pkg model.Package + if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") + } + + if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil { + return err + } + activated = true + return nil + }) + if err != nil { + return false, err + } + + return activated, nil +} + +// HasActiveMainPackage 查询载体当前是否存在生效中的主套餐。 +func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) { + return s.hasActiveMainPackage(ctx, s.db.WithContext(ctx), carrierType, carrierID) +} + // invalidateAddons 任务 9.7: 加油包级联失效 func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error { var addons []*model.PackageUsage @@ -374,13 +448,7 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m // activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐 func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error { // 查询下一个待生效主套餐 - var nextMain model.PackageUsage - err := tx.Where("status = ?", constants.PackageUsageStatusPending). - Where("master_usage_id IS NULL"). - Where(carrierType+"_id = ?", carrierID). - Order("priority ASC"). - First(&nextMain).Error - + nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) if err == gorm.ErrRecordNotFound { s.logger.Info("没有待生效的主套餐", zap.String("carrier_type", carrierType), @@ -388,7 +456,20 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor return nil } if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败") + return err + } + + canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) + if err != nil { + return err + } + if !canActivate { + s.logger.Info("下一个待生效主套餐暂不满足激活条件", + zap.Uint("usage_id", nextMain.ID), + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID), + zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) + return nil } // 查询套餐信息 @@ -397,11 +478,81 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } - // 激活套餐 - // 按 ExpiryBase 选择激活计时基准:from_purchase-购买时起算用 CreatedAt,否则用当前时刻 + return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活") +} + +func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) { + var nextMain model.PackageUsage + err := tx.WithContext(ctx). + Where("status = ?", constants.PackageUsageStatusPending). + Where("master_usage_id IS NULL"). + Where(carrierType+"_id = ?", carrierID). + Order("priority ASC, created_at ASC"). + First(&nextMain).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, err + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败") + } + return &nextMain, nil +} + +func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { + var count int64 + if err := tx.WithContext(ctx). + Model(&model.PackageUsage{}). + Where("status = ?", constants.PackageUsageStatusActive). + Where("master_usage_id IS NULL"). + Where(carrierType+"_id = ?", carrierID). + Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败") + } + return count > 0, nil +} + +func (s *ActivationService) canActivatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) (bool, error) { + if usage == nil { + return false, nil + } + if !usage.PendingRealnameActivation { + return true, nil + } + return s.isCarrierRealnamed(ctx, tx, carrierType, carrierID) +} + +func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { + switch carrierType { + case constants.AssetTypeIotCard: + var card model.IotCard + if err := tx.WithContext(ctx). + Select("real_name_status"). + First(&card, carrierID).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名状态失败") + } + return card.RealNameStatus == constants.RealNameStatusVerified, nil + case constants.AssetTypeDevice: + var count int64 + subQuery := tx.WithContext(ctx). + Model(&model.DeviceSimBinding{}). + Select("iot_card_id"). + Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound) + if err := tx.WithContext(ctx). + Model(&model.IotCard{}). + Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified). + Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名状态失败") + } + return count > 0, nil + default: + return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") + } +} + +func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error { var activatedAt time.Time if pkg.ExpiryBase == "from_purchase" { - activatedAt = nextMain.CreatedAt + activatedAt = usage.CreatedAt } else { activatedAt = now } @@ -412,23 +563,24 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor // 更新套餐使用记录 updates := map[string]interface{}{ - "status": constants.PackageUsageStatusActive, - "activated_at": activatedAt, - "expires_at": expiresAt, + "status": constants.PackageUsageStatusActive, + "pending_realname_activation": false, + "activated_at": activatedAt, + "expires_at": expiresAt, } if nextResetAt != nil { updates["next_reset_at"] = *nextResetAt } - if err := tx.Model(&nextMain).Updates(updates).Error; err != nil { + if err := tx.Model(usage).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败") } - s.syncCarrierStatusActivated(ctx, tx, &nextMain, carrierType, carrierID) + s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) - s.logger.Info("排队主套餐已激活", - zap.Uint("usage_id", nextMain.ID), - zap.Uint("package_id", nextMain.PackageID), + s.logger.Info(logMessage, + zap.Uint("usage_id", usage.ID), + zap.Uint("package_id", usage.PackageID), zap.Time("activated_at", activatedAt), zap.Time("expires_at", expiresAt)) diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index cbabd85..fcfb7f2 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -1,6 +1,6 @@ // Package refund 提供退款申请的业务逻辑服务 // 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理 -// 审批通过后异步执行佣金回扣和资产世代重置 +// 审批通过后异步执行佣金回扣和退款后资产处理 package refund import ( @@ -26,7 +26,7 @@ import ( ) // Service 退款业务服务 -// 负责退款申请的 CRUD、审批流程、佣金回扣和资产世代重置 +// 负责退款申请的 CRUD、审批流程、佣金回扣和退款后资产处理 type Service struct { db *gorm.DB refundStore *postgres.RefundStore @@ -187,7 +187,7 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er // Approve 审批通过退款申请 // 条件更新 WHERE status=1,设置审批信息 -// 事务提交成功后异步执行佣金回扣和资产重置 +// 事务提交成功后异步执行佣金回扣和退款后资产处理 func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { @@ -249,7 +249,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return err } - // 事务提交成功后,异步执行佣金回扣和资产重置(失败不影响审批结果) + // 事务提交成功后,异步执行佣金回扣和退款后资产处理(失败不影响审批结果) go func() { asyncCtx := context.Background() s.deductAllCommission(asyncCtx, id) @@ -257,7 +257,7 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe go func() { asyncCtx := context.Background() - s.handleAssetReset(asyncCtx, id) + s.handleRefundAssetProcessing(asyncCtx, id) }() return nil @@ -489,23 +489,23 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu return nil } -// handleAssetReset 异步处理资产重置 -// 包括:退款套餐精准失效、停机、世代重置(generation+1)、钱包重建 +// handleRefundAssetProcessing 异步处理退款后的资产状态 +// 包括:退款套餐精准失效、尝试接续待生效主套餐、必要时停机 // 失败仅记录日志,不影响审批结果 -func (s *Service) handleAssetReset(ctx context.Context, refundID uint) { +func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) { logger := s.logger // 查询退款单 var refund model.RefundRequest if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil { - logger.Error("资产重置:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) + logger.Error("退款资产处理:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } // 查询关联订单 var order model.Order if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil { - logger.Error("资产重置:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) + logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } @@ -515,53 +515,73 @@ func (s *Service) handleAssetReset(ctx context.Context, refundID uint) { switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil { - logger.Error("资产重置:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID)) + logger.Error("退款资产处理:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID)) return } assetType = "iot_card" assetID = *order.IotCardID case model.OrderTypeDevice: if order.DeviceID == nil { - logger.Error("资产重置:设备订单缺少 device_id", zap.Uint("order_id", order.ID)) + logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID)) return } assetType = "device" assetID = *order.DeviceID default: - logger.Error("资产重置:未知订单类型", zap.String("order_type", order.OrderType)) + logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType)) return } // 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐) - if s.packageActivationService != nil { - if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil { - fields := []zap.Field{ - zap.String("asset_type", assetType), - zap.Uint("asset_id", assetID), - zap.Uint("order_id", order.ID), - zap.Uint("refund_id", refund.ID), - zap.String("refund_no", refund.RefundNo), - zap.Error(err), - } - if refund.PackageUsageID != nil { - fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID)) - } - logger.Error("资产重置:退款套餐精准失效失败", fields...) - } + if s.packageActivationService == nil { + logger.Error("退款资产处理:套餐激活服务未注入", + zap.Uint("refund_id", refund.ID), + zap.Uint("order_id", order.ID)) + return } - - // 2. 停机 - s.stopAsset(ctx, assetType, assetID) - - // 3. 世代重置(参照 exchange/service.go) - if err := s.resetAssetGeneration(ctx, assetType, assetID); err != nil { - logger.Error("资产重置:世代重置失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err)) + if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil { + fields := []zap.Field{ + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Uint("order_id", order.ID), + zap.Uint("refund_id", refund.ID), + zap.String("refund_no", refund.RefundNo), + zap.Error(err), + } + if refund.PackageUsageID != nil { + fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID)) + } + logger.Error("退款资产处理:退款套餐精准失效失败", fields...) return } - // 4. 标记退款单资产已重置 + // 2. 尝试按购买顺序接续待生效主套餐 + if _, err := s.packageActivationService.ActivateNextPendingMainPackage(ctx, assetType, assetID); err != nil { + logger.Error("退款资产处理:接续激活待生效套餐失败", + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Uint("refund_id", refund.ID), + zap.Error(err)) + return + } + + hasActiveMain, err := s.packageActivationService.HasActiveMainPackage(ctx, assetType, assetID) + if err != nil { + logger.Error("退款资产处理:查询生效主套餐失败", + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Uint("refund_id", refund.ID), + zap.Error(err)) + return + } + if !hasActiveMain { + // 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。 + s.stopAsset(ctx, assetType, assetID) + } + + // 4. 标记退款后资产处理已完成 if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil { - logger.Error("资产重置:更新重置标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) + logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) } } @@ -574,132 +594,24 @@ func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) // 单卡停机:需要先查卡获取 iccid card, err := s.iotCardStore.GetByID(ctx, assetID) if err != nil { - logger.Error("资产重置:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err)) + logger.Error("退款资产处理:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err)) return } if s.stopResumeService != nil { if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { - logger.Error("资产重置:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) + logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) } } case "device": // 设备停机 if s.deviceService != nil { if _, err := s.deviceService.StopDevice(ctx, assetID); err != nil { - logger.Error("资产重置:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err)) + logger.Error("退款资产处理:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err)) } } } } -// resetAssetGeneration 执行资产世代重置 -// 参照 exchange/service.go 实现: -// 1. generation+1, asset_status=1, 清零累计充值和佣金标记 -// 2. 清理个人客户绑定 -// 3. 删除旧钱包、创建新空钱包 -func (s *Service) resetAssetGeneration(ctx context.Context, assetType string, assetID uint) error { - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - now := time.Now() - - if assetType == "iot_card" { - var card model.IotCard - if err := tx.Where("id = ?", assetID).First(&card).Error; err != nil { - return fmt.Errorf("查询卡信息失败: %w", err) - } - - // 世代+1, 重置状态和累计数据 - if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ - "generation": card.Generation + 1, - "asset_status": 1, - "accumulated_recharge_by_series": "{}", - "first_recharge_triggered_by_series": "{}", - "updated_at": now, - }).Error; err != nil { - return fmt.Errorf("重置卡世代失败: %w", err) - } - - // 清理个人客户绑定(按 virtual_no) - if card.VirtualNo != "" { - if err := tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { - return fmt.Errorf("清理个人客户绑定失败: %w", err) - } - } - - // 删除旧钱包、创建新空钱包 - if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil { - return fmt.Errorf("清理旧钱包失败: %w", err) - } - - shopTag := uint(0) - if card.ShopID != nil { - shopTag = *card.ShopID - } - if err := tx.Create(&model.AssetWallet{ - ResourceType: constants.ExchangeAssetTypeIotCard, - ResourceID: card.ID, - Balance: 0, - FrozenBalance: 0, - Currency: "CNY", - Status: 1, - Version: 0, - ShopIDTag: shopTag, - }).Error; err != nil { - return fmt.Errorf("创建新钱包失败: %w", err) - } - - return nil - } - - // 设备类型 - var device model.Device - if err := tx.Where("id = ?", assetID).First(&device).Error; err != nil { - return fmt.Errorf("查询设备失败: %w", err) - } - - // 世代+1, 重置状态和累计数据 - if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ - "generation": device.Generation + 1, - "asset_status": 1, - "accumulated_recharge_by_series": "{}", - "first_recharge_triggered_by_series": "{}", - "updated_at": now, - }).Error; err != nil { - return fmt.Errorf("重置设备世代失败: %w", err) - } - - // 清理个人客户绑定(按 virtual_no) - if device.VirtualNo != "" { - if err := tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { - return fmt.Errorf("清理个人客户绑定失败: %w", err) - } - } - - // 删除旧钱包、创建新空钱包 - if err := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil { - return fmt.Errorf("清理旧钱包失败: %w", err) - } - - shopTag := uint(0) - if device.ShopID != nil { - shopTag = *device.ShopID - } - if err := tx.Create(&model.AssetWallet{ - ResourceType: constants.ExchangeAssetTypeDevice, - ResourceID: device.ID, - Balance: 0, - FrozenBalance: 0, - Currency: "CNY", - Status: 1, - Version: 0, - ShopIDTag: shopTag, - }).Error; err != nil { - return fmt.Errorf("创建新钱包失败: %w", err) - } - - return nil - }) -} - // generateRefundNo 生成退款单号 // 格式:RF + 日期时间 + 6位随机数 func generateRefundNo() string { diff --git a/migrations/000142_update_refund_asset_processing_comment.down.sql b/migrations/000142_update_refund_asset_processing_comment.down.sql new file mode 100644 index 0000000..70cd5ee --- /dev/null +++ b/migrations/000142_update_refund_asset_processing_comment.down.sql @@ -0,0 +1 @@ +COMMENT ON COLUMN tb_refund_request.asset_reset IS '资产是否已重置(套餐停用+停机+世代重置)'; diff --git a/migrations/000142_update_refund_asset_processing_comment.up.sql b/migrations/000142_update_refund_asset_processing_comment.up.sql new file mode 100644 index 0000000..2d54672 --- /dev/null +++ b/migrations/000142_update_refund_asset_processing_comment.up.sql @@ -0,0 +1 @@ +COMMENT ON COLUMN tb_refund_request.asset_reset IS '退款后资产处理是否完成'; diff --git a/openspec/changes/archive/2026-05-11-add-agent-open-api/.openspec.yaml b/openspec/changes/archive/2026-05-11-add-agent-open-api/.openspec.yaml new file mode 100644 index 0000000..81cd71f --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-agent-open-api/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/archive/2026-05-11-add-agent-open-api/design.md b/openspec/changes/archive/2026-05-11-add-agent-open-api/design.md new file mode 100644 index 0000000..ac7c04e --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-agent-open-api/design.md @@ -0,0 +1,176 @@ +## Context + +当前系统已经具备代理套餐列表、后台钱包支付订单、主钱包流水、卡实名/状态和套餐使用记录等能力,但这些能力主要挂在 `/api/admin` 或 C 端接口下,认证方式依赖后台 JWT 或个人客户 JWT。代理商第三方系统对接需要长期稳定的开放接口,并且调用方需要通过请求签名保证参数未被篡改。 + +本次设计新增 `/api/open/v1` 代理开放接口域。开放接口不直接暴露后台 JWT,也不改变现有后台接口契约,而是通过独立中间件把校验通过的后台代理账号转换为内部代理上下文,再复用已有 Service 能力。所有业务实现仍遵循 Handler → Service → Store → Model 分层。 + +约束: +- 复用后台代理账号密码作为当前阶段的对接凭据。 +- 后台账号密码以 bcrypt 存储,服务端不能反推出明文密码。 +- 流量展示不能暴露虚流量、倍率、停机阈值等内部语义。 +- 开放接口暂只考虑单卡查询;套餐购买接口支持多卡输入,但按单卡分别下单。 +- 不引入新依赖,签名使用 Go 标准库 HMAC-SHA256。 + +## Goals / Non-Goals + +**Goals:** + +- 提供代理开放接口认证中间件,校验账号、密码、时间戳、随机串和签名。 +- 提供卡流量查询、卡状态查询、卡实名状态查询、代理套餐列表、钱包套餐购买、钱包余额、钱包流水接口。 +- 将开放接口请求映射为代理账号上下文,使现有 `middleware.CanManageShop`、套餐分配过滤和钱包扣款逻辑继续生效。 +- 流量字段只对外展示代理可理解的真实业务口径:总流量、已用流量、剩余流量。 +- 套餐购买按卡独立处理,允许部分成功并返回失败卡列表。 +- 保持分页、错误码、响应格式、日志和文档生成的一致性。 + +**Non-Goals:** + +- 不提供设备开放接口。 +- 不支持单次购买多个套餐编码。 +- 不引入独立 API key / API secret;当前阶段只复用后台代理账号密码。 +- 不暴露虚流量字段、展示倍率、停机阈值或内部扣减阈值。 +- 不改造现有后台 `/api/admin` 接口。 +- 不讨论或新增自动化测试文件。 + +## Decisions + +### 决策 1:新增独立开放接口域 `/api/open/v1` + +选择:新增 `internal/routes/open.go` 注册 `/api/open/v1` 路由组,并新增 `internal/handler/openapi` 包承载 Handler。 + +理由: +- 与后台管理端和 C 端接口隔离,避免第三方系统依赖后台 JWT。 +- 便于统一挂载开放接口签名中间件、访问日志和限流策略。 +- 不改变现有后台接口路径和权限语义。 + +替代方案: +- 直接开放 `/api/admin`:会暴露后台管理语义,且要求三方维护后台 JWT,不适合长期对接。 + +### 决策 2:复用后台代理账号密码,不新增开放接口授权表 + +选择:开放接口认证直接复用后台账号表和店铺数据权限。只要后台账号存在、账号类型为代理账号、账号状态启用,并且账号已绑定有效代理店铺,即默认允许调用 `/api/open/v1`。不新增 `tb_agent_open_api_account` 或其他开放接口账号授权表。 + +理由: +- 满足当前“复用后台代理账号密码”的低接入成本。 +- 业务口径明确为所有代理店铺都能使用开放接口,不需要平台额外维护启用记录。 +- 继续使用现有账号禁用和店铺状态作为统一入口控制,避免同一代理账号出现后台可用但开放接口不可用的二义性。 +- 不需要保存明文密码或第二套密钥。 + +替代方案: +- 新增开放接口授权表:增加配置和运维成本,且与“所有代理店铺都能使用开放接口”的要求冲突。 +- 新增 API secret:更安全,但当前用户已确认先复用后台账号密码。 + +### 决策 3:签名中间件先校验密码,再用本次请求密码重算 HMAC + +选择:客户端在 Header 传入: + +```text +X-Agent-Account +X-Agent-Password +X-Agent-Timestamp +X-Agent-Nonce +X-Agent-Sign +``` + +签名原文: + +```text +METHOD + "\n" + +PATH + "\n" + +canonical_query_without_sign + "\n" + +SHA256(raw_body) + "\n" + +timestamp + "\n" + +nonce + "\n" + +account +``` + +签名值: + +```text +hex(HMAC-SHA256(password, sign_payload)) +``` + +服务端流程: +1. 按账号查询后台账号。 +2. 校验账号类型必须为代理账号,账号状态必须启用,并且已绑定启用状态的代理店铺。 +3. 使用 bcrypt 校验 `X-Agent-Password`。 +4. 校验时间戳在允许时间窗内。 +5. 使用 Redis `SET NX` 记录 nonce,防止重放。 +6. 使用本次请求密码重算 HMAC 并进行常量时间比较。 +7. 注入 `ContextKeyUserID`、`ContextKeyUserType`、`ContextKeyShopID`、`ContextKeySubordinateShopIDs` 等代理上下文。 + +理由: +- 由于数据库中没有明文密码,服务端只能用请求中的明文密码重算签名。 +- HMAC 覆盖 method、path、query、body、timestamp、nonce、account,能发现请求参数或 body 被篡改。 +- Redis nonce 防止有效时间窗内重放。 + +替代方案: +- `MD5(account + params + password)`:实现简单但抗碰撞和密钥使用方式不如 HMAC。 +- 服务端保存可逆密码:不符合安全要求。 + +安全约束: +- 开放接口必须部署在 HTTPS 下;否则后台密码会被链路暴露。 +- 日志、访问日志和错误日志必须脱敏 `X-Agent-Password`、`X-Agent-Sign`。 + +### 决策 4:开放接口业务服务做编排,核心能力复用现有 Service + +选择:新增 `internal/service/agent_open_api.Service` 作为编排层,依赖注入现有服务和 Store: +- 资产/卡解析:复用资产解析或 IoT 卡 Store 的标识符查询能力。 +- 套餐列表:复用 `package.Service.List` 的代理上下文过滤。 +- 钱包购买:复用 `order.Service.CreateAdminOrder`,为每张卡构造 `CreateAdminOrderRequest`。 +- 主钱包流水:复用 `ShopCommissionService.ListMainWalletTransactions` 或抽取其查询逻辑。 +- 主钱包余额:复用 `AgentWalletStore.GetMainWallet`。 + +理由: +- 保持 Handler 只负责参数解析和响应。 +- 避免复制后台复杂的代理套餐权限、钱包扣款、套餐激活和分佣规则。 +- 让开放接口与后台业务规则保持一致。 + +替代方案: +- 为开放接口重新实现购买逻辑:容易绕过幂等、钱包乐观锁、套餐激活和佣金规则。 + +### 决策 5:流量展示只输出真实业务口径,不输出内部虚流量口径 + +选择:卡流量查询按 `PackageUsage` 快照计算: +- `total_flow_mb = data_limit_mb` +- `used_flow_mb = min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)` +- `remaining_flow_mb = max(total_flow_mb - used_flow_mb, 0)` + +对外字段使用 `total_flow_mb`、`used_flow_mb`、`remaining_flow_mb`,不出现 `virtual_*`、`ratio`、`threshold`、`reduction`。 + +理由: +- 符合“让代理觉得这就是真正的流量”的业务要求。 +- 复用现有快照字段,避免套餐配置变更影响历史使用记录。 +- 不泄露系统内部停机保护阈值。 + +替代方案: +- 直接返回 `BuildTrafficMetrics` 的所有字段:会暴露虚流量字段,不符合要求。 + +### 决策 6:多卡购买按单卡独立下单,允许部分成功 + +选择:`POST /api/open/v1/wallet/package-orders` 接收 `card_nos[]` 和单个 `package_code`。Service 按卡逐个解析套餐和创建订单,每张卡独立返回成功订单号或失败原因。 + +理由: +- 用户已确认允许部分成功。 +- 现有订单模型以单卡/设备为订单载体,逐卡复用最稳。 +- 单卡失败不影响其他卡购买,便于代理侧重试失败卡。 + +替代方案: +- 一个批次事务全部成功或全部失败:一张坏卡会拖住整批,不符合当前要求。 + +## Risks / Trade-offs + +- [Risk] 复用后台密码要求客户端传明文密码 → Mitigation:强制 HTTPS 部署,日志脱敏,后续可演进为独立 API secret。 +- [Risk] bcrypt 校验每次请求都有成本 → Mitigation:开放接口调用量初期可接受;后续可用短 TTL Redis 认证缓存,但必须谨慎避免密码变更后缓存过久。 +- [Risk] 多卡购买部分成功导致代理侧需要处理批次差异 → Mitigation:响应固定返回 `success_count`、`failed_count`、`orders`、`failed_cards`。 +- [Risk] 流量换算字段若重复手写可能与现有逻辑不一致 → Mitigation:在模型或服务层抽取开放接口专用展示方法,统一复用快照计算。 +- [Risk] 签名 canonical 规则双方理解不一致 → Mitigation:在接口文档中提供明确排序、空 body、数组参数和 JSON body 签名示例。 + +## Migration Plan + +1. 不新增数据库迁移,直接复用现有 `tb_account`、`tb_shop`、卡、套餐和钱包数据。 +2. 发布代码后,所有启用状态的代理账号可按签名规则调用开放接口。 +3. 若需要回滚,禁用 `/api/open/v1` 路由或回滚本次代码;已有后台功能不受影响。 + +## Open Questions + +无。当前已确认:所有启用代理店铺均可使用开放接口、不新增开放接口账号授权表、流量不暴露虚流量、多卡部分成功、复用后台代理账号密码、一次只买一个套餐编码、套餐列表返回生效零售价。 diff --git a/openspec/changes/archive/2026-05-11-add-agent-open-api/proposal.md b/openspec/changes/archive/2026-05-11-add-agent-open-api/proposal.md new file mode 100644 index 0000000..692c5e4 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-agent-open-api/proposal.md @@ -0,0 +1,47 @@ +## Why + +现有代理商需要通过开放接口对接套餐购买、卡状态、流量、实名和预充值钱包数据,但当前能力主要面向后台管理端,依赖后台登录态,不适合直接给第三方系统调用。需要新增一套签名校验的代理开放接口,在不暴露虚流量语义的前提下复用现有代理套餐、钱包支付和卡套餐数据能力。 + +功能 ID:feature-20260511-agent-open-api + +## What Changes + +- 新增代理开放接口域,提供账号密码、请求参数、时间戳、随机串和 `sign` 的调用校验。 +- 新增单卡开放查询接口:卡流量查询、卡状态查询、卡实名状态查询。 +- 新增代理开放套餐列表接口,分页返回代理可购买套餐,复用后台代理套餐列表的权限过滤,并返回生效零售价。 +- 新增代理预充值钱包开放接口:余额查询、流水列表查询。 +- 新增代理钱包套餐购买开放接口:一次只允许一个套餐编码,支持多张卡分开购买,允许部分成功并返回失败卡原因。 +- 流量展示对外只返回真实业务口径字段:总流量、换算后的已用流量、剩余流量;不暴露虚流量、展示倍率、停机阈值等内部字段。 +- 新增开放接口 DTO、Handler、Service、Store/查询方法和路由注册,继续遵守 Handler → Service → Store → Model 分层。 +- 新增接口文档注册,确保 OpenAPI 文档生成器包含开放接口。 +- 不新增外部依赖,签名使用 Go 标准库 HMAC-SHA256;继续使用 Fiber、GORM、Viper、Zap、Redis。 + +## Capabilities + +### New Capabilities + +- `agent-open-api`: 定义代理开放接口的认证签名、卡查询、套餐列表、钱包余额/流水和钱包购买套餐能力。 + +### Modified Capabilities + +无。现有后台套餐、订单、钱包和资产查询能力仅作为实现复用点,不改变其既有接口契约。 + +## Impact + +- 影响代码范围: + - `internal/handler/openapi/`:新增代理开放接口 Handler。 + - `internal/service/agent_open_api/`:新增开放接口编排服务,复用套餐、订单、钱包、资产解析等现有服务。 + - `internal/model/dto/`:新增开放接口请求和响应 DTO。 + - `internal/model/`:复用现有账号、店铺、卡、套餐和钱包模型,不新增开放接口账号授权模型。 + - `internal/store/postgres/`:复用现有账号和店铺 Store,必要时补充按套餐编码、卡标识、钱包流水的查询方法。 + - `internal/routes/`:新增 `/api/open/v1` 路由组。 + - `pkg/constants/`:新增开放接口相关常量、Redis nonce 防重放 key 生成函数。 + - `pkg/errors/`:补充开放接口认证、签名、时间戳、重放相关错误码。 + - `cmd/api/docs.go` 与 `cmd/gendocs/main.go`:注册新增 Handler,保证文档生成覆盖。 +- 数据影响: + - 不新增开放接口账号授权表;所有启用状态的代理账号默认可调用开放接口。 + - Redis 记录开放接口 nonce,防止时间窗内重放。 +- 性能影响: + - 列表接口分页,默认 20,最大 100。 + - 签名校验只做轻量 HMAC、bcrypt 校验和 Redis nonce 写入。 + - 流量查询按单卡读取当前/待生效套餐,避免设备级聚合和大范围扫描。 diff --git a/openspec/changes/archive/2026-05-11-add-agent-open-api/specs/agent-open-api/spec.md b/openspec/changes/archive/2026-05-11-add-agent-open-api/specs/agent-open-api/spec.md new file mode 100644 index 0000000..d2c006f --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-agent-open-api/specs/agent-open-api/spec.md @@ -0,0 +1,250 @@ +## ADDED Requirements + +### Requirement: 代理开放接口签名认证 + +系统 SHALL 为 `/api/open/v1` 下所有代理开放接口提供签名认证。调用方 MUST 在请求 Header 中传入 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。系统 MUST 校验后台账号存在、账号类型为代理账号、账号状态启用、已绑定启用状态的代理店铺、密码正确、时间戳在允许时间窗内、nonce 未被使用、签名正确。系统 MUST NOT 依赖独立开放接口账号授权表;所有启用状态的代理账号默认允许调用开放接口。 + +签名原文 SHALL 使用以下格式: + +```text +METHOD + "\n" + +PATH + "\n" + +canonical_query_without_sign + "\n" + +SHA256(raw_body) + "\n" + +timestamp + "\n" + +nonce + "\n" + +account +``` + +签名算法 SHALL 使用 `hex(HMAC-SHA256(password, sign_payload))`。`canonical_query_without_sign` MUST 按参数名升序排列并排除 `sign` 字段。空 body 的 SHA256 MUST 按空字符串计算。 + +响应 MUST 使用统一格式 `{code, msg, data, timestamp}`。认证失败错误码 MUST 在 `pkg/errors/` 定义,不得把 bcrypt、HMAC、数据库底层错误暴露给调用方。 + +#### Scenario: 签名认证通过 + +- **WHEN** 启用状态的代理账号携带正确密码、时间戳、nonce、请求参数和签名调用 `/api/open/v1/cards/status` +- **THEN** 系统通过认证并将代理账号信息写入请求上下文 + +#### Scenario: 参数被篡改 + +- **WHEN** 请求中的 query 或 body 在签名后被修改 +- **THEN** 系统验签失败并返回签名无效错误 + +#### Scenario: 密码错误 + +- **WHEN** 调用方传入的 `X-Agent-Password` 与后台代理账号密码不匹配 +- **THEN** 系统返回认证失败错误,不继续执行业务逻辑 + +#### Scenario: nonce 重放 + +- **WHEN** 同一账号在有效时间窗内重复使用相同 `X-Agent-Nonce` +- **THEN** 系统返回重复请求错误,并拒绝执行业务逻辑 + +#### Scenario: 代理账号未启用 + +- **WHEN** 后台代理账号存在但账号状态为禁用 +- **THEN** 系统返回认证失败错误,不继续执行业务逻辑 + +--- + +### Requirement: 开放接口代理上下文与数据权限 + +系统 SHALL 将通过签名认证的代理账号映射为内部代理上下文。上下文 MUST 包含 `ContextKeyUserID`、`ContextKeyUserType=UserTypeAgent`、`ContextKeyShopID`、`ContextKeySubordinateShopIDs`。开放接口业务查询 MUST 只允许访问该代理店铺及其下级店铺范围内的卡、套餐、订单和钱包数据。 + +#### Scenario: 访问自己店铺的卡 + +- **WHEN** 代理开放接口账号查询自己店铺名下卡的流量、状态或实名状态 +- **THEN** 系统返回该卡数据 + +#### Scenario: 访问下级店铺的卡 + +- **WHEN** 代理开放接口账号查询下级店铺名下卡的流量、状态或实名状态 +- **THEN** 系统返回该卡数据 + +#### Scenario: 访问无权限卡 + +- **WHEN** 代理开放接口账号查询非自己及非下级店铺名下的卡 +- **THEN** 系统返回 `CodeForbidden`,消息为“无权限操作该资源或资源不存在” + +#### Scenario: 企业账号调用开放接口 + +- **WHEN** 企业账号使用正确后台密码调用代理开放接口 +- **THEN** 系统拒绝调用,因为开放接口仅允许代理账号 + +--- + +### Requirement: 卡流量查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/traffic` 接口,按 `card_no` 查询单卡流量和套餐信息。`card_no` MUST 支持 ICCID、虚拟号、MSISDN 中任一种可解析为 IoT 卡的标识。接口 MUST 只返回单卡套餐,不返回设备级套餐信息。 + +响应 data MUST 至少包含: +- `card_no` +- `active_remaining_flow_mb`:当前生效套餐剩余流量 +- `active_total_flow_mb`:当前生效套餐总流量 +- `active_used_flow_mb`:当前生效套餐已使用流量 +- `active_packages`:当前生效套餐列表 +- `pending_packages`:待生效套餐列表 +- `active_expires_at`:当前生效套餐过期时间 + +`active_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`expires_at`、`start_at`、`package_name`、`series_name`、`package_type`、`package_type_name`。`pending_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`package_name`、`series_name`、`package_type`、`package_type_name`、`valid_days`、`priority`。 + +流量口径 MUST 满足: +- 总流量使用套餐真总量快照 `data_limit_mb` +- 已用流量使用换算后的展示已用量 `min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)` +- 剩余流量使用 `max(data_limit_mb - used_flow_mb, 0)` +- 响应 MUST NOT 包含 `virtual_*`、`ratio`、`threshold`、`reduction`、虚流量、停机阈值等内部字段或文案 + +#### Scenario: 查询有生效套餐的卡流量 + +- **WHEN** 代理查询自己名下单卡,且该卡存在生效中的正式包和加油包 +- **THEN** 系统返回当前生效套餐列表、总流量、换算后已用流量、剩余流量和当前过期时间 + +#### Scenario: 查询待生效套餐 + +- **WHEN** 代理查询单卡流量,且该卡存在待生效套餐 +- **THEN** 系统在 `pending_packages` 中返回套餐编码、真总流量、套餐名称、系列名称、套餐类型、有效天数和生效优先级 + +#### Scenario: 不暴露虚流量 + +- **WHEN** 卡的套餐启用了虚流量 +- **THEN** 响应只返回真总量、换算后已用量和剩余量,不返回任何虚流量字段或内部倍率字段 + +#### Scenario: 查询无套餐的卡 + +- **WHEN** 代理查询有权限但当前无生效或待生效套餐的卡 +- **THEN** 系统返回空套餐列表,流量数值为 0 + +--- + +### Requirement: 卡状态查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/status` 接口,按 `card_no` 查询单卡状态。响应 data MUST 包含 `card_no`、`card_status`、`card_status_name`、`stop_reason`、`stop_reason_name`。卡状态 MUST 按现有网络状态映射为停机/正常:`network_status=0` 返回停机,`network_status=1` 返回正常。 + +#### Scenario: 查询正常卡状态 + +- **WHEN** 代理查询有权限且 `network_status=1` 的卡 +- **THEN** 系统返回 `card_status=normal`,`card_status_name=正常` + +#### Scenario: 查询停机卡状态 + +- **WHEN** 代理查询有权限且 `network_status=0` 的卡 +- **THEN** 系统返回 `card_status=stopped`、`card_status_name=停机` 以及停机原因 + +--- + +### Requirement: 卡实名状态查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/realname-status` 接口,按 `card_no` 查询单卡实名状态。响应 data MUST 包含 `card_no` 和 `is_realnamed`。当 `real_name_status` 为已实名常量时,`is_realnamed` MUST 为 `true`,否则为 `false`。 + +#### Scenario: 查询已实名卡 + +- **WHEN** 代理查询有权限且 `real_name_status=1` 的卡 +- **THEN** 系统返回 `is_realnamed=true` + +#### Scenario: 查询未实名卡 + +- **WHEN** 代理查询有权限且 `real_name_status=0` 的卡 +- **THEN** 系统返回 `is_realnamed=false` + +--- + +### Requirement: 代理套餐列表开放接口 + +系统 SHALL 提供 `GET /api/open/v1/packages` 接口,分页返回代理可购买套餐列表。接口 MUST 复用后台代理套餐列表的权限过滤:只返回当前代理已分配且启用的非赠送套餐,并按代理自己的上下架状态过滤。接口 MUST 支持 `page`、`page_size`、`package_type`、`series_id`、`package_name` 查询参数,`page_size` 最大 100。 + +响应每项 MUST 包含:`package_id`、`package_code`、`package_name`、`series_name`、`package_type`、`package_type_name`、`cost_price`、`retail_price`、`total_flow_mb`、`valid_days`。`retail_price` MUST 返回代理配置的生效零售价;当代理零售价未配置时,按现有策略回退为成本价。 + +#### Scenario: 代理查询套餐列表 + +- **WHEN** 代理调用 `GET /api/open/v1/packages?page=1&page_size=20` +- **THEN** 系统分页返回该代理可购买套餐,并包含成本价和生效零售价 + +#### Scenario: 过滤未分配套餐 + +- **WHEN** 套餐未分配给当前代理 +- **THEN** 该套餐不会出现在开放套餐列表中 + +#### Scenario: 生效零售价回退 + +- **WHEN** 代理套餐分配记录未配置零售价 +- **THEN** 响应 `retail_price` 返回成本价 + +--- + +### Requirement: 代理钱包套餐购买开放接口 + +系统 SHALL 提供 `POST /api/open/v1/wallet/package-orders` 接口,允许代理使用预充值主钱包为指定卡购买套餐。请求 body MUST 包含 `card_nos` 和 `package_code`。`package_code` MUST 只允许一个,`card_nos` MUST 至少 1 个,最多 100 个。 + +系统 MUST 按卡独立创建后台钱包订单,复用后台代理钱包支付购买逻辑。每张卡购买成功后 MUST 返回订单号;购买失败时 MUST 返回该卡失败原因。任一卡失败 MUST NOT 回滚其他已成功卡的订单。 + +响应 data MUST 包含:`batch_no`、`package_code`、`success_count`、`failed_count`、`orders`、`failed_cards`。`orders` 每项 MUST 包含 `card_no`、`order_no`。`failed_cards` 每项 MUST 包含 `card_no`、`code`、`message`。 + +#### Scenario: 多卡部分成功 + +- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败 +- **THEN** 系统返回 `success_count=2`、`failed_count=1`、2 条订单号和 1 条失败卡原因 + +#### Scenario: 单套餐编码限制 + +- **WHEN** 请求包含多个套餐编码或套餐编码为空 +- **THEN** 系统返回参数错误,不创建订单 + +#### Scenario: 钱包余额不足 + +- **WHEN** 某张卡购买套餐时代理主钱包余额不足 +- **THEN** 该卡返回失败原因“余额不足”,其他卡已成功订单不受影响 + +#### Scenario: 复用后台钱包支付 + +- **WHEN** 某张卡购买套餐成功 +- **THEN** 系统创建已支付订单、扣减代理主钱包余额、创建主钱包扣款流水并激活套餐 + +--- + +### Requirement: 代理预充值钱包余额开放接口 + +系统 SHALL 提供 `GET /api/open/v1/wallet/balance` 接口,查询当前代理预充值主钱包余额。响应 data MUST 包含 `balance`、`frozen_balance`、`available_balance`、`currency`。当主钱包不存在时,系统 MUST 返回 0 余额,不报错。 + +#### Scenario: 查询主钱包余额 + +- **WHEN** 代理主钱包余额为 10000 分、冻结余额为 2000 分 +- **THEN** 系统返回 `balance=10000`、`frozen_balance=2000`、`available_balance=8000` + +#### Scenario: 主钱包不存在 + +- **WHEN** 代理从未充值且主钱包不存在 +- **THEN** 系统返回 0 余额和币种 CNY + +--- + +### Requirement: 代理预充值钱包流水开放接口 + +系统 SHALL 提供 `GET /api/open/v1/wallet/transactions` 接口,分页返回当前代理预充值主钱包流水。接口 MUST 与后台 `GET /api/admin/shops/:shop_id/main-wallet/transactions` 的返回字段保持一致,支持 `page`、`page_size`、`transaction_type`、`start_date`、`end_date` 查询参数,结果按 `created_at DESC` 排序。 + +响应每项 MUST 包含:`id`、`transaction_type`、`transaction_subtype`、`amount`、`balance_before`、`balance_after`、`remark`、`created_at`。 + +#### Scenario: 查询钱包流水 + +- **WHEN** 代理调用 `GET /api/open/v1/wallet/transactions?page=1&page_size=20` +- **THEN** 系统返回当前代理主钱包流水,字段与后台主钱包流水接口一致 + +#### Scenario: 按日期过滤流水 + +- **WHEN** 代理传入 `start_date` 和 `end_date` +- **THEN** 系统只返回该日期范围内的主钱包流水 + +--- + +### Requirement: 开放接口文档和路由注册 + +系统 SHALL 将代理开放接口注册到路由总入口和 OpenAPI 文档生成器。新增 Handler 后 MUST 同步更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `bootstrap.Handlers` 初始化,确保接口文档中出现 `/api/open/v1` 下所有开放接口。 + +#### Scenario: 文档生成覆盖开放接口 + +- **WHEN** 执行文档生成命令 +- **THEN** 生成的 OpenAPI 文档包含代理开放接口认证说明和全部 `/api/open/v1` 路由 + +#### Scenario: 路由注册覆盖开放接口 + +- **WHEN** API 服务启动 +- **THEN** `/api/open/v1/cards/traffic`、`/api/open/v1/cards/status`、`/api/open/v1/cards/realname-status`、`/api/open/v1/packages`、`/api/open/v1/wallet/package-orders`、`/api/open/v1/wallet/balance`、`/api/open/v1/wallet/transactions` 均已注册 diff --git a/openspec/changes/archive/2026-05-11-add-agent-open-api/tasks.md b/openspec/changes/archive/2026-05-11-add-agent-open-api/tasks.md new file mode 100644 index 0000000..b835fa3 --- /dev/null +++ b/openspec/changes/archive/2026-05-11-add-agent-open-api/tasks.md @@ -0,0 +1,51 @@ +## 1. 数据模型、常量和错误码 + +- [x] 1.1 不新增开放接口账号授权表;所有启用的代理账号默认可调用开放接口,继续复用 `tb_account` 和店铺数据权限。 +- [x] 1.2 复用现有 `Account` 与 `Shop` 模型,不新增开放接口授权模型。 +- [x] 1.3 复用现有 `AccountStore` 和 `ShopStore` 查询账号与下级店铺,不新增开放接口授权 Store。 +- [x] 1.4 在 `pkg/constants/` 新增开放接口 Header 名称、签名时间窗、批次号前缀、Redis nonce key 生成函数等常量,并为所有常量添加中文注释。 +- [x] 1.5 在 `pkg/errors/` 新增开放接口认证、签名无效、时间戳无效、nonce 重放等错误码和中文错误消息。 +- [x] 1.6 验证:运行 `gofmt` 覆盖新增 Go 文件,运行 `go build ./cmd/api` 确认基础模型与常量可编译。 + +## 2. 开放接口签名认证中间件 + +- [x] 2.1 新增开放接口认证中间件,解析 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。 +- [x] 2.2 实现签名原文构造:方法、路径、排序后的 query、body SHA256、时间戳、nonce、账号,排除 `sign` 字段。 +- [x] 2.3 使用 bcrypt 校验后台账号密码,使用 HMAC-SHA256 重算签名,并用常量时间比较校验签名。 +- [x] 2.4 使用 Redis `SET NX` 写入开放接口 nonce,按签名时间窗设置 TTL,拒绝重复 nonce。 +- [x] 2.5 校验账号类型、账号状态和关联店铺状态;所有启用代理账号默认允许调用开放接口。 +- [x] 2.6 认证通过后注入代理上下文:用户ID、用户类型、店铺ID、下级店铺ID列表,保证现有权限过滤可复用。 +- [x] 2.7 对开放接口密码、签名、nonce 等敏感字段做日志脱敏,避免访问日志和错误日志泄露。 +- [ ] 2.8 验证:使用构造好的签名请求和篡改参数请求,通过 curl 手动确认成功、签名失败、nonce 重放、密码错误、非代理账号、禁用账号等分支。 + +## 3. DTO 与开放接口业务服务 + +- [x] 3.1 新增开放接口 DTO:卡流量、卡状态、实名状态、套餐列表、钱包购买、钱包余额、钱包流水的请求和响应结构,description 使用中文。 +- [x] 3.2 新增 `agent_open_api.Service`,通过结构体字段注入账号、店铺、卡、套餐、钱包、订单等现有 Store/Service。 +- [x] 3.3 实现单卡标识解析逻辑:支持 ICCID、虚拟号、MSISDN,必须解析为 IoT 卡,设备标识返回参数错误。 +- [x] 3.4 实现卡流量查询:查询生效和待生效 `PackageUsage`,补充套餐编码和系列名称,只返回真实业务口径流量字段,不返回虚流量内部字段。 +- [x] 3.5 实现卡状态查询:按 `network_status` 映射正常/停机,并返回停机原因。 +- [x] 3.6 实现卡实名状态查询:按 `real_name_status` 返回 `is_realnamed`。 +- [x] 3.7 实现代理套餐列表:复用代理套餐过滤和生效零售价逻辑,分页返回套餐ID、编码、名称、系列、类型、成本价、生效零售价、总流量、有效天数。 +- [x] 3.8 实现预充值钱包余额查询:主钱包不存在时返回 0 余额,存在时返回余额、冻结余额、可用余额和币种。 +- [x] 3.9 实现预充值钱包流水查询:复用主钱包流水字段和过滤条件,只查询当前代理主钱包。 +- [x] 3.10 实现钱包套餐购买:按单个套餐编码解析套餐,逐张卡调用后台钱包订单创建逻辑,记录每张卡成功订单号或失败原因,允许部分成功。 +- [ ] 3.11 验证:使用 PostgreSQL MCP 或 SQL 查询核对套餐、卡、钱包和订单数据;使用 curl 手动核对所有开放接口响应字段和错误分支。 + +## 4. Handler、路由和依赖注入 + +- [x] 4.1 新增 `internal/handler/openapi` 包,实现开放接口 Handler,Handler 只负责参数解析、校验和统一响应。 +- [x] 4.2 新增 `internal/routes/open.go`,注册 `/api/open/v1` 路由组和全部 7 个开放接口。 +- [x] 4.3 更新 `internal/routes/routes.go`,在总路由入口挂载开放接口路由和签名认证中间件。 +- [x] 4.4 更新 `internal/bootstrap/types.go`、`internal/bootstrap/services.go`、`internal/bootstrap/handlers.go`,完成开放接口 Service、Store、Handler 的结构体字段注入。 +- [x] 4.5 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `bootstrap.Handlers` 初始化,确保新增 Handler 进入文档生成器。 +- [x] 4.6 验证:运行 `go build ./cmd/api` 和 `go build ./cmd/gendocs`,确认路由、依赖注入和文档入口可编译。 + +## 5. 文档、索引和最终验证 + +- [x] 5.1 新增 `docs/agent-open-api/功能总结.md`,说明签名规则、字段口径、接口列表、错误码和对接示例。 +- [x] 5.2 更新 README 或接口文档索引,加入代理开放接口文档入口。 +- [x] 5.3 运行 OpenAPI 文档生成命令,确认 `/api/open/v1` 下全部接口出现在文档中。 +- [x] 5.4 运行 `openspec validate add-agent-open-api --strict`,修正 OpenSpec 结构问题。 +- [x] 5.5 运行 `ccc index` 更新代码索引。 +- [ ] 5.6 最终验证:手动执行签名成功、签名失败、卡流量、卡状态、实名状态、套餐列表、钱包余额、钱包流水、多卡部分成功购买、余额不足购买等场景,并记录验证结果。 diff --git a/openspec/specs/agent-open-api/spec.md b/openspec/specs/agent-open-api/spec.md new file mode 100644 index 0000000..2b05c3d --- /dev/null +++ b/openspec/specs/agent-open-api/spec.md @@ -0,0 +1,250 @@ +# agent-open-api Specification + +## Purpose +定义代理开放接口的签名认证、数据权限、卡查询、套餐列表、预充值钱包和钱包购买能力,确保第三方代理系统可以在受控权限内对接业务接口。 + +## Requirements + +### Requirement: 代理开放接口签名认证 + +系统 SHALL 为 `/api/open/v1` 下所有代理开放接口提供签名认证。调用方 MUST 在请求 Header 中传入 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。系统 MUST 校验后台账号存在、账号类型为代理账号、账号状态启用、已绑定启用状态的代理店铺、密码正确、时间戳在允许时间窗内、nonce 未被使用、签名正确。系统 MUST NOT 依赖独立开放接口账号授权表;所有启用状态的代理账号默认允许调用开放接口。 + +签名原文 SHALL 使用以下格式: + +```text +METHOD + "\n" + +PATH + "\n" + +canonical_query_without_sign + "\n" + +SHA256(raw_body) + "\n" + +timestamp + "\n" + +nonce + "\n" + +account +``` + +签名算法 SHALL 使用 `hex(HMAC-SHA256(password, sign_payload))`。`canonical_query_without_sign` MUST 按参数名升序排列并排除 `sign` 字段。空 body 的 SHA256 MUST 按空字符串计算。 + +响应 MUST 使用统一格式 `{code, msg, data, timestamp}`。认证失败错误码 MUST 在 `pkg/errors/` 定义,不得把 bcrypt、HMAC、数据库底层错误暴露给调用方。 + +#### Scenario: 签名认证通过 + +- **WHEN** 启用状态的代理账号携带正确密码、时间戳、nonce、请求参数和签名调用 `/api/open/v1/cards/status` +- **THEN** 系统通过认证并将代理账号信息写入请求上下文 + +#### Scenario: 参数被篡改 + +- **WHEN** 请求中的 query 或 body 在签名后被修改 +- **THEN** 系统验签失败并返回签名无效错误 + +#### Scenario: 密码错误 + +- **WHEN** 调用方传入的 `X-Agent-Password` 与后台代理账号密码不匹配 +- **THEN** 系统返回认证失败错误,不继续执行业务逻辑 + +#### Scenario: nonce 重放 + +- **WHEN** 同一账号在有效时间窗内重复使用相同 `X-Agent-Nonce` +- **THEN** 系统返回重复请求错误,并拒绝执行业务逻辑 + +#### Scenario: 代理账号未启用 + +- **WHEN** 后台代理账号存在但账号状态为禁用 +- **THEN** 系统返回认证失败错误,不继续执行业务逻辑 + +--- + +### Requirement: 开放接口代理上下文与数据权限 + +系统 SHALL 将通过签名认证的代理账号映射为内部代理上下文。上下文 MUST 包含 `ContextKeyUserID`、`ContextKeyUserType=UserTypeAgent`、`ContextKeyShopID`、`ContextKeySubordinateShopIDs`。开放接口业务查询 MUST 只允许访问该代理店铺及其下级店铺范围内的卡、套餐、订单和钱包数据。 + +#### Scenario: 访问自己店铺的卡 + +- **WHEN** 代理开放接口账号查询自己店铺名下卡的流量、状态或实名状态 +- **THEN** 系统返回该卡数据 + +#### Scenario: 访问下级店铺的卡 + +- **WHEN** 代理开放接口账号查询下级店铺名下卡的流量、状态或实名状态 +- **THEN** 系统返回该卡数据 + +#### Scenario: 访问无权限卡 + +- **WHEN** 代理开放接口账号查询非自己及非下级店铺名下的卡 +- **THEN** 系统返回 `CodeForbidden`,消息为“无权限操作该资源或资源不存在” + +#### Scenario: 企业账号调用开放接口 + +- **WHEN** 企业账号使用正确后台密码调用代理开放接口 +- **THEN** 系统拒绝调用,因为开放接口仅允许代理账号 + +--- + +### Requirement: 卡流量查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/traffic` 接口,按 `card_no` 查询单卡流量和套餐信息。`card_no` MUST 支持 ICCID、虚拟号、MSISDN 中任一种可解析为 IoT 卡的标识。接口 MUST 只返回单卡套餐,不返回设备级套餐信息。 + +响应 data MUST 至少包含: +- `card_no` +- `active_remaining_flow_mb`:当前生效套餐剩余流量 +- `active_total_flow_mb`:当前生效套餐总流量 +- `active_used_flow_mb`:当前生效套餐已使用流量 +- `active_packages`:当前生效套餐列表 +- `pending_packages`:待生效套餐列表 +- `active_expires_at`:当前生效套餐过期时间 + +`active_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`expires_at`、`start_at`、`package_name`、`series_name`、`package_type`、`package_type_name`。`pending_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`package_name`、`series_name`、`package_type`、`package_type_name`、`valid_days`、`priority`。 + +流量口径 MUST 满足: +- 总流量使用套餐真总量快照 `data_limit_mb` +- 已用流量使用换算后的展示已用量 `min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)` +- 剩余流量使用 `max(data_limit_mb - used_flow_mb, 0)` +- 响应 MUST NOT 包含 `virtual_*`、`ratio`、`threshold`、`reduction`、虚流量、停机阈值等内部字段或文案 + +#### Scenario: 查询有生效套餐的卡流量 + +- **WHEN** 代理查询自己名下单卡,且该卡存在生效中的正式包和加油包 +- **THEN** 系统返回当前生效套餐列表、总流量、换算后已用流量、剩余流量和当前过期时间 + +#### Scenario: 查询待生效套餐 + +- **WHEN** 代理查询单卡流量,且该卡存在待生效套餐 +- **THEN** 系统在 `pending_packages` 中返回套餐编码、真总流量、套餐名称、系列名称、套餐类型、有效天数和生效优先级 + +#### Scenario: 不暴露虚流量 + +- **WHEN** 卡的套餐启用了虚流量 +- **THEN** 响应只返回真总量、换算后已用量和剩余量,不返回任何虚流量字段或内部倍率字段 + +#### Scenario: 查询无套餐的卡 + +- **WHEN** 代理查询有权限但当前无生效或待生效套餐的卡 +- **THEN** 系统返回空套餐列表,流量数值为 0 + +--- + +### Requirement: 卡状态查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/status` 接口,按 `card_no` 查询单卡状态。响应 data MUST 包含 `card_no`、`card_status`、`card_status_name`、`stop_reason`、`stop_reason_name`。卡状态 MUST 按现有网络状态映射为停机/正常:`network_status=0` 返回停机,`network_status=1` 返回正常。 + +#### Scenario: 查询正常卡状态 + +- **WHEN** 代理查询有权限且 `network_status=1` 的卡 +- **THEN** 系统返回 `card_status=normal`,`card_status_name=正常` + +#### Scenario: 查询停机卡状态 + +- **WHEN** 代理查询有权限且 `network_status=0` 的卡 +- **THEN** 系统返回 `card_status=stopped`、`card_status_name=停机` 以及停机原因 + +--- + +### Requirement: 卡实名状态查询开放接口 + +系统 SHALL 提供 `GET /api/open/v1/cards/realname-status` 接口,按 `card_no` 查询单卡实名状态。响应 data MUST 包含 `card_no` 和 `is_realnamed`。当 `real_name_status` 为已实名常量时,`is_realnamed` MUST 为 `true`,否则为 `false`。 + +#### Scenario: 查询已实名卡 + +- **WHEN** 代理查询有权限且 `real_name_status=1` 的卡 +- **THEN** 系统返回 `is_realnamed=true` + +#### Scenario: 查询未实名卡 + +- **WHEN** 代理查询有权限且 `real_name_status=0` 的卡 +- **THEN** 系统返回 `is_realnamed=false` + +--- + +### Requirement: 代理套餐列表开放接口 + +系统 SHALL 提供 `GET /api/open/v1/packages` 接口,分页返回代理可购买套餐列表。接口 MUST 复用后台代理套餐列表的权限过滤:只返回当前代理已分配且启用的非赠送套餐,并按代理自己的上下架状态过滤。接口 MUST 支持 `page`、`page_size`、`package_type`、`series_id`、`package_name` 查询参数,`page_size` 最大 100。 + +响应每项 MUST 包含:`package_id`、`package_code`、`package_name`、`series_name`、`package_type`、`package_type_name`、`cost_price`、`retail_price`、`total_flow_mb`、`valid_days`。`retail_price` MUST 返回代理配置的生效零售价;当代理零售价未配置时,按现有策略回退为成本价。 + +#### Scenario: 代理查询套餐列表 + +- **WHEN** 代理调用 `GET /api/open/v1/packages?page=1&page_size=20` +- **THEN** 系统分页返回该代理可购买套餐,并包含成本价和生效零售价 + +#### Scenario: 过滤未分配套餐 + +- **WHEN** 套餐未分配给当前代理 +- **THEN** 该套餐不会出现在开放套餐列表中 + +#### Scenario: 生效零售价回退 + +- **WHEN** 代理套餐分配记录未配置零售价 +- **THEN** 响应 `retail_price` 返回成本价 + +--- + +### Requirement: 代理钱包套餐购买开放接口 + +系统 SHALL 提供 `POST /api/open/v1/wallet/package-orders` 接口,允许代理使用预充值主钱包为指定卡购买套餐。请求 body MUST 包含 `card_nos` 和 `package_code`。`package_code` MUST 只允许一个,`card_nos` MUST 至少 1 个,最多 100 个。 + +系统 MUST 按卡独立创建后台钱包订单,复用后台代理钱包支付购买逻辑。每张卡购买成功后 MUST 返回订单号;购买失败时 MUST 返回该卡失败原因。任一卡失败 MUST NOT 回滚其他已成功卡的订单。 + +响应 data MUST 包含:`batch_no`、`package_code`、`success_count`、`failed_count`、`orders`、`failed_cards`。`orders` 每项 MUST 包含 `card_no`、`order_no`。`failed_cards` 每项 MUST 包含 `card_no`、`code`、`message`。 + +#### Scenario: 多卡部分成功 + +- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败 +- **THEN** 系统返回 `success_count=2`、`failed_count=1`、2 条订单号和 1 条失败卡原因 + +#### Scenario: 单套餐编码限制 + +- **WHEN** 请求包含多个套餐编码或套餐编码为空 +- **THEN** 系统返回参数错误,不创建订单 + +#### Scenario: 钱包余额不足 + +- **WHEN** 某张卡购买套餐时代理主钱包余额不足 +- **THEN** 该卡返回失败原因“余额不足”,其他卡已成功订单不受影响 + +#### Scenario: 复用后台钱包支付 + +- **WHEN** 某张卡购买套餐成功 +- **THEN** 系统创建已支付订单、扣减代理主钱包余额、创建主钱包扣款流水并激活套餐 + +--- + +### Requirement: 代理预充值钱包余额开放接口 + +系统 SHALL 提供 `GET /api/open/v1/wallet/balance` 接口,查询当前代理预充值主钱包余额。响应 data MUST 包含 `balance`、`frozen_balance`、`available_balance`、`currency`。当主钱包不存在时,系统 MUST 返回 0 余额,不报错。 + +#### Scenario: 查询主钱包余额 + +- **WHEN** 代理主钱包余额为 10000 分、冻结余额为 2000 分 +- **THEN** 系统返回 `balance=10000`、`frozen_balance=2000`、`available_balance=8000` + +#### Scenario: 主钱包不存在 + +- **WHEN** 代理从未充值且主钱包不存在 +- **THEN** 系统返回 0 余额和币种 CNY + +--- + +### Requirement: 代理预充值钱包流水开放接口 + +系统 SHALL 提供 `GET /api/open/v1/wallet/transactions` 接口,分页返回当前代理预充值主钱包流水。接口 MUST 与后台 `GET /api/admin/shops/:shop_id/main-wallet/transactions` 的返回字段保持一致,支持 `page`、`page_size`、`transaction_type`、`start_date`、`end_date` 查询参数,结果按 `created_at DESC` 排序。 + +响应每项 MUST 包含:`id`、`transaction_type`、`transaction_subtype`、`amount`、`balance_before`、`balance_after`、`remark`、`created_at`。 + +#### Scenario: 查询钱包流水 + +- **WHEN** 代理调用 `GET /api/open/v1/wallet/transactions?page=1&page_size=20` +- **THEN** 系统返回当前代理主钱包流水,字段与后台主钱包流水接口一致 + +#### Scenario: 按日期过滤流水 + +- **WHEN** 代理传入 `start_date` 和 `end_date` +- **THEN** 系统只返回该日期范围内的主钱包流水 + +--- + +### Requirement: 开放接口文档和路由注册 + +系统 SHALL 将代理开放接口注册到路由总入口和 OpenAPI 文档生成器。新增 Handler 后 MUST 同步更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `bootstrap.Handlers` 初始化,确保接口文档中出现 `/api/open/v1` 下所有开放接口。 + +#### Scenario: 文档生成器包含开放接口 + +- **WHEN** 系统生成 OpenAPI 文档 +- **THEN** 文档中包含 `/api/open/v1` 下全部代理开放接口 diff --git a/openspec/specs/refund-api/spec.md b/openspec/specs/refund-api/spec.md index b969c2f..774fae8 100644 --- a/openspec/specs/refund-api/spec.md +++ b/openspec/specs/refund-api/spec.md @@ -23,15 +23,15 @@ #### Scenario: 查询退款详情 - **WHEN** 管理员查询某退款单详情 -- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态) +- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态,其中 `asset_reset` 表示退款后资产处理是否完成) ### Requirement: 审批通过接口 `POST /api/admin/refunds/:id/approve` SHALL 审批通过退款。请求体可选 `approved_refund_amount`(不填则等于 `requested_refund_amount`)和 `remark`。审批通过后异步触发资产处理和佣金全额回扣。 #### Scenario: 审批通过并触发副作用 - **WHEN** 审批人通过退款申请 -- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间 -- **AND** 系统 SHALL 异步执行:失效所有套餐 → 停机 → 世代重置资产 → 更新订单状态为已退款 → 标记 `asset_reset=true` +- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间,并同步将订单状态更新为已退款 +- **AND** 系统 SHALL 异步执行:精准失效本次退款关联套餐 → 尝试接续待生效主套餐 → 必要时停机 → 标记 `asset_reset=true` - **AND** 系统 SHALL 异步执行:全额回扣该订单所有佣金 → 标记 `commission_deducted=true` ### Requirement: 审批拒绝接口 diff --git a/openspec/specs/refund-asset-reset/spec.md b/openspec/specs/refund-asset-reset/spec.md index 9810bd7..e09751a 100644 --- a/openspec/specs/refund-asset-reset/spec.md +++ b/openspec/specs/refund-asset-reset/spec.md @@ -1,49 +1,74 @@ ## ADDED Requirements -### Requirement: 退款后套餐失效 -退款审批通过后,系统 SHALL 查找该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。 +### Requirement: 退款后套餐精准失效 +退款审批通过后,系统 SHALL 仅失效本次退款关联订单生成的套餐使用记录。若退款单传入 `package_usage_id`,系统 SHALL 优先失效该套餐使用记录,且该记录必须属于退款订单;若未传入 `package_usage_id`,系统 SHALL 失效该订单生成的全部可失效套餐使用记录。 -#### Scenario: 失效单卡套餐 -- **WHEN** 退款订单为单卡购买,关联 iot_card_id -- **THEN** 系统 SHALL 将该卡所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` +#### Scenario: 精准失效单个套餐 +- **WHEN** 退款单包含 `package_usage_id` +- **THEN** 系统 SHALL 将该套餐使用记录从 `status IN (0,1,2)` 更新为 `status=4` +- **AND** 系统 SHALL 写入 `refund_id` 与 `refund_no` 快照 -#### Scenario: 失效设备套餐 -- **WHEN** 退款订单为设备购买,关联 device_id -- **THEN** 系统 SHALL 将该设备所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` +#### Scenario: 未指定套餐时失效订单套餐 +- **WHEN** 退款单未包含 `package_usage_id` +- **THEN** 系统 SHALL 将该订单生成的所有 `status IN (0,1,2)` 套餐使用记录更新为 `status=4` -### Requirement: 退款后停机 -套餐失效后,系统 SHALL 对资产执行停机操作。 +#### Scenario: 主套餐关联加油包失效 +- **WHEN** 被失效套餐为主套餐 +- **THEN** 系统 SHALL 将其关联的可失效加油包一并更新为 `status=4` -#### Scenario: 单卡停机 -- **WHEN** 退款订单为单卡购买 -- **THEN** 系统 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` 停机 +### Requirement: 退款后待生效套餐接续 +退款套餐失效后,系统 SHALL 按购买顺序尝试激活该资产队首待生效主套餐。系统 MUST NOT 跳过未满足实名条件的队首套餐。 -#### Scenario: 设备停机(停所有绑定卡) -- **WHEN** 退款订单为设备购买 -- **THEN** 系统 SHALL 调用 `DeviceService.StopDevice(deviceID)`,内部遍历所有绑定卡逐一停机 +#### Scenario: 立即生效套餐接续 +- **WHEN** 退款后资产无生效主套餐,且队首待生效主套餐 `pending_realname_activation=false` +- **THEN** 系统 SHALL 激活该套餐,并按套餐 `expiry_base` 计算生效时间和过期时间 -### Requirement: 退款后资产世代重置 -停机后,系统 SHALL 对资产执行世代重置(参照换货模块 `exchange/service.go` 的重置逻辑),使资产回到可重新销售状态,一次性佣金可重新触发。 +#### Scenario: 实名套餐已满足条件 +- **WHEN** 队首待生效主套餐 `pending_realname_activation=true` +- **AND** 单卡已实名,或设备任意已绑定卡已实名 +- **THEN** 系统 SHALL 激活该套餐,并清除 `pending_realname_activation` -#### Scenario: 单卡世代重置 -- **WHEN** 退款订单为单卡购买 -- **THEN** 系统 SHALL 更新 IoT 卡:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定(`tb_personal_customer_device`),删除旧资产钱包并创建新空钱包 +#### Scenario: 实名套餐未满足条件 +- **WHEN** 队首待生效主套餐 `pending_realname_activation=true` +- **AND** 单卡未实名,或设备没有任何已绑定卡实名 +- **THEN** 系统 SHALL 保持该套餐待生效 +- **AND** 系统 MUST NOT 跳过该套餐激活后续待生效套餐 -#### Scenario: 设备世代重置 -- **WHEN** 退款订单为设备购买 -- **THEN** 系统 SHALL 更新设备:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定,删除旧资产钱包并创建新空钱包 +### Requirement: 退款后必要时停机 +套餐失效和接续激活完成后,系统 SHALL 检查资产是否仍有生效主套餐。仅当资产没有生效主套餐时,系统 SHALL 执行停机操作。 -### Requirement: 退款后订单状态更新 -资产处理完成后,系统 SHALL 将订单 `payment_status` 更新为 4(已退款)。 +#### Scenario: 有接续套餐时不强停 +- **WHEN** 退款后成功接续待生效主套餐,或资产仍存在其他生效主套餐 +- **THEN** 系统 SHALL NOT 执行退款停机 -#### Scenario: 订单标记已退款 -- **WHEN** 资产处理全部成功 -- **THEN** 订单 `payment_status` SHALL 变为 4(已退款),退款单 `asset_reset` 标记为 `true` +#### Scenario: 无可用主套餐时停机 +- **WHEN** 退款后资产没有任何生效主套餐 +- **THEN** 单卡订单 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` +- **AND** 设备订单 SHALL 调用 `DeviceService.StopDevice(deviceID)` + +### Requirement: 退款不得重置资产世代和钱包 +套餐退款不是资产退回库存。退款资产处理 MUST NOT 执行资产 `generation+1`、MUST NOT 清理个人客户绑定、MUST NOT 删除或重建资产钱包。资产世代重置和钱包重建仅属于换货/转新流程。 + +#### Scenario: 退款后保留资产钱包 +- **WHEN** 套餐退款审批通过 +- **THEN** 资产钱包 ID、余额和冻结余额 SHALL 保持不被退款资产处理重建或清零 + +#### Scenario: 退款后保留资产世代 +- **WHEN** 套餐退款审批通过 +- **THEN** IoT 卡或设备的 `generation` SHALL 不因退款资产处理变化 + +### Requirement: 退款后处理标记 +退款后资产处理成功完成后,系统 SHALL 将退款单 `asset_reset` 标记为 `true`。该字段为兼容字段,语义为“退款后资产处理是否完成”。 + +#### Scenario: 资产处理完成 +- **WHEN** 套餐失效、接续激活检查和必要停机处理完成 +- **THEN** 退款单 `asset_reset` SHALL 标记为 `true` ### Requirement: 资产处理失败容错 资产处理 SHALL 在独立 Goroutine 中执行,失败不影响审批结果。 #### Scenario: 资产处理失败 -- **WHEN** 套餐失效/停机/世代重置过程中发生错误 -- **THEN** 审批结果 SHALL NOT 受影响(已通过),`asset_reset` 保持 `false`,记录 Error 日志(含 refund_id 和 error) -- **AND** 管理员可通过 `asset_reset=false` 筛选需手动处理的退款单 +- **WHEN** 套餐失效、接续激活或生效套餐查询过程中发生错误 +- **THEN** 审批结果 SHALL NOT 受影响(已通过) +- **AND** `asset_reset` SHALL 保持 `false` +- **AND** 系统 SHALL 记录 Error 日志(含 `refund_id` 和错误信息) diff --git a/openspec/specs/refund-request/spec.md b/openspec/specs/refund-request/spec.md index 29f2606..e833aa3 100644 --- a/openspec/specs/refund-request/spec.md +++ b/openspec/specs/refund-request/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: 退款申请数据模型 -系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。 +系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、退款后资产处理标记等字段。 #### Scenario: 创建退款申请 - **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因 diff --git a/scripts/legacy_device_export/README.md b/scripts/legacy_device_export/README.md new file mode 100644 index 0000000..dd66ea6 --- /dev/null +++ b/scripts/legacy_device_export/README.md @@ -0,0 +1,90 @@ +# 老系统设备迁移快照导出脚本 + +这个工具只负责从老系统 `legacy_mysql` 导出设备迁移所需的业务快照,不写入新系统数据库,也不生成新系统导入 SQL。 + +## 导出范围 + +- 设备号/IMEI:从你提供的输入 CSV 读取;不提供 ICCID 时,脚本会按老库 `tbl_card.vcode`/`tbl_card.imei` 反查主卡,并通过 `tbl_card_relate` 展开关联副卡。 +- 卡当前快照:从 `tbl_card` 读取当前卡状态、老套餐字段、当前已用量等。 +- 套餐快照:从 `tbl_card_life` 和 `tbl_next_month_card_life` 读取当前/次月套餐事实。 +- 卡钱包快照:从 `tbl_card_wallet` 读取余额和自动续费字段。 +- 佣金账户快照:按卡所属老代理,从 `tbl_agent_commission_account` 读取账户累计金额。 +- 日流量记录:不导出,只保留当前已用量和剩余量建议值。 + +## 输入 CSV + +脚本会自动识别常见表头。最省事的输入只需要设备号/IMEI,不需要提供 SN 和 ICCID。SN 只是随导出结果透传,可不填。 + +支持的常见表头: + +```csv +imei +862639076004532 +``` + +如果业务给的是设备号字段,也可以写成: + +```csv +device_virtual_no +862639076004532 +``` + +如果业务后续能直接给 ICCID,脚本也仍然支持单卡或多卡槽 ICCID 输入。 + +可识别字段包括: + +- 设备号:`device_virtual_no`、`device_no`、`virtual_no`、`mchno`、`设备号`、`设备虚拟号`、`机器码` +- IMEI:`imei`、`device_imei`、`mchno`、`设备imei`、`机器码` +- SN:`sn`、`设备sn` +- 单卡:`iccid`、`卡号`、`主卡`、`main_iccid` +- 多卡槽:`iccid1`、`iccid_1`、`slot1_iccid`、`card1`、`sim1`、`卡1`,最多识别到 4 个卡槽 + +## 使用方式 + +```bash +export LEGACY_MYSQL_DSN='user:password@tcp(host:3306)/kyhl' + +go run ./scripts/legacy_device_export \ + -input ./devices.csv \ + -out ./legacy_device_export_output +``` + +也可以通过参数指定库名: + +```bash +go run ./scripts/legacy_device_export \ + -dsn 'user:password@tcp(host:3306)/kyhl' \ + -schema kyhl \ + -input ./devices.csv \ + -out ./legacy_device_export_output +``` + +默认不会导出佣金明细。如需按输入 ICCID 额外导出 `tbl_agent_commission_detail` 明细,可加: + +```bash +-include-commission-details +``` + +## 输出文件 + +- `input_assets.csv`:标准化后的输入设备/卡关系。 +- `unresolved_inputs.csv`:无法通过输入 ICCID 或设备号/IMEI 解析出老库 ICCID 的行。 +- `card_snapshot.csv`:老卡本体快照和当前已用量,并给出 `tb_iot_card.data_usage_mb`、`current_month_usage_mb`、`last_gateway_reading_mb` 的导入建议值;状态/类型/运营商分类会同时输出中文 `*_name` 列,也会保留老卡 `expire_time`。 +- `package_usage_snapshot.csv`:老套餐快照,以及按当前已用量分摊出来的 `allocated_used_mb`、`remaining_mb` 建议值;老套餐状态、新系统建议状态会同时输出中文 `*_name` 列。时间字段同时输出老库原始 `start_at`/`expires_at` 和迁移参考 `effective_start_at`/`effective_expires_at`,具体来源看 `date_source_note`。 +- `wallet_snapshot.csv`:老卡钱包余额和自动续费信息,自动续费状态会同时输出中文 `*_name` 列。 +- `commission_account_snapshot.csv`:老代理佣金账户快照,预留 `suggested_new_shop_id` 供后续导入时映射新系统店铺。 +- `missing_cards.csv`:输入 ICCID 在老库 `tbl_card` 中未命中的清单。 +- `summary.txt`:本次导出摘要。 +- `field_dictionary.csv`:所有输出字段的中文说明。 +- `enum_dictionary.csv`:状态码、类型码、来源表等枚举值说明。 +- `README_zh.md`:随本次导出生成的阅读入口和口径提醒。 +- `commission_detail_snapshot.csv`:仅在传入 `-include-commission-details` 时生成。 + +## 注意事项 + +- `package_usage_snapshot.csv` 的已用量分摊是迁移建议值,后续生成新系统导入 SQL 前仍需要结合新系统套餐系列、套餐和供应商映射复核。 +- 输入 CSV 可以不提供 SN 和 ICCID;不提供 ICCID 时会先按老库 `tbl_card.vcode`,再兼容 `tbl_card.imei` 反查 ICCID,并通过 `tbl_card_relate` 展开关联卡。 +- CSV 表头保留英文是为了后续脚本稳定读取;人工查看时优先看同表的 `*_name` 中文列、`field_dictionary.csv` 和 `enum_dictionary.csv`。 +- 老库部分 `tbl_card_life.start_date` 为空,但 `expire_date` 和 `create_time` 有值;脚本会保留原始空值,并在 `effective_start_at` 用 `create_time` 兜底,避免误以为没有导出。 +- 新系统轮询流量会根据网关当前读数和 `last_gateway_reading_mb` 计算增量,后续导入脚本应优先使用 `card_snapshot.csv` 中的 `suggested_tb_iot_card_last_gateway_reading_mb` 避免首轮重复扣量。 +- 这个脚本不会修改老系统或新系统数据。 diff --git a/scripts/legacy_device_export/devices.csv b/scripts/legacy_device_export/devices.csv new file mode 100644 index 0000000..ee59bc9 --- /dev/null +++ b/scripts/legacy_device_export/devices.csv @@ -0,0 +1,352 @@ +imei +862639075986135 +862639075972853 +862639076004532 +862639076024787 +862639076035379 +862639076007840 +862639075987521 +862639075981524 +862639076008442 +862639076022815 +862639076016668 +862639075990475 +862639076036872 +862639076037805 +862639076030503 +862639076004870 +862639076020678 +862639076023888 +862639075998429 +862639075989949 +862639076016726 +862639076022922 +862639076004284 +862639075982696 +862639076026915 +862639076031998 +862639075979866 +862639076010695 +862639075977365 +862639076028531 +862639075979502 +862639076015843 +862639075974404 +862639076016171 +862639076037201 +862639075984924 +862639075981144 +862639076009861 +862639075991663 +862639076017195 +862639075973158 +862639076002122 +862639076005844 +862639076015421 +862639076015462 +862639075980880 +862639076008434 +862639076016932 +862639075971343 +862639076032764 +862639075972119 +862639076017609 +862639076025933 +862639075972887 +862639075978769 +862639076030594 +862639076000373 +862639076018862 +862639075979213 +862639076039637 +862639076026485 +862639076000480 +862639076006040 +862639076034802 +862639076001504 +862639076021890 +862639075987182 +862639076032012 +862639076024902 +862639075994386 +862639075972770 +862639076026840 +862639075985533 +862639075991515 +862639075974115 +862639075979155 +862639075991853 +862639076030073 +862639076015876 +862639076037821 +862639076002403 +862639075974560 +862639075980245 +862639075973539 +862639076030537 +862639075993883 +862639076020249 +862639075987778 +862639075994683 +862639076032251 +862639075984882 +862639075993693 +862639076017427 +862639076033853 +862639075994998 +862639075987331 +862639075970535 +862639076039165 +862639076001363 +862639076022450 +862639076019886 +862639076039686 +862639076015082 +862639075980781 +862639075996506 +862639075993313 +862639076030479 +862639076029760 +862639076022591 +862639076011024 +862639075971079 +862639076023672 +862639076022716 +862639075989683 +862639075980211 +862639076002395 +862639076002221 +862639076009085 +862639076010448 +862639076003500 +862639076009036 +862639076015579 +862639075984262 +862639075977233 +862639075999187 +862639075978546 +862639075976300 +862639075999732 +862639076028481 +862639076029810 +862639076028697 +862639075980492 +862639076002064 +862639076000209 +862639075987422 +862639076013905 +862639076030099 +862639075995953 +862639076036781 +862639076037177 +862639076030784 +862639076017500 +862639076014754 +862639076004219 +862639075979247 +862639075993354 +862639075998460 +862639076024969 +862639075994923 +862639076021833 +862639076007337 +862639075971327 +862639076003203 +862639076008897 +862639075975641 +862639075993701 +862639075975625 +862639076016353 +862639076018425 +862639076012089 +862639075983595 +862639075998254 +862639075999369 +862639075970543 +862639076029968 +862639076015769 +862639075987448 +862639075987505 +862639075993164 +862639075993289 +862639076035692 +862639075982175 +862639075992166 +862639075996118 +862639076023557 +862639076033168 +862639075976730 +862639075996324 +862639075974677 +862639076019241 +862639076032731 +862639076023607 +862639075997702 +862639076007014 +862639076001231 +862639075972507 +862639075993230 +862639075976490 +862639075995862 +862639076002353 +862639076026741 +862639075975500 +862639076029281 +862639076030180 +862639075979510 +862639075975245 +862639075996928 +862639075979270 +862639076022658 +862639076023474 +862639076001629 +862639076000282 +862639075996159 +862639075980310 +862639075980070 +862639076003633 +862639075980047 +862639075975765 +862639076028655 +862639076017971 +862639076032327 +862639076002841 +862639076021478 +862639075973430 +862639075996589 +862639075976888 +862639076003815 +862639076016429 +862639076010315 +862639076010901 +862639076004391 +862639075988305 +862639076031949 +862639075970949 +862639076003898 +862639075978587 +862639075984007 +862639075970220 +862639075970816 +862639076014580 +862639076026725 +862639076015074 +862639076018961 +862639075991499 +862639075974834 +862639076022302 +862639075996423 +862639075983371 +862639075995441 +862639075977829 +862639075972267 +862639076020447 +862639075987398 +862639076015231 +862639075978314 +862639076027897 +862639076023045 +862639076033846 +862639076003104 +862639075986663 +862639076035890 +862639076010067 +862639076031071 +862639075990061 +862639075983462 +862639075982480 +862639075987497 +862639076022955 +862639076017997 +862639075975922 +862639076022484 +862639076019340 +862639075997066 +862639076030214 +862639075980344 +862639076037359 +862639075985558 +862639076028986 +862639076038134 +862639076023896 +862639076002635 +862639075976458 +862639076006529 +862639076022252 +862639075985954 +862639076019894 +862639075980864 +862639076017179 +862639076026279 +862639076018607 +862639075988354 +862639076030966 +862639076001736 +862639075972259 +862639076001611 +862639075973927 +862639075974909 +862639076003872 +862639076028465 +862639076012642 +862639076023078 +862639076019290 +862639076029596 +862639075973950 +862639075970808 +862639076028275 +862639076011669 +862639076027061 +862639075993321 +862639075975732 +862639076030982 +862639076031881 +862639076026675 +862639076002551 +862639076017716 +862639076039264 +862639075970832 +862639076011321 +862639075983132 +862639075998270 +862639075999302 +862639076006727 +862639075973307 +862639076014721 +862639076020991 +862639075985897 +862639075978462 +862639075993222 +862639076007832 +862639076011339 +862639075994378 +862639075977597 +862639075988271 +862639076026709 +862639075998072 +862639076035940 +862639076033895 +862639076012659 +862639075980534 +862639075986614 +862639076036435 +862639075970725 +862639075998296 +862639076018953 +862639075978843 +862639075999609 +862639076032640 +862639076003674 +862639076038704 +862639075990657 +862639076003930 +862639075992315 +862639075981029 +862639076020603 +862639075999385 +862639076024183 +862639076035932 +862639076017823 +862639075971855 +862639076021668 +862639076026600 diff --git a/scripts/legacy_device_export/main.go b/scripts/legacy_device_export/main.go new file mode 100644 index 0000000..9e80cbb --- /dev/null +++ b/scripts/legacy_device_export/main.go @@ -0,0 +1,2050 @@ +package main + +import ( + "context" + "encoding/csv" + "flag" + "fmt" + "math" + "math/big" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const ( + defaultLegacySchema = "kyhl" + defaultBatchSize = 500 + timeFormat = "2006-01-02 15:04:05" + sourceCardLife = "tbl_card_life" + sourceNextMonthLife = "tbl_next_month_card_life" +) + +var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +type inputAsset struct { + Line int + DeviceVirtualNo string + IMEI string + SN string + SlotPosition int + ICCID string + ResolvedBy string + ResolveNote string +} + +type cardSnapshot struct { + ICCID string `gorm:"column:iccid"` + CardID string `gorm:"column:card_id"` + Phone string `gorm:"column:phone"` + PhoneZuhe string `gorm:"column:phone_zuhe"` + OldIMEI string `gorm:"column:old_imei"` + OldVCode string `gorm:"column:old_vcode"` + Status string `gorm:"column:status"` + Type string `gorm:"column:type"` + Category string `gorm:"column:category"` + CategoryName string `gorm:"column:category_name"` + CardFlow string `gorm:"column:card_flow"` + SetMealID string `gorm:"column:set_meal_id"` + CardPackage string `gorm:"column:card_package"` + ExpireTime string `gorm:"column:expire_time"` + FlowSize string `gorm:"column:flow_size"` + TotalBytesCnt string `gorm:"column:total_bytes_cnt"` + SynchroTime string `gorm:"column:synchro_time"` + ServCreateAt *time.Time `gorm:"column:serv_create_at"` + BalanceMoney string `gorm:"column:balance_money"` + AgentID string `gorm:"column:agent_id"` + AgentName string `gorm:"column:agent_name"` + AccountID string `gorm:"column:account_id"` + AccountName string `gorm:"column:account_name"` + AgentThreeID string `gorm:"column:agent_three_id"` +} + +type packageSnapshot struct { + SourceTable string `gorm:"column:source_table"` + ID string `gorm:"column:id"` + ICCID string `gorm:"column:iccid"` + MealID string `gorm:"column:meal_id"` + MealName string `gorm:"column:meal_name"` + OrderID string `gorm:"column:order_id"` + StartAt *time.Time `gorm:"column:start_at"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + Status string `gorm:"column:status"` + Type string `gorm:"column:type"` + FlowSize string `gorm:"column:flow_size"` + EffectType string `gorm:"column:effect_type"` + UpdateDateTime string `gorm:"column:update_date_time"` + EffectBeginTime *time.Time `gorm:"column:effect_begin_time"` + EffectCompleteTime *time.Time `gorm:"column:effect_complete_time"` + OrderType string `gorm:"column:order_type"` + CreatedAt *time.Time `gorm:"column:created_at"` + UpdatedAt *time.Time `gorm:"column:updated_at"` +} + +type walletSnapshot struct { + ICCID string `gorm:"column:iccid"` + MoneyBalance string `gorm:"column:money_balance"` + TxVersion string `gorm:"column:tx_version"` + MealAutoRenewStatus string `gorm:"column:meal_auto_renew_status"` + AutoRenewMealID string `gorm:"column:auto_renew_meal_id"` + AddonAutoRenewStatus string `gorm:"column:addon_auto_renew_status"` + AddonAutoRenewMealID string `gorm:"column:addon_auto_renew_meal_id"` + MealAutoRenewUpdatedAt *time.Time `gorm:"column:meal_auto_renew_updated_at"` + CreatedAt *time.Time `gorm:"column:created_at"` +} + +type commissionAccountSnapshot struct { + AgentID string + AgentName string + AgentLoginAccount string + TotalCommissionAmount string + TotalActiveAwardCommissionAmount string + MFCommissionAmount string + NoMFCommissionAmount string + TotalDrawAmount string + CanDrawAmount string + ApplyingDrawAmount string + Remark string + TxVersion string + CreatedAt *time.Time + UpdatedAt *time.Time +} + +type commissionDetailSnapshot struct { + ID string `gorm:"column:id"` + CommissionAccountID string `gorm:"column:commission_account_id"` + WxOrderID string `gorm:"column:wx_order_id"` + WxOrderNumber string `gorm:"column:wx_order_number"` + ICCID string `gorm:"column:iccid"` + Phone string `gorm:"column:phone"` + WxOrderAt *time.Time `gorm:"column:wx_order_at"` + CreatedAt *time.Time `gorm:"column:created_at"` + CommissionAmount string `gorm:"column:commission_amount"` + TotalCommissionAmount string `gorm:"column:total_commission_amount"` + MFStatus string `gorm:"column:mf_status"` + CommissionType string `gorm:"column:commission_type"` + ActiveAwardCommissionAmount string `gorm:"column:active_award_commission_amount"` + TotalActiveAwardCommissionAmount string `gorm:"column:total_active_award_commission_amount"` +} + +type legacyCardIdentity struct { + ICCID string `gorm:"column:iccid"` + IMEI string `gorm:"column:imei"` + VCode string `gorm:"column:vcode"` + ResolvedBy string + ResolveNote string + SlotPosition int +} + +type cardRelation struct { + MainICCID string `gorm:"column:main_iccid"` + FuICCID string `gorm:"column:fu_iccid"` + FuICCID2 string `gorm:"column:fu_iccid2"` +} + +type exportData struct { + Inputs []inputAsset + UnresolvedInputs []inputAsset + InputByICCID map[string][]inputAsset + Cards map[string]cardSnapshot + PackagesByICCID map[string][]packageSnapshot + Wallets map[string]walletSnapshot + CommissionAccounts map[string]commissionAccountSnapshot + CommissionDetails []commissionDetailSnapshot + MissingICCIDs []string +} + +func main() { + inputPath := flag.String("input", "", "设备/卡关系 CSV 路径") + outputDir := flag.String("out", "legacy_device_export_output", "导出目录") + dsn := flag.String("dsn", os.Getenv("LEGACY_MYSQL_DSN"), "老 MySQL DSN,也可使用 LEGACY_MYSQL_DSN") + schema := flag.String("schema", getenvDefault("LEGACY_MYSQL_SCHEMA", defaultLegacySchema), "老库 schema/database 名称") + includeCommissionDetails := flag.Bool("include-commission-details", false, "是否导出输入 ICCID 对应的佣金明细") + flag.Parse() + + if *inputPath == "" { + exitWithError("请通过 -input 指定设备/卡关系 CSV") + } + if *dsn == "" { + exitWithError("请通过 -dsn 或 LEGACY_MYSQL_DSN 提供老 MySQL 连接串") + } + if !identifierPattern.MatchString(*schema) { + exitWithError("schema 只能包含字母、数字和下划线") + } + + ctx := context.Background() + inputs, err := readInputCSV(*inputPath) + if err != nil { + exitWithError("读取输入 CSV 失败: %v", err) + } + if len(inputs) == 0 { + exitWithError("输入 CSV 中没有识别到任何设备/卡线索") + } + + db, err := openLegacyDB(*dsn) + if err != nil { + exitWithError("连接老 MySQL 失败: %v", err) + } + + resolvedInputs, unresolvedInputs, err := resolveInputAssets(ctx, db, *schema, inputs) + if err != nil { + exitWithError("按设备号/IMEI 解析 ICCID 失败: %v", err) + } + + data := &exportData{ + Inputs: resolvedInputs, + UnresolvedInputs: unresolvedInputs, + InputByICCID: groupInputsByICCID(resolvedInputs), + Cards: make(map[string]cardSnapshot), + PackagesByICCID: make(map[string][]packageSnapshot), + Wallets: make(map[string]walletSnapshot), + CommissionAccounts: make(map[string]commissionAccountSnapshot), + } + + iccids := sortedKeys(data.InputByICCID) + if err := loadCardSnapshots(ctx, db, *schema, iccids, data.Cards); err != nil { + exitWithError("查询老卡本体失败: %v", err) + } + if err := loadPackageSnapshots(ctx, db, *schema, iccids, data.PackagesByICCID); err != nil { + exitWithError("查询老套餐快照失败: %v", err) + } + if err := loadWalletSnapshots(ctx, db, *schema, iccids, data.Wallets); err != nil { + exitWithError("查询老卡钱包失败: %v", err) + } + + agentIDs := collectAgentIDs(data.Cards) + if len(agentIDs) > 0 { + if err := loadCommissionAccounts(ctx, db, *schema, agentIDs, data.CommissionAccounts); err != nil { + exitWithError("查询老佣金账户失败: %v", err) + } + } + if *includeCommissionDetails { + details, err := loadCommissionDetails(ctx, db, *schema, iccids) + if err != nil { + exitWithError("查询老佣金明细失败: %v", err) + } + data.CommissionDetails = details + } + + for _, iccid := range iccids { + if _, ok := data.Cards[iccid]; !ok { + data.MissingICCIDs = append(data.MissingICCIDs, iccid) + } + } + + if err := os.MkdirAll(*outputDir, 0755); err != nil { + exitWithError("创建导出目录失败: %v", err) + } + if err := writeOutputs(*outputDir, data, *includeCommissionDetails); err != nil { + exitWithError("写入导出文件失败: %v", err) + } + + fmt.Printf("导出完成: %s\n", *outputDir) + fmt.Printf("已解析关系: %d 条,ICCID: %d 个,未解析输入: %d 条,老库缺失: %d 个\n", len(data.Inputs), len(iccids), len(data.UnresolvedInputs), len(data.MissingICCIDs)) + fmt.Println("已跳过日记录导出,仅保留当前套餐/流量/钱包/佣金快照。") +} + +func openLegacyDB(rawDSN string) (*gorm.DB, error) { + dsn := ensureMySQLDSNOptions(rawDSN) + return gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) +} + +func ensureMySQLDSNOptions(dsn string) string { + options := []string{"parseTime=true", "loc=Asia%2FShanghai", "charset=utf8mb4"} + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + lower := strings.ToLower(dsn) + for _, option := range options { + key := strings.ToLower(strings.SplitN(option, "=", 2)[0]) + "=" + if strings.Contains(lower, key) { + continue + } + dsn += sep + option + sep = "&" + } + return dsn +} + +func readInputCSV(path string) ([]inputAsset, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + rows, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(rows) < 2 { + return nil, nil + } + + header := make(map[string]int) + for i, h := range rows[0] { + header[normalizeHeader(h)] = i + } + + var result []inputAsset + for rowIndex := 1; rowIndex < len(rows); rowIndex++ { + row := rows[rowIndex] + base := inputAsset{ + Line: rowIndex + 1, + DeviceVirtualNo: pickValue(row, header, "device_virtual_no", "device_no", "virtual_no", "mchno", "设备号", "设备虚拟号", "机器码"), + IMEI: pickValue(row, header, "imei", "device_imei", "mchno", "设备imei", "机器码"), + SN: pickValue(row, header, "sn", "设备sn"), + } + + if singleICCID := pickValue(row, header, "iccid", "卡号", "主卡", "main_iccid"); singleICCID != "" { + slot := parseIntDefault(pickValue(row, header, "slot", "slot_position", "卡槽", "槽位"), 0) + result = append(result, withICCID(base, slot, singleICCID, "input_iccid")) + } + + for slot := 1; slot <= 4; slot++ { + keys := slotHeaderCandidates(slot) + iccid := pickValue(row, header, keys...) + if iccid == "" { + continue + } + result = append(result, withICCID(base, slot, iccid, "input_slot_iccid")) + } + + if base.ICCID == "" && base.DeviceVirtualNo == "" && base.IMEI == "" && base.SN == "" { + continue + } + if !rowHasICCID(result, base.Line) { + base.ResolvedBy = "pending_device_key" + if base.IMEI == "" && base.DeviceVirtualNo == "" { + base.ResolvedBy = "unresolved" + base.ResolveNote = "未提供 ICCID 或设备号/IMEI,无法从老库反查卡号" + } + result = append(result, base) + } + } + + return dedupeInputAssets(result), nil +} + +func normalizeHeader(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimPrefix(s, "\ufeff") + replacer := strings.NewReplacer(" ", "_", "-", "_", "(", "", ")", "", "(", "", ")", "") + return replacer.Replace(s) +} + +func slotHeaderCandidates(slot int) []string { + n := strconv.Itoa(slot) + return []string{ + "iccid" + n, + "iccid_" + n, + "slot" + n + "_iccid", + "slot_" + n + "_iccid", + "card" + n, + "card_" + n, + "sim" + n, + "sim_" + n, + "卡" + n, + "卡" + n + "iccid", + "卡" + n + "_iccid", + } +} + +func pickValue(row []string, header map[string]int, keys ...string) string { + for _, key := range keys { + idx, ok := header[normalizeHeader(key)] + if !ok || idx >= len(row) { + continue + } + if value := normalizeCell(row[idx]); value != "" { + return value + } + } + return "" +} + +func normalizeCell(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "'") + s = strings.ReplaceAll(s, "\u00a0", "") + return s +} + +func withICCID(base inputAsset, slot int, iccid string, resolvedBy string) inputAsset { + base.SlotPosition = slot + base.ICCID = normalizeCell(iccid) + base.ResolvedBy = resolvedBy + return base +} + +func dedupeInputAssets(inputs []inputAsset) []inputAsset { + seen := make(map[string]bool) + result := make([]inputAsset, 0, len(inputs)) + for _, item := range inputs { + key := fmt.Sprintf("%d|%s|%s|%s|%d", item.Line, item.DeviceVirtualNo, item.IMEI, item.ICCID, item.SlotPosition) + if seen[key] { + continue + } + seen[key] = true + result = append(result, item) + } + return result +} + +func groupInputsByICCID(inputs []inputAsset) map[string][]inputAsset { + result := make(map[string][]inputAsset) + for _, item := range inputs { + if item.ICCID == "" { + continue + } + result[item.ICCID] = append(result[item.ICCID], item) + } + return result +} + +func rowHasICCID(inputs []inputAsset, line int) bool { + for _, item := range inputs { + if item.Line == line && item.ICCID != "" { + return true + } + } + return false +} + +func resolveInputAssets(ctx context.Context, db *gorm.DB, schema string, inputs []inputAsset) ([]inputAsset, []inputAsset, error) { + deviceKeys := collectPendingDeviceKeys(inputs) + cardsByDeviceKey := make(map[string][]legacyCardIdentity) + if len(deviceKeys) > 0 { + var err error + cardsByDeviceKey, err = loadCardIdentitiesByDeviceKey(ctx, db, schema, deviceKeys) + if err != nil { + return nil, nil, err + } + } + + resolved := make([]inputAsset, 0, len(inputs)) + unresolved := make([]inputAsset, 0) + for _, item := range inputs { + if item.ICCID != "" { + if item.ResolvedBy == "" { + item.ResolvedBy = "input_iccid" + } + resolved = append(resolved, item) + continue + } + keys := inputDeviceKeys(item) + if len(keys) == 0 { + item.ResolvedBy = "unresolved" + if item.ResolveNote == "" { + item.ResolveNote = "未提供 ICCID 或设备号/IMEI,无法从老库反查卡号" + } + unresolved = append(unresolved, item) + continue + } + + matches := uniqueIdentitiesForKeys(cardsByDeviceKey, keys) + if len(matches) == 0 { + item.ResolvedBy = "unresolved_device_key" + item.ResolveNote = "老库 tbl_card 未按 vcode/imei 找到设备号对应的 ICCID" + unresolved = append(unresolved, item) + continue + } + for _, match := range matches { + resolvedItem := item + resolvedItem.ICCID = match.ICCID + resolvedItem.ResolvedBy = match.ResolvedBy + resolvedItem.ResolveNote = match.ResolveNote + if match.SlotPosition > 0 { + resolvedItem.SlotPosition = match.SlotPosition + } + if resolvedItem.ResolveNote == "" && len(matches) > 1 { + resolvedItem.ResolveNote = fmt.Sprintf("同一设备号在老库命中 %d 张卡,已按 tbl_card_relate 展开", len(matches)) + } + if resolvedItem.ResolveNote == "" { + if match.VCode != "" { + resolvedItem.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID" + } else if match.IMEI != "" { + resolvedItem.ResolveNote = "按老库 tbl_card.imei 反查 ICCID" + } + } + resolved = append(resolved, resolvedItem) + } + } + + return dedupeInputAssets(resolved), dedupeInputAssets(unresolved), nil +} + +func collectPendingDeviceKeys(inputs []inputAsset) []string { + set := make(map[string]bool) + for _, item := range inputs { + if item.ICCID == "" { + for _, key := range inputDeviceKeys(item) { + addNonEmpty(set, key) + } + } + } + return sortedKeys(set) +} + +func inputDeviceKeys(item inputAsset) []string { + set := make(map[string]bool) + addNonEmpty(set, item.IMEI) + addNonEmpty(set, item.DeviceVirtualNo) + return sortedKeys(set) +} + +func uniqueIdentitiesForKeys(source map[string][]legacyCardIdentity, keys []string) []legacyCardIdentity { + byICCID := make(map[string]legacyCardIdentity) + for _, key := range keys { + for _, item := range source[key] { + if item.ICCID == "" { + continue + } + existing, ok := byICCID[item.ICCID] + if !ok || identityPriority(item) > identityPriority(existing) { + byICCID[item.ICCID] = item + } + } + } + iccids := sortedKeys(byICCID) + result := make([]legacyCardIdentity, 0, len(iccids)) + for _, iccid := range iccids { + result = append(result, byICCID[iccid]) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].SlotPosition != result[j].SlotPosition { + if result[i].SlotPosition == 0 { + return false + } + if result[j].SlotPosition == 0 { + return true + } + return result[i].SlotPosition < result[j].SlotPosition + } + return result[i].ICCID < result[j].ICCID + }) + return result +} + +func identityPriority(item legacyCardIdentity) int { + if item.SlotPosition > 0 && strings.Contains(item.ResolvedBy, "relate") { + return 3 + } + if item.VCode != "" { + return 2 + } + return 1 +} + +func loadCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) { + directByKey, err := loadDirectCardIdentitiesByDeviceKey(ctx, db, schema, keys) + if err != nil { + return nil, err + } + if len(directByKey) == 0 { + return directByKey, nil + } + + directICCIDs := collectDirectIdentityICCIDs(directByKey) + relations, err := loadCardRelationsByICCIDs(ctx, db, schema, directICCIDs) + if err != nil { + return nil, err + } + if len(relations) == 0 { + return directByKey, nil + } + + expanded := make(map[string][]legacyCardIdentity, len(directByKey)) + for key, directRows := range directByKey { + byICCID := make(map[string]legacyCardIdentity) + for _, row := range directRows { + byICCID[row.ICCID] = row + for _, relation := range relations { + if !relationContains(relation, row.ICCID) { + continue + } + addRelationIdentity(byICCID, relation.MainICCID, 1, "主卡,按 tbl_card_relate 从设备号命中卡展开") + addRelationIdentity(byICCID, relation.FuICCID, 2, "副卡1,按 tbl_card_relate 从设备号命中卡展开") + addRelationIdentity(byICCID, relation.FuICCID2, 3, "副卡2,按 tbl_card_relate 从设备号命中卡展开") + } + } + expanded[key] = identitiesFromMap(byICCID) + } + return expanded, nil +} + +func loadDirectCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) { + result := make(map[string][]legacyCardIdentity) + keySet := make(map[string]bool, len(keys)) + for _, key := range keys { + keySet[key] = true + } + + for _, batch := range chunkStrings(keys, defaultBatchSize) { + var rows []legacyCardIdentity + sql := fmt.Sprintf(` +SELECT + iccid_mark AS iccid, + imei, + vcode +FROM %s.tbl_card +WHERE (vcode IN ? OR imei IN ?) + AND iccid_mark IS NOT NULL + AND iccid_mark <> '' +ORDER BY vcode ASC, imei ASC, iccid_mark ASC +`, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch, batch).Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if row.ICCID == "" { + continue + } + if row.VCode != "" && keySet[row.VCode] { + item := row + item.ResolvedBy = "old_tbl_card_vcode" + item.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID" + result[row.VCode] = append(result[row.VCode], item) + } + if row.IMEI != "" && keySet[row.IMEI] { + item := row + item.ResolvedBy = "old_tbl_card_imei" + item.ResolveNote = "按老库 tbl_card.imei 反查 ICCID" + result[row.IMEI] = append(result[row.IMEI], item) + } + } + } + return result, nil +} + +func collectDirectIdentityICCIDs(directByKey map[string][]legacyCardIdentity) []string { + set := make(map[string]bool) + for _, rows := range directByKey { + for _, row := range rows { + addNonEmpty(set, row.ICCID) + } + } + return sortedKeys(set) +} + +func loadCardRelationsByICCIDs(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]cardRelation, error) { + var result []cardRelation + for _, batch := range chunkStrings(iccids, defaultBatchSize) { + var rows []cardRelation + sql := fmt.Sprintf(` +SELECT + main_iccid, + fu_iccid, + fu_iccid2 +FROM %s.tbl_card_relate +WHERE main_iccid IN ? OR fu_iccid IN ? OR fu_iccid2 IN ? +`, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch, batch, batch).Scan(&rows).Error; err != nil { + return nil, err + } + result = append(result, rows...) + } + return result, nil +} + +func relationContains(relation cardRelation, iccid string) bool { + return relation.MainICCID == iccid || relation.FuICCID == iccid || relation.FuICCID2 == iccid +} + +func addRelationIdentity(byICCID map[string]legacyCardIdentity, iccid string, slot int, note string) { + if iccid == "" { + return + } + item := legacyCardIdentity{ + ICCID: iccid, + ResolvedBy: "old_tbl_card_relate", + ResolveNote: note, + SlotPosition: slot, + } + existing, ok := byICCID[iccid] + if ok { + item.IMEI = existing.IMEI + item.VCode = existing.VCode + if existing.ResolvedBy == "old_tbl_card_vcode" || existing.ResolvedBy == "old_tbl_card_imei" { + item.ResolvedBy = existing.ResolvedBy + "+old_tbl_card_relate" + } + } + if !ok || identityPriority(item) > identityPriority(existing) { + byICCID[iccid] = item + } +} + +func identitiesFromMap(byICCID map[string]legacyCardIdentity) []legacyCardIdentity { + iccids := sortedKeys(byICCID) + result := make([]legacyCardIdentity, 0, len(iccids)) + for _, iccid := range iccids { + result = append(result, byICCID[iccid]) + } + sort.SliceStable(result, func(i, j int) bool { + if result[i].SlotPosition != result[j].SlotPosition { + if result[i].SlotPosition == 0 { + return false + } else { + if result[j].SlotPosition == 0 { + return true + } + } + return result[i].SlotPosition < result[j].SlotPosition + } + return result[i].ICCID < result[j].ICCID + }) + return result +} + +func loadCardSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]cardSnapshot) error { + for _, batch := range chunkStrings(iccids, defaultBatchSize) { + var rows []cardSnapshot + sql := fmt.Sprintf(` +SELECT + c.id AS card_id, + c.iccid_mark AS iccid, + c.phone, + c.phone_zuhe, + c.imei AS old_imei, + c.vcode AS old_vcode, + CAST(c.status AS CHAR) AS status, + CAST(c.type AS CHAR) AS type, + CAST(c.category AS CHAR) AS category, + vc.category_name AS category_name, + c.card_flow, + c.set_meal_id, + c.card_package, + c.expire_time, + CAST(c.flow_size AS CHAR) AS flow_size, + c.total_bytes_cnt, + c.synchro_time, + c.serv_create_date AS serv_create_at, + c.balance_money, + c.agent_id, + c.agent_name, + c.account_id, + c.account_name, + c.agent_three_id +FROM %s.tbl_card c +LEFT JOIN %s.tbl_vendor_category vc ON vc.category = c.category +WHERE c.iccid_mark IN ? +`, quoteSchema(schema), quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + if row.ICCID == "" { + continue + } + out[row.ICCID] = row + } + } + return nil +} + +func loadPackageSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string][]packageSnapshot) error { + for _, batch := range chunkStrings(iccids, defaultBatchSize) { + var currentRows []packageSnapshot + currentSQL := fmt.Sprintf(` +SELECT + '%s' AS source_table, + id, + iccid_mark AS iccid, + meal_id, + meal_name, + order_id, + start_date AS start_at, + expire_date AS expires_at, + CAST(status AS CHAR) AS status, + CAST(type AS CHAR) AS type, + CAST(flow_size AS CHAR) AS flow_size, + CAST(effect_type AS CHAR) AS effect_type, + update_date_time, + create_time AS created_at, + update_time AS updated_at +FROM %s.tbl_card_life +WHERE iccid_mark IN ? +`, sourceCardLife, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(currentSQL, batch).Scan(¤tRows).Error; err != nil { + return err + } + for _, row := range currentRows { + if row.ICCID == "" { + continue + } + out[row.ICCID] = append(out[row.ICCID], row) + } + + var pendingRows []packageSnapshot + pendingSQL := fmt.Sprintf(` +SELECT + '%s' AS source_table, + id, + iccid_mark AS iccid, + meal_id, + meal_name, + agent_wx_order_id AS order_id, + effect_begin_time AS effect_begin_time, + effect_complete_time AS effect_complete_time, + CAST(status AS CHAR) AS status, + CAST(flow_size AS CHAR) AS flow_size, + order_type, + create_date AS created_at +FROM %s.tbl_next_month_card_life +WHERE iccid_mark IN ? +`, sourceNextMonthLife, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(pendingSQL, batch).Scan(&pendingRows).Error; err != nil { + return err + } + for _, row := range pendingRows { + if row.ICCID == "" { + continue + } + out[row.ICCID] = append(out[row.ICCID], row) + } + } + + for iccid := range out { + sort.SliceStable(out[iccid], func(i, j int) bool { + left := out[iccid][i] + right := out[iccid][j] + if left.SourceTable != right.SourceTable { + return left.SourceTable == sourceCardLife + } + return timePtrBefore(left.StartAt, right.StartAt) + }) + } + return nil +} + +func loadWalletSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]walletSnapshot) error { + for _, batch := range chunkStrings(iccids, defaultBatchSize) { + var rows []walletSnapshot + sql := fmt.Sprintf(` +SELECT + iccid_mark AS iccid, + CAST(money_balance AS CHAR) AS money_balance, + CAST(tx_version AS CHAR) AS tx_version, + CAST(meal_auto_renew_status AS CHAR) AS meal_auto_renew_status, + auto_renew_meal_id, + CAST(djb_meal_auto_renew_status AS CHAR) AS addon_auto_renew_status, + djb_auto_renew_meal_id AS addon_auto_renew_meal_id, + meal_auto_renew_udpate_time AS meal_auto_renew_updated_at, + create_date AS created_at +FROM %s.tbl_card_wallet +WHERE iccid_mark IN ? +`, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + if row.ICCID == "" { + continue + } + out[row.ICCID] = row + } + } + return nil +} + +func loadCommissionAccounts(ctx context.Context, db *gorm.DB, schema string, agentIDs []string, out map[string]commissionAccountSnapshot) error { + for _, batch := range chunkStrings(agentIDs, defaultBatchSize) { + var rows []commissionAccountSnapshot + sql := fmt.Sprintf(` +SELECT + agent_id, + agent_name, + agent_login_account, + CAST(total_commision_amount AS CHAR) AS total_commission_amount, + CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount, + CAST(mf_commision_amount AS CHAR) AS mf_commission_amount, + CAST(nomf_commision_amount AS CHAR) AS no_mf_commission_amount, + CAST(total_draw_amount AS CHAR) AS total_draw_amount, + CAST(can_draw_amount AS CHAR) AS can_draw_amount, + CAST(applying_draw_amount AS CHAR) AS applying_draw_amount, + remark, + CAST(tx_version AS CHAR) AS tx_version, + create_date AS created_at, + update_date AS updated_at +FROM %s.tbl_agent_commission_account +WHERE agent_id IN ? +`, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + out[row.AgentID] = row + } + } + return nil +} + +func loadCommissionDetails(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]commissionDetailSnapshot, error) { + var result []commissionDetailSnapshot + for _, batch := range chunkStrings(iccids, defaultBatchSize) { + var rows []commissionDetailSnapshot + sql := fmt.Sprintf(` +SELECT + id, + commission_account_id, + wx_order_id, + wx_order_number, + iccid_mark AS iccid, + phone, + wx_order_date AS wx_order_at, + create_date AS created_at, + CAST(commission_amount AS CHAR) AS commission_amount, + CAST(total_commission_amount AS CHAR) AS total_commission_amount, + CAST(mf_status AS CHAR) AS mf_status, + CAST(commission_type AS CHAR) AS commission_type, + CAST(active_award_commision_amount AS CHAR) AS active_award_commission_amount, + CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount +FROM %s.tbl_agent_commission_detail +WHERE iccid_mark IN ? +ORDER BY create_date ASC +`, quoteSchema(schema)) + if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if row.ICCID == "" { + continue + } + result = append(result, row) + } + } + return result, nil +} + +func writeOutputs(outputDir string, data *exportData, includeCommissionDetails bool) error { + writers := []struct { + name string + writer func(string, *exportData) error + }{ + {"input_assets.csv", writeInputAssets}, + {"unresolved_inputs.csv", writeUnresolvedInputs}, + {"card_snapshot.csv", writeCardSnapshot}, + {"package_usage_snapshot.csv", writePackageUsageSnapshot}, + {"wallet_snapshot.csv", writeWalletSnapshot}, + {"commission_account_snapshot.csv", writeCommissionAccountSnapshot}, + {"missing_cards.csv", writeMissingCards}, + {"summary.txt", writeSummary}, + {"field_dictionary.csv", writeFieldDictionary}, + {"enum_dictionary.csv", writeEnumDictionary}, + {"README_zh.md", writeOutputGuide}, + } + if includeCommissionDetails { + writers = append(writers, struct { + name string + writer func(string, *exportData) error + }{"commission_detail_snapshot.csv", writeCommissionDetailSnapshot}) + } + + for _, item := range writers { + if err := item.writer(filepath.Join(outputDir, item.name), data); err != nil { + return fmt.Errorf("%s: %w", item.name, err) + } + } + return nil +} + +func writeInputAssets(path string, data *exportData) error { + return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error { + for _, item := range data.Inputs { + if err := w.Write([]string{ + strconv.Itoa(item.Line), + item.DeviceVirtualNo, + item.IMEI, + item.SN, + intString(item.SlotPosition), + slotPositionName(item.SlotPosition), + item.ICCID, + item.ResolvedBy, + resolvedByName(item.ResolvedBy), + item.ResolveNote, + }); err != nil { + return err + } + } + return nil + }) +} + +func writeUnresolvedInputs(path string, data *exportData) error { + return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error { + for _, item := range data.UnresolvedInputs { + if err := w.Write([]string{ + strconv.Itoa(item.Line), + item.DeviceVirtualNo, + item.IMEI, + item.SN, + intString(item.SlotPosition), + slotPositionName(item.SlotPosition), + item.ICCID, + item.ResolvedBy, + resolvedByName(item.ResolvedBy), + item.ResolveNote, + }); err != nil { + return err + } + } + return nil + }) +} + +func writeCardSnapshot(path string, data *exportData) error { + header := []string{ + "iccid", "input_device_virtual_no", "input_imei", "input_sn", "input_slot_positions", + "old_card_id", "old_phone", "old_phone_zuhe", "old_imei", "old_vcode", "old_status", "old_status_name", + "old_type", "old_type_name", "old_category", "old_category_name", + "old_account_id", "old_account_name", "old_agent_id", "old_agent_name", "old_agent_three_id", + "old_set_meal_id", "old_card_package", "old_expire_time", "old_flow_size_mb", "old_total_bytes_cnt_mb", + "current_used_mb_decimal", "current_used_mb_int", "old_synchro_time", "old_serv_create_at", "old_balance_money", + "suggested_tb_iot_card_data_usage_mb", "suggested_tb_iot_card_current_month_usage_mb", "suggested_tb_iot_card_last_gateway_reading_mb", + } + return writeCSV(path, header, func(w *csv.Writer) error { + for _, iccid := range sortedKeys(data.InputByICCID) { + card := data.Cards[iccid] + inputSummary := summarizeInputs(data.InputByICCID[iccid]) + usedDecimal := card.TotalBytesCnt + usedInt := int64FloorString(usedDecimal) + if err := w.Write([]string{ + iccid, + inputSummary.DeviceVirtualNos, + inputSummary.IMEIs, + inputSummary.SNs, + inputSummary.Slots, + card.CardID, + card.Phone, + card.PhoneZuhe, + card.OldIMEI, + card.OldVCode, + card.Status, + oldCardStatusName(card.Status), + card.Type, + oldCardTypeName(card.Type), + card.Category, + oldCardCategoryName(card.Category, card.CategoryName), + card.AccountID, + card.AccountName, + card.AgentID, + card.AgentName, + card.AgentThreeID, + card.SetMealID, + card.CardPackage, + card.ExpireTime, + card.FlowSize, + card.TotalBytesCnt, + usedDecimal, + int64String(usedInt), + card.SynchroTime, + formatTime(card.ServCreateAt), + card.BalanceMoney, + int64String(usedInt), + usedDecimal, + usedDecimal, + }); err != nil { + return err + } + } + return nil + }) +} + +func writePackageUsageSnapshot(path string, data *exportData) error { + header := []string{ + "iccid", "input_device_virtual_no", "source_table", "source_table_name", "old_usage_id", "old_meal_id", "old_meal_name", + "old_order_id", "start_at", "expires_at", "effective_start_at", "effective_expires_at", "date_source_note", + "effect_begin_time", "effect_complete_time", "record_created_at", "record_updated_at", + "old_status", "old_status_name", "old_type", "old_type_name", "effect_type", "effect_type_name", "order_type", "order_type_name", + "flow_limit_mb", "card_current_used_mb_decimal", "allocated_used_mb", "remaining_mb", + "suggested_new_status", "suggested_new_status_name", "is_pending_next_month", + } + now := time.Now() + return writeCSV(path, header, func(w *csv.Writer) error { + for _, iccid := range sortedKeys(data.InputByICCID) { + card := data.Cards[iccid] + currentUsed := int64FloorString(card.TotalBytesCnt) + remainingUsed := currentUsed + inputSummary := summarizeInputs(data.InputByICCID[iccid]) + + rows := data.PackagesByICCID[iccid] + for _, pkg := range rows { + limit := int64FloorString(pkg.FlowSize) + allocatedUsed := int64(0) + if pkg.SourceTable == sourceCardLife && isActiveOldPackage(pkg, now) { + allocatedUsed = minInt64(remainingUsed, limit) + remainingUsed -= allocatedUsed + } + remaining := limit - allocatedUsed + if remaining < 0 { + remaining = 0 + } + suggestedStatus := suggestPackageUsageStatus(pkg, allocatedUsed, limit, now) + isPendingNextMonth := "" + if pkg.SourceTable == sourceNextMonthLife { + isPendingNextMonth = "true" + } else { + isPendingNextMonth = "false" + } + + if err := w.Write([]string{ + iccid, + inputSummary.DeviceVirtualNos, + pkg.SourceTable, + sourceTableName(pkg.SourceTable), + pkg.ID, + pkg.MealID, + pkg.MealName, + pkg.OrderID, + formatTime(pkg.StartAt), + formatTime(pkg.ExpiresAt), + formatTime(effectivePackageStartAt(pkg)), + formatTime(effectivePackageExpiresAt(pkg)), + packageDateSourceNote(pkg), + formatTime(pkg.EffectBeginTime), + formatTime(pkg.EffectCompleteTime), + formatTime(pkg.CreatedAt), + formatTime(pkg.UpdatedAt), + pkg.Status, + oldPackageStatusName(pkg.SourceTable, pkg.Status), + pkg.Type, + oldPackageTypeName(pkg.Type), + pkg.EffectType, + oldEffectTypeName(pkg.EffectType), + pkg.OrderType, + oldOrderTypeName(pkg.OrderType), + int64String(limit), + card.TotalBytesCnt, + int64String(allocatedUsed), + int64String(remaining), + suggestedStatus, + newPackageUsageStatusName(suggestedStatus), + isPendingNextMonth, + }); err != nil { + return err + } + } + } + return nil + }) +} + +func writeWalletSnapshot(path string, data *exportData) error { + header := []string{ + "iccid", "money_balance_yuan", "money_balance_fen", "tx_version", + "meal_auto_renew_status", "meal_auto_renew_status_name", "auto_renew_meal_id", + "addon_auto_renew_status", "addon_auto_renew_status_name", "addon_auto_renew_meal_id", + "meal_auto_renew_updated_at", "created_at", + } + return writeCSV(path, header, func(w *csv.Writer) error { + for _, iccid := range sortedKeys(data.InputByICCID) { + wallet := data.Wallets[iccid] + if err := w.Write([]string{ + iccid, + wallet.MoneyBalance, + decimalStringToCentsString(wallet.MoneyBalance), + wallet.TxVersion, + wallet.MealAutoRenewStatus, + autoRenewStatusName(wallet.MealAutoRenewStatus), + wallet.AutoRenewMealID, + wallet.AddonAutoRenewStatus, + autoRenewStatusName(wallet.AddonAutoRenewStatus), + wallet.AddonAutoRenewMealID, + formatTime(wallet.MealAutoRenewUpdatedAt), + formatTime(wallet.CreatedAt), + }); err != nil { + return err + } + } + return nil + }) +} + +func writeCommissionAccountSnapshot(path string, data *exportData) error { + header := []string{ + "old_agent_id", "old_agent_name", "old_agent_login_account", "suggested_new_shop_id", + "total_commission_yuan", "total_commission_fen", + "total_active_award_commission_yuan", "total_active_award_commission_fen", + "mf_commission_yuan", "mf_commission_fen", + "nomf_commission_yuan", "nomf_commission_fen", + "total_draw_yuan", "total_draw_fen", + "can_draw_yuan", "can_draw_fen", + "applying_draw_yuan", "applying_draw_fen", + "remark", "tx_version", "created_at", "updated_at", + } + return writeCSV(path, header, func(w *csv.Writer) error { + for _, agentID := range sortedKeys(data.CommissionAccounts) { + account := data.CommissionAccounts[agentID] + if err := w.Write([]string{ + account.AgentID, + account.AgentName, + account.AgentLoginAccount, + "", + account.TotalCommissionAmount, + decimalStringToCentsString(account.TotalCommissionAmount), + account.TotalActiveAwardCommissionAmount, + decimalStringToCentsString(account.TotalActiveAwardCommissionAmount), + account.MFCommissionAmount, + decimalStringToCentsString(account.MFCommissionAmount), + account.NoMFCommissionAmount, + decimalStringToCentsString(account.NoMFCommissionAmount), + account.TotalDrawAmount, + decimalStringToCentsString(account.TotalDrawAmount), + account.CanDrawAmount, + decimalStringToCentsString(account.CanDrawAmount), + account.ApplyingDrawAmount, + decimalStringToCentsString(account.ApplyingDrawAmount), + account.Remark, + account.TxVersion, + formatTime(account.CreatedAt), + formatTime(account.UpdatedAt), + }); err != nil { + return err + } + } + return nil + }) +} + +func writeCommissionDetailSnapshot(path string, data *exportData) error { + header := []string{ + "id", "commission_account_id", "wx_order_id", "wx_order_number", "iccid", "phone", + "wx_order_at", "created_at", "commission_amount_yuan", "commission_amount_fen", + "total_commission_amount_yuan", "total_commission_amount_fen", "mf_status", "mf_status_name", "commission_type", "commission_type_name", + "active_award_commission_amount_yuan", "active_award_commission_amount_fen", + "total_active_award_commission_amount_yuan", "total_active_award_commission_amount_fen", + } + return writeCSV(path, header, func(w *csv.Writer) error { + for _, item := range data.CommissionDetails { + if err := w.Write([]string{ + item.ID, + item.CommissionAccountID, + item.WxOrderID, + item.WxOrderNumber, + item.ICCID, + item.Phone, + formatTime(item.WxOrderAt), + formatTime(item.CreatedAt), + item.CommissionAmount, + decimalStringToCentsString(item.CommissionAmount), + item.TotalCommissionAmount, + decimalStringToCentsString(item.TotalCommissionAmount), + item.MFStatus, + mfStatusName(item.MFStatus), + item.CommissionType, + commissionTypeName(item.CommissionType), + item.ActiveAwardCommissionAmount, + decimalStringToCentsString(item.ActiveAwardCommissionAmount), + item.TotalActiveAwardCommissionAmount, + decimalStringToCentsString(item.TotalActiveAwardCommissionAmount), + }); err != nil { + return err + } + } + return nil + }) +} + +func writeMissingCards(path string, data *exportData) error { + return writeCSV(path, []string{"iccid"}, func(w *csv.Writer) error { + for _, iccid := range data.MissingICCIDs { + if err := w.Write([]string{iccid}); err != nil { + return err + } + } + return nil + }) +} + +func writeSummary(path string, data *exportData) error { + packageCount := 0 + for _, rows := range data.PackagesByICCID { + packageCount += len(rows) + } + content := strings.Builder{} + content.WriteString("老系统设备迁移快照导出摘要\n") + content.WriteString("================================\n") + content.WriteString(fmt.Sprintf("生成时间: %s\n", time.Now().Format(timeFormat))) + content.WriteString(fmt.Sprintf("已解析输入关系行数: %d\n", len(data.Inputs))) + content.WriteString(fmt.Sprintf("未解析输入行数: %d\n", len(data.UnresolvedInputs))) + content.WriteString(fmt.Sprintf("输入 ICCID 数: %d\n", len(data.InputByICCID))) + content.WriteString(fmt.Sprintf("老库卡本体命中数: %d\n", len(data.Cards))) + content.WriteString(fmt.Sprintf("老库缺失 ICCID 数: %d\n", len(data.MissingICCIDs))) + content.WriteString(fmt.Sprintf("套餐快照行数: %d\n", packageCount)) + content.WriteString(fmt.Sprintf("钱包快照行数: %d\n", len(data.Wallets))) + content.WriteString(fmt.Sprintf("佣金账户行数: %d\n", len(data.CommissionAccounts))) + content.WriteString(fmt.Sprintf("佣金明细行数: %d\n", len(data.CommissionDetails))) + content.WriteString("\n说明:\n") + content.WriteString("- 本工具只导出当前业务快照,不导出日流量记录。\n") + content.WriteString("- 输入 CSV 可以不提供 SN 和 ICCID;不提供 ICCID 时会按老库 tbl_card.vcode/imei 反查 ICCID,并通过 tbl_card_relate 展开关联卡。\n") + content.WriteString("- unresolved_inputs.csv 中的行没有解析出 ICCID,需要补设备号/IMEI/ICCID 后再重新导出。\n") + content.WriteString("- 字段含义看 field_dictionary.csv;状态码和类型码看 enum_dictionary.csv;阅读入口看 README_zh.md。\n") + content.WriteString("- package_usage_snapshot.csv 中 allocated_used_mb/remaining_mb 是按老卡当前已用量顺序分摊后的建议值,后续导入新库前仍需结合套餐映射复核。\n") + content.WriteString("- commission_account_snapshot.csv 预留 suggested_new_shop_id 空列,后续导入时填新系统 shop_id。\n") + return os.WriteFile(path, []byte(content.String()), 0644) +} + +func writeCSV(path string, header []string, fill func(*csv.Writer) error) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + writer := csv.NewWriter(file) + if err := writer.Write(header); err != nil { + return err + } + if err := fill(writer); err != nil { + return err + } + writer.Flush() + return writer.Error() +} + +type inputSummary struct { + DeviceVirtualNos string + IMEIs string + SNs string + Slots string +} + +func summarizeInputs(inputs []inputAsset) inputSummary { + deviceNos := make(map[string]bool) + imeis := make(map[string]bool) + sns := make(map[string]bool) + slots := make(map[string]bool) + for _, item := range inputs { + addNonEmpty(deviceNos, item.DeviceVirtualNo) + addNonEmpty(imeis, item.IMEI) + addNonEmpty(sns, item.SN) + if item.SlotPosition > 0 { + slots[strconv.Itoa(item.SlotPosition)] = true + } + } + return inputSummary{ + DeviceVirtualNos: strings.Join(sortedKeys(deviceNos), "|"), + IMEIs: strings.Join(sortedKeys(imeis), "|"), + SNs: strings.Join(sortedKeys(sns), "|"), + Slots: strings.Join(sortedKeys(slots), "|"), + } +} + +func addNonEmpty(set map[string]bool, value string) { + if value != "" { + set[value] = true + } +} + +func collectAgentIDs(cards map[string]cardSnapshot) []string { + set := make(map[string]bool) + for _, card := range cards { + addNonEmpty(set, card.AgentID) + } + return sortedKeys(set) +} + +func isActiveOldPackage(pkg packageSnapshot, now time.Time) bool { + if pkg.Status != "1" { + return false + } + if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) { + return false + } + return true +} + +func suggestPackageUsageStatus(pkg packageSnapshot, used, limit int64, now time.Time) string { + if pkg.SourceTable == sourceNextMonthLife { + return "0" + } + if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) { + return "3" + } + if limit > 0 && used >= limit { + return "2" + } + if pkg.Status == "1" { + return "1" + } + return pkg.Status +} + +func effectivePackageStartAt(pkg packageSnapshot) *time.Time { + switch pkg.SourceTable { + case sourceNextMonthLife: + if pkg.EffectBeginTime != nil { + return pkg.EffectBeginTime + } + case sourceCardLife: + if pkg.StartAt != nil { + return pkg.StartAt + } + } + return pkg.CreatedAt +} + +func effectivePackageExpiresAt(pkg packageSnapshot) *time.Time { + if pkg.ExpiresAt != nil { + return pkg.ExpiresAt + } + return nil +} + +func packageDateSourceNote(pkg packageSnapshot) string { + if pkg.SourceTable == sourceNextMonthLife { + if pkg.EffectBeginTime != nil { + return "待生效套餐开始时间来自 tbl_next_month_card_life.effect_begin_time;老库未提供到期时间" + } + if pkg.CreatedAt != nil { + return "待生效套餐 effect_begin_time 为空,effective_start_at 使用 create_date 兜底;老库未提供到期时间" + } + return "待生效套餐没有开始/到期时间" + } + if pkg.StartAt != nil && pkg.ExpiresAt != nil { + return "开始时间来自 tbl_card_life.start_date,到期时间来自 tbl_card_life.expire_date" + } + if pkg.StartAt == nil && pkg.CreatedAt != nil && pkg.ExpiresAt != nil { + return "tbl_card_life.start_date 为空,effective_start_at 使用 create_time 兜底;到期时间来自 expire_date" + } + if pkg.StartAt == nil && pkg.CreatedAt != nil { + return "tbl_card_life.start_date 为空,effective_start_at 使用 create_time 兜底" + } + if pkg.ExpiresAt != nil { + return "开始时间为空;到期时间来自 tbl_card_life.expire_date" + } + return "老库套餐记录没有开始/到期时间" +} + +func decimalStringToCentsString(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + r, ok := new(big.Rat).SetString(strings.TrimSpace(value)) + if !ok { + return "" + } + r.Mul(r, big.NewRat(100, 1)) + result := new(big.Int) + // 金额只用于迁移快照,采用四舍五入到分。 + if r.Sign() >= 0 { + r.Add(r, big.NewRat(1, 2)) + } else { + r.Sub(r, big.NewRat(1, 2)) + } + result.Quo(r.Num(), r.Denom()) + return result.String() +} + +func int64FloorString(value string) int64 { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0 + } + return int64(math.Floor(f)) +} + +func parseIntDefault(value string, defaultValue int) int { + if value == "" { + return defaultValue + } + n, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + return n +} + +func formatTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.In(time.Local).Format(timeFormat) +} + +func timePtrBefore(left, right *time.Time) bool { + if left == nil && right == nil { + return false + } + if left == nil { + return false + } + if right == nil { + return true + } + return left.Before(*right) +} + +func chunkStrings(values []string, size int) [][]string { + if size <= 0 { + size = defaultBatchSize + } + var chunks [][]string + for start := 0; start < len(values); start += size { + end := start + size + if end > len(values) { + end = len(values) + } + chunks = append(chunks, values[start:end]) + } + return chunks +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func quoteSchema(schema string) string { + return "`" + schema + "`" +} + +func minInt64(a, b int64) int64 { + if a < b { + return a + } + return b +} + +func intString(value int) string { + if value == 0 { + return "" + } + return strconv.Itoa(value) +} + +func int64String(value int64) string { + return strconv.FormatInt(value, 10) +} + +func getenvDefault(key, defaultValue string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return defaultValue + } + return value +} + +func exitWithError(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} + +type fieldDefinition struct { + FileName string + ColumnName string + ChineseName string + Description string +} + +type enumDefinition struct { + FileName string + ColumnName string + Value string + Meaning string + Source string + Note string +} + +var oldCardStatusNames = map[string]string{ + "1": "待激活", + "2": "正常", + "3": "断网", + "4": "停机", + "5": "异常", + "6": "强制停机", + "7": "注销", +} + +var oldCardTypeNames = map[string]string{ + "1": "包月", + "2": "包年", +} + +var oldCategoryNames = map[string]string{ + "1": "电信", + "2": "其他卡务", + "3": "移动", + "4": "联通", + "5": "dcp电信", + "9": "中国联通", + "10": "D移动", + "21": "S物联", + "22": "W物联", + "23": "L物联", + "24": "P物联", + "25": "D物联", + "26": "M物联", + "27": "WX物联", + "28": "CH物联", + "31": "YW物联", + "40": "XS物联", + "56": "ZT物联", + "67": "TY物联", + "70": "XR物联", + "71": "ZYM物联", + "89": "LW物联", + "94": "DC物联", + "108": "CMP5G", + "112": "GD物联", + "113": "GDIot", + "118": "GDWL", + "124": "GS电信", + "125": "GS联通", + "126": "GS移动", +} + +var oldCardLifeStatusNames = map[string]string{ + "1": "正常", + "2": "停卡", +} + +var oldNextMonthLifeStatusNames = map[string]string{ + "1": "未生效", + "2": "已生效,已插入 tbl_card_life", +} + +var oldPackageTypeNames = map[string]string{ + "1": "叠加包", + "2": "月卡", + "3": "季卡", + "4": "半年卡", + "5": "年卡", +} + +var oldEffectTypeNames = map[string]string{ + "1": "本月生效", + "2": "次月生效", +} + +var oldOrderTypeNames = map[string]string{ + "1": "微信订单", + "2": "后台订单", +} + +var autoRenewStatusNames = map[string]string{ + "1": "启用", + "2": "关闭", +} + +var newPackageUsageStatusNames = map[string]string{ + "0": "待生效", + "1": "生效中", + "2": "已用完", + "3": "已过期", + "4": "已失效", +} + +var mfStatusNames = map[string]string{ + "0": "成功", + "1": "失败", +} + +var commissionTypeNames = map[string]string{ + "1": "微信订单佣金", + "2": "激活奖励佣金", + "3": "微信订单佣金扣回", + "4": "激活奖励佣金扣回", +} + +func writeFieldDictionary(path string, data *exportData) error { + fields := []fieldDefinition{ + {"input_assets.csv", "line", "输入行号", "原始输入 CSV 中的行号,便于回查业务给的源数据。"}, + {"input_assets.csv", "device_virtual_no", "输入设备号", "输入 CSV 中识别到的设备号/机器码。"}, + {"input_assets.csv", "imei", "输入 IMEI", "输入 CSV 中识别到的 IMEI;老系统接口参数 mchno 实际通常对应 tbl_card.vcode。"}, + {"input_assets.csv", "sn", "输入 SN", "输入 CSV 中透传的 SN,可为空。"}, + {"input_assets.csv", "slot_position", "卡槽序号", "脚本按老系统关联表展开后的卡槽:1 主卡,2 副卡1,3 副卡2。"}, + {"input_assets.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"}, + {"input_assets.csv", "iccid", "ICCID", "解析出来的老系统卡 ICCID。"}, + {"input_assets.csv", "resolved_by", "解析方式", "说明 ICCID 是由输入直接提供,还是按 tbl_card.vcode/imei 及 tbl_card_relate 反查得到。"}, + {"input_assets.csv", "resolved_by_name", "解析方式名称", "解析方式的中文摘要。"}, + {"input_assets.csv", "resolve_note", "解析说明", "更详细的解析过程说明。"}, + {"unresolved_inputs.csv", "line", "输入行号", "原始输入 CSV 中无法解析出 ICCID 的行号。"}, + {"unresolved_inputs.csv", "device_virtual_no", "输入设备号", "未解析行中的设备号/机器码。"}, + {"unresolved_inputs.csv", "imei", "输入 IMEI", "未解析行中的 IMEI。"}, + {"unresolved_inputs.csv", "sn", "输入 SN", "未解析行中的 SN。"}, + {"unresolved_inputs.csv", "slot_position", "卡槽序号", "未解析行中的卡槽序号。"}, + {"unresolved_inputs.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"}, + {"unresolved_inputs.csv", "iccid", "ICCID", "未解析行通常为空;若有值仍未解析,需要人工核对。"}, + {"unresolved_inputs.csv", "resolved_by", "解析方式", "未解析状态码。"}, + {"unresolved_inputs.csv", "resolved_by_name", "解析方式名称", "未解析状态的中文摘要。"}, + {"unresolved_inputs.csv", "resolve_note", "未解析原因", "说明为什么无法从老系统反查到 ICCID。"}, + {"card_snapshot.csv", "iccid", "ICCID", "老系统卡 ICCID,也是后续新系统导入时对齐卡的关键标识。"}, + {"card_snapshot.csv", "input_device_virtual_no", "输入设备号", "该卡来自哪些输入设备号;多个值用 | 分隔。"}, + {"card_snapshot.csv", "input_imei", "输入 IMEI", "该卡来自哪些输入 IMEI;多个值用 | 分隔。"}, + {"card_snapshot.csv", "input_sn", "输入 SN", "该卡来自哪些输入 SN;多个值用 | 分隔。"}, + {"card_snapshot.csv", "input_slot_positions", "输入卡槽", "该卡在设备关联中的卡槽序号;多个值用 | 分隔。"}, + {"card_snapshot.csv", "old_card_id", "老卡 ID", "老系统 tbl_card.id。"}, + {"card_snapshot.csv", "old_phone", "老系统号码", "老系统 tbl_card.phone,一般为 MSISDN/卡号展示字段。"}, + {"card_snapshot.csv", "old_phone_zuhe", "组合号码", "老系统 tbl_card.phone_zuhe。"}, + {"card_snapshot.csv", "old_imei", "老系统 IMEI", "老系统 tbl_card.imei;本批样本中很多为空。"}, + {"card_snapshot.csv", "old_vcode", "老系统设备码", "老系统 tbl_card.vcode;老接口 mchno 通常按这个字段查询。"}, + {"card_snapshot.csv", "old_status", "老卡状态值", "老系统 tbl_card.status 原值。"}, + {"card_snapshot.csv", "old_status_name", "老卡状态", "old_status 的中文含义。"}, + {"card_snapshot.csv", "old_type", "老卡类型值", "老系统 tbl_card.type 原值。"}, + {"card_snapshot.csv", "old_type_name", "老卡类型", "old_type 的中文含义。"}, + {"card_snapshot.csv", "old_category", "老运营商分类值", "老系统 tbl_card.category 原值。"}, + {"card_snapshot.csv", "old_category_name", "老运营商分类", "优先来自 tbl_vendor_category.category_name。"}, + {"card_snapshot.csv", "old_account_id", "老供应账号 ID", "老系统 tbl_card.account_id。"}, + {"card_snapshot.csv", "old_account_name", "老供应账号名称", "老系统 tbl_card.account_name。"}, + {"card_snapshot.csv", "old_agent_id", "老代理 ID", "老系统 tbl_card.agent_id。"}, + {"card_snapshot.csv", "old_agent_name", "老代理名称", "老系统 tbl_card.agent_name。"}, + {"card_snapshot.csv", "old_agent_three_id", "三级代理 ID", "老系统 tbl_card.agent_three_id。"}, + {"card_snapshot.csv", "old_set_meal_id", "老套餐 ID", "老系统 tbl_card.set_meal_id。"}, + {"card_snapshot.csv", "old_card_package", "老套餐名称", "老系统 tbl_card.card_package。"}, + {"card_snapshot.csv", "old_expire_time", "老卡到期时间", "老系统 tbl_card.expire_time;行业卡可能有值,普通设备套餐以 package_usage_snapshot.csv 为准。"}, + {"card_snapshot.csv", "old_flow_size_mb", "老套餐总量 MB", "老系统 tbl_card.flow_size。"}, + {"card_snapshot.csv", "old_total_bytes_cnt_mb", "老网关累计已用 MB", "老系统 tbl_card.total_bytes_cnt 原值。"}, + {"card_snapshot.csv", "current_used_mb_decimal", "当前已用 MB", "迁移时建议保留的小数已用量。"}, + {"card_snapshot.csv", "current_used_mb_int", "当前已用 MB 整数", "向下取整后的已用量,便于新系统整数/DECIMAL 字段导入。"}, + {"card_snapshot.csv", "old_synchro_time", "老同步时间", "老系统 tbl_card.synchro_time。"}, + {"card_snapshot.csv", "old_serv_create_at", "运营商开户时间", "老系统 tbl_card.serv_create_date。"}, + {"card_snapshot.csv", "old_balance_money", "老余额字段", "老系统 tbl_card.balance_money。"}, + {"card_snapshot.csv", "suggested_tb_iot_card_data_usage_mb", "建议卡累计用量", "建议导入新系统 tb_iot_card.data_usage_mb。"}, + {"card_snapshot.csv", "suggested_tb_iot_card_current_month_usage_mb", "建议自然月已用量", "建议导入新系统 tb_iot_card.current_month_usage_mb;需结合新系统口径复核。"}, + {"card_snapshot.csv", "suggested_tb_iot_card_last_gateway_reading_mb", "建议网关读数基线", "建议导入新系统 tb_iot_card.last_gateway_reading_mb,避免首轮轮询重复扣历史流量。"}, + {"package_usage_snapshot.csv", "iccid", "ICCID", "套餐所属老卡 ICCID。"}, + {"package_usage_snapshot.csv", "input_device_virtual_no", "输入设备号", "该套餐所属卡来自哪些输入设备号;多个值用 | 分隔。"}, + {"package_usage_snapshot.csv", "source_table", "来源表", "tbl_card_life 表示当前/历史套餐,tbl_next_month_card_life 表示次月待生效套餐。"}, + {"package_usage_snapshot.csv", "source_table_name", "来源表名称", "来源表的中文说明。"}, + {"package_usage_snapshot.csv", "old_usage_id", "老套餐记录 ID", "老系统套餐使用记录主键。"}, + {"package_usage_snapshot.csv", "old_meal_id", "老套餐 ID", "老系统套餐 ID。"}, + {"package_usage_snapshot.csv", "old_meal_name", "老套餐名称", "老系统套餐名称。"}, + {"package_usage_snapshot.csv", "old_order_id", "老订单 ID", "老系统订单 ID。"}, + {"package_usage_snapshot.csv", "start_at", "开始时间", "tbl_card_life.start_date。"}, + {"package_usage_snapshot.csv", "expires_at", "到期时间", "tbl_card_life.expire_date。"}, + {"package_usage_snapshot.csv", "effective_start_at", "有效开始时间", "迁移参考开始时间;优先使用 start_at,老库 start_at 为空时使用记录创建时间兜底。"}, + {"package_usage_snapshot.csv", "effective_expires_at", "有效到期时间", "迁移参考到期时间;当前仅使用老库 expire_date,待生效套餐老库通常没有到期时间。"}, + {"package_usage_snapshot.csv", "date_source_note", "时间来源说明", "说明开始/到期时间来自哪个老库字段,以及是否使用兜底。"}, + {"package_usage_snapshot.csv", "effect_begin_time", "待生效开始时间", "tbl_next_month_card_life.effect_begin_time。"}, + {"package_usage_snapshot.csv", "effect_complete_time", "待生效完成时间", "tbl_next_month_card_life.effect_complete_time。"}, + {"package_usage_snapshot.csv", "record_created_at", "套餐记录创建时间", "tbl_card_life.create_time 或 tbl_next_month_card_life.create_date。"}, + {"package_usage_snapshot.csv", "record_updated_at", "套餐记录更新时间", "tbl_card_life.update_time;次月待生效表没有对应字段。"}, + {"package_usage_snapshot.csv", "old_status", "老套餐状态值", "来源表中的 status 原值。"}, + {"package_usage_snapshot.csv", "old_status_name", "老套餐状态", "old_status 的中文含义;不同来源表含义不同。"}, + {"package_usage_snapshot.csv", "old_type", "老套餐类型值", "tbl_card_life.type 原值。"}, + {"package_usage_snapshot.csv", "old_type_name", "老套餐类型", "old_type 的中文含义。"}, + {"package_usage_snapshot.csv", "effect_type", "生效类型值", "tbl_card_life.effect_type 原值。"}, + {"package_usage_snapshot.csv", "effect_type_name", "生效类型", "effect_type 的中文含义。"}, + {"package_usage_snapshot.csv", "order_type", "订单类型值", "tbl_next_month_card_life.order_type 原值。"}, + {"package_usage_snapshot.csv", "order_type_name", "订单类型", "order_type 的中文含义。"}, + {"package_usage_snapshot.csv", "flow_limit_mb", "套餐总量 MB", "老套餐记录 flow_size。"}, + {"package_usage_snapshot.csv", "card_current_used_mb_decimal", "卡当前已用 MB", "来自 card_snapshot 的当前已用量。"}, + {"package_usage_snapshot.csv", "allocated_used_mb", "建议套餐已用 MB", "按当前生效套餐顺序从卡已用量中分摊出来的迁移建议值。"}, + {"package_usage_snapshot.csv", "remaining_mb", "建议套餐剩余 MB", "flow_limit_mb - allocated_used_mb。"}, + {"package_usage_snapshot.csv", "suggested_new_status", "建议新套餐状态值", "建议导入新系统 tb_package_usage.status。"}, + {"package_usage_snapshot.csv", "suggested_new_status_name", "建议新套餐状态", "suggested_new_status 的中文含义。"}, + {"package_usage_snapshot.csv", "is_pending_next_month", "是否次月待生效", "true 表示来源于 tbl_next_month_card_life。"}, + {"wallet_snapshot.csv", "iccid", "ICCID", "钱包所属老卡 ICCID。"}, + {"wallet_snapshot.csv", "money_balance_yuan", "钱包余额元", "老系统 tbl_card_wallet.money_balance。"}, + {"wallet_snapshot.csv", "money_balance_fen", "钱包余额分", "钱包余额按分换算后的整数值。"}, + {"wallet_snapshot.csv", "tx_version", "钱包版本号", "老系统钱包乐观锁版本。"}, + {"wallet_snapshot.csv", "meal_auto_renew_status", "主套餐自动续费状态值", "老系统 tbl_card_wallet.meal_auto_renew_status。"}, + {"wallet_snapshot.csv", "meal_auto_renew_status_name", "主套餐自动续费状态", "1 启用,2 关闭。"}, + {"wallet_snapshot.csv", "auto_renew_meal_id", "主套餐自动续费套餐 ID", "老系统主套餐自动续费绑定的套餐 ID。"}, + {"wallet_snapshot.csv", "addon_auto_renew_status", "叠加包自动续费状态值", "老系统 tbl_card_wallet.djb_meal_auto_renew_status。"}, + {"wallet_snapshot.csv", "addon_auto_renew_status_name", "叠加包自动续费状态", "1 启用,2 关闭。"}, + {"wallet_snapshot.csv", "addon_auto_renew_meal_id", "叠加包自动续费套餐 ID", "老系统叠加包自动续费绑定的套餐 ID。"}, + {"wallet_snapshot.csv", "meal_auto_renew_updated_at", "自动续费更新时间", "老系统 tbl_card_wallet.meal_auto_renew_udpate_time。"}, + {"wallet_snapshot.csv", "created_at", "钱包创建时间", "老系统 tbl_card_wallet.create_date。"}, + {"commission_account_snapshot.csv", "old_agent_id", "老代理 ID", "老系统佣金账户 agent_id。"}, + {"commission_account_snapshot.csv", "old_agent_name", "老代理名称", "老系统佣金账户 agent_name。"}, + {"commission_account_snapshot.csv", "old_agent_login_account", "老代理登录账号", "老系统佣金账户 agent_login_account。"}, + {"commission_account_snapshot.csv", "suggested_new_shop_id", "建议新店铺 ID", "预留给后续导入新系统时填写 shop_id。"}, + {"commission_account_snapshot.csv", "total_commission_yuan", "累计佣金元", "老佣金账户累计佣金。"}, + {"commission_account_snapshot.csv", "total_commission_fen", "累计佣金分", "累计佣金按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "total_active_award_commission_yuan", "累计激活奖励元", "老佣金账户累计激活奖励佣金。"}, + {"commission_account_snapshot.csv", "total_active_award_commission_fen", "累计激活奖励分", "累计激活奖励按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "mf_commission_yuan", "秒返佣金元", "老佣金账户秒返佣金。"}, + {"commission_account_snapshot.csv", "mf_commission_fen", "秒返佣金分", "秒返佣金按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "nomf_commission_yuan", "非秒返佣金元", "老佣金账户非秒返佣金。"}, + {"commission_account_snapshot.csv", "nomf_commission_fen", "非秒返佣金分", "非秒返佣金按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "total_draw_yuan", "累计提现元", "老佣金账户累计提现金额。"}, + {"commission_account_snapshot.csv", "total_draw_fen", "累计提现分", "累计提现金额按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "can_draw_yuan", "可提现金额元", "老佣金账户可提现余额。"}, + {"commission_account_snapshot.csv", "can_draw_fen", "可提现金额分", "可提现余额按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "applying_draw_yuan", "提现中金额元", "老佣金账户申请提现中金额。"}, + {"commission_account_snapshot.csv", "applying_draw_fen", "提现中金额分", "提现中金额按分换算后的整数值。"}, + {"commission_account_snapshot.csv", "remark", "备注", "老佣金账户备注。"}, + {"commission_account_snapshot.csv", "tx_version", "佣金账户版本号", "老佣金账户乐观锁版本。"}, + {"commission_account_snapshot.csv", "created_at", "佣金账户创建时间", "老佣金账户 create_date。"}, + {"commission_account_snapshot.csv", "updated_at", "佣金账户更新时间", "老佣金账户 update_date。"}, + {"commission_detail_snapshot.csv", "id", "佣金明细 ID", "老系统佣金明细主键。"}, + {"commission_detail_snapshot.csv", "commission_account_id", "佣金账户 ID", "老系统佣金账户 ID。"}, + {"commission_detail_snapshot.csv", "wx_order_id", "微信订单 ID", "老系统微信订单 ID。"}, + {"commission_detail_snapshot.csv", "wx_order_number", "微信订单号", "老系统微信订单号。"}, + {"commission_detail_snapshot.csv", "iccid", "ICCID", "佣金明细关联的老卡 ICCID。"}, + {"commission_detail_snapshot.csv", "phone", "手机号/卡号", "佣金明细记录的老系统 phone。"}, + {"commission_detail_snapshot.csv", "wx_order_at", "微信订单时间", "老系统 wx_order_date。"}, + {"commission_detail_snapshot.csv", "created_at", "佣金明细创建时间", "老系统 create_date。"}, + {"commission_detail_snapshot.csv", "commission_amount_yuan", "本次佣金元", "本条佣金金额。"}, + {"commission_detail_snapshot.csv", "commission_amount_fen", "本次佣金分", "本条佣金金额按分换算后的整数值。"}, + {"commission_detail_snapshot.csv", "total_commission_amount_yuan", "累计佣金元", "该明细发生后的累计佣金金额。"}, + {"commission_detail_snapshot.csv", "total_commission_amount_fen", "累计佣金分", "累计佣金金额按分换算后的整数值。"}, + {"commission_detail_snapshot.csv", "mf_status", "秒返状态值", "老系统 tbl_agent_commission_detail.mf_status。"}, + {"commission_detail_snapshot.csv", "mf_status_name", "秒返状态", "0 成功,1 失败。"}, + {"commission_detail_snapshot.csv", "commission_type", "佣金类型值", "老系统 tbl_agent_commission_detail.commission_type。"}, + {"commission_detail_snapshot.csv", "commission_type_name", "佣金类型", "commission_type 的中文含义。"}, + {"commission_detail_snapshot.csv", "active_award_commission_amount_yuan", "本次激活奖励元", "本条激活奖励佣金金额。"}, + {"commission_detail_snapshot.csv", "active_award_commission_amount_fen", "本次激活奖励分", "本条激活奖励佣金按分换算后的整数值。"}, + {"commission_detail_snapshot.csv", "total_active_award_commission_amount_yuan", "累计激活奖励元", "该明细发生后的累计激活奖励佣金。"}, + {"commission_detail_snapshot.csv", "total_active_award_commission_amount_fen", "累计激活奖励分", "累计激活奖励佣金按分换算后的整数值。"}, + {"missing_cards.csv", "iccid", "缺失 ICCID", "输入中解析到但老系统 tbl_card 未命中的 ICCID。"}, + } + + return writeCSV(path, []string{"file_name", "column_name", "chinese_name", "description"}, func(w *csv.Writer) error { + for _, item := range fields { + if err := w.Write([]string{item.FileName, item.ColumnName, item.ChineseName, item.Description}); err != nil { + return err + } + } + return nil + }) +} + +func writeEnumDictionary(path string, data *exportData) error { + enums := baseEnumDefinitions() + enums = append(enums, observedCategoryEnums(data)...) + + return writeCSV(path, []string{"file_name", "column_name", "value", "meaning", "source", "note"}, func(w *csv.Writer) error { + for _, item := range enums { + if err := w.Write([]string{item.FileName, item.ColumnName, item.Value, item.Meaning, item.Source, item.Note}); err != nil { + return err + } + } + return nil + }) +} + +func writeOutputGuide(path string, data *exportData) error { + packageCount := 0 + for _, rows := range data.PackagesByICCID { + packageCount += len(rows) + } + + content := strings.Builder{} + content.WriteString("# 老系统设备迁移导出说明\n\n") + content.WriteString(fmt.Sprintf("生成时间:%s\n\n", time.Now().Format(timeFormat))) + content.WriteString("## 优先查看\n\n") + content.WriteString("- `card_snapshot.csv`:卡本体、老卡状态、老套餐字段、当前已用量,以及新系统卡流量字段建议值。\n") + content.WriteString("- `package_usage_snapshot.csv`:老套餐记录、已用/剩余分摊建议,以及新系统套餐状态建议值。\n") + content.WriteString("- `field_dictionary.csv`:每个输出字段的中文说明。\n") + content.WriteString("- `enum_dictionary.csv`:状态码、类型码、来源表等枚举值说明。\n\n") + content.WriteString("## 本次摘要\n\n") + content.WriteString(fmt.Sprintf("- 已解析输入关系:%d 行\n", len(data.Inputs))) + content.WriteString(fmt.Sprintf("- 输入 ICCID:%d 个\n", len(data.InputByICCID))) + content.WriteString(fmt.Sprintf("- 老库卡本体命中:%d 张\n", len(data.Cards))) + content.WriteString(fmt.Sprintf("- 套餐快照:%d 行\n", packageCount)) + content.WriteString(fmt.Sprintf("- 钱包快照:%d 行\n", len(data.Wallets))) + content.WriteString(fmt.Sprintf("- 佣金账户:%d 行\n", len(data.CommissionAccounts))) + content.WriteString(fmt.Sprintf("- 未解析输入:%d 行\n", len(data.UnresolvedInputs))) + content.WriteString(fmt.Sprintf("- 老库缺失 ICCID:%d 个\n\n", len(data.MissingICCIDs))) + content.WriteString("## 口径提醒\n\n") + content.WriteString("- 英文字段名保留给后续脚本处理,中文含义请看同表的 `*_name` 列和 `field_dictionary.csv`。\n") + content.WriteString("- 老系统状态值来自老库字段注释;新系统建议状态来自当前项目常量。\n") + content.WriteString("- `suggested_tb_iot_card_last_gateway_reading_mb` 是新系统轮询的基线,导入时要保留,避免首轮轮询重复计算历史流量。\n") + content.WriteString("- `allocated_used_mb` 是迁移建议值,正式生成导入 SQL 前仍要结合新系统供应商、套餐系列、套餐映射复核。\n") + return os.WriteFile(path, []byte(content.String()), 0644) +} + +func baseEnumDefinitions() []enumDefinition { + var result []enumDefinition + appendMapEnums(&result, "card_snapshot.csv", "old_status", oldCardStatusNames, "老库 tbl_card.status 字段注释", "") + appendMapEnums(&result, "card_snapshot.csv", "old_type", oldCardTypeNames, "老库 tbl_card.type 字段注释", "") + appendMapEnums(&result, "package_usage_snapshot.csv", "source_table", map[string]string{ + sourceCardLife: sourceTableName(sourceCardLife), + sourceNextMonthLife: sourceTableName(sourceNextMonthLife), + }, "脚本来源表约定", "") + appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldCardLifeStatusNames, "老库 tbl_card_life.status 字段注释", "仅 source_table=tbl_card_life 时适用") + appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldNextMonthLifeStatusNames, "老库 tbl_next_month_card_life.status 字段注释", "仅 source_table=tbl_next_month_card_life 时适用") + appendMapEnums(&result, "package_usage_snapshot.csv", "old_type", oldPackageTypeNames, "老库 tbl_card_life.type 字段注释", "") + appendMapEnums(&result, "package_usage_snapshot.csv", "effect_type", oldEffectTypeNames, "老库 tbl_card_life.effect_type 字段注释", "") + appendMapEnums(&result, "package_usage_snapshot.csv", "order_type", oldOrderTypeNames, "老库 tbl_next_month_card_life.order_type 字段注释", "") + appendMapEnums(&result, "package_usage_snapshot.csv", "suggested_new_status", newPackageUsageStatusNames, "新系统 pkg/constants PackageUsageStatus*", "建议导入值,生成 SQL 前需复核") + appendMapEnums(&result, "wallet_snapshot.csv", "meal_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.meal_auto_renew_status 字段注释", "") + appendMapEnums(&result, "wallet_snapshot.csv", "addon_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.djb_meal_auto_renew_status 字段注释", "") + appendMapEnums(&result, "commission_detail_snapshot.csv", "mf_status", mfStatusNames, "老库 tbl_agent_commission_detail.mf_status 字段注释", "") + appendMapEnums(&result, "commission_detail_snapshot.csv", "commission_type", commissionTypeNames, "老库 tbl_agent_commission_detail.commission_type 字段注释", "") + return result +} + +func appendMapEnums(result *[]enumDefinition, fileName, columnName string, values map[string]string, source, note string) { + keys := sortedStringMapKeys(values) + for _, key := range keys { + *result = append(*result, enumDefinition{ + FileName: fileName, + ColumnName: columnName, + Value: key, + Meaning: values[key], + Source: source, + Note: note, + }) + } +} + +func observedCategoryEnums(data *exportData) []enumDefinition { + seen := make(map[string]string) + for _, card := range data.Cards { + if card.Category == "" { + continue + } + seen[card.Category] = oldCardCategoryName(card.Category, card.CategoryName) + } + keys := sortedStringMapKeys(seen) + result := make([]enumDefinition, 0, len(keys)) + for _, key := range keys { + result = append(result, enumDefinition{ + FileName: "card_snapshot.csv", + ColumnName: "old_category", + Value: key, + Meaning: seen[key], + Source: "老库 tbl_vendor_category.category_name", + Note: "仅列出本次导出中实际出现的运营商分类", + }) + } + return result +} + +func oldCardStatusName(value string) string { + return enumName(oldCardStatusNames, value) +} + +func oldCardTypeName(value string) string { + return enumName(oldCardTypeNames, value) +} + +func oldCardCategoryName(value, dbName string) string { + if strings.TrimSpace(dbName) != "" { + return dbName + } + return enumName(oldCategoryNames, value) +} + +func oldPackageStatusName(sourceTable, value string) string { + if sourceTable == sourceNextMonthLife { + return enumName(oldNextMonthLifeStatusNames, value) + } + return enumName(oldCardLifeStatusNames, value) +} + +func oldPackageTypeName(value string) string { + return enumName(oldPackageTypeNames, value) +} + +func oldEffectTypeName(value string) string { + return enumName(oldEffectTypeNames, value) +} + +func oldOrderTypeName(value string) string { + return enumName(oldOrderTypeNames, value) +} + +func autoRenewStatusName(value string) string { + return enumName(autoRenewStatusNames, value) +} + +func newPackageUsageStatusName(value string) string { + return enumName(newPackageUsageStatusNames, value) +} + +func mfStatusName(value string) string { + return enumName(mfStatusNames, value) +} + +func commissionTypeName(value string) string { + return enumName(commissionTypeNames, value) +} + +func sourceTableName(value string) string { + switch value { + case sourceCardLife: + return "当前/历史套餐记录" + case sourceNextMonthLife: + return "次月待生效套餐记录" + default: + return "" + } +} + +func slotPositionName(slot int) string { + switch slot { + case 1: + return "主卡" + case 2: + return "副卡1" + case 3: + return "副卡2" + case 4: + return "卡槽4" + default: + return "" + } +} + +func resolvedByName(value string) string { + switch { + case value == "input_iccid": + return "输入直接提供 ICCID" + case value == "input_slot_iccid": + return "输入按卡槽提供 ICCID" + case strings.Contains(value, "old_tbl_card_vcode") && strings.Contains(value, "old_tbl_card_relate"): + return "按设备码命中后展开关联卡" + case value == "old_tbl_card_vcode": + return "按老库 tbl_card.vcode 反查" + case value == "old_tbl_card_imei": + return "按老库 tbl_card.imei 反查" + case value == "old_tbl_card_relate": + return "按老库 tbl_card_relate 展开" + case value == "pending_device_key": + return "待按设备号/IMEI 解析" + case value == "unresolved": + return "无法解析" + case value == "unresolved_device_key": + return "设备号/IMEI 未命中老库" + default: + return "" + } +} + +func enumName(values map[string]string, value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if name, ok := values[value]; ok { + return name + } + return "未知值" +} + +func sortedStringMapKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/scripts/refund_wallet_reset_audit.sql b/scripts/refund_wallet_reset_audit.sql new file mode 100644 index 0000000..5f2e8b6 --- /dev/null +++ b/scripts/refund_wallet_reset_audit.sql @@ -0,0 +1,77 @@ +-- 盘点退款误重置资产钱包的候选记录。 +-- 只读脚本:不会修改任何数据。 +WITH refund_resources AS ( + SELECT + r.id AS refund_id, + r.refund_no, + r.order_id, + o.order_no, + o.generation AS order_generation, + r.processed_at, + COALESCE(r.order_type, o.order_type) AS order_type, + CASE + WHEN COALESCE(r.iot_card_id, o.iot_card_id) IS NOT NULL THEN 'iot_card' + ELSE 'device' + END AS resource_type, + COALESCE(r.iot_card_id, o.iot_card_id, r.device_id, o.device_id) AS resource_id + FROM tb_refund_request r + JOIN tb_order o ON o.id = r.order_id + WHERE r.deleted_at IS NULL + AND r.status = 2 + AND r.asset_reset = TRUE +), +wallets AS ( + SELECT + rr.*, + active_wallet.id AS active_wallet_id, + active_wallet.balance AS active_balance, + active_wallet.frozen_balance AS active_frozen_balance, + active_wallet.created_at AS active_wallet_created_at, + deleted_wallet.id AS deleted_wallet_id, + deleted_wallet.balance AS deleted_balance, + deleted_wallet.frozen_balance AS deleted_frozen_balance, + deleted_wallet.deleted_at AS deleted_wallet_deleted_at, + COALESCE(active_tx.tx_count, 0) AS active_wallet_tx_count, + COALESCE(deleted_tx.tx_count, 0) AS deleted_wallet_tx_count + FROM refund_resources rr + LEFT JOIN LATERAL ( + SELECT * + FROM tb_asset_wallet w + WHERE w.resource_type = rr.resource_type + AND w.resource_id = rr.resource_id + AND w.deleted_at IS NULL + ORDER BY w.created_at DESC, w.id DESC + LIMIT 1 + ) active_wallet ON TRUE + LEFT JOIN LATERAL ( + SELECT * + FROM tb_asset_wallet w + WHERE w.resource_type = rr.resource_type + AND w.resource_id = rr.resource_id + AND w.deleted_at IS NOT NULL + ORDER BY w.deleted_at DESC, w.id DESC + LIMIT 1 + ) deleted_wallet ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS tx_count + FROM tb_asset_wallet_transaction t + WHERE t.asset_wallet_id = active_wallet.id + AND t.deleted_at IS NULL + ) active_tx ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS tx_count + FROM tb_asset_wallet_transaction t + WHERE t.asset_wallet_id = deleted_wallet.id + AND t.deleted_at IS NULL + ) deleted_tx ON TRUE +) +SELECT + w.*, + CASE + WHEN w.deleted_wallet_id IS NULL THEN 'no_deleted_wallet' + WHEN w.active_wallet_id IS NULL THEN 'no_active_wallet' + WHEN w.active_wallet_tx_count = 0 AND w.active_balance = 0 AND w.active_frozen_balance = 0 THEN 'restore_old_wallet' + ELSE 'merge_old_balance_manual_review' + END AS suggested_action +FROM wallets w +ORDER BY w.refund_id DESC; diff --git a/scripts/refund_wallet_reset_repair.sql b/scripts/refund_wallet_reset_repair.sql new file mode 100644 index 0000000..e0e643b --- /dev/null +++ b/scripts/refund_wallet_reset_repair.sql @@ -0,0 +1,154 @@ +-- 修复退款误重置资产钱包的候选记录。 +-- 执行前先运行 scripts/refund_wallet_reset_audit.sql 复核输出。 +-- 幂等策略: +-- 1. 新钱包无余额变动时,软删新钱包并恢复旧钱包。 +-- 2. 新钱包已有余额或流水时,将旧钱包余额合并到新钱包,并把旧钱包余额清零,避免重复合并。 +BEGIN; + +CREATE TEMP TABLE refund_wallet_reset_targets ON COMMIT DROP AS +WITH refund_resources AS ( + SELECT + r.id AS refund_id, + r.refund_no, + r.order_id, + o.order_no, + o.generation AS order_generation, + r.processed_at, + CASE + WHEN COALESCE(r.iot_card_id, o.iot_card_id) IS NOT NULL THEN 'iot_card' + ELSE 'device' + END AS resource_type, + COALESCE(r.iot_card_id, o.iot_card_id, r.device_id, o.device_id) AS resource_id + FROM tb_refund_request r + JOIN tb_order o ON o.id = r.order_id + WHERE r.deleted_at IS NULL + AND r.status = 2 + AND r.asset_reset = TRUE +), +wallet_pairs AS ( + SELECT + rr.*, + active_wallet.id AS active_wallet_id, + active_wallet.balance AS active_balance, + active_wallet.frozen_balance AS active_frozen_balance, + deleted_wallet.id AS deleted_wallet_id, + deleted_wallet.balance AS deleted_balance, + deleted_wallet.frozen_balance AS deleted_frozen_balance, + COALESCE(active_tx.tx_count, 0) AS active_wallet_tx_count + FROM refund_resources rr + JOIN LATERAL ( + SELECT * + FROM tb_asset_wallet w + WHERE w.resource_type = rr.resource_type + AND w.resource_id = rr.resource_id + AND w.deleted_at IS NULL + ORDER BY w.created_at DESC, w.id DESC + LIMIT 1 + ) active_wallet ON TRUE + JOIN LATERAL ( + SELECT * + FROM tb_asset_wallet w + WHERE w.resource_type = rr.resource_type + AND w.resource_id = rr.resource_id + AND w.deleted_at IS NOT NULL + AND (w.balance <> 0 OR w.frozen_balance <> 0) + ORDER BY w.deleted_at DESC, w.id DESC + LIMIT 1 + ) deleted_wallet ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS tx_count + FROM tb_asset_wallet_transaction t + WHERE t.asset_wallet_id = active_wallet.id + AND t.deleted_at IS NULL + ) active_tx ON TRUE +) +SELECT + *, + CASE + WHEN active_wallet_tx_count = 0 AND active_balance = 0 AND active_frozen_balance = 0 THEN 'restore_old_wallet' + ELSE 'merge_old_balance' + END AS repair_mode +FROM wallet_pairs; + +-- 新钱包没有后续余额变动:先软删新钱包,再恢复旧钱包,避免唯一索引冲突。 +UPDATE tb_asset_wallet w +SET deleted_at = NOW(), + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.repair_mode = 'restore_old_wallet' + AND w.id = t.active_wallet_id + AND w.deleted_at IS NULL; + +UPDATE tb_asset_wallet w +SET deleted_at = NULL, + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.repair_mode = 'restore_old_wallet' + AND w.id = t.deleted_wallet_id + AND w.deleted_at IS NOT NULL; + +-- 新钱包已有后续余额变动:合并旧钱包余额,并清零旧钱包余额,保证重复执行不会二次入账。 +UPDATE tb_asset_wallet w +SET balance = w.balance + t.deleted_balance, + frozen_balance = w.frozen_balance + t.deleted_frozen_balance, + version = w.version + 1, + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.repair_mode = 'merge_old_balance' + AND w.id = t.active_wallet_id + AND w.deleted_at IS NULL; + +UPDATE tb_asset_wallet w +SET balance = 0, + frozen_balance = 0, + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.repair_mode = 'merge_old_balance' + AND w.id = t.deleted_wallet_id + AND w.deleted_at IS NOT NULL; + +-- 安全恢复单次误增的资产世代;复杂场景保留给人工复核。 +UPDATE tb_iot_card c +SET generation = t.order_generation, + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.resource_type = 'iot_card' + AND c.id = t.resource_id + AND c.generation = t.order_generation + 1 + AND NOT EXISTS ( + SELECT 1 + FROM tb_exchange_order e + WHERE e.deleted_at IS NULL + AND e.status = 4 + AND e.old_asset_type = t.resource_type + AND e.old_asset_id = t.resource_id + AND e.created_at >= t.processed_at + ); + +UPDATE tb_device d +SET generation = t.order_generation, + updated_at = NOW() +FROM refund_wallet_reset_targets t +WHERE t.resource_type = 'device' + AND d.id = t.resource_id + AND d.generation = t.order_generation + 1 + AND NOT EXISTS ( + SELECT 1 + FROM tb_exchange_order e + WHERE e.deleted_at IS NULL + AND e.status = 4 + AND e.old_asset_type = t.resource_type + AND e.old_asset_id = t.resource_id + AND e.created_at >= t.processed_at + ); + +SELECT + repair_mode, + COUNT(*) AS affected_resources, + SUM(deleted_balance) AS restored_balance, + SUM(deleted_frozen_balance) AS restored_frozen_balance +FROM refund_wallet_reset_targets +GROUP BY repair_mode +ORDER BY repair_mode; + +COMMIT; diff --git a/tests/agent_open_api/main.go b/tests/agent_open_api/main.go index 53bde1c..daa8aa9 100644 --- a/tests/agent_open_api/main.go +++ b/tests/agent_open_api/main.go @@ -22,9 +22,9 @@ import ( ) const ( - defaultBaseURL = "https://cmp-api.boss160.cn" - defaultAccount = "15571055000" - defaultPassword = "Admin@123456" + defaultBaseURL = "https://cmp-api.xm-iot.cn" + defaultAccount = "15065083933" + defaultPassword = "adm@3933" ) // apiResponse 是文档定义的统一响应外壳,data 保持原始 JSON 便于兼容列表或分页结构。 diff --git a/草稿.md b/草稿.md deleted file mode 100644 index 573d308..0000000 --- a/草稿.md +++ /dev/null @@ -1,276 +0,0 @@ -我发现现在的关于佣金的一致性或者说接口还是有问题,在我看来接口入参都有的情况下,估计其他地方的佣金也会有问题 -我们本质只有两种所谓的佣金(因为有一种我认为不算佣金) -1. 差价佣金 -2. 一次性佣金 - -我发现现在套餐跟套餐系列的一致性已经被破坏了,套餐在创建的时候首先有以下问题 -1. 真流量跟虚流量是共存的,我们本身在设计套餐的时候会先设置一个真实流量额度可能是1000M,这时候还需要决定是否需要开启虚流量,虚流量可以填小于等于1000M的值,我们未来的轮训停机模块会基于是否开启虚流量去决定以真实流量额度为目标值还是以虚流量为目标值 -2. 关于价格也有问题,正常来说我们只会有三个价格,分别是 成本价,建议售价 没了,就没了,后续的业务中就算分配给代理也是变更的代理的成本价,例如平台在创建套餐时设置的成本价是100元,可能分配给代理A时会把成本价增加到110,那么代理在自己的套餐列表看到的成本价就是110了 - -在说套餐跟套餐系列之前,我再次跟你说明我们的佣金规则 -1. 差价佣金: 在我们套餐创建时会填入一个成本价一个建议价,当平台给代理分配套餐时会去进行一个加价的操作(当然代理给自己的下级代理分配也会有这样一个逻辑),可能在创建套餐的时候设置的成本价是100,然后分配给代理把成本价加到110了,那么代理自己的套餐列表看到的成本价就是110了,所谓的差价佣金就是这10块钱,但是因为我们现在是平台视角,所以没有佣金,我们平台就是赚110,如果是代理给代理分,那么就是用110分销给下级代理,可能设置的成本价是 120,那么这个下级代理没销售一单,上级代理都赚10块,这就是差价佣金 -2. 一次性佣金: 一次佣金主要是作为奖励佣金存在,我的理解是他属于套餐系列这一个层次,他本质是一个条件返佣的机制,他有两种条件,一种是第一次充值(我称之为首充),一种是累计充值,我们还有一个强充机制,对于首充而言是必须的,对于累计充值而言是可选的,强充主要是影响客户端的订单创建,当客户端用户(所谓的个人用户/客户)购买套餐时预检接口发现该套餐的套餐系列要求强充,那么此时会返回提示,告诉他需要强充xxxx元,充值后会自动扣款对应套餐,然后用户点击确定后就会创建要求强充的钱,最后付款,付款后扣对应套餐的钱,这差不多就是强充的逻辑,关于强充多少钱,取决于条件返佣的类型,如果是首充,那么强充的钱就是首充的要求,如果是累计充值,且强充则需要设置一次最少要充多少钱,又说回首充跟累计充值的逻辑,首充指的就是第一次购买套餐时必须满足充值的要求,有三种情况,一种是首充要求的金额低于所购套餐价格,则首充应当是以套餐价格为准(套餐价格 100元 首充50元返10元,此时实际支付应当是100),一种是首充要求的金额等于所购套餐价格,则首充应当是以套餐价格为准(套餐价格100元 首充100元返20元,此时实际支付应当是100元),一种是首充要求的金额大于所购套餐加,则首充应当是以首充要求为准(套餐价格100元 首充200元 返 40元,此时实际支付应当是200元),累计充值指的是只要在对应套餐系列下充值就会积累充值金额,当某一次额度符合时应当返佣(套餐系列要求累计充值 200元 返40元,第一次充值 100 不返 积累100,第二次充值50 不返 积累 50元,此时已经积累150,后续当累计>=200元时应当返40佣金),但是只有充值的钱才会累计,直接购买套餐是不积累的,除非给累计佣金设置了强充,那么用户直接购买套餐会被变成上面说的强充逻辑,此时他会变成先充值后购买(这里是系统自动的),这时候需要累计 - -我们的本质佣金其实只有这两种,但是我们在一次佣金的基础上发展成了梯度一次性佣金,即可以多维度的一次性佣金 我们以一次性佣金首充的规则来讲梯度佣金,梯度佣金在一次性佣金规则的基础上还会有自身的规则,他有两种梯度控制类型,一种是销量,一种是销售额,一个梯度只能是一种类型,例如销量 >= 0 时 首充 100 返 5块,销量 >= 100时 首充 100 返10块,销量 >= 200时 首充100 返 20,当他销量达量的时候就只会作用这个规则了,这就是我们的梯度佣金 - - - -基于我上面的说明,我们现在已有的东西感觉很是混乱,关于套餐系列分配接口等等 套餐,分佣相关的接口以及代码都很混乱,而且我发现之前完全没有考虑清除,现在操作步骤也好,还是操作逻辑也好很是混乱,不清楚你能不能理解我对于混乱的理解,我总感觉他现在不是一个完美的线性操作 - - - -最终业务模型总结 -一、核心实体 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 实体关系 │ -└─────────────────────────────────────────────────────────────────────────┘ -┌─────────────────┐ -│ 套餐系列 │ -│ PackageSeries │ -├─────────────────┤ -│ • 系列名称 │ -│ • 一次性佣金规则 │ ← 可选,详见下方 -└────────┬────────┘ - │ 1:N - ▼ -┌─────────────────┐ ┌─────────────────┐ -│ 套餐 │ │ 卡/设备 │ -│ Package │ │ IoT/Device │ -├─────────────────┤ ├─────────────────┤ -│ • 成本价 │ │ • 绑定系列ID │ -│ • 建议售价 │ │ • 累计充值金额 │ ← 按系列累计 -│ • 真流量(必填) │ │ • 是否已首充 │ ← 按系列记录 -│ • 虚流量(可选) │ └────────┬────────┘ -│ • 虚流量开关 │ │ -└────────┬────────┘ │ 分配 - │ ▼ - │ 分配 ┌─────────────────┐ - ▼ │ 店铺 │ -┌─────────────────┐ │ Shop │ -│ 套餐分配 │◀─────────┤ • 代理层级 │ -│ PkgAllocation │ │ • 上级店铺ID │ -├─────────────────┤ └─────────────────┘ -│ • 店铺ID │ -│ • 套餐ID │ -│ • 成本价(加价后)│ -│ • 一次性佣金额 │ ← 给该代理的金额 -└─────────────────┘ ---- -二、套餐字段定义 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 套餐 Package │ -├──────────────────┬──────────────────────────────────────────────────────┤ -│ 字段 │ 说明 │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ cost_price │ 成本价(平台设置的基础成本价,分) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ suggested_price │ 建议售价(给代理参考,分) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ real_data_mb │ 真实流量额度(必填,MB) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ enable_virtual │ 是否启用虚流量(开关) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ virtual_data_mb │ 虚流量额度(启用时必填,≤ 真实流量,MB) │ -└──────────────────┴──────────────────────────────────────────────────────┘ -停机判断逻辑: - 目标值 = enable_virtual ? virtual_data_mb : real_data_mb ---- -三、两种佣金 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 佣金类型 │ -├─────────────────┬───────────────────────┬───────────────────────────────┤ -│ │ 差价佣金 │ 一次性佣金 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 触发时机 │ 每笔订单 │ 首充/累计充值达标时 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 触发次数 │ 每单都触发 │ 每张卡/设备只触发一次 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 计算公式 │ 下级成本价-自己成本价 │ 上级给的-给下级的 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 配置位置 │ 套餐分配时设置成本价 │ 系列定义规则+分配时设置额度 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 获得者 │ 上级代理 │ 整条代理链按约定分配 │ -├─────────────────┼───────────────────────┼───────────────────────────────┤ -│ 是否必须 │ 是 │ 否(系列可不启用) │ -└─────────────────┴───────────────────────┴───────────────────────────────┘ ---- -四、一次性佣金规则配置 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 一次性佣金规则(套餐系列层面配置) │ -├──────────────────┬──────────────────────────────────────────────────────┤ -│ 配置项 │ 说明 │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ enable │ 是否启用一次性佣金 │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ trigger_type │ 触发类型:first_recharge(首充) / │ -│ │ accumulated_recharge(累计充值) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ threshold │ 触发阈值(分):首充要求金额 或 累计要求金额 │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ commission_type │ 返佣类型:fixed(固定) / tiered(梯度) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ commission_amount│ 固定返佣金额(fixed类型时) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ tiers │ 梯度配置(tiered类型时) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ validity_type │ 时效类型:permanent(永久) / fixed_date(固定日期) / │ -│ │ relative(相对时长) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ validity_value │ 时效值(到期日期 或 月数) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ 【强充配置】 │ │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ enable_force │ 是否启用强充(首充必选,累计可选) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ force_calc_type │ 强充金额计算方式(累计充值时): │ -│ │ fixed(固定金额) / dynamic(动态差额) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ force_amount │ 强充金额(fixed类型时) │ -└──────────────────┴──────────────────────────────────────────────────────┘ ---- -五、梯度配置 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 梯度配置 │ -├──────────────────┬──────────────────────────────────────────────────────┤ -│ 配置项 │ 说明 │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ tier_dimension │ 梯度维度:sales_count(销量) / sales_amount(销售额) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ stat_scope │ 统计范围:self(仅自己) / self_and_sub(自己+下级) │ -├──────────────────┼──────────────────────────────────────────────────────┤ -│ tiers[] │ 梯度档位列表: │ -│ .threshold │ 阈值(销量或销售额) │ -│ .amount │ 返佣金额(分) │ -└──────────────────┴──────────────────────────────────────────────────────┘ -注意: - • 梯度只控制"返多少钱",不控制"触发条件" - • 触发条件(首充/累计充值阈值)仍由 trigger_type + threshold 控制 - • 统计周期与一次性佣金时效一致 ---- -六、关键业务流程 -6.1 首充流程 -客户购买套餐 - │ - ▼ -预检:系列是否启用一次性佣金且为首充? - │ - 否──────────────────────────▶ 正常购买流程 - │ - 是 - │ - ▼ -该卡/设备在该系列下是否已首充过? - │ - 是──────────────────────────▶ 正常购买流程(不再返佣) - │ - 否 - │ - ▼ -计算强充金额 = max(首充要求, 套餐售价) - │ - ▼ -返回提示:"需要充值 xxx 元" - │ - ▼ -用户确认 → 创建充值订单(金额=强充金额) - │ - ▼ -用户支付 - │ - ▼ -支付成功: - 1. 钱进入钱包 - 2. 标记该卡/设备已首充 - 3. 自动创建套餐购买订单并完成 - 4. 扣款(套餐售价) - 5. 触发一次性佣金,链式分配 -6.2 累计充值流程 -客户充值(直接充值到钱包) - │ - ▼ -累计充值金额 += 本次充值金额 - │ - ▼ -该卡/设备是否已触发过累计充值返佣? - │ - 是──────────────────────────▶ 结束(不再返佣) - │ - 否 - │ - ▼ -累计金额 >= 累计要求? - │ - 否──────────────────────────▶ 结束(继续累计) - │ - 是 - │ - ▼ -触发一次性佣金,链式分配 -标记该卡/设备已触发累计充值返佣 -6.3 强充(累计充值模式) -客户购买套餐 - │ - ▼ -系列是否启用累计充值强充? - │ - 否──────────────────────────▶ 直接购买(不累计) - │ - 是 - │ - ▼ -计算强充金额: - 固定模式:force_amount - 动态模式:max(累计要求 - 已累计, 0) - │ - ▼ -返回提示:"需要充值 xxx 元" - │ - ▼ -用户确认 → 创建充值订单 - │ - ▼ -支付成功后: - 1. 钱进入钱包 - 2. 累计金额 += 充值金额 - 3. 自动购买套餐并扣款 - 4. 检查是否触发累计充值返佣 ---- -七、一次性佣金链式分配 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 链式分配示例 │ -└─────────────────────────────────────────────────────────────────────────┘ -系列规则:首充100,梯度返佣(销量>=100返10,>=200返20) -代理链:平台 → A(销量210) → A1(销量50) → A2 -分配配置: - 平台给A:20元(适用>=200档) - A给A1:8元 - A1给A2:5元 -触发首充时: - A2 获得:5元 - A1 获得:8 - 5 = 3元 - A 获得:20 - 8 = 12元 - ───────────────────── - 合计:20元 ✓ ---- -八、约束规则汇总 -┌─────────────────────────────────────────────────────────────────────────┐ -│ 约束规则 │ -└─────────────────────────────────────────────────────────────────────────┘ -【套餐分配】 - 1. 下级成本价 >= 自己成本价 - 2. 只能分配自己有权限的套餐 - 3. 只能分配给直属下级 -【一次性佣金分配】 - 4. 给下级的金额 <= 自己能拿到的金额 - 5. 给下级的金额 >= 0(可以设为0) -【流量】 - 6. 虚流量 <= 真实流量 -【配置修改】 - 7. 修改配置只影响之后的新订单 - 8. 代理只能修改"给下级多少钱",不能改触发规则 - 9. 平台修改系列规则不影响已分配的,需收回重新分配 -【触发限制】 - 10. 一次性佣金每张卡/设备只触发一次 - 11. "首充"指该卡/设备在该系列下的第一次充值 - 12. 累计充值只统计"充值"操作,不统计"直接购买" ---- \ No newline at end of file