This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-27
|
||||
160
openspec/changes/add-asset-operation-audit-log/design.md
Normal file
160
openspec/changes/add-asset-operation-audit-log/design.md
Normal 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)常量?
|
||||
39
openspec/changes/add-asset-operation-audit-log/proposal.md
Normal file
39
openspec/changes/add-asset-operation-audit-log/proposal.md
Normal 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 手工验证,不新增自动化测试文件。
|
||||
@@ -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** 系统对超长字段进行裁剪并保留裁剪标记,保证日志写入稳定
|
||||
@@ -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`,体现网络状态和停机原因的变化
|
||||
@@ -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` 的资产审计日志,并记录错误码与错误摘要
|
||||
60
openspec/changes/add-asset-operation-audit-log/tasks.md
Normal file
60
openspec/changes/add-asset-operation-audit-log/tasks.md
Normal 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 查询结果
|
||||
58
openspec/changes/add-asset-operation-audit-log/验收准备.md
Normal file
58
openspec/changes/add-asset-operation-audit-log/验收准备.md
Normal 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 密码字段已脱敏(不出现明文)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-27
|
||||
164
openspec/changes/refactor-traffic-model-semantics/design.md
Normal file
164
openspec/changes/refactor-traffic-model-semantics/design.md
Normal 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` 的空结果契约。
|
||||
@@ -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 的现有约束。
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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** 系统不触发复机
|
||||
@@ -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`,不再混用旧展示字段
|
||||
@@ -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** 系统拒绝保存并返回参数错误
|
||||
@@ -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** 系统进入停机判断流程
|
||||
@@ -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` 当前值
|
||||
34
openspec/changes/refactor-traffic-model-semantics/tasks.md
Normal file
34
openspec/changes/refactor-traffic-model-semantics/tasks.md
Normal 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 中。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-24
|
||||
155
openspec/changes/unified-export-task-system/design.md
Normal file
155
openspec/changes/unified-export-task-system/design.md
Normal 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 过期时间”用于前端提示(当前方案不返回,仅详情返回)?
|
||||
- 分片默认大小与队列权重的初始值是否按环境差异化配置(开发/生产)?
|
||||
40
openspec/changes/unified-export-task-system/proposal.md
Normal file
40
openspec/changes/unified-export-task-system/proposal.md
Normal 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 获取、取消任务等关键流程。
|
||||
@@ -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** 列表与详情可查看当前进度与处理状态,便于用户感知导出进展
|
||||
54
openspec/changes/unified-export-task-system/tasks.md
Normal file
54
openspec/changes/unified-export-task-system/tasks.md
Normal 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 有效期验证
|
||||
Reference in New Issue
Block a user