更新提案

This commit is contained in:
2026-04-30 11:18:13 +08:00
parent 9674e0d0d9
commit 9238eeb56e
57 changed files with 0 additions and 0 deletions

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

View File

@@ -0,0 +1,109 @@
## Context
当前订单领域同时承载了三类不同语义:
1. **谁操作了订单**:应该回答“哪个账号发起了这次下单动作”。
2. **订单属于谁/卖给谁**:由 `buyer_type/buyer_id/seller_shop_id/purchase_role` 等业务字段表达。
3. **这笔订单的差价佣金该给谁**:应从 `seller_shop_id` 出发,沿完整父级链逐级计算。
现状问题在于:`operator_id/operator_type` 被混成了“真实账号 + 店铺 ID + 平台空值”三种含义,导致平台订单无法稳定展示操作者;`commission_status` 又被混成“流程状态 + 业务结果”,导致“未触发计算”“已算完但没有佣金”“链路异常待处理”无法区分。历史提案已经明确:支付成功后要自动入队佣金计算,代购订单只跳过一次性佣金,不跳过差价佣金。因此本次设计不是新增分佣能力,而是把已有能力的语义纠偏为可长期维护的模型。
## Goals / Non-Goals
**Goals:**
- 将订单操作者统一收敛为“真实账号语义”,平台与代理都能准确落库、展示和追溯。
- 将订单佣金模型拆成“流程状态 + 业务结果”两层,明确区分无佣金与异常待人工处理。
- 将差价佣金统一定义为基于 `seller_shop_id` 的完整上级链分配,不再受 `operator_id/operator_type` 影响。
- 修正平台代扣、代理自购、代理为下级代购、代购自动完单等触发链路,保证所有适用订单都能进入佣金计算。
- 设计兼容迁移方案,避免直接改变旧字段含义造成历史数据误读。
**Non-Goals:**
- 不重构一次性佣金规则,也不改变“代购订单不触发一次性佣金、不更新累计充值”的既有结论。
- 不重写整套钱包模型,不额外引入外键、关联关系或新的异步基础设施。
- 不在本次设计中解决所有历史脏数据,只定义兼容读取、渐进回填和后续修复路径。
## Decisions
### Decision 1新增“权威操作者快照”不直接复用被污染的旧 `operator_*` 字段
**选择**:订单模型新增一组权威操作者字段(账号 ID、账号类型、名称快照作为“谁操作了这笔订单”的唯一来源现有 `operator_id/operator_type` 视为历史兼容字段,在迁移期通过回填/映射兼容读取。
**原因**
- 直接把旧 `operator_id` 改成账号 ID 会让历史上保存店铺 ID 的订单立即失真。
- 单独依赖 `creator` 虽然能覆盖部分后台订单,但缺少名称快照,且无法优雅覆盖未来其他入口。
- 新增权威字段可以让写路径和读路径先稳定下来,再逐步清理历史值。
**备选方案**
- **直接复用 `operator_id/operator_type`**:实现快,但会让历史数据语义混乱,风险最高。
- **只依赖 `creator/updater`**:不需要新字段,但响应层仍需额外查询/推导,且无法表达多入口场景下的操作者快照。
### Decision 2佣金采用“双轴模型”——流程状态与业务结果分离
**选择**:保留 `commission_status` 作为流程状态字段,但重新定义为“待计算 / 已完成 / 待人工处理”;新增 `commission_result` 作为业务结果字段,至少表达“有佣金 / 无佣金 / 链路异常”。
**原因**
- “无佣金”是计算结果,不是待处理状态。
- “链路断裂”需要触发人工排障,不应与普通完成混淆。
- 双轴模型最适合补偿扫描:补偿只看流程状态,运营展示同时看流程状态和业务结果。
**备选方案**
- **仅扩展 `commission_status` 单字段**:字段值会迅速膨胀,前后端更容易再次把流程与结果混用。
- **仅依赖佣金记录条数推导结果**0 条记录既可能是无佣金,也可能是任务未执行或链路异常,无法可靠区分。
### Decision 3差价佣金只从 `seller_shop_id` 出发,沿完整父级链逐级计算
**选择**:佣金归属只依赖 `seller_shop_id` 的完整上级链,以及每一层对应的成本价/分配配置;`operator_id/operator_type/purchase_role` 不参与佣金归属判断,只参与审计和展示。
**原因**
- 这样可以统一覆盖平台代扣、代理自购、代理为下级代购、多级代理链等所有场景。
- 这与现有 `commission_calculation` 服务的主体算法一致,只是把现有“例子式理解”提升为正式规则。
- 顶级代理“无佣金”只是父链终止后的自然结果,不需要单独特判。
**备选方案**
- **按操作者类型分支**:会错误跳过平台代扣二级/三级代理的上级差价佣金。
- **按买家类型硬编码一级/二级场景**:无法覆盖灵活多级代理链,且后续每加场景都要加特判。
### Decision 4所有适用订单都要先进入佣金计算再由计算结果决定“有/无佣金”
**选择**:只要订单属于差价佣金适用范围,并在支付成功或自动完单后仍处于“待计算”,就必须入队 `commission:calculate`worker 负责最终把订单落成“已完成+有佣金”“已完成+无佣金”或“待人工处理+链路异常”。
**原因**
- 这样可以修复“平台代扣代理钱包但未入队”的现有缺口。
- “无佣金”订单也需要被 worker 明确盖章为完成,否则后台永远分不清是漏算还是确实没有佣金。
- Asynq 的幂等和补偿扫描可以继续复用,只需调整判定口径。
**备选方案**
- **创建订单时先同步判断是否有佣金再决定是否入队**:会把链路计算逻辑分散到订单服务,增加重复实现和口径漂移风险。
### Decision 5链路断裂统一落为待人工处理并记录断点信息
**选择**:当父级链缺失、分配配置不存在、成本价无法继续求解等情况发生时,订单流程状态落为“待人工处理”,业务结果落为“链路异常”;同时保留待审佣金记录和断点备注,作为人工补偿入口。
**原因**
- 这与现有 `CommissionStatusPendingReview` 及待审记录机制兼容。
- 运营和财务可以明确知道这不是“无佣金”,而是“规则/数据有问题”。
**备选方案**
- **仍然写成已完成但 0 佣金**:会掩盖真实异常,导致漏补偿。
## Risks / Trade-offs
- **[历史订单语义混杂]** → 先新增权威字段、兼容读取,再做分批回填;旧 `operator_*` 不立即删除。
- **[前端依赖旧 `operator_name` 为店铺名]** → 在提案中明确 BREAKING订单响应补充账号名与业务店铺名分离的展示规则。
- **[补偿扫描口径变化导致重复入队]** → 继续以流程状态做幂等守卫worker 内部使用条件更新和重复执行跳过。
- **[多级链路查询带来性能波动]** → 沿用现有逐级查询逻辑,但只在支付后异步执行;列表查询不做链路展开,避免影响接口时延。
- **[历史挂起订单无法自动判断应为无佣金还是异常]** → 回填脚本只处理可确定样本,其余保持待人工处理,由运营复核。
## Migration Plan
1. **Schema 先行**:为订单新增权威操作者字段与佣金结果字段,保留旧字段不删。
2. **读路径兼容**:订单详情/列表优先读新字段;若新字段为空,则按 `creator + 旧 operator_*` 做兼容回显,并在响应中明确异常/历史场景。
3. **写路径切换**:后台订单创建、支付回调、自动完单全部改为写入新操作者字段,并统一使用新佣金状态/结果模型。
4. **触发链修复**所有适用支付成功路径统一调用佣金入队逻辑worker 在完成时写回流程状态和业务结果。
5. **历史数据回填**:优先回填可由 `creator` 明确推断的后台订单;旧 `operator_id` 为店铺 ID 的记录不强改旧列,只填新列。
6. **补偿与验证**:扫描“已支付且待计算”的订单重新入队;手工验证顶级代理无佣金、多级链路有佣金、链路断裂待人工处理三类样本。
7. **回滚策略**:如新字段展示异常,可回退到兼容读逻辑并暂停新状态写入;旧字段仍保留,数据可读不丢失。
## Open Questions
- 本次不保留开放性业务问题,默认采用“状态看流程、结果看业务”的双轴模型推进;如后续需要增加“计算中”状态,可在实现阶段作为非 breaking 扩展处理。

View File

@@ -0,0 +1,38 @@
## Why
功能 IDfeature-001-order-operator-and-chain-commission-semantics。
当前订单链路把 `operator_id` 混用了“真实操作者账号”和“代理店铺 ID”两种语义导致平台下单场景无法准确追溯是谁操作订单详情中的 `operator_name` 也会在平台场景下长期为空。与此同时,`commission_status` 又被混用了“佣金计算流程状态”和“佣金业务结果”,使“未触发计算”“已计算但无佣金”“链路断裂待人工处理”三种完全不同的业务含义被压扁成同一个字段,已经影响后台判断、补偿逻辑和后续规则演进。
现在必须把这两个核心概念拆开:订单必须准确记录真实操作者;差价佣金必须按 `seller_shop_id` 的完整上级链逐级计算,“无佣金”必须成为明确结果而不是继续停留在“待计算”。
## What Changes
- 修正订单操作者语义:订单中的操作者字段统一表示“真实发起操作的账号”,不再复用为代理店铺 ID平台、代理、后续其他账号类型都必须能被准确记录和展示。
- 修正订单响应与查询语义:订单详情/列表中的操作者名称按账号语义回填,店铺归属与买家归属继续由独立业务字段表达,避免“账号名”和“店铺名”混淆。
- 修正差价佣金计算规则:所有会参与差价佣金的订单都从 `seller_shop_id` 出发,沿完整父级链逐级计算,不再使用“一级/二级代理特判”口径,也不再从 `operator_id/operator_type` 推导佣金归属。
- 修正平台代扣、代理自购、代理为下级代购等场景的触发规则:只要订单属于差价佣金适用范围,系统都必须在支付成功或自动完单后触发佣金计算;是否有佣金由链路和价差决定,而不是由操作者类型决定。
- 拆分佣金流程状态与业务结果:**BREAKING** 订单佣金状态语义将从“二元待计算/已计算”调整为“流程状态 + 结果语义”模型,至少区分“待计算/已完成/待人工处理”以及“有佣金/无佣金/链路异常”。
- 修正链路异常处理:当上级链断裂、配置缺失或无法继续向上分配时,系统必须明确落为“待人工处理/链路异常”,而不是和普通“已完成”混淆。
- 补充兼容迁移与手工验证方案:历史订单中 `operator_id` 为 NULL 或保存店铺 ID 的数据需要有兼容解释和回填方案;核心验证覆盖多级代理链、无佣金订单、链路断裂订单和补偿入队场景。
- 增补文档与 OpenAPI更新订单管理、代购订单、佣金计算、佣金触发等规范确保前后端、运营和补偿流程都以新语义为准。
## Capabilities
### New Capabilities
-
### Modified Capabilities
- `agent-order-role-tracking`: 将订单操作者从“店铺视角”修正为“真实账号视角”,并调整订单响应中的操作者展示规则。
- `order-management`: 调整订单查询与响应中的操作者信息和佣金状态语义,明确订单字段各自承担的业务角色。
- `purchase-on-behalf`: 修正平台代扣、代理自购、代理为下级代购等场景的差价佣金规则,统一为基于 `seller_shop_id` 的链路型计算。
- `commission-calculation`: 将差价佣金定义为沿完整上级链逐级分配,并明确“无佣金”“链路异常待人工处理”等业务结果。
- `commission-trigger`: 修正佣金任务触发条件与补偿口径,确保所有适用订单都能进入计算流程,且“无佣金”不再被误判为“待计算”。
## Impact
- 受影响代码:`internal/model/order.go``internal/model/dto/order_dto.go``internal/service/order/service.go``internal/service/commission_calculation/service.go``internal/store/postgres/order_store.go``internal/task/commission_calculation.go``pkg/constants/*`
- 受影响接口:后台订单创建、订单列表、订单详情,以及所有依赖订单佣金状态和操作者展示的管理端页面。
- 受影响系统Asynq 佣金任务触发/补偿链路、钱包入账链路、运营人工排障流程、OpenAPI 文档与前端展示逻辑。
- 数据影响可能需要为历史订单增加兼容解释或回填脚本需要手工验证多级代理链路、0 佣金订单、链路断裂订单和平台代扣订单的状态落点。
- 性能与测试:链路计算必须继续满足逐级查询可控、列表查询分页、核心场景手工回放可验证;本变更会补充针对多级链路和补偿场景的验证清单。

View File

@@ -0,0 +1,93 @@
## MODIFIED Requirements
### Requirement: 订单操作者记录
系统 SHALL 在订单创建时记录操作者信息(谁下的单),并将其定义为**真实发起操作的账号**,区别于买家信息(资源所属者)和店铺归属信息。
#### Scenario: 平台创建订单
- **WHEN** 平台账号创建订单
- **THEN** 订单 MUST 记录平台账号作为操作者
- **AND** 操作者信息不得再以 `NULL` 表示“平台”
#### Scenario: 代理创建订单
- **WHEN** 代理账号创建订单
- **THEN** 订单 MUST 记录代理账号作为操作者
- **AND** 操作者信息不得再复用为代理店铺 ID
#### Scenario: 代理自购
- **WHEN** 代理为自己的资源创建订单
- **THEN** 订单的买家店铺可以等于操作者所属店铺
- **BUT** 操作者字段仍表示账号身份而非店铺身份
#### Scenario: 代理代购
- **WHEN** 代理为下级代理的资源创建订单
- **THEN** 订单 MUST 同时区分买家店铺和操作者账号
- **AND** 两者是否属于同一店铺不影响操作者语义
---
### Requirement: 订单查询增强
系统 SHALL 支持代理查询其权限范围内的订单,但数据权限和筛选逻辑 MUST 以买家/卖家相关业务字段为准,不得再依赖已承载账号语义的操作者字段推断店铺权限。
#### Scenario: 代理查询自己相关的订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回买家店铺或卖家店铺在其权限范围内的订单
- **AND** 不要求操作者字段继续承载店铺 ID 语义
#### Scenario: 按订单角色筛选
- **WHEN** 代理查询订单列表,指定 `purchase_role = "self_purchase"`
- **THEN** 系统只返回自己购买的订单
#### Scenario: 按订单角色筛选给下级购买的订单
- **WHEN** 代理查询订单列表,指定 `purchase_role = "purchase_for_subordinate"`
- **THEN** 系统只返回为下级代理购买的订单
---
### Requirement: 订单响应包含角色信息
系统 SHALL 在订单响应中包含操作者和角色信息,前端展示时 MUST 将“账号操作者”与“业务店铺”分离展示。
#### Scenario: 订单响应包含操作者 ID
- **WHEN** 查询订单详情
- **THEN** 响应包含操作者账号 ID 和操作者类型字段
#### Scenario: 平台订单返回操作者名称
- **WHEN** 查询平台账号创建的订单详情
- **THEN** 响应 MUST 返回平台账号名称作为 `operator_name`
- **AND** `operator_name` 不得因为操作者类型为平台而为空
#### Scenario: 代理订单返回操作者名称
- **WHEN** 查询代理账号创建的订单详情
- **THEN** 响应 MUST 返回代理账号名称作为 `operator_name`
- **AND** 不得再以店铺名替代账号名
#### Scenario: 订单响应包含角色标识
- **WHEN** 查询订单详情
- **THEN** 响应包含 `purchase_role``is_purchased_by_parent``purchase_remark` 字段
#### Scenario: 上级代购订单的备注
- **WHEN** 查询上级代理购买的订单
- **THEN** `purchase_remark` 可以引用操作者账号名或业务文案
- **AND** 不得依赖历史店铺型 `operator_id` 才能生成备注
#### Scenario: 平台代购订单的备注
- **WHEN** 查询平台代购订单
- **THEN** `purchase_remark` 为"由平台代购"或等价平台文案
---
### Requirement: 向后兼容性
系统 SHALL 确保新增操作者语义不会导致历史订单无法查询,并对历史混合数据提供兼容解释。
#### Scenario: 现有订单字段为 NULL
- **WHEN** 查询历史订单
- **THEN** 若新操作者字段为空,系统 MAY 回退使用 `creator` 和旧 `operator_*` 进行兼容展示
- **AND** 不影响订单查询结果
#### Scenario: 历史订单 operator_id 为店铺 ID
- **WHEN** 查询历史订单且旧 `operator_id` 保存的是店铺 ID
- **THEN** 系统 MUST 将该值视为历史兼容数据
- **AND** 不得继续把该旧值作为新订单操作者语义写回

View File

@@ -0,0 +1,71 @@
## MODIFIED Requirements
### Requirement: 成本价差收入计算
系统 SHALL 为代理链上的每一级代理计算成本价差收入。差价佣金 MUST 从订单的 `seller_shop_id` 出发,沿完整父级链逐级计算;佣金归属 MUST NOT 由 `operator_id``operator_type` 或“一级/二级代理”特判决定。
#### Scenario: 单级代理
- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元
- **THEN** 一级代理获得 20 元100 - 80成本价差收入
#### Scenario: 多级代理
- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80
- **THEN** 三级获得 20 元100 - 80二级获得 10 元80 - 70一级获得 10 元70 - 60平台获得 10 元60 - 50
#### Scenario: 平台代扣二级及以上代理
- **WHEN** 平台为任意非顶级代理创建并完成订单
- **THEN** 系统仍从该订单的 `seller_shop_id` 向上逐级计算差价佣金
- **AND** 只要上级链存在且存在有效价差,各级上级都获得对应佣金
#### Scenario: 顶级代理订单无上级
- **WHEN** 顶级代理完成订单,`seller_shop_id` 无父级链
- **THEN** 系统完成佣金计算流程
- **AND** 不创建上级差价佣金记录
- **AND** 该结果被标记为“无佣金”而非“待计算”
#### Scenario: 成本价相同
- **WHEN** 某级代理成本价等于下级成本价
- **THEN** 该级代理成本价差收入为 0不创建佣金记录
---
### Requirement: 代购订单佣金计算规则
代购订单 SHALL 计算差价佣金,但不触发一次性佣金。差价佣金的计算对象 MUST 是买家所属卖方链路上的全部有效上级,而不是仅限最近一级。
#### Scenario: 代购订单计算差价佣金
- **WHEN** 代购订单is_purchase_on_behalf = true完成买家有上级代理链
- **THEN** 系统按 `seller_shop_id` 的完整上级链计算差价佣金
- **AND** 将各级有效价差佣金分别发放给对应上级
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成,佣金计算时检查订单类型
- **THEN** 系统跳过一次性佣金判断逻辑,不发放一次性佣金
#### Scenario: 代购订单示例
- **WHEN** 平台为三级代理代购,订单金额 100 元(三级成本价),各级成本价:一级 60 → 二级 70 → 三级 80
- **THEN** 三级作为销售代理获得零售价差收入(如适用)
- **AND** 二级获得 10 元80 - 70差价佣金一级获得 10 元70 - 60差价佣金
- **AND** 该订单不触发一次性佣金
## ADDED Requirements
### Requirement: 佣金计算结果落单
系统 SHALL 在佣金计算完成后,将订单落为明确的流程状态和业务结果,至少区分“有佣金”“无佣金”“链路异常待人工处理”。
#### Scenario: 计算完成且生成佣金记录
- **WHEN** 订单佣金计算成功,生成一条或多条佣金记录
- **THEN** 订单佣金流程状态为“已完成”
- **AND** 订单佣金业务结果为“有佣金”
#### Scenario: 计算完成但无佣金记录
- **WHEN** 订单佣金计算成功,但整条有效上级链上都没有正差价
- **THEN** 订单佣金流程状态为“已完成”
- **AND** 订单佣金业务结果为“无佣金”
#### Scenario: 上级链断裂
- **WHEN** 佣金计算过程中发现父级链、成本价配置或分配关系无法继续向上求解
- **THEN** 系统创建待人工处理的异常记录或备注
- **AND** 订单佣金流程状态为“待人工处理”
- **AND** 订单佣金业务结果为“链路异常”

View File

@@ -0,0 +1,72 @@
## MODIFIED Requirements
### Requirement: 支付成功后自动入队佣金计算任务
系统 SHALL 在订单首次进入“已支付”或自动完单后的待计算状态时自动 enqueue 佣金计算异步任务(`commission:calculate`),确保所有适用订单都进入佣金计算流程。系统 MUST NOT 预先根据操作者类型推断“这单没有佣金所以不用算”。
**触发条件**
- 订单从"待支付"变为"已支付",或订单创建后即自动完单
- 订单佣金流程状态为待计算
- 订单属于差价佣金适用范围
**任务参数**
- 任务类型:`commission:calculate`
- Payload`{"order_id": <订单ID>}`
#### Scenario: 首次支付成功触发计算
- **WHEN** 订单支付成功,订单状态从"待支付"变为"已支付"
- **THEN** 系统自动 enqueue `commission:calculate` 任务payload 包含订单 ID
- **AND** 订单佣金流程状态保持为待计算,直到 worker 写回最终结果
#### Scenario: 自动完单订单触发计算
- **WHEN** 订单创建后即自动完单(如平台代购、适用的后台即时支付场景)
- **THEN** 系统同样自动 enqueue `commission:calculate` 任务
#### Scenario: 平台代扣顶级代理订单仍然入队
- **WHEN** 平台代扣顶级代理钱包订单,系统尚未知道最终是否有佣金
- **THEN** 系统仍然 enqueue `commission:calculate` 任务
- **AND** 由 worker 最终判定该订单结果为“无佣金”或其他结果
#### Scenario: 重复支付不重复触发
- **WHEN** 订单已经是"已支付"状态,再次收到支付成功通知(幂等场景)
- **THEN** 系统不重复 enqueue 佣金计算任务
- **AND** 日志记录"订单已支付,跳过重复入队"
#### Scenario: 已完成佣金流程的订单不触发
- **WHEN** 订单佣金流程状态已为“已完成”或“待人工处理”
- **THEN** 系统跳过入队操作
- **AND** 日志记录"订单佣金流程已结束,跳过入队"
---
### Requirement: 佣金计算任务幂等性
系统 SHALL 确保佣金计算任务可重复执行不重复发放佣金worker 完成后 MUST 明确写回订单的佣金流程状态和业务结果。
**幂等检查**
- 任务执行前检查订单佣金流程状态
- 如果流程已结束,则跳过计算并返回成功
**状态更新**
- 计算完成后将订单写为“已完成 + 有佣金/无佣金”或“待人工处理 + 链路异常”
- 状态更新与佣金记录创建在同一事务中
#### Scenario: 任务重复执行跳过计算
- **WHEN** 佣金计算任务执行时,订单佣金流程状态已为“已完成”
- **THEN** 系统跳过佣金计算和钱包入账操作
- **AND** 任务返回成功(避免 Asynq 重试)
#### Scenario: 并发任务只有一个成功
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
- **THEN** 第一个任务成功完成计算并写回最终状态
- **AND** 第二个任务检查到流程已结束后跳过计算
#### Scenario: 无佣金订单也写回完成状态
- **WHEN** worker 执行完毕,确认该订单没有任何应发差价佣金
- **THEN** worker MUST 将订单写回“已完成 + 无佣金”
- **AND** 不允许订单继续停留在“待计算”
#### Scenario: 任务失败可安全重试
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
- **THEN** Asynq 自动重试任务
- **AND** 重试时幂等检查确保不重复发放佣金

View File

@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: 订单响应包含佣金流程状态与业务结果
系统 SHALL 在订单列表和订单详情响应中分别表达佣金流程状态和佣金业务结果,避免前端和运营把“待计算”“无佣金”“链路异常”混为一谈。
#### Scenario: 已计算无佣金订单
- **WHEN** 查询一笔已完成计算但没有任何应发佣金的订单
- **THEN** 响应中 MUST 返回“已完成”类佣金流程状态
- **AND** MUST 返回“无佣金”类业务结果
#### Scenario: 生成佣金记录的订单
- **WHEN** 查询一笔已经生成佣金记录的订单
- **THEN** 响应中 MUST 返回“已完成”类佣金流程状态
- **AND** MUST 返回“有佣金”类业务结果
#### Scenario: 链路断裂订单
- **WHEN** 查询一笔因上级链断裂而需要人工处理的订单
- **THEN** 响应中 MUST 返回“待人工处理”类佣金流程状态
- **AND** MUST 返回“链路异常”类业务结果
### Requirement: 订单响应包含真实操作者展示信息
系统 SHALL 在订单列表和订单详情中返回真实操作者信息,并将账号操作者展示与业务店铺展示分离。
#### Scenario: 平台账号创建的订单
- **WHEN** 查询平台账号创建的订单
- **THEN** 响应 MUST 返回平台账号名称作为操作者名称
#### Scenario: 代理账号创建的订单
- **WHEN** 查询代理账号创建的订单
- **THEN** 响应 MUST 返回代理账号名称作为操作者名称
#### Scenario: 历史订单兼容展示
- **WHEN** 查询尚未完成回填的历史订单
- **THEN** 系统 MAY 通过 `creator` 或历史兼容字段补全操作者展示
- **AND** 不得因为平台场景而默认返回空字符串

View File

@@ -0,0 +1,75 @@
## MODIFIED Requirements
### Requirement: 代购订单自动完成
代购订单创建后 SHALL 自动完成支付流程,激活套餐,但不触发一次性佣金。
#### Scenario: 代购订单自动激活套餐
- **WHEN** 创建代购订单成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 代购订单不扣钱包
- **WHEN** 创建线下代购订单
- **THEN** 系统不扣减任何钱包余额(线下已收款)
#### Scenario: 代购订单不更新累计充值
- **WHEN** 代购订单完成
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
#### Scenario: 代购订单计算链路型差价佣金
- **WHEN** 代购订单完成,买家存在有效上级代理链
- **THEN** 系统从订单的 `seller_shop_id` 出发沿完整父级链计算差价佣金
- **AND** 将各级有效差价分别发放给对应上级代理
#### Scenario: 代购订单无上级链
- **WHEN** 代购订单对应的是顶级代理或其上级链没有任何正差价
- **THEN** 系统完成佣金计算流程
- **AND** 将订单结果标记为“无佣金”
#### Scenario: 代购订单不触发一次性佣金
- **WHEN** 代购订单完成
- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金
---
### Requirement: 代理钱包代购
系统 SHALL 允许代理使用钱包支付wallet为下级代理创建代购订单从自己钱包扣款并立即激活套餐。
#### Scenario: 代理为下级代理钱包代购
- **WHEN** 代理选择下级代理的资源创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`buyer_id` = 下级代理店铺 ID`is_purchase_on_behalf` = true`payment_method` = "wallet"`payment_status` = 2已支付
- **AND** 操作者字段记录真实代理账号,而不是代理店铺 ID
#### Scenario: 钱包代购扣款操作者钱包
- **WHEN** 代理使用 wallet 为下级代理购买套餐
- **THEN** 系统从操作者所属代理的钱包扣款
#### Scenario: 钱包代购使用操作者成本价扣款
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建 wallet 代购订单
- **THEN** 系统从一级代理钱包扣款 80 元(操作者成本价)
#### Scenario: 钱包代购订单金额显示买家成本价
- **WHEN** 一级代理为二级代理钱包代购
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
#### Scenario: 钱包代购余额不足
- **WHEN** 代理使用 wallet 代购,但钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
#### Scenario: 钱包代购自动激活套餐
- **WHEN** 钱包代购订单创建成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 钱包代购触发差价佣金计算
- **WHEN** 代理使用 wallet 代购订单完成
- **THEN** 系统 MUST 触发差价佣金计算任务
- **AND** 最终是否有佣金由 `seller_shop_id` 的完整上级链和价差结果决定
#### Scenario: 钱包代购不触发一次性佣金
- **WHEN** 代理使用 wallet 代购订单完成
- **THEN** 系统不检查一次性佣金阈值,不发放一次性佣金
#### Scenario: 钱包代购创建钱包流水
- **WHEN** 代理使用 wallet 代购扣款成功
- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct"`transaction_subtype` = "purchase_for_subordinate"`related_shop_id` = 下级代理店铺 ID

View File

@@ -0,0 +1,35 @@
## 1. 数据模型与迁移基线
- [x] 1.1 梳理订单现有 `operator_*``creator``commission_status` 字段的历史语义与使用点,产出兼容映射清单,并用 `lsp_diagnostics` 确认相关模型文件当前无错误
- [x] 1.2 创建订单操作者权威字段与佣金结果字段的 migration保留旧字段不删除并核对 up/down 文件命名、注释与回滚策略完整
- [x] 1.3 更新订单模型常量与字段定义,明确“佣金流程状态”和“佣金业务结果”两套枚举,并用 `lsp_diagnostics` 确认 `internal/model/order.go``pkg/constants/*` 无错误
## 2. 订单操作者语义修复
- [x] 2.1 调整后台订单创建链路,统一写入真实操作者账号信息,覆盖平台下单、代理自购、代理为下级代购、平台代扣代理钱包等场景,并用手工代码走查验证每条路径都有明确操作者来源
- [x] 2.2 调整订单响应构建逻辑,按账号语义回填 `operator_name`,并将账号展示与业务店铺展示分离,用 `lsp_diagnostics` 确认 `internal/service/order/service.go` 与 DTO 文件无错误
- [x] 2.3 设计并实现历史订单兼容读取策略:新字段优先,旧字段与 `creator` 兜底;手工抽查平台旧订单、代理旧订单各至少一条,确认不会再出现平台操作者名称默认空白
## 3. 链路型差价佣金模型修复
- [x] 3.1 调整佣金计算服务,统一以 `seller_shop_id` 为起点沿完整父级链逐级计算差价佣金,移除对 `operator_id/operator_type` 的佣金归属依赖,并用代码走查确认顶级、二级、三级、多级场景口径一致
- [x] 3.2 在佣金计算完成后显式写回“已完成+有佣金 / 已完成+无佣金 / 待人工处理+链路异常”结果,确保 0 佣金订单不再停留在待计算
- [x] 3.3 保留并增强链路断裂待人工处理逻辑,手工检查断点备注、待审记录与订单状态结果三者一致
## 4. 佣金触发与补偿链修复
- [x] 4.1 统一修复所有差价佣金适用路径的入队逻辑,覆盖支付回调、自动完单、平台代理扣代理钱包、代理钱包代购等场景,并用代码走查确认不会遗漏 `self_purchase` 的平台代理扣订单
- [x] 4.2 调整佣金任务幂等判断与补偿扫描口径,使其基于新流程状态工作,避免“无佣金订单”被重复补偿入队
- [x] 4.3 手工验证三类样本:顶级代理无佣金订单、多级代理有佣金订单、链路断裂待人工处理订单,确认任务执行后的订单状态与佣金记录符合预期
## 5. 订单查询与文档同步
- [x] 5.1 更新订单列表/详情相关 DTO 与接口文档,明确操作者字段语义以及佣金流程状态/业务结果字段语义,并用 `lsp_diagnostics` 确认 DTO 与 Handler 文件无错误
- [x] 5.2 若接口响应结构发生变化,按项目规范同步更新文档生成器相关注册与说明文档,确保 OpenAPI 输出与新语义一致
- [x] 5.3 补充中文功能总结与手工验收清单,覆盖平台订单操作者展示、多级链路差价佣金、无佣金完成态、链路异常待人工处理四类核心验收场景
## 6. 最终手工验收
- [x] 6.1 使用数据库查询逐条核对订单、佣金记录、钱包流水与操作者字段,确认核心样本数据落库符合新语义
- [x] 6.2 对本次变更涉及的关键 Go 文件运行 `lsp_diagnostics`,确认无错误、无遗留坏状态
- [x] 6.3 按验收清单完成后台订单列表/详情、补偿入队、异常订单识别的手工验证,确认提案范围内的业务流程可闭环

View File

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

View File

@@ -0,0 +1,134 @@
## Context
轮询停复机系统(`stop_resume_service.go` + 三个轮询 Handler存在三个已在生产环境触发的缺陷均已通过日志 + DB 分析定位到根因:
1. **连坐停机**(已复现):设备 862639076038233 下 card_id=5未实名触发 `checkStopReasons → not_realname → stopDeviceCards`,将同设备已实名的 card_id=2、card_id=4 一并停机。
2. **无防抖误停**(潜在风险):`polling_realname_handler` 单次检测到实名逆转即触发停机,上游运营商同步延迟时单次 false 可导致误停。
3. **carrier 停机状态不完整**(已复现):`polling_cardstatus_handler` 检测到网关侧停机时仅更新 `network_status=0``stop_reason` 留空,导致卡进入无法被自动复机的死锁状态。
涉及文件:
- `internal/service/iot_card/stop_resume_service.go`EvaluateAndAct
- `internal/task/polling_realname_handler.go`
- `internal/task/polling_cardstatus_handler.go`
- `pkg/constants/iot.go`
- `pkg/constants/redis.go`
## Goals / Non-Goals
**Goals:**
- `not_realname` 停机仅影响被评估的单卡,不扩散至设备维度
- 实名逆转需连续 3 次才触发停机,消除上游单次误报
- 网关侧停机事件写入明确的 `stop_reason`,确保卡状态完整可追踪
**Non-Goals:**
- 不修改 `no_package` / `traffic_exhausted` 的设备维度停机逻辑(仍保持连带停机)
- 不引入数据库 schema 变更
- 不回填存量 `stop_reason=''` 的历史数据
- `carrier_stopped` 不纳入 `isPollingStopReason`(不自动复机,运营商停机需人工确认)
## Decisions
### 决策 1isDeviceScopeReason——停机范围判断
**背景**`not_realname` 是单卡属性(每张卡独立实名),`no_package` / `traffic_exhausted` 是设备共享资源,两者停机范围语义完全不同。
**方案**:新增 `isDeviceScopeReason(reason string) bool`,仅 `no_package` / `traffic_exhausted` 返回 true。`EvaluateAndAct` 中,只有当 `isDeviceScopeReason(primaryReason)` 为 true 时才调用 `stopDeviceCards`;否则走 `stopCardWithRetry`(单卡)。
```go
// 停机范围为设备维度的原因(套餐/流量是设备共享资源)
func isDeviceScopeReason(reason string) bool {
return reason == constants.StopReasonNoPackage ||
reason == constants.StopReasonTrafficExhausted
}
// EvaluateAndAct 修改后的在线分支
primaryReason := reasons[0]
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr == nil && bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
```
**边界确认**:若卡同时满足 `no_package + not_realname``primaryReason = "no_package"`(优先级更高),走设备维度——符合预期(设备整体无套餐)。
---
### 决策 2实名逆转防抖——Redis 滑动计数器
**背景**:运营商实名接口存在同步延迟,单次返回 false 不足以确认实名已失效。
**方案**:在 `polling_realname_handler` 检测到 `1→0` 逆转时,写入 Redis 计数器INCR + 独立 TTL仅当计数 `>= 3` 时才触发 `EvaluateAndAct`;检测到 `true` 时清除计数器。**不修改 DB 写入逻辑**`real_name_status` 仍实时同步,只延迟停机决策)。
```
Redis Key: polling:card:realname:reversal:{card_id}
TTL: 10 分钟(若 10 分钟内未连续触发,计数归零)
阈值: 3 次(常量 PollingRealnameReversalThreshold = 3
```
**流程**
```
gateway 返回 false已更新 DB real_name_status=0
INCR polling:card:realname:reversal:{card_id}TTL=10min
├─ count < 3仅记录不触发停机返回
└─ count >= 3DEL key → EvaluateAndAct触发停机
gateway 返回 true已更新 DB real_name_status=1 或无变化)
DEL polling:card:realname:reversal:{card_id}
若 DB 为 0→1触发 EvaluateAndAct复机评估逻辑不变
```
**注意**`INCR` 仅在当前 `newStatus == RealNameStatusNotVerified` 时执行,不影响 true 的处理路径。
---
### 决策 3carrier_stopped——网关侧停机原因
**背景**`polling_cardstatus_handler` 通过轮询 Gateway 卡状态(非主动停机操作)检测到在线→停机变化,当前未写入 `stop_reason`,导致 `isPollingStopReason('')=false`,卡永久无法自动复机。
**方案**
- 新增常量 `StopReasonCarrierStopped = "carrier_stopped"`
- `polling_cardstatus_handler` 在检测到在线→停机变化时,在 `fields` 中补写 `stop_reason = carrier_stopped`
- `carrier_stopped` **不**加入 `isPollingStopReason`(运营商停机原因不明,不自动复机)
- `carrier_stopped` 作为合法状态存储,管理员可在后台查看并手动决策
```go
// polling_cardstatus_handler.go 修改处
if statusChanged {
fields["network_status"] = newNetworkStatus
if newNetworkStatus == constants.NetworkStatusOffline {
fields["stop_reason"] = constants.StopReasonCarrierStopped
}
}
```
**关于存量数据**:已处于 `stop_reason=''` 的停机卡不回填,需运维人工处理(可通过 SQL 批量检查后决策)。
---
### 关于 resumeDeviceCards 的兼容性
现有 `resumeDeviceCards` 在遍历停机卡时已对 `isRealnameOK` 做过滤:
```go
for _, card := range cards {
if !s.isRealnameOK(card) {
s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname)
continue
}
s.resumeSingleCard(ctx, card.ID)
}
```
修复后,`not_realname` 只停单卡,触发 `resumeDeviceCards` 的场景(`no_package` / `traffic_exhausted` 复机)不会遇到 `not_realname` 卡,此处逻辑仍保留作为防御性检查,无需修改。
## Risks / Trade-offs
| 风险 | 评估 | 缓解 |
|------|------|------|
| 防抖计数 3 次意味着最多 ~3 分钟延迟停机 | 低风险,实名验证丢失通常是持续状态 | 阈值做成常量,可快速调整 |
| carrier_stopped 卡无法自动复机,需人工处理 | 已接受(运营商停机原因不明,保守处理) | 后台日志/状态可见,管理员可手动触发复机 |
| 不回填存量 stop_reason='' 数据 | 存量问题继续存在 | 运维可执行一次性 SQL 修复,超出本次 change 范围 |

View File

@@ -0,0 +1,42 @@
## Why
轮询停复机系统存在三个已确认的逻辑缺陷,导致已实名、有套餐的正常卡被错误停机:
1. **连坐问题**:设备下任意一张未实名卡触发 `stopDeviceCards`,会将同设备所有在线卡(包括已实名卡)一并停机,`not_realname` 是单卡属性,不应扩散至设备维度。
2. **无防抖**`polling_realname_handler` 单次检测到实名逆转1→0即立刻触发停机上游运营商接口在数据同步延迟期间可能短暂返回 false导致误停。
3. **carrier 停机无 stop_reason**`polling_cardstatus_handler` 检测到网关侧停机(在线→停机状态变化)时,只更新 `network_status=0`,不写 `stop_reason`,导致卡的 `stop_reason=''``isPollingStopReason` 判断为非轮询原因,卡永久无法被自动复机。
## What Changes
- **修改 `stop_resume_service.go`EvaluateAndAct**:引入 `isDeviceScopeReason()` 判断,`not_realname` 不再触发 `stopDeviceCards`,只停被评估的单卡;`no_package` / `traffic_exhausted` 保持设备维度停机不变。
- **修改 `polling_realname_handler.go`**:实名逆转时不立即停机,改为写入 Redis 计数器;连续 N 次(默认 3 次)检测到逆转后才触发 `EvaluateAndAct`;检测到 true 时重置计数器。
- **新增常量 `StopReasonCarrierStopped`**`pkg/constants/iot.go`):值为 `"carrier_stopped"`,用于标记运营商侧主动停机。
- **修改 `polling_cardstatus_handler.go`**:检测到在线→停机状态变化时,补写 `stop_reason='carrier_stopped'``carrier_stopped` 不进入 `isPollingStopReason`(运营商停机不自动复机,需人工确认)。
- **新增 Redis Key 常量**`pkg/constants/redis.go``RedisPollingRealnameReversalCountKey(cardID)` 用于实名逆转计数。
## Capabilities
### New Capabilities
无新能力,本次为纯缺陷修复。
### Modified Capabilities
- `polling-stop-resume-logic``not_realname` 停机范围从设备维度缩小为单卡维度;新增 `carrier_stopped` 作为合法停机原因(不参与自动复机)。
- `polling-task-handlers`:实名检查 handler 新增逆转防抖逻辑;卡状态检查 handler 新增 carrier 停机原因写入。
## Impact
**受影响代码:**
- `internal/service/iot_card/stop_resume_service.go`
- `internal/task/polling_realname_handler.go`
- `internal/task/polling_cardstatus_handler.go`
- `pkg/constants/iot.go`
- `pkg/constants/redis.go`
**无 DB 变更**(无迁移文件)。
**无 API 变更**(纯内部逻辑调整)。
**现存数据影响:**
- 已处于 `stop_reason=''` 停机状态的卡(如 card_id=2需人工干预设置正确 `stop_reason` 才能自动复机,本次代码修复不回填存量数据。

View File

@@ -0,0 +1,44 @@
> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-stop-resume-logic/spec.md` 的变更部分。未提及的需求保持不变。
---
### 变更not_realname 停机范围——从设备维度缩小为单卡维度
**原行为**`EvaluateAndAct` 在非独立卡(`is_standalone=false`)触发停机时,无论停机原因如何,均调用 `stopDeviceCards` 停机整个设备的所有在线卡。
**新行为**:仅当停机原因为 `no_package``traffic_exhausted`(设备共享资源)时,才调用 `stopDeviceCards`;当停机原因为 `not_realname` 时,调用 `stopCardWithRetry` 只停被评估的单卡。
#### Scenario: not_realname 只停单卡,不连坐已实名卡
- **GIVEN** 设备 D 下有卡 A`real_name_status=0`,在线)和卡 B`real_name_status=1`,在线)
- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["not_realname"]`
- **THEN** 调用 `stopCardWithRetry(cardA, "not_realname")`;卡 A 停机;卡 B 保持在线,`network_status` 不变
#### Scenario: no_package 仍走设备维度停机
- **GIVEN** 设备 D 下有卡 A 和卡 B 均在线,设备无有效套餐
- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["no_package"]`
- **THEN** 调用 `stopDeviceCards(deviceID, "no_package")`;卡 A 和卡 B 均被停机(设备维度停机保持不变)
#### Scenario: 优先级 no_package > not_realname 时仍走设备维度
- **GIVEN** 卡 A未实名 + 无套餐),卡 B已实名均在线
- **WHEN** 卡 A 的 `checkStopReasons` 返回 `["no_package", "not_realname"]``primaryReason = "no_package"`
- **THEN** `isDeviceScopeReason("no_package") = true`;调用 `stopDeviceCards`;卡 A 和卡 B 均停机
---
### 新增carrier_stopped——运营商侧停机原因
新增常量 `StopReasonCarrierStopped = "carrier_stopped"`,表示由运营商/Gateway 侧主动停机(非本系统发起)。
**与现有停机原因的区别**
| 原因 | 触发方 | 自动复机 |
|------|--------|----------|
| `no_package` | 本系统轮询 | ✅ 是 |
| `traffic_exhausted` | 本系统轮询 | ✅ 是 |
| `not_realname` | 本系统轮询 | ✅ 是(实名完成后) |
| `manual` | 管理员手动 | ❌ 否 |
| `carrier_stopped` | 运营商/Gateway | ❌ 否(需人工确认) |
#### Scenario: carrier_stopped 不触发自动复机
- **GIVEN** 卡停机,`stop_reason='carrier_stopped'`
- **WHEN** 下次轮询调用 `EvaluateAndAct`
- **THEN** `isPollingStopReason("carrier_stopped") = false`;跳过复机逻辑;卡保持停机状态直到管理员手动处理

View File

@@ -0,0 +1,58 @@
> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-task-handlers/spec.md` 的变更部分。未提及的需求保持不变。
---
### 变更polling_realname_handler——实名逆转防抖
**原行为**:检测到实名状态 `1→0` 逆转时,立即更新 DB 并调用 `EvaluateAndAct` 触发停机。
**新行为**:检测到逆转时,通过 Redis 计数器累计,仅在**连续 3 次**检测到逆转后才触发 `EvaluateAndAct`;检测到 `true` 时重置计数器。DB 写入行为(`real_name_status` 同步)保持不变——只延迟停机决策,不延迟状态记录。
**Redis Key**`polling:card:realname:reversal:{card_id}``RedisPollingRealnameReversalCountKey`
**TTL**10 分钟(超时自动归零)
**阈值常量**`PollingRealnameReversalThreshold = 3`
#### Scenario: 单次逆转不触发停机
- **GIVEN** card_id=4`real_name_status=1`DBGateway 返回 `false`
- **WHEN** `polling_realname_handler` 处理该卡
- **THEN** DB 更新 `real_name_status=0`Redis 计数器 `reversal:4` 变为 1TTL=10min**不调用 EvaluateAndAct**;卡保持在线
#### Scenario: 连续 3 次逆转才触发停机
- **GIVEN** card_id=4前 2 次轮询 Gateway 均返回 false计数器为 2
- **WHEN** 第 3 次轮询 Gateway 仍返回 false
- **THEN** Redis 计数器变为 3达到阈值删除计数器调用 `EvaluateAndAct(freshCard)`;触发停机,`stop_reason='not_realname'`
#### Scenario: 中间出现 true 则重置计数
- **GIVEN** card_id=4已记录 2 次逆转(计数器=2
- **WHEN** 下次轮询 Gateway 返回 true`real_name_status` 重置为 1
- **THEN** 删除 Redis 计数器(归零);若状态从 0→1 则触发复机评估(逻辑不变)
#### Scenario: 10 分钟未触发则计数自动归零
- **GIVEN** card_id=4 记录了 1 次逆转(计数器=1之后 10 分钟内 Gateway 均返回 true
- **WHEN** 10 分钟 TTL 到期
- **THEN** Redis key 自动过期,计数归零;后续逆转需重新从 1 开始累计
---
### 变更polling_cardstatus_handler——网关侧停机补写 stop_reason
**原行为**:检测到 Gateway 报告卡为"停机"(在线→停机状态变化)时,仅更新 `network_status=0`,不设置 `stop_reason`
**新行为**:在写入 `network_status=0` 的同时,若当前 `stop_reason` 为空,补写 `stop_reason='carrier_stopped'`
> **说明**:只在状态变化时补写,若 `stop_reason` 已有值(如已被本系统停机)则不覆盖。
#### Scenario: 网关侧停机事件补写 carrier_stopped
- **GIVEN** 卡在线(`network_status=1``stop_reason=''`Gateway 本次返回"停机"
- **WHEN** `polling_cardstatus_handler` 处理,检测到 `statusChanged=true``newNetworkStatus=0`
- **THEN** DB 更新 `network_status=0, stop_reason='carrier_stopped'``isPollingStopReason('carrier_stopped')=false``EvaluateAndAct` 不触发自动复机
#### Scenario: 不覆盖已有 stop_reason
- **GIVEN** 卡已停机(`network_status=0``stop_reason='not_realname'`Gateway 本次仍返回"停机"
- **WHEN** `polling_cardstatus_handler` 处理,`statusChanged=false`(状态未变)
- **THEN** 不写入任何字段;`stop_reason` 保持 `not_realname` 不变
#### Scenario: carrier_stopped 的卡通过 EvaluateAndAct 调用时正确跳过复机
- **GIVEN** 卡停机,`stop_reason='carrier_stopped'`,套餐有效,已实名
- **WHEN** 其他轮询任务调用 `EvaluateAndAct(card)`
- **THEN** `isPollingStopReason('carrier_stopped')=false`;直接返回 nil不执行复机

View File

@@ -0,0 +1,190 @@
# 实现任务清单
## Fix 1not_realname 连坐修复
### 1.1 新增 isDeviceScopeReason 函数
- **文件**`internal/service/iot_card/stop_resume_service.go`
- **位置**:紧接 `isPollingStopReason` 函数之后
- **内容**
```go
// isDeviceScopeReason 判断停机原因是否应扩散到设备维度
// 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡
func isDeviceScopeReason(reason string) bool {
return reason == constants.StopReasonNoPackage ||
reason == constants.StopReasonTrafficExhausted
}
```
### 1.2 修改 EvaluateAndAct 在线分支的停机判断
- **文件**`internal/service/iot_card/stop_resume_service.go`
- **修改位置**`EvaluateAndAct` 函数的 `NetworkStatusOnline` 分支
- **原逻辑**
```go
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil { ... }
else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
```
- **改为**
```go
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil { ... }
else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
```
- **注意**:仅在条件中增加 `&& isDeviceScopeReason(primaryReason)`,其余代码不变
### 1.3 数据库验证
- 使用 PostgreSQL MCP 确认设备 862639076038233 下 card_id=5未实名触发停机时card_id=2 和 card_id=4 不再被停机
---
## Fix 2实名逆转防抖
### 2.1 新增 Redis Key 常量
- **文件**`pkg/constants/redis.go`
- **新增**
```go
// RedisPollingRealnameReversalCountKey 实名逆转连续计数器,防止上游单次误报触发停机
// TTL: 10分钟
func RedisPollingRealnameReversalCountKey(cardID uint) string {
return fmt.Sprintf("polling:card:realname:reversal:%d", cardID)
}
```
### 2.2 新增防抖阈值常量
- **文件**`pkg/constants/iot.go`
- **新增**(放在实名状态常量附近):
```go
// PollingRealnameReversalThreshold 实名逆转防抖阈值:连续 N 次检测到逆转才触发停机
PollingRealnameReversalThreshold = 3
```
### 2.3 修改 polling_realname_handler.go 逆转处理逻辑
- **文件**`internal/task/polling_realname_handler.go`
- **修改位置**`Handle` 函数中 `else if newStatus == constants.RealNameStatusNotVerified` 分支
- **原逻辑**(约第 154-167 行):
```go
} else if newStatus == constants.RealNameStatusNotVerified {
h.base.logger.Warn("检测到实名逆转,立即触发停机评估", ...)
if h.stopResumeSvc != nil {
freshCard, _ := h.iotCardStore.GetByID(ctx, cardID)
h.stopResumeSvc.EvaluateAndAct(ctx, freshCard)
}
}
```
- **改为**
```go
} else if newStatus == constants.RealNameStatusNotVerified {
reversalKey := constants.RedisPollingRealnameReversalCountKey(cardID)
count, incrErr := h.base.redis.Incr(ctx, reversalKey).Result()
if incrErr == nil {
h.base.redis.Expire(ctx, reversalKey, 10*time.Minute)
}
h.base.logger.Warn("检测到实名逆转",
zap.Uint("card_id", cardID),
zap.Int64("reversal_count", count),
zap.Int("threshold", constants.PollingRealnameReversalThreshold))
if count >= int64(constants.PollingRealnameReversalThreshold) {
h.base.redis.Del(ctx, reversalKey)
h.base.logger.Warn("实名逆转达到阈值,触发停机评估",
zap.Uint("card_id", cardID))
if h.stopResumeSvc != nil {
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr == nil {
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("实名逆转后触发停机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
}
}
}
}
```
- **同时**:在 `isFirstRealname` 分支0→1复机成功前清除计数器
```go
// 0→1 时清除逆转计数器
h.base.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID))
```
放在 `triggerFirstRealnameActivation` 调用之前
### 2.4 确认 PollingBase 有 redis 字段可访问
- 检查 `internal/task/polling_base.go`(或同类文件),确认 `h.base.redis` 可用
- 若不可用,通过 `PollingRealnameHandler` 直接持有 `redis *redis.Client` 字段,在 `NewPollingRealnameHandler` 中注入
---
## Fix 3carrier_stopped 停机原因
### 3.1 新增 StopReasonCarrierStopped 常量
- **文件**`pkg/constants/iot.go`
- **新增**(放在现有 StopReason 常量组中):
```go
StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机
```
### 3.2 修改 polling_cardstatus_handler.go 补写 stop_reason
- **文件**`internal/task/polling_cardstatus_handler.go`
- **修改位置**:第 97-103 行,`statusChanged` 判断块内
- **原逻辑**
```go
fields := map[string]any{"last_card_status_check_at": now}
if statusChanged {
fields["network_status"] = newNetworkStatus
}
```
- **改为**
```go
fields := map[string]any{"last_card_status_check_at": now}
if statusChanged {
fields["network_status"] = newNetworkStatus
// 网关侧停机事件:补写 stop_reason便于状态追踪和区分手动停机
if newNetworkStatus == constants.NetworkStatusOffline && card.StopReason == "" {
fields["stop_reason"] = constants.StopReasonCarrierStopped
}
}
```
- **注意**`card.StopReason == ""` 条件确保不覆盖已有停机原因(如 `not_realname`
### 3.3 确认 isPollingStopReason 不包含 carrier_stopped
- **文件**`internal/service/iot_card/stop_resume_service.go`
- **确认**`isPollingStopReason` 函数的 switch case 中**不包含** `carrier_stopped`
- **无需修改**(已符合预期),仅做代码 review 确认
---
## 验证
### V1验证 Fix 1连坐修复
```sql
-- 通过 PostgreSQL MCP 检查card_id=5 停机时card_id=2、card_id=4 应保持在线
SELECT id, iccid, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_virtual_no = '862639076038233'
ORDER BY id;
```
预期card_id=5 被停机card_id=2 和 card_id=4 不受影响(或按各自状态独立判断)
### V2验证 Fix 2防抖
- 在 Redis 中观察 `polling:card:realname:reversal:*` key 的变化
- 确认单次 false 后 key 计数为 1 且不触发停机日志
- 确认 3 次 false 后出现"实名逆转达到阈值"日志并触发停机
### V3验证 Fix 3carrier_stopped
```sql
-- 检查状态为 carrier_stopped 的卡能被正确识别
SELECT id, iccid, network_status, stop_reason, stopped_at
FROM tb_iot_card
WHERE stop_reason = 'carrier_stopped';
```
预期:`stop_reason='carrier_stopped'` 的卡不会被轮询系统自动复机(需观察日志)

View File

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

View File

@@ -0,0 +1,186 @@
## Context
`tb_iot_card.iccid` 是 varchar(20) 的全局唯一字段,所有精确查询均基于它。物联网平台上有两类运营商:
- **19 位运营商**如中国电信ICCID 本身为 19 位,是唯一标识符
- **20 位运营商**如移动部分号段ICCID 需要完整 20 位才唯一,第 20 位是真实业务数据而非 Luhn 校验码
上游 IoT 平台返回的 ICCID **格式不固定**:同一张卡,不同时候可能传回 19 位或 20 位。当前代码使用 `WHERE iccid = ?` 精确匹配,格式不一致时静默失败(`iotCardID == 0`,函数返回 nil导致 `is_current` 状态更新无效。这是不可观测的数据错乱。
## Goals / Non-Goals
**Goals:**
- 无论上游传入 19 位还是 20 位,均能正确命中对应卡记录
- 查询性能不降级:每次查询仍走单列精确索引,不引入模糊查询或 OR 条件
- Miss 可观测:上游格式与存储不一致时,记录 Warn 日志暴露数据质量问题
- 覆盖 `tb_personal_customer_iccid` 的同类问题
**Non-Goals:**
- 不修复上游数据质量问题(这是运营侧职责)
- 不实现跨列降级匹配19 位 Miss 不去查 20 位列,防止错误命中)
- 不修改 ICCID 的展示逻辑和 Response DTO
- 不影响模糊查询LIKE和范围查询`>=` / `<=`),这类查询语义上不依赖唯一性
- 不改造个人客户发起的 ICCID 查询路径(`client_auth/service.go``exchange/service.go``asset/service.go` 中的 `OR iccid = ?` 条件):这三处查询的 ICCID 来自个人客户手动输入(扫码/手动填写),输入格式与数据库存储格式一致,不存在上游平台的 19/20 位不一致问题其架构违规Service 层直接写 SQL留待专项重构处理
## Decisions
### 决策 1双列存储iccid_19 / iccid_20而非前缀 LIKE
**选项 A选定**:新增 `iccid_19 varchar(19)``iccid_20 varchar(20)`,分别建 Partial Index。查询时按上游传入长度路由。
**选项 B否决**:保留原列,所有精确查询改用 `iccid LIKE 'prefix%'`
否决原因:
- PostgreSQL 在非 C locale 下 B-tree 索引不支持 LIKE需要额外 `varchar_pattern_ops` 索引
- LIKE 仍是范围扫描,效率低于等值查找
- 不解决 20 位运营商 19 位前缀不唯一的根本问题(可能错误命中另一张卡)
**选项 C否决**:全量标准化为 19 位存储。
否决原因:部分运营商的 19 位前缀不唯一,截断 20 位 ICCID 会造成数据冲突,存量迁移存在数据丢失风险。
---
### 决策 2按长度路由禁止跨列降级
```
len(upstream_iccid) == 19 → WHERE iccid_19 = ?
len(upstream_iccid) == 20 → WHERE iccid_20 = ?
其他长度 → 拒绝,记录 Error 日志
Miss 时 → 记录 Warn 日志,不更新数据,不降级查另一列
```
**禁止降级的原因**:若 20 位上游 miss 后降级查 iccid_19前 19 位),可能命中另一个运营商的 19 位卡,造成 `is_current` 被更新到错误卡上——**错误命中比 Miss 危险得多,且不可观测**。
---
### 决策 3Partial Index 而非全量索引
```sql
-- iccid_19所有卡都有值过滤软删除
CREATE INDEX idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
-- iccid_20仅 20 位卡有值,额外过滤 NULL
CREATE INDEX idx_iot_card_iccid_20 ON tb_iot_card (iccid_20)
WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
```
Partial Index 体积更小(排除软删除记录和 NULL 记录),查询效率更高。
---
### 决策 3.5`iccid_20` 模型字段必须使用指针类型 `*string`
Go 中 `string` 类型的零值是 `""`空字符串。GORM 不会将空字符串自动映射为 SQL `NULL`,写入结果是 `''`,导致:
1. Partial Index `WHERE iccid_20 IS NOT NULL` 会把所有 19 位卡行纳入索引,体积膨胀,优化失效
2. 任何 `WHERE iccid_20 = ?` 查询不受影响(`'' != 任何合法 ICCID`),但 miss 率虚高,触发大量误 Warn 告警
**正确声明**
```go
ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`
```
`SplitICCID` 工具函数签名须对应:
```go
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
// 19 位:返回 (iccid, nil)
// 20 位:返回 (iccid[:19], &iccid)
// 其他:返回 ("", nil)
```
---
### 决策 4写入路径在 Task 层赋值,不修改 Store 层
IotCard 记录只有一个创建入口(`iot_card_import.go``processBatch()`)。在 IotCard 初始化时计算并赋值 `ICCID19` / `ICCID20`Store 层的 `Create`/`CreateBatch` 无需感知新字段GORM 自动处理)。
---
### 决策 5.5`GetBoundICCIDs` 混合长度路由实现方案
当调用方传入 iccids 混合 19 位和 20 位时,禁止用 OR 条件(`c.iccid_19 IN ? OR c.iccid_20 IN ?`),因为 OR 会导致全表扫描,无法走 Partial Index。
**采用两次独立查询 + 应用层合并**
```go
func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
var list19, list20 []string
for _, id := range iccids {
if len(id) == 19 { list19 = append(list19, id) }
if len(id) == 20 { list20 = append(list20, id) }
}
result := make(map[string]bool)
// 19 位组查 iccid_19SELECT c.iccid_19 AS iccid
// 20 位组查 iccid_20SELECT c.iccid_20 AS iccid
// 合并两组结果到 result
}
```
同样规则适用于 `IotCardStore.GetByICCIDs`(分组后分别 Find合并 `[]*model.IotCard` 并去重)。
---
### 决策 6enterprise_card/service.go 的内联 SQL 移交 Store 层
该文件有两处(第 46 行、第 185 行)`WHERE iccid IN ?` 内联 SQL 直接绕过了 Store 层,且同时绕过了数据权限过滤(`middleware.ApplyShopFilter`)。此次改造中将其重构为调用 `IotCardStore.GetByICCIDs()`,消除架构违规并恢复数据权限过滤。
**依赖注入变更**`enterprise_card.Service` struct 需新增 `iotCardStore *postgres.IotCardStore` 字段,`New()` 签名增加该参数,`internal/bootstrap/services.go` 调用处同步传入 `s.IotCard`
## Risks / Trade-offs
**[风险] 存量数据中可能存在 19/20 位冲突记录** → 迁移前先执行冲突检测 SQL若有冲突人工处理后再执行回填。
**[风险] 迁移期间(已加列、未更新代码)查询仍走旧 iccid 列** → 迁移分两阶段:先上线代码(新代码查新列),再回填存量数据;或先回填数据再上线代码。推荐先回填数据再发布代码,避免窗口期数据不一致。
**[Trade-off] 两次查询 vs 一次查询**正常路径仍是一次精确索引查询。Miss 场景不发起第二次查询,直接记录日志,无性能影响。
**[Trade-off] 存储空间**:每张卡多存约 39 字节19 + 20百万卡约 39MB可接受。
## Migration Plan
**第一步(迁移文件)**
> **注意**`CREATE INDEX CONCURRENTLY` 不能在事务块内执行,而 golang-migrate 默认每个迁移文件在事务中运行。迁移文件中使用 `CREATE INDEX IF NOT EXISTS`(非 CONCURRENTLY生产环境若需零停机建索引可在发布窗口期**手动**执行 CONCURRENTLY 版本后再合并代码。(与本项目 `000058_add_covering_index_for_deep_pagination.up.sql` 范式一致。)
```sql
-- 1. 加列(允许 NULL不阻塞写入
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_19 varchar(19);
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_20 varchar(20);
-- 2. 冲突检测(执行后人工确认均为 0 行,否则停止迁移人工处理)
-- 2a. 检测 19 位前缀重复19 位运营商卡的 iccid_19 必须唯一)
SELECT LEFT(iccid, 19), COUNT(*)
FROM tb_iot_card WHERE deleted_at IS NULL
GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1;
-- 2b. 检测异常长度 ICCID长度既非 19 也非 20 的记录,回填后将永远无法通过新查询路径命中)
SELECT id, iccid, LENGTH(iccid) AS len
FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20);
-- 3. 回填存量数据
UPDATE tb_iot_card SET
iccid_19 = LEFT(iccid, 19),
iccid_20 = CASE WHEN LENGTH(iccid) = 20 THEN iccid ELSE NULL END;
-- 4. 建索引(迁移文件中不用 CONCURRENTLY避免事务冲突
-- 生产零停机版本(手动执行):
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_19
-- ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_20
-- ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19
ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20
ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
```
`tb_personal_customer_iccid` 同理,仅加 `iccid_19` 一列该表只需要按19位匹配
**第二步(代码发布)**:更新 Model、Store 查询方法、Task 写入逻辑。
**回滚方案**:新列加 NULL 约束可随时 DROP代码回滚至旧版本恢复 `WHERE iccid = ?` 查询即可。旧 iccid 列全程保留,不做变更。
## Open Questions
(无)

View File

@@ -0,0 +1,39 @@
## Why
上游 IoT 平台返回的 ICCID 格式不固定(部分运营商 19 位为唯一标识,部分运营商需要完整 20 位才能唯一标识一张卡),而当前 `tb_iot_card.iccid` 仅支持精确匹配查询。当上游传入的位数与数据库存储的位数不一致时,查询静默失败(返回空结果但不报错),导致设备当前使用卡的 `is_current` 状态更新失效,产生不可观测的数据错乱。
## What Changes
-`tb_iot_card` 表新增 `iccid_19`varchar(19))和 `iccid_20`varchar(20))两列,分别存储 ICCID 的前 19 位和完整 20 位19 位卡的 `iccid_20` 为 NULL
-`tb_personal_customer_iccid` 表新增 `iccid_19` 列,采用相同策略
- 所有 ICCID 精确查询改为**按上游传入长度路由**19 位查 `iccid_19`20 位查 `iccid_20`Miss 时记录告警而非降级
- 不降级、不跨列匹配——上游数据质量问题在日志层暴露,不在查询层消化
## Capabilities
### New Capabilities
(无新增业务能力)
### Modified Capabilities
- `iot-card``tb_iot_card` 存储结构变更新增两列及对应索引IotCardStore 的所有精确查询方法GetByICCID、GetByICCIDs、ExistsByICCID、ExistsByICCIDBatch改用新列路由
- `iot-card-import-task`:导入任务在写入 IotCard 时同步填充 `iccid_19` / `iccid_20` 两列
## Impact
**数据库**
- `tb_iot_card`:新增 `iccid_19``iccid_20` 两列 + Partial Index过滤软删除
- `tb_personal_customer_iccid`:新增 `iccid_19` 列 + 索引
- 存量数据一次性回填脚本(迁移文件中执行)
**Store 层**(共 3 个文件,~10 个方法)
- `iot_card_store.go`GetByICCID、GetByICCIDs、ExistsByICCID、ExistsByICCIDBatch
- `personal_customer_iccid_store.go`GetByICCID、GetByCustomerAndICCID
- `device_sim_binding_store.go`UpdateIsCurrentByDeviceID内部子查询、GetBoundICCIDs
**Service 层**1 处内联 SQL
- `enterprise_card/service.go``WHERE iccid IN ?` 直接 SQL 改为调用 Store 方法或适配新列
**Task 层**1 处写入路径)
- `iot_card_import.go`processBatch() 中 IotCard 初始化时赋值新字段

View File

@@ -0,0 +1,73 @@
## MODIFIED Requirements
### Requirement: 导入任务实体定义
系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。
**实体字段**:
**任务信息**:
- `id`: 任务 ID主键BIGINT
- `task_no`: 任务编号VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX
- `status`: 任务状态INT1-待处理 2-处理中 3-已完成 4-失败)
**导入参数**:
- `carrier_id`: 运营商 IDBIGINT必填
- `carrier_type`: 运营商类型VARCHAR(10)CMCC/CUCC/CTCC/CBN
- `batch_no`: 批次号VARCHAR(100),可选)
- `file_name`: 原始文件名VARCHAR(255),可选)
**待导入数据**:
- `card_list`: 待导入卡列表JSONB结构: [{iccid, msisdn}],替代原 iccid_list
**进度统计**:
- `total_count`: 总数INTCSV 文件总行数)
- `success_count`: 成功数INT成功导入的卡数量
- `skip_count`: 跳过数INT因重复等原因跳过的数量
- `fail_count`: 失败数INT因格式错误等原因失败的数量
**结果详情**:
- `skipped_items`: 跳过记录详情JSONB结构: [{line, iccid, msisdn, reason}]
- `failed_items`: 失败记录详情JSONB结构: [{line, iccid, msisdn, reason}]
**时间和错误**:
- `started_at`: 开始处理时间TIMESTAMP可空
- `completed_at`: 完成时间TIMESTAMP可空
- `error_message`: 任务级错误信息TEXT可空如文件解析失败等
**系统字段**:
- `shop_id`: 店铺 IDBIGINT可空记录发起导入的店铺
- `created_at`: 创建时间TIMESTAMP自动填充
- `updated_at`: 更新时间TIMESTAMP自动填充
- `creator`: 创建人 IDBIGINT
- `updater`: 更新人 IDBIGINT
#### Scenario: 创建导入任务
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
- **WHEN** 系统解析 CSV 并创建导入任务
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1待处理
---
### Requirement: IotCard 写入时同步填充 ICCID 双列
`processBatch()` 在初始化 `model.IotCard`SHALL 调用 `utils.SplitICCID` 赋值 `ICCID19``ICCID20` 两个字段。
- `ICCID19 string` = `iccid19``SplitICCID` 返回值第一个,所有合法卡必填)
- `ICCID20 *string` = `iccid20``SplitICCID` 返回值第二个,**指针类型**19 位卡返回 `nil`GORM 写入 SQL NULL禁止赋空字符串 `""`
-`SplitICCID` 返回 `iccid19 == ""`(异常长度 ICCID不写入数据库标记为 fail此情况在 `ValidateICCID` 长度校验后理论上不应出现,作防御性保留)
`SplitICCID` 函数签名:
```go
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
// 19 位:返回 (iccid, nil)
// 20 位:返回 (iccid[:19], &iccid)
// 其他:返回 ("", nil)
```
#### Scenario: 导入 19 位卡时填充双列
- **WHEN** 批量导入时某张卡的 ICCID 长度为 19 位
- **THEN** 写入数据库时 `iccid_19` = ICCID 原值,`iccid_20`**SQL NULL**(通过 `*string` 赋值 `nil` 实现)
#### Scenario: 导入 20 位卡时填充双列
- **WHEN** 批量导入时某张卡的 ICCID 长度为 20 位
- **THEN** 写入数据库时 `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值(通过 `*string` 赋非 nil 指针实现)

View File

@@ -0,0 +1,121 @@
## ADDED Requirements
### Requirement: ICCID 双列存储
`tb_iot_card` SHALL 新增 `iccid_19 varchar(19)``iccid_20 varchar(20)` 两列。
- `iccid_19`:存储 ICCID 前 19 位(所有卡均有值,对应 19 位运营商卡即为完整 ICCID
- `iccid_20`:存储完整 20 位 ICCID仅 20 位运营商卡有值19 位运营商卡为 **SQL NULL**
- 两列均建立 Partial Index过滤 `deleted_at IS NULL``iccid_20` 额外过滤 NULL
**Go Model 类型约束**
- `ICCID19` 字段类型为 `string`(非指针,所有卡必填)
- `ICCID20` 字段类型为 **`*string`**指针19 位卡赋值 `nil` → GORM 写入 NULL禁止赋空字符串 `""`GORM 不会将其转为 NULL会破坏 Partial Index 语义)
#### Scenario: 导入 19 位运营商卡
- **WHEN** 导入 ICCID 长度为 19 位的卡
- **THEN** `iccid_19` = ICCID 原值,`iccid_20` = NULL
#### Scenario: 导入 20 位运营商卡
- **WHEN** 导入 ICCID 长度为 20 位的卡
- **THEN** `iccid_19` = ICCID 前 19 位,`iccid_20` = ICCID 原值
---
### Requirement: ICCID 按长度路由查询
系统 SHALL 根据上游传入 ICCID 的字符串长度,路由到对应列进行精确查询。
- 上游传入 19 位 → 查询 `WHERE iccid_19 = ?`
- 上游传入 20 位 → 查询 `WHERE iccid_20 = ?`
- 上游传入其他长度 → 记录 Error 日志,返回未找到,不执行查询
- 禁止跨列降级:任意列 Miss 时直接记录 Warn 日志,不再查另一列
#### Scenario: 上游传入 19 位命中 19 位卡
- **WHEN** 上游传入 19 位 ICCID数据库存在对应 19 位卡
- **THEN** 系统查询 `iccid_19` 列,精确命中,返回卡记录
#### Scenario: 上游传入 20 位命中 20 位卡
- **WHEN** 上游传入 20 位 ICCID数据库存在对应 20 位卡
- **THEN** 系统查询 `iccid_20` 列,精确命中,返回卡记录
#### Scenario: 上游传入 19 位但数据库无对应记录
- **WHEN** 上游传入 19 位 ICCID数据库中不存在匹配的 `iccid_19`
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID返回未找到不修改任何数据
#### Scenario: 上游传入 20 位但数据库无对应记录
- **WHEN** 上游传入 20 位 ICCID数据库中不存在匹配的 `iccid_20`
- **THEN** 系统记录 Warn 日志(含 ICCID 和 deviceID返回未找到不修改任何数据
#### Scenario: 上游传入异常长度 ICCID
- **WHEN** 上游传入长度既非 19 也非 20 的 ICCID
- **THEN** 系统记录 Error 日志,直接返回未找到,不执行数据库查询
---
### Requirement: IotCard 精确查询方法适配
`IotCardStore` 的精确查询方法 SHALL 全部适配双列查询路由策略。
受影响方法:`GetByICCID``GetByICCIDs``ExistsByICCID``ExistsByICCIDBatch`
#### Scenario: GetByICCID 单卡精确查询
- **WHEN** 调用 `GetByICCID(ctx, iccid)` 且 ICCID 长度为 19 或 20 位
- **THEN** 系统 SHALL 根据长度路由到 `iccid_19``iccid_20` 列查询,走 Partial Index
#### Scenario: GetByICCIDs 批量精确查询
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中所有 ICCID 长度一致(均为 19 或均为 20
- **THEN** 系统 SHALL 路由到对应列使用 `IN ?` 查询
#### Scenario: GetByICCIDs 混合长度输入
- **WHEN** 调用 `GetByICCIDs(ctx, iccids)` 且 iccids 中同时包含 19 位和 20 位 ICCID
- **THEN** 系统 SHALL 按长度分组,分别查询 `iccid_19 IN ?``iccid_20 IN ?`,合并结果返回
---
### Requirement: DeviceSimBinding 按 ICCID 查询适配
`DeviceSimBindingStore` 中涉及 ICCID 查询的方法 SHALL 适配双列路由策略。
#### Scenario: UpdateIsCurrentByDeviceID 子查询适配
- **WHEN** 调用 `UpdateIsCurrentByDeviceID(ctx, deviceID, currentIccid)` 且 currentIccid 非空
- **THEN** 子查询 SHALL 根据 currentIccid 长度路由到 `iccid_19``iccid_20` 列查询 iot_card_id
- **AND** Miss 时记录 Warn 日志,不更新 is_current不降级
#### Scenario: GetBoundICCIDs JOIN 查询适配
- **WHEN** 调用 `GetBoundICCIDs(ctx, iccids)` 查询已绑定设备的 ICCID 列表
- **THEN** JOIN 条件 SHALL 按传入 iccids 长度路由到对应列
---
### Requirement: PersonalCustomerICCIDStore 查询适配
`PersonalCustomerICCIDStore` 的精确查询方法 SHALL 适配双列查询路由策略。
`tb_personal_customer_iccid` 表 SHALL 新增 `iccid_19 varchar(19)` 列及对应索引。
受影响方法:`GetByICCID``GetByCustomerAndICCID``ExistsByCustomerAndICCID``CreateOrUpdateLastUsed`
#### Scenario: 个人客户 ICCID 记录写入时同步回填
- **WHEN** 系统创建或更新 PersonalCustomerICCID 记录
- **THEN** `iccid_19` SHALL 同步赋值为 ICCID 前 19 位
#### Scenario: 个人客户 ICCID 查询按长度路由
- **WHEN** 调用 `GetByICCID(ctx, iccid)``GetByCustomerAndICCID(ctx, customerID, iccid)`
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19`19 位)或原 `iccid`20 位)查询
#### Scenario: 个人客户 ICCID 绑定存在性校验按长度路由
- **WHEN** 调用 `ExistsByCustomerAndICCID(ctx, customerID, iccid)` 检查客户是否已绑定该 ICCID
- **THEN** 系统 SHALL 根据 iccid 长度路由到 `iccid_19`19 位)或原 `iccid`20 位)查询,与 `GetByCustomerAndICCID` 保持一致的路由策略
- **AND** 若路由结果 Miss返回 `false`(未绑定),不降级查另一列
> **设计说明**`tb_personal_customer_iccid` 仅新增 `iccid_19` 列而不添加 `iccid_20` 列。原因:该表的 ICCID 来自**个人客户手动输入**(扫码/手动填写),而非上游 IoT 平台回调。上游平台才是 19/20 位格式不一致问题的来源;个人客户输入的格式与数据库存储格式一致,因此 20 位查询走原 `iccid` 列即可,无需双列适配。
---
### Requirement: 内联 ICCID SQL 归还 Store 层
`enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL SHALL 重构为调用 `IotCardStore.GetByICCIDs()`,消除架构违规并恢复数据权限过滤。
该重构需要同步变更:
1. `enterprise_card.Service` struct 新增 `iotCardStore *postgres.IotCardStore` 字段
2. `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
3. `internal/bootstrap/services.go` 调用处传入 `s.IotCard`
#### Scenario: 企业卡分配预览调用 Store 层查询
- **WHEN** 执行企业卡分配预览或分配操作,需要按 ICCIDs 查询卡列表
- **THEN** 系统 SHALL 通过 `IotCardStore.GetByICCIDs()` 查询,不允许 Service 层直接拼写 ICCID 过滤 SQL

View File

@@ -0,0 +1,82 @@
## 1. 数据库迁移
> **注意**1.11.5 全部写在**同一个迁移文件**中,保持原子性;执行迁移后再发布代码。
>
> **索引注意**`CREATE INDEX CONCURRENTLY` 不能在事务块内执行golang-migrate 默认开启事务,迁移文件中必须使用 `CREATE INDEX IF NOT EXISTS`(不带 CONCURRENTLY。生产环境若需零停机建索引可在发布窗口期手动执行 CONCURRENTLY 版本。(与本项目 `000058_add_covering_index_for_deep_pagination.up.sql` 范式一致。)
- [x] 1.1 新建迁移文件,给 `tb_iot_card` 添加 `iccid_19 varchar(19)``iccid_20 varchar(20)` 两列(允许 NULL使用 `ADD COLUMN IF NOT EXISTS`
- [x] 1.1a 迁移文件中执行冲突检测(结果须为 0 行,否则停止迁移人工处理):
- 检测 19 位前缀重复:`SELECT LEFT(iccid, 19), COUNT(*) FROM tb_iot_card WHERE deleted_at IS NULL GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1`
- 检测异常长度 ICCID`SELECT id, iccid, LENGTH(iccid) AS len FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20)`(异常记录回填后将永远无法通过新查询路径命中)
- [x] 1.2 迁移文件中回填存量数据:`iccid_19 = LEFT(iccid, 19)``iccid_20 = iccid`(仅当 LENGTH(iccid)=20否则 NULL
- [x] 1.3 迁移文件中为 `iccid_19` 创建 Partial Index`CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL`**禁止** CONCURRENTLY原因见上方注意事项
- [x] 1.4 迁移文件中为 `iccid_20` 创建 Partial Index`CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20 ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL`**禁止** CONCURRENTLY
- [x] 1.5 同一迁移文件中,给 `tb_personal_customer_iccid` 添加 `iccid_19 varchar(19)` 列(允许 NULL并回填数据`iccid_19 = LEFT(iccid, 19)`),创建对应 Partial Index`CREATE INDEX IF NOT EXISTS idx_personal_customer_iccid_19 ON tb_personal_customer_iccid (iccid_19) WHERE deleted_at IS NULL`
- [x] 1.6 执行迁移,验证所有卡记录的 `iccid_19``iccid_20` 已正确回填(使用 PostgreSQL MCP 查询验证)
## 2. Model 层更新
- [x] 2.1 在 `internal/model/iot_card.go``IotCard` 结构体中新增以下两个字段,补充 gorm 标签和中文注释:
- `ICCID19 string`非指针所有卡必填gorm tag 示例:`gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位"`
- `ICCID20 *string`**指针类型**19 位卡为 `nil`GORM 写入 NULL20 位卡为非 nil 指针gorm tag 示例:`gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`
- 禁止使用 `string` 类型存 `ICCID20`GORM 不会将空字符串自动转为 NULL会破坏 `WHERE iccid_20 IS NOT NULL` 索引语义
- [x] 2.2 在 `internal/model/personal_customer_iccid.go``PersonalCustomerICCID` 结构体中新增 `ICCID19` 字段,补充 gorm 标签和中文注释
- [x] 2.3 运行 `lsp_diagnostics` 确认 model 文件无编译错误
## 3. 工具函数与校验
- [x] 3.1 在 `pkg/utils/iccid.go` 中实现 `SplitICCID` 函数,签名和语义如下:
```go
// SplitICCID 按 ICCID 长度拆分为双列存储值
// 19 位卡:返回 (iccid, nil)
// 20 位卡:返回 (iccid[:19], &iccid)
// 其他长度:返回 ("", nil),调用方应将对应卡标记为失败
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
```
**禁止**返回空字符串 `""` 作为 `iccid20`ICCID20 字段类型为 `*string`19 位卡必须返回 `nil`GORM 才会写入 NULL。
- [x] 3.2 更新 `pkg/validator/iccid.go` 的 `ValidateICCID` 函数增加长度校验ICCID 长度必须为 19 或 20 位,否则返回 `ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"}`;防止非法长度 ICCID 通过导入写入数据库后永远 miss
## 4. IotCard 写入路径适配
- [x] 4.1 在 `internal/task/iot_card_import.go` 的 `processBatch()` 中IotCard 初始化时调用 `utils.SplitICCID` 赋值 `ICCID19` 和 `ICCID20` 字段(此步骤在 task 3.2 的长度校验之后,理论上不会出现异常长度,但防御性保留:若 `iccid19 == ""`,标记 fail 并跳过,不写入数据库)
- [x] 4.2 使用 PostgreSQL MCP 验证导入后新记录的 `iccid_19` 和 `iccid_20` 字段已正确写入
## 5. IotCardStore 精确查询方法适配
- [x] 5.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询;异常长度记录 Error 日志Miss 记录 Warn 日志
- [x] 5.2 改造 `GetByICCIDs(ctx, iccids)`:按长度分组,分别查询 `iccid_19 IN ?` 和 `iccid_20 IN ?`,合并去重结果
- [x] 5.3 改造 `ExistsByICCID(ctx, iccid)`:按长度路由到对应列
- [x] 5.4 改造 `ExistsByICCIDBatch(ctx, iccids)`按长度分组查询19 位组 `Pluck("iccid_19", &list19)`20 位组 `Pluck("iccid_20", &list20)`),合并结果;因各分组内 Pluck 返回值恰好等于原始 ICCID19 位组:`iccid_19 == 原始 ICCID`20 位组:`iccid_20 == 原始 ICCID`),直接以 Pluck 值为 map key调用方 `existingMap[card.ICCID]` 逻辑无需修改
- [x] 5.5 运行 `lsp_diagnostics` 确认 `iot_card_store.go` 无编译错误
## 6. DeviceSimBindingStore 查询方法适配
- [x] 6.1 改造 `UpdateIsCurrentByDeviceID` 内部子查询:按 currentIccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询 iot_card_idMiss 时记录 Warn 日志(日志字段:`iccid`、`device_id`、`column_used`,该方法持有 `deviceID` 参数可直接记录),不更新数据,不降级
- [x] 6.2 改造 `GetBoundICCIDs`:按传入 iccids 长度**分组**,分别执行两次 JOIN 查询后在应用层合并:
- 19 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_19 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`SELECT `c.iccid_19 AS iccid`
- 20 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_20 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`SELECT `c.iccid_20 AS iccid`
- 两组结果合并为 `map[string]bool`key 使用各组 SELECT 的 iccid 值(即原始 ICCID
- [x] 6.3 运行 `lsp_diagnostics` 确认 `device_sim_binding_store.go` 无编译错误
## 7. PersonalCustomerICCIDStore 查询方法适配
- [x] 7.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由19 位查 `iccid_19`20 位查原 `iccid` 列
- [x] 7.2 改造 `GetByCustomerAndICCID(ctx, customerID, iccid)`:同上,按长度路由
- [x] 7.3 改造 `CreateOrUpdateLastUsed(ctx, customerID, iccid)`:写入时同步赋值 `ICCID19` 字段
- [x] 7.3.5 改造 `ExistsByCustomerAndICCID(ctx, customerID, iccid)`:按 iccid 长度路由19 位查 `iccid_19`20 位查原 `iccid` 列;路由策略与 `GetByCustomerAndICCID` 保持一致Miss 时直接返回 `false`,不降级
- [x] 7.4 运行 `lsp_diagnostics` 确认 `personal_customer_iccid_store.go` 无编译错误
## 8. 架构违规修复
- [x] 8.1 在 `internal/service/enterprise_card/service.go` 的 `Service` struct 中新增 `iotCardStore *postgres.IotCardStore` 字段,并更新 `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
- [x] 8.2 更新 `internal/bootstrap/services.go` 第 191 行 `enterpriseCardSvc.New()` 调用,传入 `s.IotCard`IotCardStore 实例)
- [x] 8.3 将 `enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL第 46 行、第 185 行)重构为调用 `s.iotCardStore.GetByICCIDs()`
- [x] 8.4 运行 `lsp_diagnostics` 确认 `enterprise_card/service.go` 和 `bootstrap/services.go` 无编译错误
## 9. 整体验证
- [x] 9.1 执行 `go build ./...` 确认全量编译通过
- [x] 9.2 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 19 位 ICCID确认正确命中 19 位卡的 `is_current` 更新
- [x] 9.3 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 20 位 ICCID确认正确命中 20 位卡的 `is_current` 更新
- [x] 9.4 确认日志中 Miss 场景正确输出 Warn 日志(可通过修改测试数据触发)

View File

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

View File

@@ -0,0 +1,89 @@
## Context
当前套餐价格在多个链路里被直接读取后台配置、C 端展示、订单创建、自动购包和代理授权都在用同一批字段,但它们需要的其实不是同一种值。与此同时,赠送套餐已经被业务明确为独立语义,既不是普通可售套餐的别名,也不能靠 `package_type` 或数值 0 去推断。这个变更会同时影响 Handler、Service、Store、Model 和迁移脚本,必须先把语义拆开,再改各条链路。
## Goals / Non-Goals
**Goals:**
- 建立普通可售套餐的原始价与生效价分层。
- 在建议零售价未配置时,统一回退到成本价。
- 普通可售套餐不能显式配置 0 作为售卖价0 只保留给赠送语义。
- 把赠送套餐做成独立语义,平台专属,禁止代理分配和 C 端自购。
- 保留原始配置值,避免把回退后的值写回配置页。
- 完成历史数据与存量记录的迁移回填,并输出人工复核清单。
**Non-Goals:**
- 不重做套餐类型体系,也不把 gift 语义塞进 `package_type`
- 不新增外部依赖,不改用新的队列或存储组件。
- 不在本次变更里重构整个套餐管理域,只处理价格与赠送政策相关路径。
## Decisions
### 1. 价格状态与价格数值分开存
采用独立的价格配置状态来区分“未配置”“赠送 0”“已配置为非 0”原始建议零售价继续保留生效售价由服务层计算。
**原因**:普通可售套餐不能显式卖 0但赠送套餐仍可能使用 0不能再用数值 0 当哨兵值。
**备选方案**
- 继续用 0 代表未配置,最简单,但会把合法 0 价和未配置混在一起,不能接受。
- 直接覆盖原始字段为生效价,查询简单,但配置页会丢失真实原值。
### 2. 赠送语义独立于价格和套餐类型
新增独立的赠送语义标记,平台专属限制和价格规则彼此独立。
**原因**:平台-only、不可分配给代理、不可自购这些都是业务权限问题不是价格问题。
**备选方案**
- 仅靠 `package_type=formal/addon` 推断,已经被明确否决。
- 仅靠价格为 0 推断,无法区分合法 0 价与未配置。
### 3. 生效价格在 Service 层统一计算
Handler 只接收和返回值不做价格推导。Store 只取原始数据Service 统一计算生效价并下发给下游。
**原因**:价格规则会同时影响 C 端列表、下单、自动购包和授权链路,放在 Service 层更容易复用,也更符合分层职责。
**备选方案**
- 在 Handler 里临时计算,容易散落和重复。
- 在 Store 里拼接业务规则,会把数据访问和业务逻辑混在一起。
### 4. 迁移采用“先加字段,再回填,再切流”的顺序
先把新语义字段和状态字段上线,再用迁移脚本回填历史数据,最后让读链路切到生效价。
**原因**:这样可以避免一次性改动过大导致历史数据无法解释,也方便回滚。
**备选方案**
- 直接替换旧字段,风险太高,历史记录会失真。
- 先切读后补数据,会出现短时间内的口径不一致。
### 5. 不引入价格缓存
价格计算只依赖单条套餐和分配记录,属于 O(1) 读取,不新增 Redis 缓存层。
**原因**:这次变更的核心是语义和一致性,不是性能瓶颈。现有分页与查询边界已经足够。
**备选方案**
- 引入缓存可以做,但会增加失效和回填复杂度,不适合作为首版方案。
## Risks / Trade-offs
- [历史数据无法准确判断是否为赠送 0 价] → 迁移脚本优先读取历史发放记录和配置快照,只有能明确识别为赠送 0 的记录才保留为赠送 0其余旧 0 一律转为未配置并输出待人工核验清单。
- [赠送语义改动会影响多条购买链路] → 先在 Service 层统一封装判定函数,再逐步替换各入口的直接读取。
- [价格展示口径切换后前端可能误解返回值] → 接口文档明确区分原始值、生效值和赠送语义,前后端一起切换。
- [存量分配记录字段回填出错] → 迁移前后保留回滚脚本,按批次执行并校验记录数。
## Migration Plan
1. 增加价格状态字段、赠送语义字段和平台专属标记,保持原始价格字段不变。
2. 回填存量套餐的价格状态和生效价,优先使用历史配置与发放记录判断。只有能明确识别为赠送 0 的记录才保留为赠送 0其余旧 0 一律转为未配置,并输出人工复核清单。
3. 回填分配记录、历史订单和自动购包相关快照,让展示链路能读取生效价。
4. 切换 Service 层统一价格解析与赠送校验,再切换 Handler 输出。
5. 验证完成后,清理旧的直接读取路径,保留原始值用于配置页和审计。
## Implementation Notes
- 人工复核清单只负责承接无法正向识别的旧 0 数据,不回写为赠送语义。
- 赠送套餐历史识别优先以后台发放记录为准,补录清单只作为迁移输入,不改变判定标准。

View File

@@ -0,0 +1,34 @@
## Why
当前套餐价格在后台采购、C 端展示、自动购包和分配记录里口径不一致,`suggested_retail_price` 被直接当成展示价和成交价使用,空配置、旧 0 价和赠送语义也混在一起。现在业务已经明确两件事,普通可售套餐不能显式卖 0赠送套餐则是独立语义必须平台专属、仅后端发放不能再继续靠价格字段临时凑语义。
## What Changes
- feature-001-package-effective-price-fallback普通可售套餐在建议零售价未配置时生效售价回退到成本价覆盖后台采购、C 端展示与下单、自动购包及相关链路。
- feature-002-package-raw-vs-effective-price-view配置页和编辑页继续展示原始值销售页和购买页切换为展示生效值避免把“配置值”和“成交值”混为一谈。
- feature-003-gift-package-independent-semantic新增赠送套餐独立语义不再依赖套餐类型去硬解释赠送行为且必须与价格语义分离。
- feature-004-platform-only-gift-package-policy赠送套餐仅允许平台侧创建和发放不能分配给代理也不能出现在 C 端可购买套餐列表中。
- feature-005-backend-grant-not-self-purchase赠送加油包只能通过后台发放进入用户资产不允许用户自购或前台下单获得。
- feature-006-history-and-migration补齐历史数据与迁移规则保证存量数据只保留可明确识别的“赠送 0 价”,其余旧 0 一律转为未配置并进入人工复核清单。
- **BREAKING** 价格与赠送语义不再共享同一含义,任何依赖旧口径的列表、下单、分配和展示链路都需要按新规则调整。
## Capabilities
### New Capabilities
- `package-price-policy`:定义普通可售套餐的原始价、生效价、回退规则、展示口径和历史迁移规则。
- `gift-package-policy`:定义赠送套餐的独立语义、平台专属限制、后端发放规则和 C 端可见性规则。
### Modified Capabilities
- `package-management`:套餐配置、编辑和详情页要按原始价与生效价分层展示。
- `package-purchase-validation`:购买校验要使用生效价,并排除赠送套餐的自购路径。
- `client-asset-info`C 端可购买套餐列表要按生效价展示,并隐藏不可购买的赠送套餐。
- `client-order-purchase`C 端列表、下单和自动购包要使用生效价,并隐藏不可购买的赠送套餐。
- `agent-series-grant`:套餐授权和发放链路要识别赠送套餐的平台专属属性,禁止向代理分配。
## Impact
- 影响模块:`internal/service/purchase_validation/service.go``internal/handler/app/client_asset.go``internal/service/client_order/service.go``internal/service/order/service.go``internal/task/auto_purchase.go``internal/service/shop_package_batch_allocation/service.go``internal/service/shop_series_grant/service.go`
- 影响数据:套餐模型、分配记录、历史回填字段和迁移脚本都需要支持原始值、生效值与赠送语义的并存。
- 影响 API后台套餐管理接口、C 端套餐列表与下单接口、代理授权与平台发放接口都会出现返回值和校验规则变化。
- 影响文档:需要同步更新 OpenSpec、接口文档和字段说明确保“未配置价格”“赠送 0 价”“平台专属不可分配”三种语义不会再混用。
- 验证方式:实现阶段按项目约束使用手工接口验证和 PostgreSQL 数据核验,不引入新的自动化测试要求。

View File

@@ -0,0 +1,17 @@
## ADDED Requirements
### Requirement: 赠送套餐不可纳入代理系列授权
系统 SHALL 将赠送套餐视为平台专属资产,禁止将其加入代理系列授权、套餐分配或任何下级可见的授权列表。赠送套餐的发放 MUST 走后台发放链路,而不是代理授权链路。
#### Scenario: 代理创建授权时包含赠送套餐
- **WHEN** 代理在系列授权中尝试加入一个赠送套餐
- **THEN** 系统返回错误并拒绝创建
#### Scenario: 平台后台发放赠送套餐
- **WHEN** 平台管理员通过后台发放流程授予赠送套餐
- **THEN** 系统允许创建发放记录,但不生成代理可分配权限
#### Scenario: 授权列表隐藏赠送套餐
- **WHEN** 系统查询任意代理系列授权的可选套餐列表
- **THEN** 赠送套餐 MUST 不出现在列表中

View File

@@ -0,0 +1,13 @@
## MODIFIED Requirements
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取分配记录上的生效售价,平台渠道取套餐生效售价。若建议零售价未配置,系统 SHALL 使用成本价作为生效售价。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立,并且 MUST 排除赠送套餐。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐建议零售价未配置
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按生效价格升序输出
#### Scenario: 赠送套餐不出现在可购列表
- **WHEN** 赠送套餐已存在于后台数据中
- **THEN** C 端可购买套餐列表 MUST 不返回该套餐

View File

@@ -0,0 +1,17 @@
## MODIFIED Requirements
### Requirement: 订单与自动购包价格规则
系统 SHALL 在既有订单创建和自动购包流程中统一使用生效售价。普通可售套餐若建议零售价未配置,则生效售价 MUST 回退到成本价;赠送套餐 MUST NOT 通过这些自购或自动购包路径创建。
#### Scenario: 普通可售套餐未配置建议零售价
- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价未配置
- **THEN** 系统 SHALL 使用成本价作为生效售价
#### Scenario: 普通可售套餐已配置建议零售价
- **WHEN** 订单创建或自动购包处理一个普通可售套餐,且其建议零售价已配置
- **THEN** 系统 SHALL 使用已配置的建议零售价作为生效售价
#### Scenario: 赠送套餐进入购买路径
- **WHEN** 任一自购或自动购包路径命中赠送套餐
- **THEN** 系统 MUST 拒绝创建订单

View File

@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: 赠送套餐语义必须显式标记,且 0 价仅用于赠送语义
系统 MUST 将赠送套餐视为独立业务语义,不能仅通过 `package_type` 推导赠送含义。若套餐显式配置为 0 价,则该 0 价 MUST 仅用于赠送语义。
#### Scenario: 赠送语义不能仅靠 package_type 推导
- **WHEN** 套餐类型为 formal 或 addon但未显式标记为赠送
- **THEN** 系统 MUST NOT 仅凭套餐类型判断其是否为赠送套餐
#### Scenario: 赠送套餐显式使用 0 价
- **WHEN** 平台创建赠送套餐并将建议零售价显式配置为 0
- **THEN** 系统 MUST 保留该 0 价配置并将其识别为赠送语义
---
### Requirement: 赠送套餐仅限平台侧且不可分配给代理
系统 SHALL 将赠送套餐限定为平台侧能力。赠送套餐 MUST NOT 被分配给代理,也 MUST NOT 出现在任何代理可操作的套餐授权列表中。
#### Scenario: 平台可创建赠送套餐
- **WHEN** 平台管理员创建赠送套餐
- **THEN** 系统允许保存该赠送语义
#### Scenario: 代理不能分配赠送套餐
- **WHEN** 代理尝试将赠送套餐加入自己的授权或分配给下级
- **THEN** 系统返回错误并拒绝操作
#### Scenario: 赠送套餐不进入代理可售列表
- **WHEN** 系统生成代理可见的套餐授权列表
- **THEN** 赠送套餐 MUST 被过滤掉
---
### Requirement: 赠送套餐仅能后台发放,不能前台自购
系统 SHALL 仅允许通过后台发放流程创建赠送套餐资产。C 端与前台购买路径 MUST NOT 直接购买赠送套餐,且赠送套餐 MUST NOT 出现在 C 端可购买套餐列表中。
#### Scenario: 后台发放赠送加油包
- **WHEN** 平台通过后台发放赠送加油包
- **THEN** 系统创建发放记录并授予目标资产
#### Scenario: C 端不能自购赠送套餐
- **WHEN** 个人客户在 C 端尝试购买赠送套餐
- **THEN** 系统返回不可购买错误
#### Scenario: 赠送套餐不出现在 C 端可购列表
- **WHEN** C 端请求可购买套餐列表
- **THEN** 系统 MUST 不返回任何赠送套餐

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: 套餐配置页展示原始价格与价格状态
系统 SHALL 在套餐创建、编辑、详情接口中返回原始建议成本价、原始建议零售价和价格配置状态。配置页 MUST 以原始值为准展示,不得用生效价覆盖原始值。
普通可售套餐 MUST NOT 把显式配置的建议零售价 0 作为正常销售路径;该 0 价仅应保留给赠送语义使用。
#### Scenario: 编辑页展示未配置价格
- **WHEN** 管理员打开一个建议零售价未配置的套餐编辑页
- **THEN** 页面 SHALL 显示原始建议零售价为空或未配置状态
#### Scenario: 编辑页展示赠送 0 价
- **WHEN** 管理员打开一个已标记为赠送语义且建议零售价显式配置为 0 的套餐编辑页
- **THEN** 页面 SHALL 显示原始建议零售价为 0并同时展示赠送语义与已配置状态
#### Scenario: 普通可售套餐创建时填写 0 被拒绝
- **WHEN** 管理员创建一个普通可售套餐,并将建议零售价填写为 0
- **THEN** 系统 SHALL 拒绝保存
- **THEN** 系统 SHALL 提示普通可售套餐不允许显式 0
#### Scenario: 普通可售套餐编辑时改成 0 被拒绝
- **WHEN** 管理员编辑一个普通可售套餐,并将原建议零售价修改为 0
- **THEN** 系统 SHALL 拒绝保存
- **THEN** 系统 SHALL 保持原有价格状态不变
#### Scenario: 详情页不回填生效价
- **WHEN** 管理员查看套餐详情
- **THEN** 系统 SHALL 返回原始价格字段,而不是把回退后的生效价写回原始字段

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: 价格配置状态与原始值分离
系统 MUST 同时保存套餐价格的原始配置值和价格配置状态,且 MUST 区分以下三种语义:未配置、赠送 0、已配置为非 0。普通可售套餐 MUST NOT 显式配置 0 作为售卖价0 仅可用于赠送语义。配置页与编辑页 SHALL 展示原始值,不能用生效价覆盖原始值。
#### Scenario: 未配置价格与 0 价必须可区分
- **WHEN** 管理员创建两个套餐,一个建议零售价未配置,一个已被标记为赠送且建议零售价显式配置为 0
- **THEN** 系统 MUST 为两者保存不同的价格配置状态
- **THEN** 系统 MUST NOT 仅依赖数值 0 判断价格是否已配置
#### Scenario: 普通可售套餐显式 0 被拒绝
- **WHEN** 管理员创建或编辑一个普通可售套餐,并将建议零售价填写为 0
- **THEN** 系统 MUST 拒绝保存
- **THEN** 系统 MUST 返回普通可售套餐不允许显式 0 的错误
#### Scenario: 配置页展示原始值
- **WHEN** 管理员进入套餐详情或编辑页
- **THEN** 页面 SHALL 展示原始建议成本价和原始建议零售价
---
### Requirement: 普通可售套餐的生效售价回退
系统 SHALL 对普通可售套餐计算生效售价。若建议零售价未配置,生效售价 MUST 回退为成本价。该规则 MUST 适用于后台采购、C 端展示与下单、自动购包以及所有依赖套餐成交价的链路。
#### Scenario: 建议零售价未配置时回退到成本价
- **WHEN** 普通可售套餐的建议零售价未配置,成本价为 8000 分
- **THEN** 系统返回的生效售价 MUST 为 8000 分
#### Scenario: 建议零售价已配置时保持原值
- **WHEN** 普通可售套餐的建议零售价已配置为 12000 分,成本价为 8000 分
- **THEN** 系统返回的生效售价 MUST 为 12000 分
---
### Requirement: 历史数据回填与迁移
系统 MUST 对存量套餐、分配记录和订单相关价格快照执行迁移回填,补齐价格配置状态和生效价字段。迁移后,只有能被正向识别为赠送 0 的记录才保留为赠送 0其余历史 0 数据 MUST 转为未配置并进入人工复核清单。
#### Scenario: 存量未配置价格被回填为未配置状态
- **WHEN** 迁移执行前某套餐历史上从未配置过建议零售价
- **THEN** 迁移后该套餐 MUST 保持未配置状态
- **THEN** 其生效售价 MUST 按成本价回填
#### Scenario: 正向识别的赠送 0 被保留
- **WHEN** 迁移执行前某套餐的建议零售价为 0且历史记录能明确证明其为赠送语义
- **THEN** 迁移后系统 MUST 保留赠送 0 状态
#### Scenario: 模糊旧 0 转为未配置并进入人工复核
- **WHEN** 迁移执行前某套餐的建议零售价为 0但历史记录无法明确证明其为赠送语义
- **THEN** 迁移后系统 MUST 将其转为未配置状态
- **THEN** 系统 MUST 将该记录加入人工复核清单
#### Scenario: 历史快照按生效价展示
- **WHEN** 用户查询历史订单或历史分配记录
- **THEN** 系统 SHALL 使用迁移后的生效价字段进行展示

View File

@@ -0,0 +1,48 @@
## MODIFIED Requirements
### Requirement: 获取购买价格
系统 MUST 根据买家身份返回正确的购买价格,并且 MUST 使用生效售价,不得直接把未配置的建议零售价当成交价返回。个人客户、平台自营和自动购包路径 SHALL 以套餐生效售价为准;代理为店铺购买时 SHALL 以分配记录上的生效售价为准。
#### Scenario: 个人客户购买
- **WHEN** 个人客户购买一个普通可售套餐,且建议零售价未配置
- **THEN** 系统 SHALL 返回回退后的生效售价,金额等于成本价
#### Scenario: 代理为店铺购买
- **WHEN** 代理为自己店铺购买套餐
- **THEN** 系统 SHALL 返回该分配记录对应的生效售价
---
### Requirement: validatePackages() 价格累加与展示校验
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的生效售价进行累加计算,并在代理渠道保持价格展示可见性校验。未配置建议零售价时,累计金额 MUST 使用回退后的生效售价,而不是原始空值。
#### Scenario: 代理渠道累加使用生效售价
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的生效售价
#### Scenario: 平台渠道累加使用生效售价
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的生效售价
#### Scenario: 未配置价格不再触发错误展示
- **WHEN** 某个普通可售套餐的建议零售价未配置
- **THEN** 系统 SHALL 使用回退后的生效售价继续校验
- **THEN** 系统 MUST NOT 因原始建议零售价为空而把该套餐当成异常价格直接拦截
---
### Requirement: 赠送套餐禁止进入自购下单
系统 SHALL 拒绝任何用户通过自购路径购买赠送套餐。赠送加油包只能通过后台发放进入资产,不能通过 C 端或后台自助下单购买。
赠送场景 SHALL 仅允许使用显式配置的建议零售价 0但该 0 价仅表示赠送语义,不能作为普通可售套餐的正常销售路径。
#### Scenario: C 端自购赠送套餐被拦截
- **WHEN** 个人客户尝试购买一个启用赠送语义的套餐
- **THEN** 系统返回不可购买错误,订单不创建
#### Scenario: 后台自助下单赠送套餐被拦截
- **WHEN** 后台操作者尝试通过普通购买接口创建赠送套餐订单
- **THEN** 系统 MUST 拒绝该订单

View File

@@ -0,0 +1,33 @@
## 1. 验证准备
- [x] 1.1 梳理现有价格读取、赠送发放和分配链路整理一份手工验证清单覆盖后台配置、C 端列表、下单、自动购包和授权发放。
- [x] 1.2 记录当前会受影响的接口与数据表,确认回滚点和历史数据来源,避免后续迁移时把未配置价格、赠送 0 价和旧的模糊 0 数据混在一起。
- [x] 1.3 对涉及的现有实现文件先跑一次 `lsp_diagnostics`,保留基线结果,作为改动前对比。
## 2. 数据模型与迁移
- [x] 2.1 为套餐及相关价格快照补充价格状态、赠送语义和平台专属标记的迁移设计,保持原始价格字段不变,并明确普通可售套餐不能显式配置 0。
- [x] 2.2 编写 golang-migrate 迁移文件,先加字段,再回填存量数据,确保历史记录能区分未配置价格、可明确识别的赠送 0 价和需要人工复核的旧 0 数据。
- [x] 2.3 补齐历史回填逻辑和回滚脚本,完成后用数据库核验确认字段状态、默认值、人工复核清单和历史记录数量一致。
- [x] 2.4 对迁移相关文件和模型定义运行 `lsp_diagnostics`,确认没有语法或引用错误。
## 3. 价格回退与展示链路
- [x] 3.1 提炼统一的生效价解析方法,确保普通可售套餐在建议零售价未配置时回退到成本价,同时保留原始值供配置页使用。
- [x] 3.2 更新后台套餐配置、编辑和详情返回值,只展示原始价格与价格状态,不把生效价写回配置字段。
- [x] 3.3 更新 C 端可购套餐列表、订单创建和自动购包链路,统一使用生效价累加、展示和落单。
- [ ] 3.4 对价格回退链路做接口回放和手工验收,确认未配置价格回退正确、普通可售套餐显式 0 被拒绝、赠送 0 保持赠送语义。
- [x] 3.5 对改动后的价格相关服务、处理器和任务文件运行 `lsp_diagnostics`
## 4. 赠送套餐政策与授权发放
- [x] 4.1 在套餐授权与分配链路加入赠送语义校验,禁止代理拿到、转发或分配赠送套餐。
- [x] 4.2 在 C 端可购列表和购买校验里隐藏并拦截赠送套餐,确保用户无法通过自购路径获取。
- [x] 4.3 保留后台发放入口的赠送套餐授予能力,确保赠送加油包只能通过后台 grant 进入资产,同时在后台创建、编辑时拒绝非赠送套餐的显式 0。
- [ ] 4.4 对赠送政策相关的服务、接口和任务文件运行 `lsp_diagnostics`,并用正反例手工核验平台专属与不可自购规则。
## 5. 文档与最终验收
- [x] 5.1 更新接口文档、字段说明和变更说明,明确原始值、生效值、未配置价格、赠送 0 价和普通套餐显式 0 被拒绝的区别。
- [x] 5.2 重新整理一份最终验收清单,覆盖价格回退、赠送拦截、平台发放和历史迁移四类核心场景。
- [ ] 5.3 对所有改动文件做最终 `lsp_diagnostics`,再执行一次完整手工回归,确认没有遗漏的旧口径入口。

View File

@@ -0,0 +1,98 @@
# 实施基线与手工验证清单
## 一、当前受影响接口与链路
### 1. 后台套餐管理
- `GET /api/admin/packages`
- `GET /api/admin/packages/:id`
- `POST /api/admin/packages`
- `PUT /api/admin/packages/:id`
- `PUT /api/admin/packages/:id/shelf-status`
- `PUT /api/admin/packages/:id/retail-price`
### 2. C 端可购套餐与下单
- `GET /api/c/v1/asset/packages?identifier=xxx`
- C 端创建订单链路(`internal/service/client_order/service.go`
- 充值后自动购包链路(`internal/task/auto_purchase.go`
### 3. 后台订单与发放链路
- 后台创建订单(`internal/service/order/service.go`
- 套餐激活链路(`internal/service/order/service.go``internal/service/package/activation_service.go`
### 4. 代理授权与分配
- 系列授权创建/维护(`internal/service/shop_series_grant/service.go`
- 批量分配(`internal/service/shop_package_batch_allocation/service.go`
## 二、当前受影响数据表
- `tb_package`:套餐主数据,当前仅有 `cost_price``suggested_retail_price`
- `tb_shop_package_allocation`:代理分配记录,当前仅有 `cost_price``retail_price`
- `tb_order`:订单主表
- `tb_order_item`:订单价格快照
- `tb_package_usage`:套餐使用记录快照
- `tb_shop_package_allocation_price_history`:当前仅记录成本价变更历史
## 三、回滚点与历史数据来源
### 1. 回滚点
- 迁移文件回滚:撤销新增字段、清理人工复核清单表
- Service 层回滚:恢复到直接读取 `suggested_retail_price` / `retail_price` 的旧逻辑
- 发放策略回滚:撤销后台赠送订单专属分支,恢复统一后台订单逻辑
### 2. 历史数据判定来源
- `tb_package.suggested_retail_price`
- `tb_shop_package_allocation.retail_price`
- `tb_order_item.unit_price`
- `tb_order.total_amount` / `tb_order.actual_paid_amount`
- `tb_package_usage.paid_amount`
- 后台创建订单记录(用于识别“发放=后台创建订单”的赠送语义)
## 四、改动前诊断基线
### 1. 无阻塞诊断错误的核心文件
- `internal/model/package.go`
- `internal/service/purchase_validation/service.go`
- `internal/service/client_order/service.go`
- `internal/task/auto_purchase.go`
- `internal/service/shop_package_batch_allocation/service.go`
- `internal/service/shop_series_grant/service.go`
### 2. 已知基线提示(非本次改动引入)
- `internal/service/order/service.go` 存在若干 `QF1003` 提示、`unusedparams` 提示和既有 `nilness` warning作为改动前基线保留
- `internal/handler/app/client_asset.go` 存在 `unusedfunc` 基线提示
## 五、手工验证清单
### 1. 后台套餐配置
- 创建普通可售套餐,建议售价留空,保存成功
- 创建普通可售套餐,建议售价填 `0`,保存失败
- 创建赠送套餐,建议售价填 `0`,保存成功
- 编辑普通可售套餐将建议售价改成 `0`,保存失败
- 详情页/编辑页能区分“未配置价格”和“赠送 0 价”
### 2. C 端可购列表
- 平台渠道普通套餐未配置建议售价时,列表显示成本价
- 代理渠道分配记录未配置零售价时,列表显示成本价
- 赠送套餐不出现在可购列表
- 无主套餐时,加油包仍然不可见
### 3. 下单与自动购包
- C 端普通套餐下单按生效价落单
- H5/后台普通购买接口无法购买赠送套餐
- 自动购包命中赠送套餐时被拦截
- 同一订单仍禁止正式套餐和加油包混买
### 4. 后台发放(走创建订单)
- 平台后台可为目标资产创建赠送订单并激活套餐
- 赠送订单无需支付凭证,不产生正常购买语义
- 赠送加油包必须已有主套餐,否则发放失败
### 5. 代理授权与分配
- 代理授权列表不展示赠送套餐
- 系列授权新增赠送套餐失败
- 批量分配不会把赠送套餐分配给代理
### 6. 迁移与历史数据
- 迁移后能区分未配置价格、赠送 0 价、已配置非 0
- 历史模糊 0 数据进入人工复核清单
- 历史快照查询不再把普通未配置价格当成显式 0 售卖价

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 有效期验证