修正数据
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s

This commit is contained in:
2026-04-27 18:10:08 +08:00
parent bb33232b1b
commit fe4c545308
57 changed files with 4477 additions and 226 deletions

View File

@@ -211,6 +211,7 @@ default:
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md)
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)

View File

@@ -0,0 +1,98 @@
# 资产操作审计日志功能总结
## 1. 功能目标
为资产域IoT 卡/设备)补齐统一审计落库能力,解决“谁在什么时间对哪个资产做了什么变更、结果如何”的追溯问题。
## 2. 数据模型与落库
- 新增表:`tb_asset_operation_log`
- 关键字段:
- 操作人:`operator_id/operator_type/operator_name`
- 资产:`asset_type/asset_id/asset_identifier`
- 操作:`operation_type/operation_desc`
- 镜像:`before_data/after_data`JSONB
- 请求上下文:`request_id/ip_address/user_agent/request_path/request_method`
- 结果:`result_status/error_code/error_msg`
- 批量统计:`batch_total/success_count/fail_count`
- 索引:
- `idx_asset_log_asset_created`
- `idx_asset_log_identifier_created`
- `idx_asset_log_operator_created`
- `idx_asset_log_operation_created`
- `idx_asset_log_result_created`
## 3. 统一封装设计
### 3.1 基础设施统一入口
- `internal/service/asset_audit/service.go`
- 统一 `LogOperation(ctx, log)` 异步写入
- 写入失败只打 Error 日志,不阻断业务
- `internal/service/asset_audit/builder.go`
- 统一组装 `before/after`、请求上下文、错误码摘要
- 统一脱敏与裁剪
### 3.2 业务侧统一 helper
- IoT 卡:`internal/service/iot_card/audit.go`
- 设备:`internal/service/device/audit.go`
- 统一资产入口轮询:`internal/service/polling/asset_polling_service.go` 内部 helper
- 资产停用:`internal/service/asset/lifecycle_service.go` 内部 helper
- 导入任务:
- `internal/service/device_import/audit.go`
- `internal/service/iot_card_import/audit.go`
业务函数只传结构化参数,避免日志拼装散落在各分支。
## 4. 覆盖范围
### 4.1 IoT 卡
- 分配/回收、系列绑定、轮询开关、实名策略、手动实名状态、删除/批量删除
- 自动停复机 + 手动停复机(含 denied/failed
### 4.2 设备
- 删除、分配/回收、系列绑定、绑卡/解绑
- 设备停机/复机(含成功数、失败数、失败摘要)
- 远程控制限速、WiFi、切卡、切卡模式、重启、恢复出厂
### 4.3 统一资产入口与导入
- 统一入口:停用、轮询状态、实名策略
- 导入任务创建:设备导入、卡导入
## 5. 脱敏与体积控制
- 敏感键脱敏:`password/passwd/pwd/secret/token/wifi_password/wifipwd`
- WiFi 场景使用 `password` 键传参,落库前统一掩码处理
- `before_data/after_data` 超过阈值时裁剪并标记 `_truncated=true`
## 6. 查询与排障示例
### 6.1 审计日志查询 API前端
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 操作人相关字段:
- `operator_type` 返回中文标准文案(如平台用户、代理账号、企业账号、个人客户、系统)
- `operator_type_code` 保留底层枚举编码(如 `admin_user``agent_user`
- `operator_name` 优先返回可读名称,历史 `类型#ID` 占位值会在查询时尽量补全
- 建议前端优先使用:
- `operation_content_before`(操作内容变更前)
- `operation_content_after`(操作内容变更后)
- `operation_fields_desc`(字段中文说明)
- 原始字段 `before_data/after_data` 建议仅用于调试回放。
- 字段明细文档:`docs/add-asset-operation-audit-log/操作内容字段说明.md`
### 6.2 SQL 排障示例
完整 SQL 见:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
常用排障问题:
1. 某资产最近操作:按 `asset_type + asset_id` 查询
2. 某操作人近期高风险动作:按 `operator_id` + `result_status in ('failed','denied')`
3. WiFi 明文泄露检查:检索 `after_data::text ILIKE '%\"password\":\"%'` 并人工确认是否掩码

View File

@@ -0,0 +1,89 @@
-- 资产操作审计日志手工验收 SQL
-- 表tb_asset_operation_log
-- 1) 表结构与索引核验
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'tb_asset_operation_log';
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'tb_asset_operation_log'
ORDER BY indexname;
-- 2) 最近 50 条审计日志
SELECT id,
created_at,
operator_type,
operator_id,
asset_type,
asset_id,
asset_identifier,
operation_type,
result_status,
batch_total,
success_count,
fail_count
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 50;
-- 3) success / failed / denied 三态分布
SELECT operation_type, result_status, COUNT(*) AS cnt
FROM tb_asset_operation_log
GROUP BY operation_type, result_status
ORDER BY operation_type, result_status;
-- 4) before/after 镜像完整性核验(近 200 条)
SELECT id,
operation_type,
result_status,
(before_data IS NOT NULL) AS has_before,
(after_data IS NOT NULL) AS has_after
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 200;
-- 5) 批量聚合字段核验batch_total/success_count/fail_count
SELECT id,
operation_type,
batch_total,
success_count,
fail_count,
(batch_total >= success_count + fail_count) AS counter_ok
FROM tb_asset_operation_log
WHERE batch_total > 0
ORDER BY id DESC
LIMIT 200;
-- 6) 系统触发语义核验
SELECT id,
operation_type,
operator_type,
operator_id,
operator_name,
result_status
FROM tb_asset_operation_log
WHERE operation_type IN ('card_auto_stop', 'card_auto_start')
ORDER BY id DESC
LIMIT 100;
-- 7) WiFi 脱敏核验(不应出现明文密码)
SELECT id,
operation_type,
after_data
FROM tb_asset_operation_log
WHERE operation_type = 'device_set_wifi'
ORDER BY id DESC
LIMIT 20;
-- 8) 拒绝与失败热点排查
SELECT operation_type,
result_status,
COUNT(*) AS cnt
FROM tb_asset_operation_log
WHERE result_status IN ('failed', 'denied')
GROUP BY operation_type, result_status
ORDER BY cnt DESC, operation_type;

View File

@@ -0,0 +1,69 @@
# 资产审计日志接口回放示例
以下示例用于构造 success / failed / denied 三类日志数据。请替换 `<token>``<identifier>``<device_id>``<card_id>` 等占位符。
## 1. success 示例
### 1.1 设备限速(`device_speed_limit`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<identifier>/speed-limit" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"speed_limit":1024}'
```
### 1.2 资产轮询开关(`asset_polling_status`
```bash
curl -X PATCH "http://127.0.0.1:8080/api/admin/assets/<identifier>/polling-status" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enable_polling":false}'
```
## 2. denied 示例
### 2.1 保护期内停机(`device_stop` / `card_manual_stop`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/assets/<identifier>/stop" \
-H "Authorization: Bearer <token>"
```
若命中保护期规则,应返回拒绝并写入 `result_status=denied`
### 2.2 越权分配设备(`device_allocate`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/allocate" \
-H "Authorization: Bearer <agent-token>" \
-H "Content-Type: application/json" \
-d '{"target_shop_id":999999,"device_ids":[1,2,3],"remark":"审计验收"}'
```
## 3. failed 示例
### 3.1 无效设备标识触发远程控制失败(`device_reboot`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<invalid-identifier>/reboot" \
-H "Authorization: Bearer <token>"
```
### 3.2 导入任务入队失败(`device_import_task_create` / `iot_card_import_task_create`
通过临时关闭队列依赖或传入无效参数触发失败,确认写入 `result_status=failed` 和错误摘要。
## 4. 结果核对
执行完回放后,使用:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
重点核对:
1. 同一操作存在 success/failed/denied
2. `before_data``after_data` 有效
3. WiFi 密码字段为掩码
4. 批量字段 `batch_total/success_count/fail_count` 合理

View File

@@ -0,0 +1,102 @@
# 资产审计日志操作内容字段说明
## 1. 查询接口
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 说明:先按资产标识符解析资产,再查询该资产的审计日志。
## 2. 前端应优先使用的字段
为避免历史数据格式差异,前端应优先使用以下三个字段:
1. `operation_content_before`:操作内容(变更前)
2. `operation_content_after`:操作内容(变更后)
3. `operation_fields_desc`:字段中文说明(`key=字段名``value=中文解释`
`before_data/after_data` 保留为原始审计数据,建议仅作为调试或回放使用。
## 3. 通用字段
| 字段 | 说明 |
|---|---|
| `operator_type` | 操作人类型中文名称(如平台用户、代理账号、企业账号、个人客户、系统) |
| `operator_type_code` | 操作人类型编码(如 `admin_user``agent_user` |
| `operator_name` | 操作人可读名称,优先返回用户名/店铺名/企业名/客户昵称 |
| `batch_total` | 批量总数 |
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `failed_items` | 失败明细 |
| `reason` | 原因说明 |
## 4. 常见操作字段
### 4.1 分配/回收
| 字段 | 说明 |
|---|---|
| `target_shop_id` / `to_shop_id` | 目标店铺ID |
| `source_shop_id` / `from_shop_id` | 来源店铺ID |
| `device_ids` | 设备ID列表 |
| `card_ids` | 卡ID列表 |
| `selection_type` | 选择方式 |
| `new_status` | 变更后状态 |
### 4.2 设备绑卡/解绑
| 字段 | 说明 |
|---|---|
| `binding_id` | 绑定记录ID |
| `slot_position` | 插槽位置 |
| `iot_card_id` | 卡ID |
| `iccid` | 卡ICCID |
| `unbind` | 是否执行解绑 |
### 4.3 设备停复机
| 字段 | 说明 |
|---|---|
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `skip_count` | 跳过数量 |
| `failed_items` | 失败明细含ICCID和原因 |
### 4.4 远程控制
| 字段 | 说明 |
|---|---|
| `speed_limit` | 限速值KB/s |
| `ssid` | WiFi 名称 |
| `password` | WiFi 密码(已脱敏) |
| `enabled` | WiFi 开关状态 |
| `target_iccid` | 目标ICCID |
| `switch_mode` | 切卡模式0自动/1手动 |
### 4.5 轮询与实名
| 字段 | 说明 |
|---|---|
| `enable_polling` | 轮询开关 |
| `realname_policy` | 实名策略none/before_order/after_order |
| `real_name_status` | 实名状态0未实名/1已实名 |
## 5. 兼容性说明
历史日志中 `before_data` 可能是资产快照而不是操作内容。接口已做归一化处理:
- 自动抽取并返回 `operation_content_before/after`
- 当历史数据缺少“变更前操作内容”时,会按“变更后字段”与资产快照尽量回填
- 历史日志中的 `后台用户#ID``代理用户#ID` 等占位名称,接口会尽量按当前账号资料补全为可读名称
因此,前端无需再自行解析 `before_data/after_data` 的多种历史格式。
## 6. 前端处理建议
建议前端按以下优先级渲染变更明细:
1. 遍历 `operation_content_after` 的字段键作为“变更项集合”
2. 字段名展示优先使用 `operation_fields_desc[key]`,没有则回退为 `key`
3. 变更前值读取 `operation_content_before[key]`
4. 变更后值读取 `operation_content_after[key]`
5. 当前值为 `null` 时按“未知/未记录”展示,不要直接当空字符串
`before_data/after_data` 建议只用于问题排查,不参与主界面渲染。

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@
### 基础说明
- 路径参数 `asset_type` 取值:`card`(卡)或 `device`(设备
- 资产相关路由已统一为 `:identifier` 口径(虚拟号 / ICCID / IMEI / SN / MSISDN
- 企业账号调用 `resolve` 接口会返回 403
---
@@ -73,9 +73,12 @@
| `real_name_status` | int | 实名状态0 未实名 / 1 实名中 / 2 已实名 |
| `network_status` | int | 网络状态0 停机 / 1 开机(仅 card |
| `current_package` | string | 当前套餐名称(无则空) |
| `package_total_mb` | int64 | 当前套餐总虚流量 MB |
| `package_used_mb` | float64 | 已用虚流量 MB |
| `package_remain_mb` | float64 | 剩余虚流量 MB |
| `current_package_usage_id` | uint | 当前套餐的套餐使用记录 ID无主套餐时为 null |
| `real_total_mb` | int64 | 当前主套餐真实总量 MB |
| `real_used_mb` | int64 | 当前主套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 当前主套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 当前主套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device |
| `activated_at` | time | 激活时间 |
| `created_at` | time | 创建时间 |
@@ -108,7 +111,7 @@
---
### `GET /api/admin/assets/:asset_type/:id/realtime-status`
### `GET /api/admin/assets/:identifier/realtime-status`
读取资产实时状态(直接读 DB/Redis不调网关
@@ -127,7 +130,7 @@
---
### `POST /api/admin/assets/:asset_type/:id/refresh`
### `POST /api/admin/assets/:identifier/refresh`
主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。
@@ -135,7 +138,7 @@
---
### `GET /api/admin/assets/:asset_type/:id/packages`
### `GET /api/admin/assets/:identifier/packages`
查询该资产所有套餐记录,含虚流量换算字段。
@@ -149,12 +152,11 @@
| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) |
| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 |
| `status_name` | string | 状态中文名 |
| `data_limit_mb` | int64 | 真流量总量 MB |
| `virtual_limit_mb` | int64 | 虚流量总量 MB已按 virtual_ratio 换算) |
| `data_usage_mb` | int64 | 已用真流量 MB |
| `virtual_used_mb` | float64 | 已用虚流量 MB |
| `virtual_remain_mb` | float64 | 剩余虚流量 MB |
| `virtual_ratio` | float64 | 虚流量比例 |
| `real_total_mb` | int64 | 套餐真实总量 MB |
| `real_used_mb` | int64 | 套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `activated_at` | time | 激活时间 |
| `expires_at` | time | 到期时间 |
| `master_usage_id` | uint | 主套餐 ID加油包时有值 |
@@ -163,9 +165,9 @@
---
### `GET /api/admin/assets/:asset_type/:id/current-package`
### `GET /api/admin/assets/:identifier/current-package`
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 404
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 `200 + null`
---

View File

@@ -0,0 +1,110 @@
# 流量模型统一字段替换矩阵
## 1. 目的
本文用于固化 `refactor-traffic-model-semantics` 变更前后的字段替换关系,作为后台 / C 端接口、OpenAPI 文档和手工回放的统一对照基线。
## 2. 统一后的核心字段
本次 contract 收口后,流量相关字段统一为:
| 字段 | 含义 |
|------|------|
| `current_package_usage_id` | 当前主套餐对应的套餐使用记录 ID无主套餐时为 `null` |
| `real_total_mb` | 套餐真实总量,内部来源 `tb_package_usage.data_limit_mb` |
| `real_used_mb` | 套餐真实已用,内部来源 `tb_package_usage.data_usage_mb` |
| `virtual_total_mb` | 套餐业务停机阈值,优先来源 `tb_package_usage.virtual_total_mb_snapshot` |
| `virtual_used_mb` | 展示已用量,公式 `min(real_used_mb * display_gain_ratio, real_total_mb)` |
| `reduction_pct` | 展示增幅比例,公式 `(real_total_mb / virtual_total_mb) - 1` |
## 3. 字段替换矩阵
### 3.1 后台 `GET /api/admin/assets/resolve/:identifier`
| 现状字段 | 现状来源 | 新字段 | 新语义 |
|---------|---------|--------|--------|
| `current_package` | 主套餐名称 | `current_package` | 保留,语义不变 |
| `package_total_mb` | `data_limit_mb / virtual_ratio` | `real_total_mb` | 真总量 |
| `package_used_mb` | `data_usage_mb / virtual_ratio` | `real_used_mb` | 真已用 |
| `package_remain_mb` | `(data_limit_mb - data_usage_mb) / virtual_ratio` | `virtual_total_mb` / `virtual_used_mb` | 不再返回 remain改为显式真/虚语义 |
| 无 | 无 | `current_package_usage_id` | 当前主套餐使用记录 ID |
| 无 | 无 | `reduction_pct` | 展示增幅比例 |
### 3.2 后台 `GET /api/admin/assets/:identifier/current-package`
| 现状字段 | 现状来源 | 新字段 | 新语义 |
|---------|---------|--------|--------|
| `data_limit_mb` | `tb_package_usage.data_limit_mb` | `real_total_mb` | 真总量 |
| `data_usage_mb` | `tb_package_usage.data_usage_mb` | `real_used_mb` | 真已用 |
| `virtual_limit_mb` | `data_limit_mb / virtual_ratio` | `virtual_total_mb` | 业务停机阈值 |
| `virtual_used_mb` | `data_usage_mb / virtual_ratio` | `virtual_used_mb` | 改为统一公式计算 |
| `virtual_remain_mb` | `(data_limit_mb - data_usage_mb) / virtual_ratio` | 删除 | 不再返回 |
| `virtual_ratio` | `tb_package.virtual_ratio` | `reduction_pct` | 只保留外部增幅比例 |
| 无 | 无 | `package_usage_id` | 保留既有字段 |
### 3.3 后台 `GET /api/admin/assets/:identifier/packages`
| 现状字段 | 现状来源 | 新字段 | 新语义 |
|---------|---------|--------|--------|
| `data_limit_mb` | `tb_package_usage.data_limit_mb` | `real_total_mb` | 真总量 |
| `data_usage_mb` | `tb_package_usage.data_usage_mb` | `real_used_mb` | 真已用 |
| `virtual_limit_mb` | `data_limit_mb / virtual_ratio` | `virtual_total_mb` | 业务停机阈值 |
| `virtual_used_mb` | `data_usage_mb / virtual_ratio` | `virtual_used_mb` | 统一公式 |
| `virtual_remain_mb` | `(data_limit_mb - data_usage_mb) / virtual_ratio` | 删除 | 不再返回 |
| `virtual_ratio` | `tb_package.virtual_ratio` | `reduction_pct` | 只保留外部比例 |
| `package_usage_id` | `tb_package_usage.id` | `package_usage_id` | 保留,作为记录主键 |
### 3.4 C 端 `GET /api/c/v1/asset/info`
| 现状字段 | 现状来源 | 新字段 | 新语义 |
|---------|---------|--------|--------|
| `current_package` | 主套餐名称 | `current_package` | 保留 |
| `package_total_mb` | 来自后台资产解析结果 | `real_total_mb` | 真总量 |
| `package_used_mb` | 来自后台资产解析结果 | `real_used_mb` | 真已用 |
| `package_remain_mb` | 来自后台资产解析结果 | `virtual_total_mb` / `virtual_used_mb` | 不再返回 remain |
| 无 | 无 | `current_package_usage_id` | 当前主套餐使用记录 ID |
| 无 | 无 | `reduction_pct` | 展示增幅比例 |
### 3.5 C 端 `GET /api/c/v1/asset/package-history`
| 现状字段 | 现状来源 | 新字段 | 新语义 |
|---------|---------|--------|--------|
| `data_limit_mb` | `tb_package_usage.data_limit_mb` | `real_total_mb` | 真总量 |
| `data_usage_mb` | `tb_package_usage.data_usage_mb` | `real_used_mb` | 真已用 |
| `virtual_limit_mb` | `data_limit_mb / virtual_ratio` | `virtual_total_mb` | 业务停机阈值 |
| `virtual_used_mb` | `data_usage_mb / virtual_ratio` | `virtual_used_mb` | 统一公式 |
| `virtual_remain_mb` | `(data_limit_mb - data_usage_mb) / virtual_ratio` | 删除 | 不再返回 |
| `virtual_ratio` | `tb_package.virtual_ratio` | `reduction_pct` | 只保留外部比例 |
| `package_usage_id` | `tb_package_usage.id` | `package_usage_id` | 保留 |
## 4. OpenAPI 对齐基线
当前 `docs/admin-openapi.yaml` 仍存在以下待淘汰字段定义:
- `package_total_mb`
- `package_used_mb`
- `package_remain_mb`
- `virtual_limit_mb`
- `virtual_remain_mb`
- `virtual_ratio`
本次变更完成后,后台 OpenAPI 需仅保留:
- `current_package_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
## 5. 路由基线
当前实际注册路由已经统一为 `:identifier` 口径:
- `GET /api/admin/assets/resolve/:identifier`
- `GET /api/admin/assets/:identifier/current-package`
- `GET /api/admin/assets/:identifier/packages`
- `GET /api/c/v1/asset/info`
- `GET /api/c/v1/asset/package-history`
因此,本次变更仅做字段契约收口,不调整上述路由路径。

View File

@@ -0,0 +1,140 @@
-- 流量模型统一改造手工验收脚本
-- 用途:验证 package_usage 快照、统一公式和 B/C 端 contract 改造后的数据库基线。
-- 1. 查看 package_usage 快照字段是否存在
SELECT
column_name,
data_type,
column_default,
is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'tb_package_usage'
AND column_name IN (
'data_limit_mb',
'data_usage_mb',
'virtual_total_mb_snapshot',
'display_gain_ratio_snapshot',
'enable_virtual_data_snapshot'
)
ORDER BY column_name;
-- 2. 查看历史记录快照回填结果
SELECT
pu.id AS package_usage_id,
pu.package_id,
pu.data_limit_mb,
pu.data_usage_mb,
pu.virtual_total_mb_snapshot,
pu.display_gain_ratio_snapshot,
pu.enable_virtual_data_snapshot,
pu.status,
pu.master_usage_id,
pu.activated_at,
pu.expires_at
FROM tb_package_usage pu
ORDER BY pu.id DESC
LIMIT 50;
-- 3. 统一公式核验:启用虚流量时的五字段与 reduction_pct
SELECT
pu.id AS package_usage_id,
pu.data_limit_mb AS real_total_mb,
pu.data_usage_mb AS real_used_mb,
CASE
WHEN pu.enable_virtual_data_snapshot AND pu.virtual_total_mb_snapshot > 0 THEN pu.virtual_total_mb_snapshot
ELSE pu.data_limit_mb
END AS virtual_total_mb,
CASE
WHEN pu.enable_virtual_data_snapshot AND pu.virtual_total_mb_snapshot > 0
THEN LEAST(pu.data_usage_mb * pu.display_gain_ratio_snapshot, pu.data_limit_mb)
ELSE pu.data_usage_mb
END AS virtual_used_mb,
CASE
WHEN pu.enable_virtual_data_snapshot AND pu.virtual_total_mb_snapshot > 0
THEN pu.display_gain_ratio_snapshot - 1
ELSE 0
END AS reduction_pct
FROM tb_package_usage pu
ORDER BY pu.id DESC
LIMIT 50;
-- 4. 停机阈值核验:统一 effective_threshold_mb
SELECT
pu.id AS package_usage_id,
pu.data_limit_mb AS real_total_mb,
pu.data_usage_mb AS real_used_mb,
CASE
WHEN pu.enable_virtual_data_snapshot AND pu.virtual_total_mb_snapshot > 0 THEN pu.virtual_total_mb_snapshot
ELSE pu.data_limit_mb
END AS effective_threshold_mb,
CASE
WHEN pu.data_usage_mb >= CASE
WHEN pu.enable_virtual_data_snapshot AND pu.virtual_total_mb_snapshot > 0 THEN pu.virtual_total_mb_snapshot
ELSE pu.data_limit_mb
END THEN TRUE
ELSE FALSE
END AS is_depleted
FROM tb_package_usage pu
ORDER BY pu.id DESC
LIMIT 50;
-- 5. 样例一100 / 70 / 50 -> 71.43
WITH sample AS (
SELECT
100::bigint AS real_total_mb,
50::bigint AS real_used_mb,
70::bigint AS virtual_total_mb,
ROUND(100::numeric / 70::numeric, 6) AS display_gain_ratio
)
SELECT
real_total_mb,
real_used_mb,
virtual_total_mb,
display_gain_ratio,
ROUND(LEAST(real_used_mb * display_gain_ratio, real_total_mb)::numeric, 2) AS expected_virtual_used_mb,
ROUND((display_gain_ratio - 1)::numeric, 6) AS expected_reduction_pct
FROM sample;
-- 6. 样例二100 / 70 / 69 -> 98.57
WITH sample AS (
SELECT
100::bigint AS real_total_mb,
69::bigint AS real_used_mb,
70::bigint AS virtual_total_mb,
ROUND(100::numeric / 70::numeric, 6) AS display_gain_ratio
)
SELECT
real_total_mb,
real_used_mb,
virtual_total_mb,
display_gain_ratio,
ROUND(LEAST(real_used_mb * display_gain_ratio, real_total_mb)::numeric, 2) AS expected_virtual_used_mb,
ROUND((display_gain_ratio - 1)::numeric, 6) AS expected_reduction_pct
FROM sample;
-- 7. 样例三:未启用虚流量时应退化为真流量视图
WITH sample AS (
SELECT
100::bigint AS real_total_mb,
69::bigint AS real_used_mb,
FALSE AS enable_virtual_data_snapshot,
100::bigint AS virtual_total_mb_snapshot,
1.0::numeric AS display_gain_ratio_snapshot
)
SELECT
real_total_mb,
real_used_mb,
CASE
WHEN enable_virtual_data_snapshot AND virtual_total_mb_snapshot > 0 THEN virtual_total_mb_snapshot
ELSE real_total_mb
END AS expected_virtual_total_mb,
CASE
WHEN enable_virtual_data_snapshot AND virtual_total_mb_snapshot > 0 THEN LEAST(real_used_mb * display_gain_ratio_snapshot, real_total_mb)
ELSE real_used_mb
END AS expected_virtual_used_mb,
CASE
WHEN enable_virtual_data_snapshot AND virtual_total_mb_snapshot > 0 THEN display_gain_ratio_snapshot - 1
ELSE 0
END AS expected_reduction_pct
FROM sample;

View File

@@ -0,0 +1,149 @@
# 流量模型统一接口回放样例
## 1. 目的
本文用于给 `refactor-traffic-model-semantics` 提供固定回放样例,确保后台 / C 端接口、OpenAPI 和数据库公式在同一口径下验收。
## 2. 典型样例
### 样例 A100 / 70 / 50 → 71.43
- `real_total_mb = 100`
- `virtual_total_mb = 70`
- `real_used_mb = 50`
- `display_gain_ratio = 100 / 70 = 1.428571`
- `virtual_used_mb = min(50 * 1.428571, 100) = 71.43`
- `reduction_pct = 1.428571 - 1 = 0.428571`
### 样例 B100 / 70 / 69 → 98.57
- `real_total_mb = 100`
- `virtual_total_mb = 70`
- `real_used_mb = 69`
- `display_gain_ratio = 100 / 70 = 1.428571`
- `virtual_used_mb = min(69 * 1.428571, 100) = 98.57`
- `reduction_pct = 0.428571`
### 样例 C100 / 70 / 70 → 100
- `real_total_mb = 100`
- `virtual_total_mb = 70`
- `real_used_mb = 70`
- `virtual_used_mb = min(70 * 1.428571, 100) = 100`
- 该套餐应判定为耗尽
### 样例 D未启用虚流量退化为真流量视图
- `real_total_mb = 100`
- `virtual_total_mb = 100`
- `real_used_mb = 69`
- `virtual_used_mb = 69`
- `reduction_pct = 0`
## 3. 后台接口回放
### 3.1 资产解析
```bash
curl -X GET "http://localhost:8080/api/admin/assets/resolve/<identifier>" \
-H "Authorization: Bearer <admin-token>"
```
重点核对:
- `current_package_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
- 不再出现 `package_total_mb` / `package_used_mb` / `package_remain_mb`
### 3.2 当前主套餐
```bash
curl -X GET "http://localhost:8080/api/admin/assets/<identifier>/current-package" \
-H "Authorization: Bearer <admin-token>"
```
重点核对:
- 有主套餐时仅返回主套餐记录
- 无主套餐时返回 `200 + null`
- 记录字段包含 `package_usage_id + 统一五字段 + reduction_pct`
### 3.3 套餐历史
```bash
curl -X GET "http://localhost:8080/api/admin/assets/<identifier>/packages?page=1&page_size=20" \
-H "Authorization: Bearer <admin-token>"
```
重点核对:
- 列表每条记录都包含 `package_usage_id + 统一五字段 + reduction_pct`
- 不再出现 `virtual_limit_mb` / `virtual_remain_mb` / `virtual_ratio`
## 4. C 端接口回放
### 4.1 资产信息
```bash
curl -X GET "http://localhost:8080/api/c/v1/asset/info?identifier=<identifier>" \
-H "Authorization: Bearer <client-token>"
```
重点核对:
- 仅返回当前主套餐摘要
- 无主套餐时五字段为 `0``current_package_usage_id = null`
- 不再出现 `package_total_mb` / `package_used_mb` / `package_remain_mb`
### 4.2 套餐历史
```bash
curl -X GET "http://localhost:8080/api/c/v1/asset/package-history?identifier=<identifier>&page=1&page_size=20" \
-H "Authorization: Bearer <client-token>"
```
重点核对:
- 列表项统一返回 `package_usage_id + 统一五字段 + reduction_pct`
- `generation` 过滤仍然有效
- 不新增 `total.virtual_used_mb` 或等价聚合字段
## 5. OpenAPI 核对点
### 5.1 后台 `docs/admin-openapi.yaml`
必须包含:
- `current_package_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
必须移除:
- `package_total_mb`
- `package_used_mb`
- `package_remain_mb`
- `virtual_limit_mb`
- `virtual_remain_mb`
- `virtual_ratio`
## 6. 验收结论模板
每次回放后,按下述模板记录:
```markdown
- [ ] 样例 A100/70/50 → 71.43)通过
- [ ] 样例 B100/70/69 → 98.57)通过
- [ ] 样例 C100/70/70 → 100且耗尽通过
- [ ] 样例 D未启用虚流量退化通过
- [ ] 后台 resolve/current-package/packages 不再返回旧字段
- [ ] C 端 asset/info/package-history 不再返回旧字段
- [ ] OpenAPI 与 DTO 完全一致
```

View File

@@ -0,0 +1,634 @@
# 流量模型统一改造方案
## 1. 背景
当前项目中的流量逻辑同时存在以下几套口径:
1. **卡同步口径**
- `tb_iot_card.last_gateway_reading_mb`
- `tb_iot_card.current_month_usage_mb`
- `tb_iot_card.data_usage_mb`
2. **套餐使用口径**
- `tb_package_usage.data_limit_mb`
- `tb_package_usage.data_usage_mb`
3. **展示换算口径**
- `tb_package.virtual_ratio`
- B 端/C 端多个接口中对 `virtual_*` 字段的换算
这三套口径目前没有统一到同一业务模型上,导致以下问题:
1. 流量同步与流量展示不是同一套业务事实源。
2. 停机判断仍按真总量而不是按虚阈值判断。
3. 历史套餐展示依赖 `tb_package` 当前值,缺少使用记录快照。
4. 资产视角接口和套餐视角接口混用,前端不知道该信哪一个字段。
5. 已存在字段 `real_data_usage_mb``virtual_data_usage_mb` 没有进入主链路,语义噪音较大。
本方案的目标不是“小修展示口径”,而是**统一整个项目的流量业务模型**。
---
## 2. 核心结论
### 2.1 业务事实源统一
**结论:业务流量永远跟随套餐,不跟随卡。**
- **卡表(`tb_iot_card`** 只负责:
- 同步上游网关累计流量
- 计算增量
- 记录同步状态与同步时间
- 排查上游异常
- **套餐使用表(`tb_package_usage`** 才是:
- 停机判断事实源
- B 端资产流量展示事实源
- C 端流量展示事实源
- 套餐历史与当前套餐流量事实源
### 2.2 流量摘要主体统一
**结论:流量摘要的主体应为 `package_usage_id`,不是 `asset_id`,也不是 `package_id`。**
原因:
1. 同一个资产可能存在多条套餐使用记录(主套餐、加油包、历史套餐)。
2. 同一个 `package_id` 会被多个资产、多次购买复用。
3. 前端真正要看的,是某一条具体套餐使用记录当前的四个业务值。
因此,流量摘要应以 **`tb_package_usage.id`** 为主键进行查询和展示。
### 2.3 前端最终只看四个业务值
本次统一后的对外流量模型只保留四个业务值:
1. `real_total_mb`:真总量
2. `real_used_mb`:真已用
3. `virtual_total_mb`:虚总量(原始虚阈值)
4. `virtual_used_mb`:按比例换算后、与真总量同尺度的展示值
说明:
- 用户已确认:`virtual_total_mb` 使用**原始虚总量**,不是换算后的总量。
- 用户已确认:`virtual_used_mb` 使用**按比例换算后的展示值**。
---
## 3. 统一后的业务定义
### 3.1 套餐模板层(`tb_package`
套餐模板保留以下字段定义:
- `real_data_mb`:套餐真总量
- `virtual_data_mb`:套餐虚总量 / 虚阈值
- `enable_virtual_data`:是否启用虚流量
- `virtual_ratio`:内部展示倍率,公式为 `real_data_mb / virtual_data_mb`
### 3.2 套餐使用层(`tb_package_usage`
套餐使用记录作为最终业务事实源,统一定义为:
- `data_limit_mb`**真总量快照**
- `data_usage_mb`**真已用**
- `virtual_total_mb_snapshot`**虚总量快照**
- `display_gain_ratio_snapshot`**展示倍率快照**
- `enable_virtual_data_snapshot`**是否启用虚流量快照**
### 3.3 四个业务值公式
对于任意一条 `package_usage`,统一用以下公式计算:
#### 场景 A启用虚流量且 `virtual_total_mb_snapshot > 0`
- `real_total_mb = data_limit_mb`
- `real_used_mb = data_usage_mb`
- `virtual_total_mb = virtual_total_mb_snapshot`
- `virtual_used_mb = min(real_used_mb * display_gain_ratio_snapshot, real_total_mb)`
- `reduction_pct = display_gain_ratio_snapshot - 1`
#### 场景 B未启用虚流量
- `real_total_mb = data_limit_mb`
- `real_used_mb = data_usage_mb`
- `virtual_total_mb = data_limit_mb`
- `virtual_used_mb = real_used_mb`
说明:
1. 未启用虚流量时,虚流量视图退化为真流量视图,保证前端仍然能稳定收到四个值。
2. `virtual_used_mb` 的上限必须封顶到 `real_total_mb`,避免出现展示值超过真总量。
### 3.4 停机判断公式
统一后的停机判断必须基于套餐使用记录:
#### 场景 A启用虚流量且 `virtual_total_mb_snapshot > 0`
-`real_used_mb >= virtual_total_mb` 时,套餐视为已耗尽
#### 场景 B未启用虚流量
-`real_used_mb >= real_total_mb` 时,套餐视为已耗尽
这一定义与本次 OpenSpec 的典型样例一致:
-`100` / 虚 `70`:真已用到 `70` 时停机,展示 `virtual_used_mb = 100`
- 未启用虚流量:真已用到 `100` 时停机,展示 `virtual_used_mb = real_used_mb`
### 3.5 `virtual_data_mb` 校验规则
本次统一后的规则为:
1. `enable_virtual_data = true` 时,`virtual_data_mb` 必须大于 `0`
2. `enable_virtual_data = true` 时,`virtual_data_mb` 必须小于等于 `real_data_mb`
3. 未启用虚流量时,展示视图退化为真流量视图
---
## 4. 同步链路统一方案
### 4.1 卡同步链路保留,但降级为“同步事实链路”
当前同步链路的核心算法可以保留:
1. 从网关读取当前累计流量读数 `Used`
2. 使用 `Used - last_gateway_reading_mb` 计算本次增量
3. 遇到上游重置窗口时,将当前读数视为新周期增量
该链路应继续写入卡表:
- `tb_iot_card.last_gateway_reading_mb`
- `tb_iot_card.current_month_usage_mb`
- `tb_iot_card.current_month_start_date`
- `tb_iot_card.last_month_total_mb`
- `tb_iot_card.last_sync_time`
- `tb_iot_card.last_data_check_at`
### 4.2 卡同步增量如何进入套餐层
同步出的真流量增量必须继续扣减到 `tb_package_usage.data_usage_mb`
统一口径:
- **同步层永远只同步“真流量增量”**
- **套餐层永远只累加“真已用”**
- **虚流量不参与同步,只参与展示换算和停机阈值判断**
### 4.3 `tb_iot_card.data_usage_mb` 的处理
当前 `tb_iot_card.data_usage_mb` 表示卡生命周期累计流量,但它不是业务展示主来源。
本次建议:
- **保留字段**
- **降级为同步辅助/运维诊断字段**
- 不再作为 B 端/C 端业务展示的主流量来源
说明:
1. 当前该字段写入时直接使用 `int64(increment)`,存在小数截断。
2. 套餐层已通过 `UsageService` 的余量缓存机制处理小数累计,更适合作为业务事实源。
---
## 5. 展示链路统一方案
### 5.1 资产视角不再直接承载流量摘要
`asset` 本身不是流量摘要的正确主体,因为一个资产可能有多条套餐使用记录。
因此:
- `资产详情`
- `资产解析`
- `C 端资产信息`
不应再把流量摘要作为“资产级别唯一摘要”长期保留。
### 5.2 套餐视角承载流量摘要
流量摘要应绑定到具体的 `package_usage_id`
- B 端:资产套餐列表 / 当前套餐
- C 端:当前套餐 / 套餐历史 / 套餐流量详情
都应围绕 `package_usage_id` 返回四个业务值。
### 5.3 建议的接口模型
#### 方案 A现有套餐接口直接返回四值
- `GET /api/admin/assets/:identifier/packages`
- `GET /api/admin/assets/:identifier/current-package`
- `GET /api/c/v1/asset/package-history`
每条 `AssetPackageResponse` 直接返回:
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
#### 方案 B新增“套餐流量摘要”接口
`package_usage_id` 查询单条套餐流量摘要,例如:
- `GET /api/admin/asset-packages/:package_usage_id/traffic-summary`
- `GET /api/c/v1/asset/package-usages/:package_usage_id/traffic-summary`
推荐做法:
1. 套餐列表接口仍返回四值,方便列表页直接展示
2. 同时提供单条摘要接口,供详情弹窗/详情页按 `package_usage_id` 精确查询
### 5.4 本次建议下线的旧展示字段
以下字段语义模糊,应从对外 DTO 中逐步移除:
- `package_total_mb`
- `package_used_mb`
- `package_remain_mb`
- `virtual_remain_mb`
替代为显式字段:
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
---
## 6. 数据库改造方案
### 6.1 本次必须新增的字段
`tb_package_usage` 新增:
1. `virtual_total_mb_snapshot BIGINT NOT NULL DEFAULT 0`
2. `display_gain_ratio_snapshot DECIMAL(18,6) NOT NULL DEFAULT 1.0`
3. `enable_virtual_data_snapshot BOOLEAN NOT NULL DEFAULT FALSE`
### 6.2 为什么必须加快照字段
如果不加快照,会有三个问题:
1. 历史套餐展示依赖 `tb_package` 当前值,模板一改,历史流量就漂
2. 历史停机判断无法复现当时口径
3. 退款、历史订单、历史套餐详情都无法稳定回放
### 6.3 回填策略
对已有 `tb_package_usage` 数据执行回填:
#### 若 `tb_package.enable_virtual_data = true` 且 `tb_package.virtual_data_mb > 0`
- `virtual_total_mb_snapshot = tb_package.virtual_data_mb`
- `display_gain_ratio_snapshot = tb_package.virtual_ratio`
- `enable_virtual_data_snapshot = true`
#### 否则
- `virtual_total_mb_snapshot = data_limit_mb`
- `display_gain_ratio_snapshot = 1.0`
- `enable_virtual_data_snapshot = false`
### 6.4 现有字段暂不重命名
本次不建议直接重命名以下数据库字段:
- `tb_package_usage.data_limit_mb`
- `tb_package_usage.data_usage_mb`
原因:
1. 改名会牵涉大量 SQL、模型、历史脚本、迁移与兼容问题
2. 当前可以通过“统一文义 + 新增快照字段”先完成业务收口
本次统一后的文义为:
- `data_limit_mb` = 真总量
- `data_usage_mb` = 真已用
### 6.5 最终可删除字段
以下字段当前未进入主链路,本轮已在迁移中物理删除:
- `tb_package_usage.real_data_usage_mb`
- `tb_package_usage.virtual_data_usage_mb`
执行顺序:
1. 代码层先完全停止依赖它们
2. 再通过独立迁移删除数据库旧列
---
## 7. 代码改造清单
### 7.1 数据模型与迁移
需要改动:
- `migrations/`:新增 `tb_package_usage` 快照字段迁移与历史回填
- `internal/model/package.go`
改动内容:
1. `PackageUsage` 增加三类快照字段
2.`data_limit_mb``data_usage_mb` 的注释补充“真总量 / 真已用”的语义说明
3. 为后续删除 `real_data_usage_mb``virtual_data_usage_mb` 做准备
### 7.2 套餐创建与激活链路
需要改动:
- `internal/service/order/service.go`
- `internal/task/auto_purchase.go`
改动内容:
1. 创建 `PackageUsage` 时写入:
- `data_limit_mb = pkg.RealDataMB`
- `virtual_total_mb_snapshot`
- `display_gain_ratio_snapshot`
- `enable_virtual_data_snapshot`
2. 所有主套餐、加油包、自动购包路径保持一致
### 7.3 套餐配置校验
需要改动:
- `internal/service/package/service.go`
改动内容:
1. 保留“启用虚流量时必须填写虚流量”的校验
2. 收紧为 `virtual_data_mb > 0 且 <= real_data_mb`
3. `virtual_ratio` 继续使用 `real_data_mb / virtual_data_mb`
### 7.4 流量同步链路
需要改动:
- `internal/task/polling_carddata_handler.go`
- `internal/service/iot_card/service.go`
改动内容:
1. 核心同步算法保持不变
2. 明确注释与日志:该链路同步的是“真流量增量”
3. 保持将真增量扣减到套餐层
4. 不在该链路引入任何虚流量换算逻辑
### 7.5 套餐扣减链路
需要改动:
- `internal/service/package/usage_service.go`
改动内容:
1. `data_usage_mb` 继续作为真已用累加
2. 套餐是否“已用完”必须改为按“虚阈值快照或真总量”判断
3. 加油包 / 主套餐的耗尽逻辑全部统一到快照字段
4. 日记录 `PackageUsageDailyRecord` 继续记录真流量使用量
### 7.6 停机判断链路
需要改动:
- `internal/service/iot_card/stop_resume_service.go`
改动内容:
1. `isTrafficExhausted` 改为基于 `package_usage` 快照字段判断
2. 启用虚流量时,按 `real_used >= virtual_total_snapshot`
3. 未启用时,按 `real_used >= real_total`
### 7.7 套餐重置链路
需要改动:
- `internal/service/package/reset_service.go`
- `internal/store/postgres/package_usage_store.go`
改动内容:
1. 重置时继续清零 `data_usage_mb`
2. 重置后根据快照阈值恢复 `status=active`
3. 不引入额外的虚流量计数器
### 7.8 后台管理接口
需要改动:
- `internal/service/asset/service.go`
- `internal/model/dto/asset_dto.go`
- 如有前端依赖生成文档,还需同步更新 `docs/admin-openapi.yaml`
改动内容:
1. `AssetResolveResponse` 去掉资产级别模糊流量摘要字段
2. `AssetPackageResponse` 改为输出四个显式业务值:
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
3. `packages` / `current-package` 统一按 `package_usage_id` 展示和计算
### 7.9 C 端接口
需要改动:
- `internal/handler/app/client_asset.go`
- `internal/model/dto/client_asset_dto.go`
- `internal/service/package/customer_view_service.go`
改动内容:
1. C 端资产信息页不再把资产本身当作唯一流量摘要主体
2. C 端套餐历史/当前套餐改为套餐视角四值
3. 如果需要单条摘要页,则新增按 `package_usage_id` 查询的接口
### 7.10 套餐商品展示联动确认
需要关注:
- `internal/handler/app/client_asset.go` 中可购套餐列表当前 `data_allowance` 在启用虚流量时直接展示 `virtual_data_mb`
该处不是本次核心链路,但建议联动确认:
1. 商品展示要展示真流量、虚流量,还是只展示一种
2. 若前端也要透明展示两种总量DTO 需补充显式字段
---
## 8. DTO 与接口建议
### 8.1 建议新增或替换的字段
建议在套餐相关 DTO 中统一使用:
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
### 8.2 建议废弃的字段
建议废弃:
- `package_total_mb`
- `package_used_mb`
- `package_remain_mb`
- `virtual_limit_mb`
- `virtual_remain_mb`
- `data_limit_mb`(对外 DTO 层)
- `data_usage_mb`(对外 DTO 层)
说明:
数据库内部暂时仍可保留 `data_limit_mb` / `data_usage_mb`,但 DTO 层应改为语义明确的新字段。
### 8.3 接口职责建议
#### 卡实时状态接口
保留同步诊断字段,例如:
- `last_gateway_reading_mb`
- `current_month_usage_mb`
- `last_sync_time`
#### 套餐流量接口
只返回四个业务值,不再掺杂卡级同步字段。
---
## 9. 迁移与上线顺序
建议按以下顺序执行:
### 第一步:数据库准备
1. 新增 `tb_package_usage` 快照字段
2. 回填历史数据
3. 为后续删列保留可回滚窗口
### 第二步:写链路切换
1. 套餐激活/自动购包路径写入快照字段
2. 扣减逻辑与停机逻辑改为读取快照字段
### 第三步:读链路切换
1. B 端套餐接口改为返回四个业务值
2. C 端套餐接口改为返回四个业务值
3. 资产级模糊流量摘要降级或移除
### 第四步:前端联调
1. 管理后台改为只消费套餐视角四值
2. C 端改为只消费套餐视角四值
3. 如需套餐流量详情页,按 `package_usage_id` 查询
### 第五步:清理旧字段
1. 停止使用 `real_data_usage_mb` / `virtual_data_usage_mb`
2. 物理删除旧列
3. 对外移除旧 DTO 字段
---
## 10. 手工验证清单
本项目不写自动化测试,本次改造建议用以下方式手工验证:
### 10.1 PostgreSQL 验证
验证项:
1. `tb_package_usage` 快照字段是否已正确回填
2. 启用虚流量的套餐是否满足:
- `virtual_total_mb_snapshot = tb_package.virtual_data_mb`
- `display_gain_ratio_snapshot = tb_package.virtual_ratio`
3. 未启用虚流量的套餐是否满足:
- `virtual_total_mb_snapshot = data_limit_mb`
- `display_gain_ratio_snapshot = 1.0`
### 10.2 上游同步验证
验证项:
1. 轮询后 `tb_iot_card.last_gateway_reading_mb` 是否更新
2. 同步增量是否正确进入 `tb_package_usage.data_usage_mb`
3. 跨月与运营商重置窗口时增量是否正确
### 10.3 停机验证
验证项:
1.`100` / 虚 `70`:真已用到 `70` 时停机
2.`100` / 虚 `70` / 真已用 `69`:展示 `virtual_used_mb = 98.57`
3. 未启用虚流量:真已用到真总量时停机
### 10.4 展示验证
验证项:
1. B 端套餐列表与当前套餐返回四个业务值
2. C 端套餐信息返回四个业务值
3. 资产详情页不再混用卡同步流量与套餐业务流量
---
## 11. 本次改造范围边界
### 本次必须做
1. 套餐使用记录补快照
2. 同步、扣减、停机、展示统一到套餐层
3. DTO 和接口统一四个业务值
4. 资产流量视角切换为套餐视角
### 本次不建议强行做
1. 直接重命名数据库旧字段
2. 在本轮立即物理删除所有历史字段
3. 把卡同步辅助字段全部移除
原因:
1. 本次首要目标是统一业务口径,先消除逻辑混乱
2. 物理清理应放在口径稳定后单独做
---
## 12. 最终结论
本次流量统一改造的核心原则只有两条:
1. **同步看卡,业务看套餐**
2. **展示永远以 `package_usage_id` 为主体,不以资产为主体**
按本方案落地后:
1. 同步链路只负责真流量增量采集
2. 套餐链路负责真已用、停机阈值和四值展示
3. B 端和 C 端最终只消费:
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
这套模型可以同时覆盖:
1. 真总量大于虚阈值
2. 真总量小于虚阈值
3. 未启用虚流量
4. 历史套餐快照展示
5. 当前套餐与套餐历史流量统一展示

View File

@@ -0,0 +1,103 @@
# 统一导出任务功能总结
## 1. 功能概览
本次交付新增统一导出任务系统,提供全局任务入口,支持:
- 创建导出任务(`scene=device/iot_card``format=xlsx/csv`
- 导出任务列表与详情
- 导出任务取消(待处理/处理中)
- 异步三段式执行:`dispatch -> shard -> finalize`
- 分片产物与最终产物上传 OSS
- 详情接口直出下载链接(固定 24 小时)
## 2. 新增接口
- `POST /api/admin/export-tasks`:创建导出任务
- `GET /api/admin/export-tasks`:导出任务列表(支持 `scene/status/time` 过滤)
- `GET /api/admin/export-tasks/:id`:导出任务详情(已完成时返回 `download_url`
- `POST /api/admin/export-tasks/:id/cancel`:取消导出任务
## 3. 状态机
### 3.1 主任务状态
- `1` 待处理
- `2` 处理中
- `3` 已完成
- `4` 已失败
- `5` 已取消
### 3.2 分片状态
- `1` 待处理
- `2` 处理中
- `3` 已成功
- `4` 已失败
- `5` 已取消
## 4. 核心设计
### 4.1 模型与迁移
新增两张表:
- `tb_export_task`:主任务
- `tb_export_shard_task`:分片任务
关键点:
- 无外键,仅使用 `task_id` 做逻辑关联
- 主任务保留进度、分片统计、权限快照、产物信息
- 分片任务保留游标范围、分片产物、执行状态
### 4.2 场景策略注册中心
新增 `internal/exporter` 场景注册中心与策略接口,首批接入:
- `device` 场景策略
- `iot_card` 场景策略
统一定义:
- `Headers()`:导出表头
- `NextShardBoundary()`:按 keyset 方式计算分片边界
- `QueryRows()`:按分片边界查询行数据
### 4.3 执行链路
- `export:dispatch`:构建分片并入队 `export:shard`
- `export:shard`:按分片查询数据、写文件、上传 OSS、回写分片结果
- `export:finalize`:汇总分片结果并生成最终文件,更新主任务状态
### 4.4 幂等与重试安全
- dispatch/shard/finalize 增加 Redis 锁,避免并发重复执行
- 分片状态条件更新,重复消费不会重复计数
- shard/finalize 利用 Asynq 重试上下文,在最终重试失败时才落库失败状态
### 4.5 取消语义
- 待处理:直接更新为已取消
- 处理中:仅设置 `cancel_requested=true`,由 Worker 检查点协作停止
- dispatch/shard/finalize 均增加取消检查点
### 4.6 下载链接
-`status=已完成` 返回 `download_url`
- 链接有效期固定 `24h`(常量 `ExportDownloadURLExpire`
- URL 由详情接口实时生成,过期后再次访问可刷新
## 5. 权限与可见性
- 平台/超管:可见全部导出任务
- 代理:仅可见其店铺范围内创建的导出任务
- 详情越权统一返回:`无权限操作该资源或资源不存在`
## 6. 文档与生成器更新
已同步更新:
- 路由文档(新增 `导出任务` Tag 的 4 个接口)
- 文档构建 Handler 集合(`pkg/openapi/handlers.go` 新增 `ExportTask`
- OpenAPI 文件重新生成:`docs/admin-openapi.yaml`

View File

@@ -0,0 +1,56 @@
# 统一导出任务手工验证清单
## 验证工具与原则
- 数据库结构与数据状态:使用 PostgreSQL MCP只读
- 接口契约验证:使用 Postman 或 curl
- 明确约束:不新增自动化测试文件(`*_test.go`
## A. 创建导出任务
- [ ] `POST /api/admin/export-tasks``scene=device``format=xlsx`
- [ ] `POST /api/admin/export-tasks``scene=iot_card``format=csv`
- [ ] 非法 `scene`(如 `order`)返回参数错误
- [ ] 非法 `format`(如 `pdf`)返回参数错误
## B. 列表与过滤
- [ ] `GET /api/admin/export-tasks?page=1&page_size=20` 默认分页生效
- [ ] `scene` 过滤生效(`device` / `iot_card`
- [ ] `status` 过滤生效1~5
- [ ] `start_time/end_time` 时间过滤生效
- [ ] `page_size > 100` 时被限制到 100
## C. 详情与下载
- [ ] 未完成任务详情不返回 `download_url`
- [ ] 已完成任务详情返回 `download_url`
- [ ] `download_url` 有效期固定 24 小时
- [ ] 过期后再次访问详情可刷新新链接
## D. 取消语义
- [ ] 待处理任务可取消,状态立即变为已取消
- [ ] 处理中任务可提交取消请求(`cancel_requested=true`
- [ ] 已完成/已失败/已取消任务再次取消被拒绝
- [ ] 重复取消处理中任务幂等返回
## E. 异步链路dispatch -> shard -> finalize
- [ ] 创建任务后 `export:dispatch` 被消费
- [ ] 分片任务写入 `tb_export_shard_task`
- [ ] `export:shard` 执行后分片状态更新,产物写入 OSS
- [ ] `export:finalize` 汇总后主任务状态收敛
- [ ] 分片失败时主任务最终失败,错误信息可追踪
## F. 数据权限与可见性
- [ ] 平台用户可见全量任务
- [ ] 代理仅可见其店铺范围任务
- [ ] 越权访问详情统一返回:`无权限操作该资源或资源不存在`
## G. 数据库核查项PostgreSQL MCP
- [ ] `tb_export_task` 表存在,字段与注释完整
- [ ] `tb_export_shard_task` 表存在,字段与注释完整
- [ ] 关键索引存在并生效(任务号唯一索引、`task_id+status``scene+status+created_at`

View File

@@ -0,0 +1,59 @@
# 统一导出任务验收记录
## 1. 验收范围
- 代码编译验证
- 数据库迁移验证PostgreSQL MCP
- OpenAPI 生成验证
- 手工接口链路验证准备(受环境变量约束)
## 2. 已完成结果
### 2.1 编译结果
- 命令:`go build ./...`
- 结果:通过
### 2.2 OpenAPI 结果
- 命令:`go run cmd/gendocs/main.go`
- 结果:通过
- 产物:`docs/admin-openapi.yaml`
### 2.3 数据库迁移结果
- 命令:`migrate -path migrations -database <...> up 1`
- 结果:`135/u create_export_task_tables`
### 2.4 PostgreSQL MCP 结构校验
已确认:
- 存在 `tb_export_task`
- 存在 `tb_export_shard_task`
- 表注释和字段注释已生效
已确认索引:
- `idx_export_task_no`(唯一,软删条件)
- `idx_export_task_scene_status_created_at`
- `idx_export_task_creator_user_id`
- `idx_export_task_creator_shop_id`
- `idx_export_task_creator_enterprise_id`
- `idx_export_shard_task_task_shard_no`(唯一,软删条件)
- `idx_export_shard_task_task_status`
## 3. 待手工执行项(环境阻塞)
以下项已准备 curl/Postman 清单与模板,但当前环境缺少 API 启动所需配置(`JUNHONG_REDIS_ADDRESS``JUNHONG_JWT_SECRET_KEY` 等),暂未执行:
- 接口契约验证(创建/列表/详情/取消)
- 异步状态流转观察dispatch/shard/finalize
- 两个场景(`device` / `iot_card`)各 `xlsx``csv` 完整导出
- 取消场景回归(待处理取消、处理中取消、重复取消)
可复现命令与模板见:
- `docs/unified-export-task-system/手工验证清单.md`
- `docs/unified-export-task-system/验收记录模板.md`
- `docs/unified-export-task-system/验证数据样本.md`

View File

@@ -0,0 +1,64 @@
# 统一导出任务验收记录模板
## 1. 基本信息
- 验收日期:
- 验收环境:
- 验收人:
- 代码版本commit
## 2. 请求与响应记录
### 2.1 创建任务
- 请求:
- 响应:
- 期望:
- 结果:通过 / 不通过
### 2.2 列表过滤
- 请求:
- 响应:
- 期望:
- 结果:通过 / 不通过
### 2.3 详情下载
- 请求:
- 响应:
- 期望:
- 结果:通过 / 不通过
### 2.4 取消任务
- 请求:
- 响应:
- 期望:
- 结果:通过 / 不通过
## 3. 数据库快照PostgreSQL MCP
- `tb_export_task` 关键字段快照:
- `tb_export_shard_task` 关键字段快照:
- 索引核查结果:
## 4. OSS 产物记录
- 分片文件 Key 列表:
- 最终文件 Key
- 文件大小:
## 5. 日志关键字段
- `task_id`
- `task_no`
- `scene`
- `status`
- `error`(如有):
## 6. 下载 URL 有效期验证
- 首次获取时间:
- 返回 `download_expires_at`
- 过期后刷新结果:

View File

@@ -0,0 +1,84 @@
# 统一导出任务验证数据样本
## 目标
`device``iot_card` 场景准备两类样本:
- 小数据集便于快速验证状态流转10~30 条)
- 大数据集:便于验证分片执行(>= 5000 条)
## 1. device 场景
### 1.1 小数据集(示例)
```sql
-- 建议在测试库执行,按实际字段补齐 creator/updater
INSERT INTO tb_device (creator, updater, virtual_no, device_name, device_model, device_type, max_sim_slots, manufacturer, status)
VALUES
(1, 1, 'EXP-DEV-S-0001', '导出设备样本1', 'M1', 'tracker', 4, 'JH', 1),
(1, 1, 'EXP-DEV-S-0002', '导出设备样本2', 'M1', 'tracker', 4, 'JH', 1),
(1, 1, 'EXP-DEV-S-0003', '导出设备样本3', 'M2', 'router', 4, 'JH', 1);
```
### 1.2 大数据集(示例)
```sql
-- 生成 5000 条设备样本
INSERT INTO tb_device (creator, updater, virtual_no, device_name, device_model, device_type, max_sim_slots, manufacturer, status)
SELECT
1,
1,
'EXP-DEV-L-' || LPAD(gs::text, 6, '0'),
'导出设备大样本' || gs,
'M-L',
'tracker',
4,
'JH',
1
FROM generate_series(1, 5000) gs;
```
## 2. iot_card 场景
### 2.1 小数据集(示例)
```sql
INSERT INTO tb_iot_card (
creator, updater, iccid, iccid_19, carrier_id, carrier_type, carrier_name,
msisdn, status, activation_status, real_name_status, network_status
)
VALUES
(1, 1, '89860000000000000001', '8986000000000000000', 1, 'CMCC', '中国移动', '13900000001', 1, 0, 0, 1),
(1, 1, '89860000000000000002', '8986000000000000000', 1, 'CMCC', '中国移动', '13900000002', 1, 0, 0, 1),
(1, 1, '89860000000000000003', '8986000000000000000', 1, 'CMCC', '中国移动', '13900000003', 1, 0, 0, 1);
```
### 2.2 大数据集(示例)
```sql
INSERT INTO tb_iot_card (
creator, updater, iccid, iccid_19, carrier_id, carrier_type, carrier_name,
msisdn, status, activation_status, real_name_status, network_status
)
SELECT
1,
1,
'8986001' || LPAD(gs::text, 13, '0'),
LEFT('8986001' || LPAD(gs::text, 13, '0'), 19),
1,
'CMCC',
'中国移动',
'138' || LPAD(gs::text, 8, '0'),
1,
0,
0,
1
FROM generate_series(1, 5000) gs;
```
## 3. 清理建议
```sql
DELETE FROM tb_device WHERE virtual_no LIKE 'EXP-DEV-%';
DELETE FROM tb_iot_card WHERE iccid LIKE '8986001%';
```

View File

@@ -766,9 +766,12 @@ GET /api/admin/assets/resolve/:identifier
|------|------|------|
| `real_name_status` | int | 实名状态0未实名 1实名中 2已实名 |
| `current_package` | string | 当前套餐名称(无套餐时为空) |
| `package_total_mb` | int64 | 当前套餐总虚流量(MB),已按 `virtual_ratio` 换算 |
| `package_used_mb` | float64 | 当前已用虚流量(MB),已按 `virtual_ratio` 换算 |
| `package_remain_mb` | float64 | 当前套餐剩余虚流量(MB),已按 `virtual_ratio` 换算 |
| `current_package_usage_id` | uint | 当前套餐的套餐使用记录 ID无主套餐时为 null |
| `real_total_mb` | int64 | 当前主套餐真实总量(MB) |
| `real_used_mb` | int64 | 当前套餐真实已用量(MB) |
| `virtual_total_mb` | int64 | 当前主套餐业务停机阈值(MB) |
| `virtual_used_mb` | float64 | 当前主套餐展示已用量(MB) |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `device_protect_status` | string | 设备保护期状态:`none`/`stop`/`start`(仅 `asset_type=device` |
**绑定关系字段**
@@ -825,7 +828,7 @@ GET /api/admin/assets/resolve/:identifier
#### Z2 资产实时状态
```
GET /api/admin/assets/:asset_type/:id/realtime-status
GET /api/admin/assets/:identifier/realtime-status
```
- **认证**:需要后台 Bearer Token
@@ -834,8 +837,7 @@ GET /api/admin/assets/:asset_type/:id/realtime-status
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `asset_type` | string | 是 | 资产类型:`card``device` |
| `id` | uint | 是 | 资产ID |
| `identifier` | string | 是 | 资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN |
**响应体(`AssetRealtimeStatusResponse`**
@@ -855,7 +857,7 @@ GET /api/admin/assets/:asset_type/:id/realtime-status
#### Z3 刷新资产状态
```
POST /api/admin/assets/:asset_type/:id/refresh
POST /api/admin/assets/:identifier/refresh
```
- **认证**:需要后台 Bearer Token
@@ -868,7 +870,7 @@ POST /api/admin/assets/:asset_type/:id/refresh
#### Z4 资产套餐列表
```
GET /api/admin/assets/:asset_type/:id/packages
GET /api/admin/assets/:identifier/packages
```
- **认证**:需要后台 Bearer Token
@@ -885,12 +887,11 @@ GET /api/admin/assets/:asset_type/:id/packages
| `usage_type` | string | 使用类型:`single_card`/`device` |
| `status` | int | 状态0待生效 1生效中 2已用完 3已过期 4已失效 |
| `status_name` | string | 状态名称 |
| `data_limit_mb` | int64 | 套餐真流量总量(MB) |
| `virtual_limit_mb` | int64 | 套餐虚流量总量(MB),按 `virtual_ratio` 换算 |
| `data_usage_mb` | int64 | 已用真流量(MB) |
| `virtual_used_mb` | float64 | 已用虚流量(MB),按 `virtual_ratio` 换算 |
| `virtual_remain_mb` | float64 | 剩余虚流量(MB),按 `virtual_ratio` 换算 |
| `virtual_ratio` | float64 | 虚流量比例(real/virtual) |
| `real_total_mb` | int64 | 套餐真总量(MB) |
| `real_used_mb` | int64 | 套餐真实已用量(MB) |
| `virtual_total_mb` | int64 | 套餐业务停机阈值(MB) |
| `virtual_used_mb` | float64 | 套餐展示已用量(MB) |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `activated_at` | string | 激活时间(待生效套餐为空) |
| `expires_at` | string | 到期时间(待生效套餐为空) |
| `master_usage_id` | uint | 主套餐ID加油包时有值 |
@@ -902,12 +903,13 @@ GET /api/admin/assets/:asset_type/:id/packages
#### Z5 当前生效套餐
```
GET /api/admin/assets/:asset_type/:id/current-package
GET /api/admin/assets/:identifier/current-package
```
- **认证**:需要后台 Bearer Token
- **路径参数**:同 Z2
- **响应体**`AssetPackageResponse`(字段同 Z4
- **无主套餐时**:返回 `200 + null`
---

View File

@@ -165,9 +165,12 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
WalletBalance: resolved.WalletBalance,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
PackageTotalMB: resolved.Asset.PackageTotalMB,
PackageUsedMB: resolved.Asset.PackageUsedMB,
PackageRemainMB: resolved.Asset.PackageRemainMB,
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
RealTotalMB: resolved.Asset.RealTotalMB,
RealUsedMB: resolved.Asset.RealUsedMB,
VirtualTotalMB: resolved.Asset.VirtualTotalMB,
VirtualUsedMB: resolved.Asset.VirtualUsedMB,
ReductionPct: resolved.Asset.ReductionPct,
DeviceName: resolved.Asset.DeviceName,
IMEI: resolved.Asset.IMEI,
SN: resolved.Asset.SN,
@@ -340,12 +343,13 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
ratio := 1.0
pkgName := ""
metrics := usage.BuildTrafficMetrics()
pkgName := usage.PackageName
pkgType := ""
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
}
@@ -357,12 +361,11 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
DataLimitMB: usage.DataLimitMB,
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
DataUsageMB: usage.DataUsageMB,
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
@@ -581,13 +584,6 @@ func nonZeroTimePtr(t time.Time) *time.Time {
return &t
}
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
func packageStatusName(status int) string {
switch status {
case constants.PackageUsageStatusPending:

View File

@@ -23,9 +23,12 @@ type AssetResolveResponse struct {
RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"`
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB)已按virtual_ratio换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB)已按virtual_ratio换算"`
PackageRemainMB float64 `json:"package_remain_mb" description:"当前套餐剩余虚流量(MB)已按virtual_ratio换算"`
CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前套餐的套餐使用记录ID无主套餐时为 null"`
RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"当前套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态none/stop/start仅asset_type=device时有效"`
// 绑定关系字段
ICCID string `json:"iccid,omitempty" description:"卡ICCIDasset_type=card时有效"`
@@ -105,13 +108,11 @@ type AssetPackageResponse struct {
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB)按virtual_ratio换算"`
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB)按virtual_ratio换算"`
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB)按virtual_ratio换算"`
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
RealTotalMB int64 `json:"real_total_mb" description:"套餐真总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID加油包时有值"`

View File

@@ -33,10 +33,13 @@ type AssetInfoResponse struct {
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
// === 套餐信息(通用) ===
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按虚流量比例换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按虚流量比例换算"`
PackageRemainMB float64 `json:"package_remain_mb" description:"当前剩余虚流量(MB),已按虚流量比例换算"`
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前套餐的套餐使用记录ID无主套餐时为 null"`
RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
// === 设备专属字段asset_type=device 时有效) ===
DeviceName string `json:"device_name,omitempty" description:"设备名称"`

View File

@@ -36,7 +36,7 @@ type Package struct {
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,业务停机阈值)" json:"virtual_data_mb"`
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
@@ -47,7 +47,7 @@ type Package struct {
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
// ExpiryBase 到期时间基准from_activation-实名激活时起算from_purchase-购买时起算
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"`
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:展示倍率(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
}
// TableName 指定表名
@@ -68,10 +68,11 @@ type PackageUsage struct {
UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"`
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"`
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"`
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:真总量快照(MB)" json:"data_limit_mb"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:真已用(MB)" json:"data_usage_mb"`
VirtualTotalMBSnapshot int64 `gorm:"column:virtual_total_mb_snapshot;type:bigint;default:0;comment:虚总量快照(MB业务停机阈值)" json:"virtual_total_mb_snapshot"`
DisplayGainRatioSnapshot float64 `gorm:"column:display_gain_ratio_snapshot;type:decimal(18,6);default:1.0;comment:展示倍率快照(real_total_mb/virtual_total_mb)" json:"display_gain_ratio_snapshot"`
EnableVirtualDataSnapshot bool `gorm:"column:enable_virtual_data_snapshot;type:boolean;default:false;comment:是否启用虚流量快照" json:"enable_virtual_data_snapshot"`
ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"`
ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_at"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`

View File

@@ -0,0 +1,96 @@
package model
import "math"
const unlimitedRemainingQuotaMB = int64(^uint64(0) >> 1)
// PackageTrafficMetrics 套餐流量统一展示值
// 统一封装对外使用的真值、虚值和展示比例,避免各层重复实现公式。
type PackageTrafficMetrics struct {
RealTotalMB int64
RealUsedMB int64
VirtualTotalMB int64
VirtualUsedMB float64
ReductionPct float64
}
// CalculateDisplayGainRatio 计算展示倍率
// 启用虚流量且虚总量大于 0 时,倍率 = 真总量 / 虚总量;否则退化为 1。
func CalculateDisplayGainRatio(enableVirtualData bool, realTotalMB, virtualTotalMB int64) float64 {
if enableVirtualData && virtualTotalMB > 0 {
return float64(realTotalMB) / float64(virtualTotalMB)
}
return 1.0
}
// BuildPackageUsageSnapshotValues 计算套餐使用记录应写入的快照值。
func BuildPackageUsageSnapshotValues(pkg *Package) (virtualTotalMB int64, displayGainRatio float64, enableVirtualData bool) {
if pkg == nil {
return 0, 1.0, false
}
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
return pkg.VirtualDataMB, CalculateDisplayGainRatio(true, pkg.RealDataMB, pkg.VirtualDataMB), true
}
return pkg.RealDataMB, 1.0, false
}
// EffectiveThresholdMB 返回套餐业务耗尽阈值。
// 启用虚流量时按虚总量快照判断;否则按真总量判断。
func (u *PackageUsage) EffectiveThresholdMB() int64 {
if u == nil {
return 0
}
if u.EnableVirtualDataSnapshot && u.VirtualTotalMBSnapshot > 0 {
return u.VirtualTotalMBSnapshot
}
return u.DataLimitMB
}
// RemainingQuotaMB 返回套餐剩余业务额度。
func (u *PackageUsage) RemainingQuotaMB() int64 {
if u == nil {
return 0
}
threshold := u.EffectiveThresholdMB()
if threshold <= 0 {
return unlimitedRemainingQuotaMB
}
return threshold - u.DataUsageMB
}
// IsTrafficDepleted 判断套餐是否达到业务耗尽阈值。
func (u *PackageUsage) IsTrafficDepleted() bool {
if u == nil {
return false
}
threshold := u.EffectiveThresholdMB()
if threshold <= 0 {
return false
}
return u.DataUsageMB >= threshold
}
// BuildTrafficMetrics 组装统一五字段和 reduction_pct。
func (u *PackageUsage) BuildTrafficMetrics() PackageTrafficMetrics {
metrics := PackageTrafficMetrics{}
if u == nil {
return metrics
}
metrics.RealTotalMB = u.DataLimitMB
metrics.RealUsedMB = u.DataUsageMB
if u.EnableVirtualDataSnapshot && u.VirtualTotalMBSnapshot > 0 {
metrics.VirtualTotalMB = u.VirtualTotalMBSnapshot
metrics.VirtualUsedMB = math.Min(float64(u.DataUsageMB)*u.DisplayGainRatioSnapshot, float64(u.DataLimitMB))
metrics.ReductionPct = u.DisplayGainRatioSnapshot - 1
return metrics
}
metrics.VirtualTotalMB = u.DataLimitMB
metrics.VirtualUsedMB = float64(u.DataUsageMB)
metrics.ReductionPct = 0
return metrics
}

View File

@@ -297,16 +297,21 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes
return
}
pkg, err := s.packageStore.GetByID(ctx, usage.PackageID)
if err != nil || pkg == nil {
return
resp.CurrentPackageUsageID = &usage.ID
resp.CurrentPackage = usage.PackageName
if resp.CurrentPackage == "" {
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
if pkgErr == nil && pkg != nil {
resp.CurrentPackage = pkg.PackageName
}
}
resp.CurrentPackage = pkg.PackageName
ratio := safeVirtualRatio(pkg.VirtualRatio)
resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio)
resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio
resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio
metrics := usage.BuildTrafficMetrics()
resp.RealTotalMB = metrics.RealTotalMB
resp.RealUsedMB = metrics.RealUsedMB
resp.VirtualTotalMB = metrics.VirtualTotalMB
resp.VirtualUsedMB = metrics.VirtualUsedMB
resp.ReductionPct = metrics.ReductionPct
}
// fillShopName 填充店铺名称
@@ -698,15 +703,14 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
all := make([]*dto.AssetPackageResponse, 0, len(usages))
for _, u := range usages {
pkg := pkgMap[u.PackageID]
ratio := 1.0
pkgName := ""
metrics := u.BuildTrafficMetrics()
pkgName := u.PackageName
pkgType := ""
enableVirtualData := false
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
enableVirtualData = pkg.EnableVirtualData
}
item := &dto.AssetPackageResponse{
PackageUsageID: u.ID,
@@ -720,13 +724,11 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
DataLimitMB: u.DataLimitMB,
VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio),
DataUsageMB: u.DataUsageMB,
VirtualUsedMB: float64(u.DataUsageMB) / ratio,
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
VirtualRatio: ratio,
EnableVirtualData: enableVirtualData,
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
@@ -779,15 +781,14 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
}
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
ratio := 1.0
pkgName := ""
metrics := usage.BuildTrafficMetrics()
pkgName := usage.PackageName
pkgType := ""
enableVirtualData := false
if pkgErr == nil && pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
enableVirtualData = pkg.EnableVirtualData
}
return &dto.AssetPackageResponse{
PackageUsageID: usage.ID,
@@ -801,13 +802,11 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
DataLimitMB: usage.DataLimitMB,
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
DataUsageMB: usage.DataUsageMB,
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
EnableVirtualData: enableVirtualData,
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
@@ -838,14 +837,6 @@ func nonZeroTimePtr(t time.Time) *time.Time {
return &t
}
// safeVirtualRatio 安全获取虚流量比例,避免除零
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
// GetOrders 查询资产历史订单,支持换货链跨代追溯
// page/pageSize 仅对本代订单生效前代订单每代最多返回20条
// 最多追溯10代超出后 truncated=true

View File

@@ -195,18 +195,23 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.
}
}
pkg, err := s.packageUsageStore.GetActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
packages, err := s.packageUsageStore.ListActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if pkg.Status == constants.PackageUsageStatusDepleted {
return true, nil
if len(packages) == 0 {
return false, nil
}
return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB, nil
for _, pkg := range packages {
if pkg == nil {
continue
}
if !pkg.IsTrafficDepleted() {
return false, nil
}
}
return true, nil
}
// isRealnameOK 检查卡是否满足实名要求
@@ -221,16 +226,7 @@ func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) {
var reasons []string
// 条件A无有效套餐(优先级最高)
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return nil, err
}
if !hasPackage {
reasons = append(reasons, constants.StopReasonNoPackage)
}
// 条件B虚流量耗尽
// 条件A流量耗尽
exhausted, err := s.isTrafficExhausted(ctx, card)
if err != nil {
return nil, err
@@ -239,6 +235,15 @@ func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.Io
reasons = append(reasons, constants.StopReasonTrafficExhausted)
}
// 条件B无有效套餐仅在未命中流量耗尽时判断
hasPackage, err := s.hasValidPackage(ctx, card)
if err != nil {
return nil, err
}
if !hasPackage && !exhausted {
reasons = append(reasons, constants.StopReasonNoPackage)
}
// 条件C非行业卡且未实名
if !s.isRealnameOK(card) {
reasons = append(reasons, constants.StopReasonNotRealname)

View File

@@ -2018,6 +2018,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
}
// 创建套餐使用记录
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
@@ -2029,6 +2030,9 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
@@ -2103,6 +2107,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
expiresAt = now
}
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
@@ -2114,6 +2119,9 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
MasterUsageID: &mainPackage.ID,

View File

@@ -740,8 +740,5 @@ func formatAmount(amountFen int64) string {
// calculateVirtualRatio 计算虚流量比例
// enable_virtual_data=true 且 virtual_data_mb>0 时 = real_data_mb/virtual_data_mb否则 = 1.0
func calculateVirtualRatio(enableVirtualData bool, realDataMB, virtualDataMB int64) float64 {
if enableVirtualData && virtualDataMB > 0 {
return float64(realDataMB) / float64(virtualDataMB)
}
return 1.0
return model.CalculateDisplayGainRatio(enableVirtualData, realDataMB, virtualDataMB)
}

View File

@@ -76,7 +76,11 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
shouldSuspend := false
suspendCarrierType := ""
var suspendCarrierID uint
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
if err != nil {
return err
@@ -96,7 +100,8 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
// 计算当前套餐剩余额度
remainingQuota := pkg.DataLimitMB - pkg.DataUsageMB
effectiveThresholdMB := pkg.EffectiveThresholdMB()
remainingQuota := pkg.RemainingQuotaMB()
if remainingQuota <= 0 {
// 套餐已用完,标记为已用完
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
@@ -120,7 +125,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
// 检查是否用完
if newUsage >= pkg.DataLimitMB {
if effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB {
updates["status"] = constants.PackageUsageStatusDepleted
}
@@ -139,6 +144,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
zap.Uint("usage_id", pkg.ID),
zap.Int64("deduct_mb", deductFromPkg),
zap.Int64("new_usage_mb", newUsage),
zap.Int64("effective_threshold_mb", effectiveThresholdMB),
zap.Int64("data_limit_mb", pkg.DataLimitMB))
}
@@ -153,12 +159,25 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
if err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID); err != nil {
shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
if err != nil {
return err
}
shouldSuspend = shouldSuspendCurrent
suspendCarrierType = targetCarrierType
suspendCarrierID = targetCarrierID
return nil
})
if err != nil {
return err
}
if shouldSuspend {
s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID)
}
return nil
}
// convertToDeductMB 将浮点增量转换为可扣减的整数 MB
@@ -328,70 +347,98 @@ func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packa
}
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
// 查询是否还有生效中的套餐
var activeCount int64
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
query := tx.Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive)
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
if carrierType == constants.AssetTypeIotCard {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == constants.AssetTypeDevice {
query = query.Where("device_id = ?", carrierID)
} else {
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
if err := query.Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐数量失败")
var packages []*model.PackageUsage
if err := query.Find(&packages).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
}
// 如果没有生效中的套餐,触发停机操作
if activeCount == 0 {
hasAvailablePackage := false
for _, pkg := range packages {
if pkg == nil {
continue
}
if pkg.IsTrafficDepleted() {
if pkg.Status != constants.PackageUsageStatusDepleted {
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
}
}
continue
}
hasAvailablePackage = true
break
}
if !hasAvailablePackage {
s.logger.Warn("所有套餐已用完,触发停机",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
if carrierType == constants.AssetTypeIotCard {
if s.stopResumeCallback != nil {
go func() {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID),
zap.Error(err))
}
}()
}
} else if carrierType == constants.AssetTypeDevice {
if s.stopResumeCallback == nil {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
zap.Uint("device_id", carrierID))
} else {
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
if err != nil {
s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
zap.Error(err))
} else if len(bindings) == 0 {
s.logger.Debug("设备无绑定卡,跳过停机",
zap.Uint("device_id", carrierID))
} else {
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID),
zap.Error(err))
}
}(cardID)
}
}
}
}
return true, nil
}
return nil
return false, nil
}
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) {
if s.stopResumeCallback == nil {
if carrierType == constants.AssetTypeDevice {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
zap.Uint("device_id", carrierID))
}
return
}
if carrierType == constants.AssetTypeIotCard {
go func() {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID),
zap.Error(err))
}
}()
return
}
if carrierType != constants.AssetTypeDevice {
return
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
if err != nil {
s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
zap.Error(err))
return
}
if len(bindings) == 0 {
s.logger.Debug("设备无绑定卡,跳过停机",
zap.Uint("device_id", carrierID))
return
}
for _, b := range bindings {
cardID := b.IotCardID
go func(cID uint) {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID),
zap.Error(err))
}
}(cardID)
}
}

View File

@@ -212,6 +212,28 @@ func (s *PackageUsageStore) GetActiveOrDepletedByCarrier(ctx context.Context, ca
return &pkg, nil
}
// ListActiveOrDepletedByCarrier 查询载体下所有生效中或已用完套餐。
func (s *PackageUsageStore) ListActiveOrDepletedByCarrier(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
var packages []*model.PackageUsage
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("status IN ?", []int{
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}).
Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, activated_at ASC")
if carrierType == "device" {
query = query.Where("device_id = ?", carrierID)
} else {
query = query.Where("iot_card_id = ?", carrierID)
}
if err := query.Find(&packages).Error; err != nil {
return nil, err
}
return packages, nil
}
// ListByCarrier 查询载体的套餐使用记录(可按状态筛选)
func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType string, carrierID uint, status *int) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage

View File

@@ -504,6 +504,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
// REALNAME-02: 自动购包属于 C 端充值触发不需要等实名C 端已做前置实名检查)
// ExpiryBase 仅影响后台囤货路径,此处无需判断
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
@@ -515,6 +516,9 @@ func (h *AutoPurchaseHandler) activateMainPackage(
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
@@ -577,6 +581,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
priority := maxPriority + 1
expiresAt := mainPackage.ExpiresAt
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
@@ -588,6 +593,9 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: constants.PackageUsageStatusActive,
Priority: priority,
MasterUsageID: &mainPackage.ID,

View File

@@ -0,0 +1,4 @@
ALTER TABLE tb_package_usage
DROP COLUMN IF EXISTS enable_virtual_data_snapshot,
DROP COLUMN IF EXISTS display_gain_ratio_snapshot,
DROP COLUMN IF EXISTS virtual_total_mb_snapshot;

View File

@@ -0,0 +1,27 @@
-- 为 tb_package_usage 增加流量语义快照字段,并按当前套餐模板回填历史数据。
ALTER TABLE tb_package_usage
ADD COLUMN virtual_total_mb_snapshot BIGINT NOT NULL DEFAULT 0,
ADD COLUMN display_gain_ratio_snapshot DECIMAL(18,6) NOT NULL DEFAULT 1.0,
ADD COLUMN enable_virtual_data_snapshot BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN tb_package_usage.virtual_total_mb_snapshot IS '虚总量快照(业务停机阈值)';
COMMENT ON COLUMN tb_package_usage.display_gain_ratio_snapshot IS '展示倍率快照real_total_mb / virtual_total_mb';
COMMENT ON COLUMN tb_package_usage.enable_virtual_data_snapshot IS '是否启用虚流量快照';
UPDATE tb_package_usage pu
SET
virtual_total_mb_snapshot = CASE
WHEN p.enable_virtual_data = TRUE AND p.virtual_data_mb > 0 THEN p.virtual_data_mb
ELSE pu.data_limit_mb
END,
display_gain_ratio_snapshot = CASE
WHEN p.enable_virtual_data = TRUE AND p.virtual_data_mb > 0 THEN ROUND((pu.data_limit_mb::NUMERIC / p.virtual_data_mb::NUMERIC), 6)
ELSE 1.0
END,
enable_virtual_data_snapshot = CASE
WHEN p.enable_virtual_data = TRUE AND p.virtual_data_mb > 0 THEN TRUE
ELSE FALSE
END
FROM tb_package p
WHERE p.id = pu.package_id;

View File

@@ -0,0 +1,15 @@
ALTER TABLE tb_package_usage
ADD COLUMN real_data_usage_mb BIGINT NOT NULL DEFAULT 0,
ADD COLUMN virtual_data_usage_mb BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_package_usage.real_data_usage_mb IS '历史遗留字段:真流量使用(MB)';
COMMENT ON COLUMN tb_package_usage.virtual_data_usage_mb IS '历史遗留字段:虚流量使用(MB)';
UPDATE tb_package_usage
SET
real_data_usage_mb = data_usage_mb,
virtual_data_usage_mb = CASE
WHEN enable_virtual_data_snapshot = TRUE AND virtual_total_mb_snapshot > 0
THEN LEAST(ROUND(data_usage_mb * display_gain_ratio_snapshot), data_limit_mb)::BIGINT
ELSE data_usage_mb
END;

View File

@@ -0,0 +1,5 @@
-- 删除 tb_package_usage 中已退出主链路的历史遗留流量字段。
ALTER TABLE tb_package_usage
DROP COLUMN IF EXISTS real_data_usage_mb,
DROP COLUMN IF EXISTS virtual_data_usage_mb;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-27

View File

@@ -0,0 +1,160 @@
## Context
当前系统已有 `tb_account_operation_log`,但该模型以账号为中心(如 `target_account_id``target_username`),不适合承载资产域(卡/设备)高频敏感操作。现有资产相关写操作分散在多个模块:
- `iot_card`:分配、回收、系列绑定、轮询开关、实名策略、手动实名、删除
- `device`:删除、分配回收、绑定解绑、系列绑定、设备停复机、网关远程控制
- `asset`:统一入口停复机、停用、轮询状态、实名策略
- `auto-stop-resume`:轮询触发自动停复机
这些链路当前多为业务日志zap缺少可结构化查询的审计落库无法稳定支持安全审计、操作追责与问题复盘。
约束:
- 必须遵循 `Handler -> Service -> Store -> Model`,审计写入在 Service 层触发。
- 审计能力按业务域拆分,本次仅实现资产域,不做全局万能日志表。
- 主流程性能不可明显退化,审计写入采用异步非阻塞。
- 不新增自动化测试文件,采用 PostgreSQL MCP + Postman/curl 手工验证。
## Goals / Non-Goals
**Goals:**
- 新增资产域专用审计日志模型、存储与服务(`tb_asset_operation_log` + `asset_audit`)。
- 覆盖卡与设备敏感写操作,要求成功/失败/拒绝全部记录。
- 统一日志字段:操作人、时间、动作、目标资产、变更前后、请求上下文、结果。
- 在现有模块中实现可复用的审计调用模式,减少重复拼装逻辑。
- 提供可回滚的迁移方案与明确的手工验收清单。
**Non-Goals:**
- 本阶段不实现通用“跨域审计查询平台”。
- 本阶段不改造账号域日志(`tb_account_operation_log`)历史数据。
- 本阶段不新增审计查询 API先完成采集落库查询按 DB 手工验证)。
## Decisions
### 决策1资产域独立日志模型与表
**选择**:新增 `tb_asset_operation_log`,不复用 `tb_account_operation_log`
**理由**
- 资产日志与账号日志目标对象不同,字段语义差异大。
- 独立表可避免“万能表”字段污染,支持未来按域继续拆分(财务/权限等)。
- 资产操作体量高,独立索引策略可控。
**备选方案**:复用 `tb_account_operation_log` 增加 nullable 字段。
**放弃原因**:语义混杂、索引冲突、后续演进成本高。
---
### 决策2统一资产审计服务 + 结构化日志构建器
**选择**:新增 `internal/service/asset_audit`,提供统一 `LogOperation(ctx, entry)`;各业务 Service 在成功/失败/拒绝节点调用。
**理由**
- 降低每个模块重复组装字段成本。
- 便于统一控制日志格式、字段裁剪、异常降级。
- 与现有账号审计模式一致,降低团队理解成本。
**备选方案**:每个业务 Service 直接写 Store。
**放弃原因**:重复代码多,格式易漂移,难做统一治理。
---
### 决策3全结果态记录success/failed/denied
**选择**:结果字段使用 `result_status`(成功/失败/拒绝),并记录 `error_code/error_msg`(失败或拒绝时)。
**理由**
- 仅记录成功不足以支撑安全审计(越权尝试、失败操作同样关键)。
- 便于风控与运维排查(失败高发点、越权热点)。
**备选方案**:只记录成功。
**放弃原因**:审计链不完整。
---
### 决策4前后镜像字段规范化
**选择**`before_data/after_data` 统一为 JSONB
- create/绑定类:`before_data` 可为空,`after_data` 必填关键字段
- update/状态流转类:`before_data``after_data` 同时记录
- delete/解绑类:`before_data` 必填,`after_data` 可为空或最小状态
**理由**
- 满足“从什么变成什么”的核心诉求。
- 便于后续做差异化检索与审计导出。
---
### 决策5依赖注入与接入矩阵
**选择**:在 bootstrap 注入 `assetAuditService` 到以下服务:
- `iot_card.Service`
- `device.Service`
- `iot_card.StopResumeService`
- `asset.LifecycleService`
- `polling.AssetPollingService`(设备轮询开关路径)
- `device_import.Service` / `iot_card_import.Service`(任务创建记录)
**理由**
- 关键写操作分布在多个服务,必须覆盖所有真实入口。
- 避免仅在 Handler 记录导致丢失 Service 内部失败/回滚细节。
---
### 决策6异步写入 + 失败不阻断
**选择**:审计写入使用 Goroutine 异步落库,写入失败仅记录 Error 日志,不影响主业务结果。
**理由**
- 符合当前项目审计日志规范。
- 避免写操作主路径被审计 IO 放大。
**补充约束**
- 单条日志大小限制(对大字段做截断/脱敏),避免超大 JSON 影响 DB。
- 脱敏字段(如 WiFi 密码)仅记录摘要,不明文落库。
---
### 决策7常量与操作类型字典化
**选择**:在 `pkg/constants` 定义资产审计操作类型、结果类型、目标类型常量,禁止硬编码。
**理由**
- 统一语义,避免多模块字符串漂移。
- 便于统计与后续扩展。
## Risks / Trade-offs
- **[风险] 日志量增长导致表膨胀和查询变慢**
**缓解**:增加组合索引(资产、操作人、时间、结果),后续按月归档策略预留。
- **[风险] before/after 组装不一致,影响可读性**
**缓解**:统一字段模板与 helper代码评审按固定清单检查。
- **[风险] 异步写入在进程异常退出时丢少量日志**
**缓解**:接受该权衡;关键安全场景后续可升级为异步队列化写入。
- **[风险] 远程控制接口包含敏感参数(如密码)**
**缓解**:明确脱敏规则,禁止明文入 `after_data`
- **[权衡] 全量记录失败/拒绝会增加写入量**
**接受**:审计完整性优先于少量存储成本。
## Migration Plan
1. 新增模型与迁移:`tb_asset_operation_log`(含必要索引)。
2. 新增 `asset_audit` store/service并在 bootstrap 完成注入。
3. 按操作矩阵分批接入:
- 第一批:停复机、绑定解绑、分配回收、停用、实名策略、轮询开关
- 第二批:远程控制、导入任务创建、删除/批量删除
4. 补充常量与日志构建 helper统一字段格式与脱敏。
5. 使用 PostgreSQL MCP + Postman/curl 执行手工验收输出证据请求、响应、DB 快照)。
**回滚策略:**
- 业务回滚:关闭审计调用开关(或临时空实现),不影响主流程。
- 数据回滚:执行 migration down 删除新表。
## Open Questions
- 是否在本期即提供后台审计查询 API还是维持 DB 手工查询到下一期?
- `after_data` 的最大存储体积阈值定为多少(如 16KB/32KB
- 是否需要为“自动停复机(系统触发)”定义专门 `operator_type`system常量

View File

@@ -0,0 +1,39 @@
## Why
当前系统仅在账号、微信配置、代理充值等少量模块落了审计日志,卡与设备的大量敏感写操作(停复机、绑卡解绑、分配回收、远程控制、实名策略、轮询开关、停用等)缺少统一落库审计,出现问题时难以回答“谁在什么时间做了什么、从什么变成什么、结果如何”。
随着资产规模和运维复杂度增长,该缺口已经影响安全审计、问题追溯和责任界定,需尽快补齐资产域操作日志能力并做全量覆盖。
## What Changes
- `feature-001-asset-audit-domain-model`:新增资产域专用审计日志能力,建设独立日志模型与存储(不复用账号日志表)。
- `feature-002-asset-audit-full-coverage`:覆盖卡与设备相关全部敏感写操作,包含成功、失败、拒绝三类结果。
- `feature-003-asset-audit-context-complete`:每条日志必须记录操作人、操作时间、操作内容、变更前后数据、请求上下文与结果。
- `feature-004-asset-audit-service-integration`:在 `Handler -> Service -> Store -> Model` 分层内完成审计服务注入与调用,主流程非阻塞。
- `feature-005-asset-audit-observability`:补充文档与手工验收方案,保证审计数据可查询、可追溯、可核对。
## Capabilities
### New Capabilities
- `asset-operation-audit`:资产域操作审计能力,定义资产日志数据模型、记录规范、覆盖范围与落库语义(含成功/失败/拒绝)。
### Modified Capabilities
- `iot-device`:设备分配、远程控制、导入等场景从“原则要求记录日志”升级为“必须按统一资产审计字段规范落库”。
- `auto-stop-resume`:停复机相关流程明确使用资产域审计日志模型并记录操作结果、前后状态。
## Impact
- 影响模块:
- `internal/model`:新增资产操作日志模型(独立于账号日志模型)。
- `internal/store/postgres`:新增资产日志存储与查询基础能力。
- `internal/service`:卡、设备、资产、停复机、轮询管控等写操作接入审计日志。
- `internal/bootstrap`:注入资产审计服务依赖。
- `pkg/constants`:新增资产审计操作类型与结果常量。
- 影响 API
- 对现有接口契约原则上无破坏性变更;行为变化为“写操作新增审计落库”。
- 影响数据:
- 新增资产审计日志表及索引,用于按资产、操作人、时间、操作类型追溯。
- 影响性能:
- 审计写入采用异步非阻塞;控制单次日志体大小,避免对主链路造成显著延迟。
- 验证计划:
- 按项目约束使用 PostgreSQL MCP + Postman/curl 手工验证,不新增自动化测试文件。

View File

@@ -0,0 +1,64 @@
## ADDED Requirements
### Requirement: 资产域操作日志使用独立数据模型
系统 SHALL 为资产域操作日志使用独立数据模型与数据表,不得复用账号域日志表;资产日志模型 MUST 支持记录资产类型、资产ID、资产标识符、操作动作、操作结果、前后镜像和请求上下文。
#### Scenario: 新增资产日志表并独立于账号日志
- **WHEN** 系统初始化资产域审计能力
- **THEN** 系统使用独立的 `tb_asset_operation_log` 存储资产日志,且不写入 `tb_account_operation_log`
#### Scenario: 资产日志表不使用外键约束
- **WHEN** 创建资产日志表结构
- **THEN** 表间关联通过 ID 字段维护,不创建数据库外键和 GORM 关联标签
### Requirement: 资产敏感写操作必须全量记录
系统 SHALL 对卡和设备相关敏感写操作进行全量审计记录,结果态 MUST 覆盖 `success``failed``denied`
#### Scenario: 成功操作写入 success 日志
- **WHEN** 设备分配、卡绑定、手动实名、停用等写操作执行成功
- **THEN** 系统写入 `result_status=success` 的资产操作日志
#### Scenario: 业务失败写入 failed 日志
- **WHEN** 写操作执行过程中发生网关失败或数据库失败
- **THEN** 系统写入 `result_status=failed` 的资产操作日志,并记录错误码与错误摘要
#### Scenario: 权限或规则拒绝写入 denied 日志
- **WHEN** 操作被权限规则、保护期规则或业务前置条件拒绝
- **THEN** 系统写入 `result_status=denied` 的资产操作日志,并记录拒绝原因
### Requirement: 审计日志必须包含完整上下文
系统 SHALL 在资产日志中记录“谁、何时、做了什么、从什么变成什么”,并包含请求链路上下文。
#### Scenario: 记录操作人和时间
- **WHEN** 生成任一资产操作日志
- **THEN** 日志包含 `operator_id``operator_type``operator_name``created_at`
#### Scenario: 记录前后镜像
- **WHEN** 记录状态流转或配置修改类操作
- **THEN** 日志同时包含 `before_data``after_data`,可直接体现变更差异
#### Scenario: 记录请求上下文
- **WHEN** 记录资产操作日志
- **THEN** 日志包含 `request_id``ip_address``user_agent``request_path``request_method`
### Requirement: 审计写入异步且不阻塞主流程
系统 SHALL 采用异步方式写入资产审计日志,日志写入失败 MUST 不影响业务主流程结果。
#### Scenario: 异步写入
- **WHEN** 业务操作完成并触发审计记录
- **THEN** 主流程先返回业务结果,日志由异步流程落库
#### Scenario: 日志写入失败降级
- **WHEN** 资产日志落库失败
- **THEN** 系统仅记录 Error 级别应用日志,不回滚已成功的主业务操作
### Requirement: 敏感字段脱敏与日志体积控制
系统 SHALL 对敏感字段执行脱敏处理,并对日志体积设置上限,避免泄露与性能风险。
#### Scenario: 远程配置敏感字段脱敏
- **WHEN** 记录设备 WiFi/密码等敏感配置变更日志
- **THEN** `before_data`/`after_data` 中不得出现明文密码,仅保留掩码或摘要
#### Scenario: 超长数据裁剪
- **WHEN** 业务上下文数据超过日志体积阈值
- **THEN** 系统对超长字段进行裁剪并保留裁剪标记,保证日志写入稳定

View File

@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: 自动停复机流程必须写入资产审计日志
系统 SHALL 在自动停机与自动复机流程中写入资产审计日志,且日志结果态 MUST 覆盖成功与失败。
#### Scenario: 自动停机成功写入日志
- **WHEN** 轮询判定流量耗尽并成功停机
- **THEN** 系统写入资产审计日志,记录操作类型、停机原因、变更前后网络状态与 `result_status=success`
#### Scenario: 自动停机失败写入日志
- **WHEN** 自动停机在重试后仍失败
- **THEN** 系统写入资产审计日志,记录 `result_status=failed`、错误摘要与重试信息摘要
#### Scenario: 自动复机成功写入日志
- **WHEN** 满足复机条件且自动复机成功
- **THEN** 系统写入资产审计日志,记录复机前后状态、触发来源与 `result_status=success`
#### Scenario: 自动复机失败写入日志
- **WHEN** 自动复机执行失败
- **THEN** 系统写入资产审计日志,记录 `result_status=failed` 与失败原因
### Requirement: 手动停复机必须与自动流程使用同一审计标准
系统 SHALL 对手动停机/复机使用与自动流程一致的资产审计字段规范,并在拒绝场景记录 denied 日志。
#### Scenario: 手动停机被拒绝时记录 denied 日志
- **WHEN** 用户手动停机因未实名或保护期规则被拒绝
- **THEN** 系统写入 `result_status=denied` 的资产审计日志,并记录拒绝原因
#### Scenario: 手动复机被拒绝时记录 denied 日志
- **WHEN** 用户手动复机因保护期规则被拒绝
- **THEN** 系统写入 `result_status=denied` 的资产审计日志,并记录拒绝原因
#### Scenario: 手动停复机成功时记录变更镜像
- **WHEN** 用户手动停机或复机成功
- **THEN** 系统在日志中记录 `before_data``after_data`,体现网络状态和停机原因的变化

View File

@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: 设备敏感写操作必须写入资产审计日志
系统 SHALL 对设备能力中的敏感写操作写入资产域审计日志,包括但不限于:删除设备、绑定/解绑卡、批量分配/回收、设备停机/复机、远程控制(限速/WiFi/切卡/切卡模式/重启/恢复出厂)、批量导入任务创建。
#### Scenario: 设备分配与回收记录审计日志
- **WHEN** 运营人员执行设备批量分配或回收
- **THEN** 系统记录资产审计日志,包含目标设备列表、操作前后归属信息、操作结果
#### Scenario: 绑定与解绑记录审计日志
- **WHEN** 平台用户执行设备绑卡或解绑
- **THEN** 系统记录资产审计日志包含设备ID、卡ID、插槽信息和前后绑定状态
#### Scenario: 设备停复机记录审计日志
- **WHEN** 用户对设备执行停机或复机
- **THEN** 系统记录资产审计日志,包含处理卡数量、成功数、失败数、失败原因摘要
#### Scenario: 远程控制记录审计日志
- **WHEN** 用户执行限速、WiFi设置、切卡、切卡模式、重启、恢复出厂
- **THEN** 系统记录资产审计日志,包含操作类型、关键参数摘要、执行结果和错误信息(如失败)
### Requirement: 设备操作拒绝和失败场景也必须审计
系统 SHALL 在设备操作被拒绝或执行失败时同样写入审计日志,不得仅记录成功操作。
#### Scenario: 规则拒绝写入 denied 日志
- **WHEN** 设备操作因保护期或权限限制被拒绝
- **THEN** 系统写入 `result_status=denied` 的资产审计日志,并记录拒绝原因
#### Scenario: 网关或数据库失败写入 failed 日志
- **WHEN** 设备操作执行过程中出现网关调用失败或数据库更新失败
- **THEN** 系统写入 `result_status=failed` 的资产审计日志,并记录错误码与错误摘要

View File

@@ -0,0 +1,60 @@
## 0. 验证准备与覆盖矩阵确认
- [x] 0.1 梳理资产敏感写操作覆盖矩阵(卡/设备/统一资产入口/自动停复机/导入任务创建),明确每个操作对应 `operation_type`
- [x] 0.2 定义日志字段验收清单:`operator_*``asset_*``operation_*``before_data``after_data``request_*``result_*`
- [x] 0.3 准备手工验收数据与脚本Postman/curl + PostgreSQL MCP 查询语句),覆盖 success/failed/denied 三类结果
## 1. 资产域日志模型与迁移
- [x] 1.1 新增资产操作日志 Model`tb_asset_operation_log`)与 JSONB 字段结构,确保无外键/无 GORM 关联标签
- [x] 1.2 新增迁移文件up/down创建资产日志表和索引按资产、操作人、时间、结果等维度
- [x] 1.3 新增 Store 能力Create + 常用查询基础方法),满足异步写入与后续审计检索
- [x] 1.4 使用 PostgreSQL MCP 验证表结构、索引、字段类型与约束符合设计
## 2. 审计基础设施与常量
- [x] 2.1 在 `pkg/constants` 新增资产审计常量:操作类型、结果类型、资产类型、系统操作者类型等
- [x] 2.2 新增 `asset_audit` 服务与统一写入接口(异步落库、失败不阻断主流程)
- [x] 2.3 新增日志构建 helper统一 before/after、请求上下文、错误信息组装
- [x] 2.4 在 bootstrap 完成依赖注入(`iot_card``device``stop_resume``asset lifecycle``asset polling`、导入服务)
- [x] 2.5 编译校验与静态检查(`gofmt``go build`)确保注入链路无回归
## 3. IoT 卡相关操作接入审计
- [x] 3.1 接入单卡分配/回收审计日志(包含前后归属、结果态)
- [x] 3.2 接入卡系列绑定、轮询开关、实名策略更新审计日志
- [x] 3.3 接入手动实名状态更新审计日志(必须记录 before/after
- [x] 3.4 接入卡删除/批量删除审计日志
- [x] 3.5 对卡相关拒绝与失败路径补充 denied/failed 审计日志
- [ ] 3.6 手工验证卡操作日志完整性success/failed/denied + before/after
## 4. 设备相关操作接入审计
- [x] 4.1 接入设备删除、分配、回收、系列绑定审计日志
- [x] 4.2 接入设备绑卡/解绑审计日志(记录设备、卡、插槽与状态变化)
- [x] 4.3 接入设备停机/复机审计日志(记录成功数、失败数、失败摘要)
- [x] 4.4 接入设备远程控制审计日志(限速/WiFi/切卡/切卡模式/重启/恢复出厂)
- [x] 4.5 落实敏感字段脱敏WiFi 密码等不得明文写入日志)
- [ ] 4.6 手工验证设备操作日志(含敏感字段脱敏校验)
## 5. 统一资产入口与停复机链路接入
- [x] 5.1 在统一资产入口相关写操作中补齐审计日志(停复机、停用、轮询状态、实名策略)
- [x] 5.2 在自动停复机链路补齐审计日志(自动停机/自动复机成功与失败)
- [x] 5.3 在手动停复机拒绝场景补齐 denied 审计日志(未实名、保护期等)
- [x] 5.4 校验系统触发操作的操作者语义(`operator_type=system` 等)
- [ ] 5.5 手工验证统一入口与停复机链路审计闭环
## 6. 导入任务创建与批量链路补齐
- [x] 6.1 接入设备导入任务创建审计日志(记录任务参数摘要与创建结果)
- [x] 6.2 接入卡导入任务创建审计日志(记录任务参数摘要与创建结果)
- [ ] 6.3 校验批量操作日志的聚合字段(`batch_total/success_count/fail_count`
- [ ] 6.4 手工验证导入与批量链路审计数据可追溯
## 7. 文档与最终验收
- [x] 7.1 编写功能总结文档(`docs/{feature-id}/`),说明字段规范、覆盖范围、脱敏规则、查询示例
- [x] 7.2 更新 README 相关章节(资产审计日志能力与排障入口)
- [ ] 7.3 完成全链路手工验收:卡与设备典型操作各覆盖 success/failed/denied 至少一例
- [ ] 7.4 输出验收证据:接口请求响应、日志样例、数据库快照与关键 SQL 查询结果

View File

@@ -0,0 +1,58 @@
# 资产审计日志验收准备
## 0.1 资产敏感写操作覆盖矩阵
| 业务域 | 场景 | `operation_type` | 主要入口 |
|---|---|---|---|
| IoT 卡 | 分配 | `card_allocate` | `iot_card.Service.AllocateCards` |
| IoT 卡 | 回收 | `card_recall` | `iot_card.Service.RecallCards` |
| IoT 卡 | 系列绑定 | `card_series_binding` | `iot_card.Service.BatchSetSeriesBinding` |
| IoT 卡 | 轮询开关 | `card_polling_status` | `iot_card.Service.UpdatePollingStatus/BatchUpdatePollingStatus` |
| IoT 卡 | 实名策略 | `card_realname_policy` | `iot_card.Service.UpdateRealnamePolicy` |
| IoT 卡 | 手动实名状态 | `card_realname_status` | `iot_card.Service.ManualUpdateRealnameStatus` |
| IoT 卡 | 删除/批量删除 | `card_delete` / `card_batch_delete` | `iot_card.Service.DeleteCard/BatchDeleteCards` |
| IoT 卡停复机 | 自动停机/复机 | `card_auto_stop` / `card_auto_start` | `iot_card.StopResumeService.stopCardWithRetry/resumeSingleCard` |
| IoT 卡停复机 | 手动停机/复机 | `card_manual_stop` / `card_manual_start` | `iot_card.StopResumeService.ManualStopCard/ManualStartCard` |
| 设备 | 删除 | `device_delete` | `device.Service.Delete` |
| 设备 | 分配/回收 | `device_allocate` / `device_recall` | `device.Service.AllocateDevices/RecallDevices` |
| 设备 | 系列绑定 | `device_series_binding` | `device.Service.BatchSetSeriesBinding` |
| 设备 | 绑卡/解绑 | `device_bind_card` / `device_unbind_card` | `device.Service.BindCard/UnbindCard` |
| 设备 | 停机/复机 | `device_stop` / `device_start` | `device.Service.StopDevice/StartDevice` |
| 设备远程控制 | 限速 | `device_speed_limit` | `device.Service.GatewaySetSpeedLimit` |
| 设备远程控制 | WiFi 设置 | `device_set_wifi` | `device.Service.GatewaySetWiFi` |
| 设备远程控制 | 切卡 | `device_switch_card` | `device.Service.GatewaySwitchCard` |
| 设备远程控制 | 切卡模式 | `device_switch_mode` | `device.Service.GatewaySwitchMode` |
| 设备远程控制 | 重启/恢复出厂 | `device_reboot` / `device_reset` | `device.Service.GatewayRebootDevice/ResetDevice` |
| 统一资产入口 | 停用 | `asset_deactivate` | `asset.LifecycleService.DeactivateIotCard/DeactivateDevice` |
| 统一资产入口 | 轮询开关 | `asset_polling_status` | `polling.AssetPollingService.UpdatePollingStatus` |
| 统一资产入口 | 实名策略 | `asset_realname_policy` | `device.Service.UpdateRealnamePolicy`(设备路径) |
| 导入任务 | 设备导入任务创建 | `device_import_task_create` | `device_import.Service.CreateImportTask` |
| 导入任务 | 卡导入任务创建 | `iot_card_import_task_create` | `iot_card_import.Service.CreateImportTask` |
## 0.2 审计字段验收清单
| 字段组 | 核对项 | 说明 |
|---|---|---|
| `operator_*` | `operator_id/operator_type/operator_name` | 系统触发场景 `operator_type=system``operator_id` 可为空 |
| `asset_*` | `asset_type/asset_id/asset_identifier` | 设备使用虚拟号,卡使用 ICCID批量/导入场景允许按任务摘要记录 |
| `operation_*` | `operation_type/operation_desc` | `operation_type` 必须来自 `pkg/constants/asset_audit.go` |
| `before_data/after_data` | 变更镜像 | 状态/配置修改类要求体现前后差异 |
| `request_*` | `request_id/ip_address/user_agent/request_path/request_method` | 由日志中间件注入到 `context`,审计统一读取 |
| `result_*` | `result_status/error_code/error_msg` | 覆盖 `success/failed/denied`;失败/拒绝需记录错误摘要 |
| 批量聚合 | `batch_total/success_count/fail_count` | 批量与设备停复机场景需写入聚合统计 |
## 0.3 手工验收数据与脚本
### 接口回放建议Postman/curl
1. 成功样例:设备分配、卡分配、设备远程控制(限速)
2. 失败样例:远程控制网关失败、非法设备 ID、事务失败
3. 拒绝样例:停复机保护期、越权分配、无效实名策略
### SQL 脚本
- 统一查询脚本:`docs/add-asset-operation-audit-log/手工验收脚本.sql`
- 重点核对:
- 同一操作存在 `success/failed/denied` 三类结果
- `before_data/after_data` 存在且字段合理
- WiFi 密码字段已脱敏(不出现明文)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-27

View File

@@ -0,0 +1,164 @@
## Context
当前仓库中的流量语义分散在三层:
1. **同步层**`tb_iot_card.last_gateway_reading_mb``current_month_usage_mb``data_usage_mb` 负责承接上游累计量与增量。
2. **套餐层**`tb_package_usage.data_limit_mb``data_usage_mb` 负责真实使用量累计、主套餐/加油包优先级扣减、状态流转与停复机联动。
3. **展示层**:后台 `asset-resolve / current-package / packages`、C 端 `asset/info / package-history`、客户视图流量接口,又额外暴露了 `package_total_mb``package_used_mb``virtual_limit_mb``virtual_ratio` 等旧字段,且不同接口对 ratio 有“乘”和“除”两种口径。
本次变更不是新增一个流量接口,而是把“真实消耗事实”“停机阈值”“前端展示映射”三件事拆开并统一命名。已确认的业务共识如下:
- `virtual_total_mb` 永远大于 0 且小于等于 `real_total_mb`
- 一个载体同一时刻只有一个生效主套餐,可以存在多个生效/已用完的加油包
- 对外必须统一返回:`real_total_mb``real_used_mb``virtual_total_mb``virtual_used_mb``reduction_pct`
- `virtual_used_mb` 不是简单的“缩小后的已用量”,而是把真已用映射回真总量刻度后的展示值
- 旧的模糊字段最终要退出,避免继续误导后续开发和前端消费方
当前约束:
- 保持 Handler → Service → Store → Model 分层,不引入新依赖
- 数据库仍使用 GORM 与手动 ID 关联,不使用外键 / GORM 关联标签
- 停复机、轮询、扣减、展示接口都要共用同一套语义,不能只改 DTO 名称
## Goals / Non-Goals
**Goals:**
- 统一套餐流量事实字段,明确 `tb_package_usage` 才是业务事实源
- 为套餐使用记录补充虚总量与展示映射快照,避免历史展示继续读取 live `tb_package`
- 统一 `virtual_used_mb``reduction_pct` 的计算规则
- 统一主套餐 / 加油包在后台、C 端、停复机中的流量语义
- 用一次重构完成外部 DTO / OpenAPI / 文档的字段清理,不长期保留误导字段
**Non-Goals:**
- 不重写卡同步增量算法本身(仍由网关累计量驱动)
- 不重构主套餐排队机制与加油包生命周期拓扑(继续沿用 `master_usage_id` + `priority`
- 不在本次变更中新增全新的停机原因体系或网关接入方式
- 不在本次提案中引入自动化测试框架变更执行阶段继续以手工验收、PostgreSQL 核验和接口回放为主
- 不在本次变更中设计或重构 customer-view 的 total 聚合语义
## Decisions
### 决策 1统一事实层与展示层字段含义
**决策**
- `real_total_mb`:套餐真实总量,内部仍以 `tb_package_usage.data_limit_mb` 作为真实总量快照
- `real_used_mb`:套餐真实已用,内部仍以 `tb_package_usage.data_usage_mb` 作为真实已用累计值
- `virtual_total_mb`:套餐业务停机阈值,需要落到 `tb_package_usage` 快照字段,不再直接依赖 `tb_package.virtual_data_mb`
- `virtual_used_mb`:前端展示值,定义为 `min(real_used_mb * display_gain_ratio, real_total_mb)`
- `display_gain_ratio`:内部展示映射比例,定义为 `real_total_mb / virtual_total_mb`
- `reduction_pct`:对外字段名继续沿用“缩减比例”业务口语,但公式固定为 `display_gain_ratio - 1`,用于表达“真已用映射到虚已用时的增幅”;接口返回格式固定为 `0.428571` 这种比例小数,而不是 `42.8571`
**为什么这样做**
- `real_used_mb``virtual_used_mb` 是两种不同语义:前者是事实,后者是展示
- 继续沿用“旧 `virtual_ratio` 既当内部比例又当外部字段”的方式,仍会导致接口消费者误读
- 用内部 `display_gain_ratio` + 外部 `reduction_pct` 的分离命名,可以同时保住公式正确性和对外可读性
**备选方案**
- 继续直接对外暴露 `virtual_ratio`:实现简单,但会继续把“倍率”和“百分比”混为一谈
- 只对外暴露真值 + 比例,让前端自己算虚已用:会把业务曲线责任下放到前端,导致多端不一致
### 决策 2停机判断统一按套餐使用快照阈值执行
**决策**
- 启用虚流量时,套餐耗尽阈值为 `virtual_total_mb_snapshot`
- 未启用虚流量时,套餐耗尽阈值为 `real_total_mb`
- 套餐剩余额度统一定义为 `effective_threshold_mb - real_used_mb`
- 套餐状态置为“已用完”的条件,统一改为 `real_used_mb >= effective_threshold_mb`
- 载体停机条件统一为:没有任何 active 套餐仍有剩余业务额度
**为什么这样做**
- 当前 `data_limit_mb/data_usage_mb` 的比较逻辑只适用于“真总量即停机阈值”的旧模型
- 本次业务已经明确 `virtual_total_mb` 是业务停机阈值,因此必须在扣减、耗尽、停机、复机四条链路使用同一阈值定义
**备选方案**
- 继续按 `data_limit_mb` 判耗尽,只在展示层补充虚流量字段:会产生“展示说用完了但系统不停机”或“系统停机了但展示还没满”的双重口径
### 决策 3历史展示不再依赖 live `tb_package`
**决策**:在 `tb_package_usage` 新增至少以下快照字段:
- `virtual_total_mb_snapshot`
- `display_gain_ratio_snapshot`
- `enable_virtual_data_snapshot`
历史回填按当前 `Package` 数据做一次 best-effort 回填,并在设计与实施文档中明确这是“语义收口后的基线”,不承诺恢复所有历史上已经漂移过的展示事实。
**为什么这样做**
- 当前 B/C 端多个历史流量展示接口仍会读取 live `tb_package` 值,套餐模板变更后历史详情会漂
- 本次要清理旧字段,就必须先把历史可回放的最小快照集补齐
**备选方案**
- 完全不回填,仅对新创建套餐使用记录生效:能降低迁移复杂度,但同一接口会同时出现新老两套语义
### 决策 4接口层按“主套餐视图”和“套餐记录视图”分开定义
**决策**
- `current-package` 与资产摘要类接口只代表当前生效主套餐
- `packages``package-history` 代表按 `package_usage_id` 组织的套餐记录视图
- 主套餐与加油包共存关系继续通过 `master_usage_id``priority` 表达,不引入新的关系模型
- `asset-resolve``asset/info` 返回当前主套餐时同步返回 `current_package_usage_id`
- `current-package` 在无生效主套餐时返回 `200 + null`,而不是 `404`
- 当前未正式暴露到路由层的 customer-view total 聚合不纳入本次 contract 收口范围
**为什么这样做**
- 当前业务已经确认“一个 active 主套餐 + 多个加油包”是稳定模型
- 如果资产摘要接口试图把主套餐和多个加油包重新聚成一个唯一流量总览,会再次把事实层和展示层混在一起
**备选方案**
- 在所有资产接口里统一返回聚合总览:前端方便,但会牺牲 `package_usage` 作为业务事实源的清晰性
### 决策 5采用一次 breaking 重构,而不是长期兼容旧字段
**决策**
- 本变更允许在一个版本窗口内完成数据库迁移、服务重构、DTO 替换、OpenAPI 更新和文档切换
- 旧字段不作为长期兼容字段保留;除迁移执行所需的短暂过渡外,确认废弃的数据库旧列也在本次变更中一并物理删除
**为什么这样做**
- 当前旧字段名称本身已经是误导源,长期并存只会让后续代码继续写出“半新半旧”的逻辑
- 用户已经明确希望推倒重来,不希望遗留字段继续误导开发者
**备选方案**
- 新旧字段并存多个版本:对前端更温和,但会明显提高 Service、DTO、OpenAPI、文档和人工验收成本
## Risks / Trade-offs
- **[字段命名仍可能让人误解]** → 在 spec 与 design 中同时定义“内部展示倍率”和外部 `reduction_pct` 的精确定义,并要求 DTO 命名避免继续使用旧 `virtual_ratio`
- **[历史回填无法百分百恢复真实历史展示]** → 本次接受 best-effort 回填,并在验收中重点核对回填基线样例;如需完全回放,后续单独发起“历史流量审计基线”变更
- **[停机逻辑与展示逻辑同时改动,容易联调混乱]** → 按“写链路先收口、读链路后切换、旧字段最后删除”的顺序执行
- **[资产摘要是否展示主套餐还是聚合视图存在前端预期差]** → 在 spec 中显式写死 `current-package` 与资产摘要的语义,不允许实现层各自解释
- **[比例字段口径争议]** → 将字段名称保留为 Open Question在 proposal/spec 已明确公式的前提下允许后续改名但不改语义
## Migration Plan
1. 新增 `tb_package_usage` 快照字段并执行历史回填。
2. 重构套餐创建/激活路径,确保新写入的 `PackageUsage` 同时落真实总量、虚总量、展示倍率快照。
3. 重构扣减、耗尽、停机、复机逻辑,统一改为按 `effective_threshold_mb` 判断。
4. 重构后台 `asset-resolve / current-package / packages` 与 C 端 `asset/info / package-history` 的 DTO 和组装逻辑。
5. 更新后台/C 端 OpenAPI、文档和手工验收脚本。
6. 移除旧 DTO 字段、旧组装逻辑和确认废弃的数据库旧列,完成一次性语义收口。
**Rollback**
- 数据库迁移阶段保留核心真实用量列 `data_limit_mb / data_usage_mb`,新增快照字段可逆
- 在物理删除旧列前,可通过切回旧读链路快速回滚
- 一旦完成旧列删除与 contract 切换后,不再承诺对旧客户端兼容
## Open Questions
- 当前无阻塞实施的开放问题;若后续需要继续优化,仅允许围绕字段文案与展示文案做非语义性微调,不再更改公式、返回结构或 `current-package` 的空结果契约。

View File

@@ -0,0 +1,38 @@
## Why
feature-001-traffic-model-refactor当前项目的流量语义分散在卡同步字段、套餐使用字段和 B/C 端展示字段三套口径中,`virtual_ratio``package_total_mb``virtual_limit_mb` 等命名也持续放大理解成本。现在业务已经明确新的统一口径:虚总量永远不大于真总量、一个载体同一时刻只有一个生效主套餐但可挂多个加油包、对外必须同时提供真值/虚值/展示换算值,因此需要正式发起一次流量模型重构,而不是继续在旧字段上叠补丁。
## What Changes
- 新增统一的流量业务语义,明确 `package_usage` 为套餐流量事实源,对外统一为真总量、真已用、虚总量、虚已用和 `reduction_pct`(缩减比例)字段。
- 明确虚流量约束:`virtual_total_mb` 必须大于 0 且小于等于 `real_total_mb`;废弃现有允许纯虚流量或虚总量大于真总量的旧约定。 **BREAKING**
- 统一虚已用展示公式,停止在不同接口里混用“除以 ratio”和“乘以 ratio”的实现。 **BREAKING**
- 将停机判断从“按 `data_limit_mb/data_usage_mb` 的旧直觉口径”收束为“按套餐使用记录上的真值/虚阈值语义”统一判断。 **BREAKING**
- 统一主套餐 / 加油包流量展示模型:`current-package` 仅表示当前生效主套餐,套餐历史与 C 端套餐列表按 `package_usage_id` 逐条展示各套餐用量。
- 清理并替换误导性旧字段,包含但不限于 `package_total_mb``package_used_mb``package_remain_mb``virtual_limit_mb``virtual_remain_mb`、旧式 `virtual_ratio` 展示语义,并在本次变更中一并删除确认废弃的数据库旧列。 **BREAKING**
- 为历史套餐使用记录补充必要快照字段,避免继续依赖 `tb_package` 当前值回放历史流量展示。
- 同步更新 OpenAPI / DTO / 文档 / 手工验收口径,确保 Handler → Service → Store → Model 各层使用相同字段定义。
## Capabilities
### New Capabilities
- `traffic-model-semantics`: 定义统一的套餐流量事实字段、展示字段、比例字段、停机阈值语义及历史快照规则。
### Modified Capabilities
- `iot-package`: 修改套餐真流量/虚流量定义、校验规则、`virtual_ratio` 使用语义及展示说明。
- `asset-resolve`: 修改资产解析接口中的套餐流量字段与展示计算规则。
- `asset-queries`: 修改后台套餐历史与当前主套餐接口的流量字段、当前主套餐语义与错误处理约定。
- `client-asset-info`: 修改 C 端资产信息与套餐历史接口的流量响应结构和展示口径。
- `package-usage-priority`: 修改按优先级扣减后的耗尽判断与套餐使用字段语义。
- `auto-stop-resume`: 修改自动停复机的流量耗尽判定基础口径。
## Impact
- **分层影响**
- Handler`internal/handler/admin/asset.go``internal/handler/app/client_asset.go` 等返回结构与错误语义需更新。
- Service`internal/service/asset/service.go``internal/service/package/usage_service.go``internal/service/iot_card/stop_resume_service.go``internal/task/polling_carddata_handler.go` 等需统一流量计算口径。
- Store / Model`internal/model/package.go`、相关 DTO、`tb_package_usage` 快照字段与查询语义需要调整。
- **API / 文档影响**:后台 `resolve / current-package / packages`、C 端 `asset/info / package-history` 及相关 OpenAPI 文档都会产生字段级 breaking change。
- **数据影响**:需要为 `tb_package_usage` 做历史回填、快照迁移和旧列删除,避免历史展示继续读取 live `tb_package` 值或继续依赖废弃字段。
- **验证计划**:本项目执行阶段以 PostgreSQL 手工核验、接口回放、网关同步链路验收为主,不在本提案内引入新的自动化测试要求。
- **性能考虑**:保持现有分页与查询边界,不引入新的重型聚合查询;关键接口仍需满足列表默认 20/最大 100、API P95 < 200ms、数据库查询 < 50ms 的现有约束。

View File

@@ -0,0 +1,53 @@
## MODIFIED Requirements
### Requirement: 套餐历史列表查询
系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。
**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages`
**每条记录响应字段**:
- `package_usage_id`
- `package_name`
- `package_type`
- `master_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
- `activated_at`
- `expires_at`
- `status`
- `paid_amount`
**兼容规则**:
- 不再返回 `real_data_mb``virtual_data_mb``package_used_mb``package_remain_mb` 等旧展示字段
#### Scenario: 历史列表返回统一五字段
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`
- **THEN** 每条套餐记录都返回统一五字段和 `reduction_pct`
#### Scenario: 无套餐历史时返回空数组
- **WHEN** 管理员查询一张从未购买套餐的卡
- **THEN** 系统返回空数组,不报错
### Requirement: 当前主套餐详情查询
系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示主套餐详细信息。
**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package`
**查询条件**: `status = 1 AND master_usage_id IS NULL`
**响应字段**:
- 与套餐历史列表中的单条记录字段一致
- 不包含加油包汇总
- 无生效主套餐时返回 HTTP 200`data = null`
#### Scenario: 当前套餐只返回主套餐
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡存在 1 个生效主套餐和多个加油包
- **THEN** 系统只返回主套餐的统一五字段与 `reduction_pct`
#### Scenario: 无当前主套餐返回 200 + null
- **WHEN** 管理员查询没有生效中主套餐的资产
- **THEN** 系统返回 HTTP 200
- **AND** 响应体中的 `data = null`

View File

@@ -0,0 +1,29 @@
## MODIFIED Requirements
### Requirement: 统一资产解析入口
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
**流量相关响应结构AssetResolveResponse**:
- `current_package`: 当前主套餐名称(无主套餐时返回空字符串)
- `current_package_usage_id`: 当前主套餐的套餐使用记录 ID无主套餐时为 null
- `real_total_mb`: 当前主套餐真实总量
- `real_used_mb`: 当前主套餐真实已用量
- `virtual_total_mb`: 当前主套餐业务停机阈值
- `virtual_used_mb`: 当前主套餐展示已用量
- `reduction_pct`: 按 `(real_total_mb / virtual_total_mb) - 1` 计算
**流量展示规则**:
- 流量摘要只代表当前生效主套餐,不聚合加油包
- 无当前生效主套餐时,上述流量字段全部返回 0`current_package_usage_id` 返回 null
#### Scenario: 返回当前主套餐五字段摘要
- **WHEN** 管理员解析一张存在生效主套餐的卡
- **THEN** 响应中包含 `current_package_usage_id``real_total_mb``real_used_mb``virtual_total_mb``virtual_used_mb``reduction_pct`
#### Scenario: 无主套餐时返回空摘要
- **WHEN** 管理员解析一台没有当前生效主套餐的设备
- **THEN** 响应中 `current_package = ""`
- **AND** `current_package_usage_id = null`
- **AND** 流量字段均返回 `0`

View File

@@ -0,0 +1,27 @@
## MODIFIED Requirements
### Requirement: 流量耗尽自动停机
系统 SHALL 在主套餐和所有加油包都达到各自业务耗尽阈值后,调用运营商接口停机。
**业务耗尽阈值**:
- 启用虚流量:`real_used_mb >= virtual_total_mb`
- 未启用虚流量:`real_used_mb >= real_total_mb`
#### Scenario: 主套餐达到虚阈值触发停机
- **WHEN** 卡的当前主套餐 `real_total_mb=100``virtual_total_mb=70`,并且 `real_used_mb=70`
- **THEN** 系统将该主套餐视为耗尽并进入停机检查
#### Scenario: 加油包仍有业务额度时不停机
- **WHEN** 主套餐已达到业务阈值,但仍存在加油包 `real_used_mb < effective_threshold_mb`
- **THEN** 系统不触发停机
### Requirement: 购买套餐自动复机
系统 SHALL 在存在 active 套餐且该套餐仍有业务剩余额度时,自动触发复机。
#### Scenario: 购买加油包后恢复可用额度则自动复机
- **WHEN** 卡已因 `traffic_exhausted` 停机,随后新增一条 active 加油包,且该加油包 `real_used_mb < effective_threshold_mb`
- **THEN** 系统自动调用运营商复机接口
#### Scenario: 仅存在已用完套餐时不复机
- **WHEN** 卡处于停机状态,所有 active 或 depleted 套餐都满足 `real_used_mb >= effective_threshold_mb`
- **THEN** 系统不触发复机

View File

@@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token
**流量相关响应字段**:
- `current_package`
- `current_package_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
**约束**:
- 流量摘要仅表示当前主套餐,不聚合加油包
- 不再返回 `package_total_mb``package_used_mb``package_remain_mb` 等旧字段
#### Scenario: C 端资产信息返回主套餐五字段
- **WHEN** 客户调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产存在当前主套餐
- **THEN** 响应包含当前主套餐的统一五字段与 `reduction_pct`
#### Scenario: 无主套餐时返回空摘要
- **WHEN** 客户查询的资产没有当前主套餐
- **THEN** 响应中流量字段返回 `0``current_package_usage_id` 返回 `null`
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。
**列表项字段**:
- `package_usage_id`
- `package_name`
- `package_type`
- `master_usage_id`
- `real_total_mb`
- `real_used_mb`
- `virtual_total_mb`
- `virtual_used_mb`
- `reduction_pct`
- `activated_at`
- `expires_at`
- `status`
#### Scenario: C 端历史列表复用统一字段
- **WHEN** 客户调用 `GET /api/c/v1/asset/package-history`
- **THEN** 列表项使用统一五字段与 `reduction_pct`,不再混用旧展示字段

View File

@@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: 套餐实体定义
系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于展示映射的内部比例字段。
**实体字段**:
- `real_data_mb`: 真流量额度MB套餐真实总量
- `virtual_data_mb`: 虚流量额度MB业务停机阈值必须大于 0 且小于等于 `real_data_mb`
- `virtual_ratio`: 内部展示映射比例,定义为 `real_data_mb / virtual_data_mb`
- `enable_virtual_data`: 是否启用虚流量(未启用时 `virtual_ratio = 1.0`
**约束**:
- `enable_virtual_data = true` 时,`virtual_data_mb` MUST 大于 0
- `enable_virtual_data = true` 时,`virtual_data_mb` MUST 小于等于 `real_data_mb`
- `virtual_ratio` 由 Service 层自动计算并存储,不由调用方传入
#### Scenario: 创建启用虚流量的套餐
- **WHEN** 平台创建套餐,`real_data_mb=100``virtual_data_mb=70``enable_virtual_data=true`
- **THEN** 系统自动计算并存储 `virtual_ratio = 100 / 70 ≈ 1.428571`
#### Scenario: 创建虚流量大于真流量的套餐失败
- **WHEN** 平台创建套餐,`real_data_mb=100``virtual_data_mb=120``enable_virtual_data=true`
- **THEN** 系统拒绝保存并返回参数错误
### Requirement: 套餐流量类型和真虚流量共存
系统 SHALL 支持同一套餐同时定义真实总量和业务阈值,但 `virtual_data_mb` 不再表示与真流量相加的“另一份总量”,而是展示映射与停机判断所依赖的阈值。
**流量类型定义**:
- **真流量(real_data_mb)**: 套餐真实总量
- **虚流量(virtual_data_mb)**: 业务停机阈值和展示映射基准
- **未启用虚流量**: `virtual_data_mb` 视为等于 `real_data_mb`
#### Scenario: 真总量与虚阈值并存
- **WHEN** 平台创建套餐,`real_data_mb=100``virtual_data_mb=70`
- **THEN** 系统将其解释为“真实总量 100、业务阈值 70”而不是“总量 170”
#### Scenario: 不允许纯虚流量套餐
- **WHEN** 平台尝试创建 `real_data_mb=0``virtual_data_mb>0` 的套餐
- **THEN** 系统拒绝保存并返回参数错误

View File

@@ -0,0 +1,22 @@
## MODIFIED Requirements
### Requirement: 流量优先扣减加油包
系统 SHALL 在扣减流量时优先扣减加油包,再扣减主套餐;每条套餐的剩余额度 SHALL 基于业务耗尽阈值而不再直接等同于 `real_total_mb`
**业务阈值定义**:
- 启用虚流量:`effective_threshold_mb = virtual_total_mb_snapshot`
- 未启用虚流量:`effective_threshold_mb = real_total_mb`
**扣减规则**:
- `real_used_mb` 继续累计真实用量
- `remaining_quota_mb = effective_threshold_mb - real_used_mb`
-`real_used_mb >= effective_threshold_mb` 时,套餐状态更新为已用完
#### Scenario: 启用虚流量的加油包按虚阈值耗尽
- **WHEN** 某加油包 `real_total_mb=100``virtual_total_mb=70`、当前 `real_used_mb=68`,本次需要继续扣减 2MB
- **THEN** 系统将该加油包扣减到 `real_used_mb=70`
- **AND** 将其状态更新为已用完
#### Scenario: 所有 active 套餐均达到业务阈值后触发停机检查
- **WHEN** 主套餐和所有加油包都满足 `real_used_mb >= effective_threshold_mb`
- **THEN** 系统进入停机判断流程

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: 套餐流量对外语义统一
系统 SHALL 以套餐使用记录为事实源,对外统一返回 `real_total_mb``real_used_mb``virtual_total_mb``virtual_used_mb``reduction_pct`,不再要求调用方从旧字段或 live `tb_package` 值自行拼接流量语义。
**字段语义**:
- `real_total_mb`: 套餐真实总量
- `real_used_mb`: 套餐真实已用量
- `virtual_total_mb`: 套餐业务停机阈值
- `virtual_used_mb`: 将 `real_used_mb` 映射到 `real_total_mb` 刻度后的展示值
- `reduction_pct`: 默认按 `(real_total_mb / virtual_total_mb) - 1` 计算,字段名继续沿用业务口语“缩减比例”,接口返回值使用 `0.428571` 这种比例小数格式
#### Scenario: 启用虚流量时返回统一五字段
- **WHEN** 某条套餐使用记录的 `real_total_mb=100``real_used_mb=50``virtual_total_mb=70`
- **THEN** 系统返回 `virtual_used_mb=71.43`
- **AND** 同时返回 `reduction_pct=0.428571...`
#### Scenario: 未启用虚流量时退化为真流量视图
- **WHEN** 某条套餐使用记录未启用虚流量
- **THEN** 系统返回 `virtual_total_mb = real_total_mb`
- **AND** `virtual_used_mb = real_used_mb`
- **AND** `reduction_pct=0`
### Requirement: 虚已用展示公式统一
系统 SHALL 使用统一公式计算 `virtual_used_mb``min(real_used_mb * (real_total_mb / virtual_total_mb), real_total_mb)`
#### Scenario: 真已用接近虚阈值时展示接近满量程
- **WHEN** `real_total_mb=100``virtual_total_mb=70``real_used_mb=69`
- **THEN** 系统返回 `virtual_used_mb=98.57`
#### Scenario: 真已用达到虚阈值时展示封顶
- **WHEN** `real_total_mb=100``virtual_total_mb=70``real_used_mb=70`
- **THEN** 系统返回 `virtual_used_mb=100`
### Requirement: 套餐耗尽阈值统一
系统 SHALL 将套餐业务耗尽阈值统一定义为:启用虚流量时使用 `virtual_total_mb`,未启用虚流量时使用 `real_total_mb`
#### Scenario: 启用虚流量时按虚阈值耗尽
- **WHEN** 某条套餐使用记录 `real_total_mb=100``virtual_total_mb=70``real_used_mb=70`
- **THEN** 系统将该套餐判定为已用完
#### Scenario: 未启用虚流量时按真总量耗尽
- **WHEN** 某条套餐使用记录未启用虚流量且 `real_used_mb=real_total_mb`
- **THEN** 系统将该套餐判定为已用完
### Requirement: 历史套餐流量快照可回放
系统 SHALL 在 `tb_package_usage` 上持久化虚总量与展示映射所需快照,以保证历史流量展示不继续依赖 live `tb_package` 值。
#### Scenario: 创建新套餐使用记录时写入快照
- **WHEN** 系统创建新的 `PackageUsage`
- **THEN** 同步写入 `virtual_total_mb_snapshot`、展示倍率快照和虚流量开关快照
#### Scenario: 历史套餐记录执行一次性回填
- **WHEN** 本次变更上线执行历史数据迁移
- **THEN** 系统为已有 `PackageUsage` 回填虚总量与展示倍率快照
- **AND** 后续读链路优先读取快照值而不是 `tb_package` 当前值

View File

@@ -0,0 +1,34 @@
## 0. 验收基线准备
- [x] 0.1 梳理后台 `asset-resolve / current-package / packages` 与 C 端 `asset/info / package-history` 的现有字段清单,并整理旧字段到新字段的替换矩阵,验证点包括逐接口比对返回 JSON 与 OpenAPI 文档。
- [x] 0.2 固化本次变更的手工验收基线:准备 PostgreSQL 核验 SQL、典型套餐样例如 100/70/50→71.43、100/70/69→98.57)、接口回放样例,验证点包括所有样例都能复算出一致结果。
## 1. 套餐流量语义与快照模型
- [x] 1.1 为 `tb_package_usage` 设计并新增虚总量/展示倍率/开关快照字段迁移,包含历史回填脚本,验证点包括迁移执行后新列存在、默认值正确、历史记录完成回填。
- [x] 1.2 更新 `internal/model/package.go` 与相关 DTO/注释,统一 `data_limit_mb=data real_total``data_usage_mb=real_used` 的内部语义,验证点包括模型字段、中文注释与 OpenSpec 设计文档一致。
- [x] 1.3 收紧套餐创建/更新校验,确保 `virtual_data_mb > 0 且 <= real_data_mb`,并同步统一内部展示倍率计算逻辑,验证点包括创建/更新接口对非法样例拒绝、合法样例正确落库。
## 2. 写链路与耗尽阈值收口
- [x] 2.1 修改订单购包、自动购包、主套餐激活/排队激活路径,确保新建 `PackageUsage` 时同时写入虚总量与展示倍率快照,验证点包括主套餐、加油包、待实名激活三条路径的落库字段完整。
- [x] 2.2 重构 `package/usage_service` 的扣减逻辑,统一按 `effective_threshold_mb` 计算剩余额度和已用完状态,同时继续保持“加油包优先、主套餐兜底”,验证点包括 100/70 阈值样例和多加油包优先级样例都符合预期。
- [x] 2.3 重构 `stop_resume_service`、轮询触发和重置路径,统一按虚阈值/真总量判断耗尽与复机条件,验证点包括“主套餐达到虚阈值停机”“加油包仍有额度不停机”“新增有效加油包后自动复机”三类场景。
## 3. 后台读链路与契约切换
- [x] 3.1 重构后台 `asset-resolve` 的流量摘要组装,仅返回当前主套餐的统一五字段与 `reduction_pct`,并返回 `current_package_usage_id`,验证点包括无主套餐返回空摘要、有主套餐返回新字段且不再混入旧字段。
- [x] 3.2 重构后台 `current-package``packages` 的 DTO 和 Service 组装逻辑,按 `package_usage_id` 返回统一五字段与 `reduction_pct`,并将无主套餐场景统一为 `200 + null`,验证点包括主套餐详情仅返回主套餐、历史列表包含主套餐和加油包的新字段集。
- [x] 3.3 更新后台 OpenAPI / 文档生成产物,移除 `package_total_mb``package_used_mb``virtual_limit_mb` 等旧字段描述,验证点包括 `docs/admin-openapi.yaml` 与新 DTO 一致且无遗留字段说明。
## 4. C 端切换与范围约束
- [x] 4.1 重构 C 端 `asset/info` 的流量摘要字段,只返回当前主套餐的统一五字段、`reduction_pct``current_package_usage_id`,验证点包括旧字段移除后接口返回结构与 spec 一致。
- [x] 4.2 重构 C 端 `package-history` 的列表项字段,统一为 `package_usage_id + 五字段 + 比例字段`验证点包括分页、generation 过滤和历史套餐展示都使用新语义。
- [x] 4.3 明确 customer-view total 聚合不属于本次 contract 收口范围,确保本次实现不新增 `total.virtual_used_mb` 或等价聚合字段,验证点包括 change 范围内无新 total 聚合契约、无相关 DTO 改造任务。
## 5. 旧字段清理与最终验收
- [x] 5.1 删除后台/C 端读链路中对 `package_total_mb``package_used_mb``package_remain_mb``virtual_limit_mb``virtual_remain_mb`、旧式 `virtual_ratio` 展示语义以及确认废弃数据库旧列的依赖,验证点包括代码搜索结果不再存在这些旧展示字段的组装逻辑,迁移后数据库中对应旧列已被删除。
- [x] 5.2 更新 `docs/traffic-model-unification/流量模型统一改造方案.md`、相关功能总结和提案说明文档,使文字描述、公式和 DTO 名称与本次 OpenSpec 完全一致,验证点包括文档中的示例与接口返回公式一致。
- [ ] 5.3 执行最终手工验收PostgreSQL 核对快照回填、轮询增量入套餐、虚已用公式、停复机逻辑和 B/C 端接口返回;验证点包括全部样例通过且无旧字段残留在最终 contract 中。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-24

View File

@@ -0,0 +1,155 @@
## Context
当前项目已具备成熟的异步导入任务能力(设备导入、单卡导入)与对象存储能力,但导出能力尚未形成统一任务系统。面对未来超大数据量,直接同步导出会引发请求超时、内存峰值高、用户等待时间不可控等问题。
本次变更要先建立“通用导出任务骨架”,并以 `device``iot_card` 作为首批场景验证架构可扩展性;导出字段与具体查询细节后续按场景迭代补充。
约束条件:
- 必须遵循 `Handler -> Service -> Store -> Model` 分层。
- 必须使用 Asynq 异步任务机制。
- 禁止外键约束,任务主从关系通过 ID 字段维护。
- 常量、状态枚举、Redis Key 必须定义在 `pkg/constants/`,禁止硬编码。
## Goals / Non-Goals
**Goals:**
- 提供全局导出任务中心,统一任务创建、列表、详情、取消。
- 支持 `xlsx/csv` 两种格式选择。
- 支持异步分片执行,满足大数据量导出性能需求。
- 导出产物上传 OSS并在任务详情直接返回可下载 URL默认 24h
- 设计可扩展场景机制,首批支持 `device``iot_card`
**Non-Goals:**
- 本阶段不定义最终导出字段清单与前端展示细节。
- 本阶段不实现跨场景聚合导出(仅单场景任务)。
- 本阶段不实现复杂运营能力(如任务重跑、任务克隆、批量取消)。
## Decisions
### 决策1统一导出引擎 + 场景策略注册
**选择**:采用“通用导出任务引擎 + 场景策略Strategy+ 执行模板Template Method”模式。
**理由**
- 避免设备导出、单卡导出重复实现任务编排、分片、上传、状态流转。
- 后续新增导出场景仅需注册策略(查询器、表头、行映射),无需改核心引擎。
**备选方案**:每个场景单独实现导出任务链路。
**放弃原因**:重复代码多,后续维护成本高,难以统一性能与取消语义。
---
### 决策2三段式异步任务流水线
**选择**Asynq 任务拆分为 `dispatch -> shard -> finalize` 三段。
**执行语义**
- `dispatch`:按查询规模创建分片子任务并入队。
- `shard`:每个分片独立查询与写文件,上传 OSS回写分片状态。
- `finalize`:聚合分片结果,生成主任务最终结果(`file_key/download_url`)。
**理由**
- 分片并行可线性提升吞吐。
- 分片失败可独立重试,不影响已完成分片。
- finalize 统一收口,便于生成一致的下载结果。
---
### 决策3任务数据模型拆分主任务与分片任务
**选择**:新增两张表(不建外键):
- `tb_export_task`(主任务)
- `tb_export_shard_task`(分片任务)
**关键字段**
- 主任务:`task_no``scene``format``status``progress``cancel_requested``file_key``error_message``started_at``completed_at`
- 分片任务:`task_id``shard_no``status``cursor_start/cursor_end``row_count``file_key``error_message`
**事务设计**
- `dispatch` 中“创建分片记录 + 分片任务入队标记”在事务内完成,避免分片记录与队列状态不一致。
- `shard``finalize` 使用状态条件更新保证幂等(如 `WHERE status = expected`)。
---
### 决策4下载信息仅在详情接口返回且直出可下载 URL
**选择**
- 任务列表仅返回任务元信息,不返回下载 URL。
- 任务详情在 `status=已完成` 时返回可直接下载的 `download_url`,有效期默认 24 小时。
**理由**
- 减少列表接口的预签名生成开销。
- 与用户已确认的交互模式一致。
---
### 决策5取消机制采用“控制面标记 + Worker 协作停止”
**选择**
- 取消接口将主任务标记为“取消请求”(`cancel_requested=true`)。
- `dispatch/shard/finalize` 在关键节点检查取消标记,命中即停止并回写取消状态。
**理由**
- 兼容已运行任务,避免强杀导致的中间态脏数据。
- 实现简单且可控,符合当前 Asynq 使用方式。
**权衡**
- 取消不是“瞬时终止”,而是“尽快停止”;单个分片运行中的小批次会执行到检查点再退出。
---
### 决策6性能基线采用 Keyset 分页 + 流式写出
**选择**
- 分片数据读取使用 Keyset 分页(`id > last_id`),避免深分页性能衰减。
- 文件写出采用流式写出策略,避免全量数据载入内存。
- 进度按批回写(例如每 N 行)降低数据库写放大。
**理由**
- 满足未来超大数据量导出场景。
- 控制数据库与 Worker 资源占用,减少峰值风险。
---
### 决策7依赖注入与模块边界
**选择**
- Handler 仅做参数解析、权限前置与统一响应。
- Service 注入 `ExportTaskStore``ExportShardTaskStore``queue.Client``storage.Service``SceneRegistry`
- Worker 通过 `pkg/queue/handler.go` 统一注册导出相关处理器。
**理由**
- 与现有导入任务模式保持一致,降低接入复杂度。
- 强化模块边界,便于后续场景扩展。
## Risks / Trade-offs
- **[风险] 分片大小配置不合理导致队列拥塞或分片过小**
**缓解**:分片大小与并发度配置化;增加任务监控指标与告警阈值。
- **[风险] xlsx 在极大数据量场景下生成耗时高**
**缓解**:允许用户选择 `csv`;必要时按分片生成多文件并由 finalize 打包归档。
- **[风险] 取消与 finalize 并发竞争造成状态抖动**
**缓解**统一使用状态条件更新finalize 前再次检查 `cancel_requested`
- **[权衡] 详情接口实时生成预签名 URL 会增加一次存储调用**
**接受**:仅详情触发,调用频率低,收益大于成本。
## Migration Plan
1. 新增导出任务常量、状态枚举、任务类型、Redis Key 生成函数。
2. 新增导出主任务与分片任务模型、Store、数据库迁移。
3. 新增全局导出任务 Handler/Service/Route创建、列表、详情、取消
4. 新增导出 Worker 处理器并注册 Asynq 任务类型dispatch/shard/finalize
5. 接入首批场景策略:`device``iot_card`(先完成骨架与最小可运行实现)。
6. 更新接口文档生成器与业务文档,补充手工验证步骤。
回滚策略:
- 若上线后发现异常,可先关闭导出任务入口(路由开关/权限收敛),保留已生成文件不影响现网核心交易链路。
- 数据库迁移按标准 down 脚本回滚新增表与字段。
## Open Questions
- 是否需要在任务列表返回“最近一次下载 URL 过期时间”用于前端提示(当前方案不返回,仅详情返回)?
- 分片默认大小与队列权重的初始值是否按环境差异化配置(开发/生产)?

View File

@@ -0,0 +1,40 @@
## Why
当前系统已有设备与单卡导入任务,但缺少统一的异步导出任务能力,随着数据规模持续增长,直接同步导出会带来超时、内存占用和用户体验问题。现在需要先落地可复用的“导出任务骨架”,为后续多个业务场景复用,避免重复建设。
## What Changes
- `feature-001-unified-export-task-center`:新增全局导出任务中心,统一创建、查询、详情、取消导出任务。
- `feature-002-export-format-selectable`:创建导出任务时支持用户选择 `xlsx/csv` 格式。
- `feature-003-scene-plugin-support`:首批支持 `device``iot_card` 两个导出场景,并设计为可扩展插件式接入。
- `feature-004-async-shard-export`:导出采用 Asynq 异步执行,支持分片处理与进度回写,面向大数据量场景。
- `feature-005-export-oss-delivery`:导出产物上传 OSS任务详情直接返回可下载 URL默认有效期 24 小时)。
- `feature-006-export-task-cancel`:支持取消待处理/执行中的导出任务,并在 Worker 侧协作停止。
- 架构遵循 `Handler -> Service -> Store -> Model` 分层,导出能力以通用引擎 + 场景策略方式实现。
## Capabilities
### New Capabilities
- `unified-export-task-system`统一导出任务系统能力覆盖全局任务入口、异步分片导出、OSS 交付、详情直出下载 URL 与任务取消。
### Modified Capabilities
-
## Impact
- 影响模块:
- `internal/routes`:新增导出任务全局路由。
- `internal/handler`:新增导出任务 Handler。
- `internal/service`:新增导出任务编排服务与场景策略注册。
- `internal/store`:新增导出任务与分片任务存储。
- `internal/task``pkg/queue`:新增导出 dispatch/shard/finalize/cancel 相关任务处理。
- `pkg/constants`新增导出任务类型、状态、Redis Key 常量。
- 影响 API
- 新增全局导出任务创建/列表/详情/取消接口(按 `scene` 过滤展示)。
- 影响数据与基础设施:
- 新增导出主任务与分片任务表。
- 复用现有 OSS 能力与 Asynq 队列能力。
- 性能考虑:
- 使用分片、流式写出、批量进度更新、Keyset 分页策略,避免大数据量导出时超时与内存峰值。
- 验证计划:
- 按项目约束采用 PostgreSQL MCP + Postman/curl 的手工验证,覆盖创建任务、分片执行、下载 URL 获取、取消任务等关键流程。

View File

@@ -0,0 +1,129 @@
## ADDED Requirements
### Requirement: 全局导出任务中心
系统 SHALL 提供统一的全局导出任务中心,覆盖任务创建、任务列表、任务详情、任务取消四类接口,并使用统一响应格式 `{code, msg, data, timestamp}`
#### Scenario: 创建导出任务成功
- **WHEN** 已认证用户调用创建导出任务接口并提交合法参数
- **THEN** 系统返回 `task_id``task_no`、初始任务状态和创建成功提示
#### Scenario: 查询全局任务列表并按场景过滤
- **WHEN** 用户调用任务列表接口并传入 `scene=device`
- **THEN** 系统仅返回 `device` 场景任务,且结果按创建时间倒序分页返回
#### Scenario: 查询任务详情
- **WHEN** 用户调用任务详情接口并传入存在的任务 ID
- **THEN** 系统返回任务状态、进度、格式、场景、错误信息、产物信息等完整详情
---
### Requirement: 导出任务创建参数与格式选择
导出任务创建接口 SHALL 支持用户选择导出格式,且 `format` MUST 仅允许 `xlsx``csv``scene` MUST 支持首批 `device``iot_card`
#### Scenario: 选择 xlsx 格式创建任务
- **WHEN** 用户创建任务时传入 `format=xlsx`
- **THEN** 系统创建任务成功并记录任务格式为 `xlsx`
#### Scenario: 选择 csv 格式创建任务
- **WHEN** 用户创建任务时传入 `format=csv`
- **THEN** 系统创建任务成功并记录任务格式为 `csv`
#### Scenario: 非法格式被拒绝
- **WHEN** 用户创建任务时传入 `format=pdf`
- **THEN** 系统返回参数错误,不创建任务
#### Scenario: 非法场景被拒绝
- **WHEN** 用户创建任务时传入未注册的 `scene`
- **THEN** 系统返回参数错误,不创建任务
---
### Requirement: 导出任务异步分片执行
系统 SHALL 使用 Asynq 异步任务执行导出流程,并采用 `dispatch -> shard -> finalize` 三段式处理;大数据量导出 MUST 支持分片并行执行。
#### Scenario: 创建任务后异步执行
- **WHEN** 导出任务创建成功
- **THEN** 系统将主任务入队,任务状态从“待处理”推进到“处理中”
#### Scenario: 分片任务并行处理
- **WHEN** dispatch 阶段根据查询规模生成多个分片
- **THEN** 系统并行执行多个 shard 任务并按分片回写进度
#### Scenario: 分片失败触发重试
- **WHEN** 某个 shard 任务因临时错误失败
- **THEN** 系统按 Asynq 重试策略重试该分片,且不影响其他分片状态
#### Scenario: finalize 收敛任务结果
- **WHEN** 所有分片任务完成(成功或失败)
- **THEN** 系统执行 finalize 汇总并更新主任务最终状态
---
### Requirement: 导出结果 OSS 交付与详情下载链接
系统 SHALL 将导出产物上传至 OSS并在任务详情接口返回可直接下载的 `download_url`;该下载链接默认有效期 MUST 为 24 小时。
#### Scenario: 已完成任务详情返回下载链接
- **WHEN** 任务状态为“已完成”且产物上传成功
- **THEN** 任务详情接口返回 `file_key` 与可直接下载的 `download_url`
#### Scenario: 未完成任务不返回下载链接
- **WHEN** 任务状态为“待处理”或“处理中”
- **THEN** 任务详情接口不返回可用的 `download_url`
#### Scenario: 下载链接过期后可重新获取
- **WHEN** 用户在 URL 过期后再次调用任务详情接口
- **THEN** 系统重新生成新的 24 小时有效下载链接
---
### Requirement: 导出任务取消能力
系统 SHALL 支持取消任务,取消操作 MUST 支持待处理与处理中任务;已完成/已失败/已取消任务 MUST 不可重复取消。
#### Scenario: 取消待处理任务
- **WHEN** 用户取消处于待处理状态的任务
- **THEN** 系统将任务状态更新为“已取消”,且不再调度执行
#### Scenario: 取消处理中任务
- **WHEN** 用户取消处于处理中状态的任务
- **THEN** 系统记录取消请求并在 Worker 检查点协作停止,最终状态为“已取消”
#### Scenario: 取消已完成任务被拒绝
- **WHEN** 用户取消处于已完成状态的任务
- **THEN** 系统返回业务错误,提示该状态不可取消
---
### Requirement: 导出任务数据权限与可见性
系统 SHALL 对导出任务应用数据权限控制:用户仅可查看有权限的任务与导出结果;跨角色/跨租户越权访问 MUST 被拒绝。
#### Scenario: 用户查看自己创建的任务
- **WHEN** 用户查询其本人创建的任务
- **THEN** 系统返回任务数据
#### Scenario: 用户查看无权限任务
- **WHEN** 用户查询不属于其数据权限范围的任务
- **THEN** 系统返回无权限错误,不泄露任务存在性细节
#### Scenario: 场景导出遵循原业务数据权限
- **WHEN** 用户创建 `scene=iot_card``scene=device` 导出任务
- **THEN** 系统按对应场景既有数据权限规则导出可见数据,不导出越权数据
---
### Requirement: 导出任务性能基线
系统 MUST 面向大数据量导出实现稳定性能,查询与写出流程 SHALL 采用可扩展方案(如 Keyset 分页、流式写出、批量进度回写)。
#### Scenario: 超大数据量导出不发生单次全量加载
- **WHEN** 导出数据规模超过单批次内存可承受范围
- **THEN** 系统按分批/分片方式处理,不进行单次全量加载
#### Scenario: 进度可观测
- **WHEN** 任务正在执行
- **THEN** 列表与详情可查看当前进度与处理状态,便于用户感知导出进展

View File

@@ -0,0 +1,54 @@
## 0. 验证准备(手工验证基线)
- [x] 0.1 梳理导出任务手工验证清单(创建/列表/详情下载/取消/分片执行),明确使用 PostgreSQL MCP + Postman/curl 验证(不新增自动化测试)
- [x] 0.2 准备验证数据样本:`device``iot_card` 两个场景各至少一组小数据与一组大数据
- [x] 0.3 定义验收记录模板请求参数、响应、数据库快照、OSS 文件 Key、日志关键字段
## 1. 导出任务数据模型与迁移
- [x] 1.1 新增导出任务相关常量任务类型、状态枚举、格式枚举、场景枚举、Redis Key 生成函数(全部放 `pkg/constants/`
- [x] 1.2 设计并实现 `tb_export_task``tb_export_shard_task` 的 Model/DTO无外键仅 ID 关联)
- [x] 1.3 编写数据库迁移文件up/down创建导出主任务与分片任务表及必要索引
- [x] 1.4 实现导出任务 Store创建、分页查询、详情查询、状态流转、进度更新、取消标记、条件更新幂等
- [x] 1.5 使用 PostgreSQL MCP 验证迁移结果与索引生效,记录表结构与关键字段
## 2. 全局导出任务管理接口
- [x] 2.1 新增全局导出任务路由与 Handler创建、列表、详情、取消统一响应格式 `{code,msg,data,timestamp}`
- [x] 2.2 新增导出任务 Service 编排:参数校验、场景校验、格式校验、任务号生成、任务入队
- [x] 2.3 实现列表按 `scene/status/time` 过滤和分页(默认 20最大 100
- [x] 2.4 实现权限与可见性控制:仅返回当前用户有权限的任务,越权访问统一拒绝
- [x] 2.5 更新文档生成器注册(`cmd/api/docs.go``cmd/gendocs/main.go`)并更新路由文档描述
- [ ] 2.6 用 Postman/curl 手工验证接口契约(正常与异常参数、越权场景、分页过滤)
## 3. 异步导出执行引擎dispatch/shard/finalize
- [x] 3.1 新增 Asynq 任务载荷结构与任务类型注册:`export:dispatch``export:shard``export:finalize`
- [x] 3.2 实现 dispatch 处理器:读取任务配置、生成分片记录、分片任务入队、状态推进
- [x] 3.3 实现 shard 处理器:按分片查询数据、流式写出文件、上传 OSS、回写分片结果
- [x] 3.4 实现 finalize 处理器:汇总分片结果、更新主任务最终状态、写入产物信息
- [x] 3.5 增加幂等保护与重试安全:状态条件更新、重复消费防重、失败重试不重复产出
- [ ] 3.6 手工验证异步链路:创建任务后观察状态从待处理到完成/失败的完整流转
## 4. 场景策略注册与首批场景接入
- [x] 4.1 实现场景策略注册中心Scene Registry约定统一接口查询器、表头定义、行映射器
- [x] 4.2 接入 `device` 导出策略骨架(先实现最小可运行数据输出)
- [x] 4.3 接入 `iot_card` 导出策略骨架(先实现最小可运行数据输出)
- [x] 4.4 实现场景级数据权限收敛:导出结果必须遵循对应场景既有权限规则
- [ ] 4.5 手工验证两个场景导出任务均可独立创建、执行并产出文件
## 5. 详情下载链接与任务取消能力
- [x] 5.1 在任务详情接口中接入 `download_url` 生成逻辑(仅 `status=已完成` 返回)
- [x] 5.2 下载链接有效期固定为 24 小时,过期后重新访问详情可刷新新链接
- [x] 5.3 实现取消接口:支持待处理/处理中任务,已完成/已失败/已取消任务拒绝取消
- [x] 5.4 在 dispatch/shard/finalize 增加取消检查点,命中后协作停止并更新为已取消
- [ ] 5.5 手工验证取消场景:待处理取消、处理中取消、重复取消、取消后不再继续执行
## 6. 文档与收尾验收
- [x] 6.1 补充导出任务功能文档(`docs/{feature-id}/`),说明接口、状态机、分片策略、取消语义、下载规则
- [x] 6.2 更新 README 与 API 使用示例(全局任务入口 + `scene` 过滤展示约定)
- [ ] 6.3 进行全链路手工验收:`device``iot_card` 各完成一次 `xlsx``csv` 导出
- [ ] 6.4 输出验收记录接口响应样例、数据库状态快照、OSS 文件 key、下载 URL 有效期验证