修复一些问题,主要是生效套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s

This commit is contained in:
2026-05-12 10:32:38 +08:00
parent b7369a9c71
commit 95fc0b0a1b
32 changed files with 5984 additions and 553 deletions

View File

@@ -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

View File

@@ -16,6 +16,10 @@
君鸿卡管系统是一个物联网卡和号卡的全生命周期管理平台,支持三种客户类型和两种组织实体的多租户管理。
### 后台运营批量能力
后台卡/设备套餐系列绑定接口支持按显式列表、号段范围、筛选条件三种方式批量选择资源。运营人员可按完整筛选结果批量设置或清除套餐系列绑定,不受列表分页勾选限制。
### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 |

File diff suppressed because it is too large Load Diff

View File

@@ -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`。认证类失败只返回统一错误码和消息。

View File

@@ -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 | 创建时间 |

View File

@@ -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`,避免长期依赖手工基线流程。

View File

@@ -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`

View File

@@ -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`

View File

@@ -97,6 +97,7 @@ curl -X GET "http://localhost:8080/api/c/v1/asset/info?identifier=<identifier>"
- 仅返回当前主套餐摘要
- 无主套餐时五字段为 `0``current_package_usage_id = null`
- 当前主套餐摘要包含 `enable_virtual_data`
- 不再出现 `package_total_mb` / `package_used_mb` / `package_remain_mb`
### 4.2 套餐历史

View File

@@ -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:"创建时间"`

View File

@@ -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 指定表名

View File

@@ -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 {

View File

@@ -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, &currentUsage, 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, &currentUsage, &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))

View File

@@ -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 {

View File

@@ -0,0 +1 @@
COMMENT ON COLUMN tb_refund_request.asset_reset IS '资产是否已重置(套餐停用+停机+世代重置)';

View File

@@ -0,0 +1 @@
COMMENT ON COLUMN tb_refund_request.asset_reset IS '退款后资产处理是否完成';

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-11

View File

@@ -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
无。当前已确认:所有启用代理店铺均可使用开放接口、不新增开放接口账号授权表、流量不暴露虚流量、多卡部分成功、复用后台代理账号密码、一次只买一个套餐编码、套餐列表返回生效零售价。

View File

@@ -0,0 +1,47 @@
## Why
现有代理商需要通过开放接口对接套餐购买、卡状态、流量、实名和预充值钱包数据,但当前能力主要面向后台管理端,依赖后台登录态,不适合直接给第三方系统调用。需要新增一套签名校验的代理开放接口,在不暴露虚流量语义的前提下复用现有代理套餐、钱包支付和卡套餐数据能力。
功能 IDfeature-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 写入。
- 流量查询按单卡读取当前/待生效套餐,避免设备级聚合和大范围扫描。

View File

@@ -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` 均已注册

View File

@@ -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` 包,实现开放接口 HandlerHandler 只负责参数解析、校验和统一响应。
- [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 最终验证:手动执行签名成功、签名失败、卡流量、卡状态、实名状态、套餐列表、钱包余额、钱包流水、多卡部分成功购买、余额不足购买等场景,并记录验证结果。

View File

@@ -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` 下全部代理开放接口

View File

@@ -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: 审批拒绝接口

View File

@@ -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` 和错误信息)

View File

@@ -1,7 +1,7 @@
## ADDED Requirements
### Requirement: 退款申请数据模型
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID数据权限过滤、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID数据权限过滤、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、退款后资产处理标记等字段。
#### Scenario: 创建退款申请
- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因

View File

@@ -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` 避免首轮重复扣量。
- 这个脚本不会修改老系统或新系统数据。

View File

@@ -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
1 imei
2 862639075986135
3 862639075972853
4 862639076004532
5 862639076024787
6 862639076035379
7 862639076007840
8 862639075987521
9 862639075981524
10 862639076008442
11 862639076022815
12 862639076016668
13 862639075990475
14 862639076036872
15 862639076037805
16 862639076030503
17 862639076004870
18 862639076020678
19 862639076023888
20 862639075998429
21 862639075989949
22 862639076016726
23 862639076022922
24 862639076004284
25 862639075982696
26 862639076026915
27 862639076031998
28 862639075979866
29 862639076010695
30 862639075977365
31 862639076028531
32 862639075979502
33 862639076015843
34 862639075974404
35 862639076016171
36 862639076037201
37 862639075984924
38 862639075981144
39 862639076009861
40 862639075991663
41 862639076017195
42 862639075973158
43 862639076002122
44 862639076005844
45 862639076015421
46 862639076015462
47 862639075980880
48 862639076008434
49 862639076016932
50 862639075971343
51 862639076032764
52 862639075972119
53 862639076017609
54 862639076025933
55 862639075972887
56 862639075978769
57 862639076030594
58 862639076000373
59 862639076018862
60 862639075979213
61 862639076039637
62 862639076026485
63 862639076000480
64 862639076006040
65 862639076034802
66 862639076001504
67 862639076021890
68 862639075987182
69 862639076032012
70 862639076024902
71 862639075994386
72 862639075972770
73 862639076026840
74 862639075985533
75 862639075991515
76 862639075974115
77 862639075979155
78 862639075991853
79 862639076030073
80 862639076015876
81 862639076037821
82 862639076002403
83 862639075974560
84 862639075980245
85 862639075973539
86 862639076030537
87 862639075993883
88 862639076020249
89 862639075987778
90 862639075994683
91 862639076032251
92 862639075984882
93 862639075993693
94 862639076017427
95 862639076033853
96 862639075994998
97 862639075987331
98 862639075970535
99 862639076039165
100 862639076001363
101 862639076022450
102 862639076019886
103 862639076039686
104 862639076015082
105 862639075980781
106 862639075996506
107 862639075993313
108 862639076030479
109 862639076029760
110 862639076022591
111 862639076011024
112 862639075971079
113 862639076023672
114 862639076022716
115 862639075989683
116 862639075980211
117 862639076002395
118 862639076002221
119 862639076009085
120 862639076010448
121 862639076003500
122 862639076009036
123 862639076015579
124 862639075984262
125 862639075977233
126 862639075999187
127 862639075978546
128 862639075976300
129 862639075999732
130 862639076028481
131 862639076029810
132 862639076028697
133 862639075980492
134 862639076002064
135 862639076000209
136 862639075987422
137 862639076013905
138 862639076030099
139 862639075995953
140 862639076036781
141 862639076037177
142 862639076030784
143 862639076017500
144 862639076014754
145 862639076004219
146 862639075979247
147 862639075993354
148 862639075998460
149 862639076024969
150 862639075994923
151 862639076021833
152 862639076007337
153 862639075971327
154 862639076003203
155 862639076008897
156 862639075975641
157 862639075993701
158 862639075975625
159 862639076016353
160 862639076018425
161 862639076012089
162 862639075983595
163 862639075998254
164 862639075999369
165 862639075970543
166 862639076029968
167 862639076015769
168 862639075987448
169 862639075987505
170 862639075993164
171 862639075993289
172 862639076035692
173 862639075982175
174 862639075992166
175 862639075996118
176 862639076023557
177 862639076033168
178 862639075976730
179 862639075996324
180 862639075974677
181 862639076019241
182 862639076032731
183 862639076023607
184 862639075997702
185 862639076007014
186 862639076001231
187 862639075972507
188 862639075993230
189 862639075976490
190 862639075995862
191 862639076002353
192 862639076026741
193 862639075975500
194 862639076029281
195 862639076030180
196 862639075979510
197 862639075975245
198 862639075996928
199 862639075979270
200 862639076022658
201 862639076023474
202 862639076001629
203 862639076000282
204 862639075996159
205 862639075980310
206 862639075980070
207 862639076003633
208 862639075980047
209 862639075975765
210 862639076028655
211 862639076017971
212 862639076032327
213 862639076002841
214 862639076021478
215 862639075973430
216 862639075996589
217 862639075976888
218 862639076003815
219 862639076016429
220 862639076010315
221 862639076010901
222 862639076004391
223 862639075988305
224 862639076031949
225 862639075970949
226 862639076003898
227 862639075978587
228 862639075984007
229 862639075970220
230 862639075970816
231 862639076014580
232 862639076026725
233 862639076015074
234 862639076018961
235 862639075991499
236 862639075974834
237 862639076022302
238 862639075996423
239 862639075983371
240 862639075995441
241 862639075977829
242 862639075972267
243 862639076020447
244 862639075987398
245 862639076015231
246 862639075978314
247 862639076027897
248 862639076023045
249 862639076033846
250 862639076003104
251 862639075986663
252 862639076035890
253 862639076010067
254 862639076031071
255 862639075990061
256 862639075983462
257 862639075982480
258 862639075987497
259 862639076022955
260 862639076017997
261 862639075975922
262 862639076022484
263 862639076019340
264 862639075997066
265 862639076030214
266 862639075980344
267 862639076037359
268 862639075985558
269 862639076028986
270 862639076038134
271 862639076023896
272 862639076002635
273 862639075976458
274 862639076006529
275 862639076022252
276 862639075985954
277 862639076019894
278 862639075980864
279 862639076017179
280 862639076026279
281 862639076018607
282 862639075988354
283 862639076030966
284 862639076001736
285 862639075972259
286 862639076001611
287 862639075973927
288 862639075974909
289 862639076003872
290 862639076028465
291 862639076012642
292 862639076023078
293 862639076019290
294 862639076029596
295 862639075973950
296 862639075970808
297 862639076028275
298 862639076011669
299 862639076027061
300 862639075993321
301 862639075975732
302 862639076030982
303 862639076031881
304 862639076026675
305 862639076002551
306 862639076017716
307 862639076039264
308 862639075970832
309 862639076011321
310 862639075983132
311 862639075998270
312 862639075999302
313 862639076006727
314 862639075973307
315 862639076014721
316 862639076020991
317 862639075985897
318 862639075978462
319 862639075993222
320 862639076007832
321 862639076011339
322 862639075994378
323 862639075977597
324 862639075988271
325 862639076026709
326 862639075998072
327 862639076035940
328 862639076033895
329 862639076012659
330 862639075980534
331 862639075986614
332 862639076036435
333 862639075970725
334 862639075998296
335 862639076018953
336 862639075978843
337 862639075999609
338 862639076032640
339 862639076003674
340 862639076038704
341 862639075990657
342 862639076003930
343 862639075992315
344 862639075981029
345 862639076020603
346 862639075999385
347 862639076024183
348 862639076035932
349 862639076017823
350 862639075971855
351 862639076021668
352 862639076026600

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 便于兼容列表或分页结构。

276
草稿.md
View File

@@ -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
分配配置:
平台给A20元适用>=200档
A给A18元
A1给A25元
触发首充时:
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. 累计充值只统计"充值"操作,不统计"直接购买"
---