feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
主要变更: - 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等) - 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束 - 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析 - 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合 - 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝 - Excel 工具函数优化,前端导入指引文档同步更新 - 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no - 更新 OpenAPI 文档及相关 DTO Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
@@ -0,0 +1,75 @@
|
||||
## Context
|
||||
|
||||
系统中换货流程已有完整的数据记录:
|
||||
- `ExchangeOrder` 表存有 `old_asset_id`、`old_asset_identifier`、`new_asset_id`、`new_asset_identifier`,形成资产的"世代链"
|
||||
- `Order` 表通过提案一新增的 `asset_identifier` 快照字段,可直接按标识符查询订单
|
||||
- `Device`/`IotCard` 表有 `generation` 字段记录当前世代编号
|
||||
|
||||
当前缺失的是:把这三张表的信息串联起来,给管理员呈现"这个资产在各世代的订单全貌"。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 新增 `GET /api/admin/assets/:identifier/orders` 接口,默认返回本代订单
|
||||
- 支持 `include_previous=true` 参数,追溯换货链返回前代订单(明确标注世代)
|
||||
- 分页支持
|
||||
|
||||
**Non-Goals:**
|
||||
- C 端不暴露跨代订单(C 端用户视角只关心当前资产本代)
|
||||
- 修改订单数据本身(只读接口)
|
||||
- 跨代订单的聚合统计(如历代总充值额),留给未来迭代
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:通过 ExchangeOrder 逆向追溯前代资产 ID
|
||||
|
||||
**查询逻辑**:
|
||||
```
|
||||
给定 identifier(当前资产):
|
||||
1. 通过注册表/Resolve 得到 asset_type + asset_id(当前代)
|
||||
2. 查当前资产的订单:WHERE asset_identifier = identifier(直接走快照字段)
|
||||
3. 若 include_previous=true:
|
||||
a. 查 ExchangeOrder WHERE new_asset_id = asset_id(找到换货记录)
|
||||
b. 得到 old_asset_id + old_asset_identifier(前代标识符)
|
||||
c. 查前代订单:WHERE asset_identifier = old_asset_identifier
|
||||
d. 递归至无更多前代(链式追溯,最多向前追溯 N 代,防止死循环)
|
||||
4. 合并结果,按世代分组,每条订单附加 generation 字段
|
||||
```
|
||||
|
||||
**理由**:直接使用 `asset_identifier` 快照字段查询,无需 JOIN;换货链通过 ExchangeOrder 逆向遍历,逻辑清晰。
|
||||
|
||||
### 决策 2:响应结构按世代分组,不跨代混合排序
|
||||
|
||||
**选择**:响应分为 `current_generation`(本代)和 `previous_generations`(前代数组)两个区块
|
||||
|
||||
**备选方案**:全部打平按时间排序。问题:管理员看到时间线上跳跃的世代可能产生困惑(订单时间早于资产创建时间)
|
||||
|
||||
**理由**:分区块展示逻辑清晰——管理员一眼能看出"这是这台设备自己的订单"vs"这是上一台的历史"
|
||||
|
||||
### 决策 3:限制追溯深度,防止换货链过长
|
||||
|
||||
最大追溯世代数为 **10 代**,超出则截断并在响应中说明(`truncated: true`)。实际业务中换货链极少超过 3 代,此限制仅为安全保障。
|
||||
|
||||
### 决策 4:本接口依赖提案一的 asset_identifier 快照字段
|
||||
|
||||
若提案一未完成(Order 表无 `asset_identifier` 字段),本接口降级为通过 `iot_card_id`/`device_id` 查询(只支持本代,不支持 include_previous)。实际按提案一完成后实现。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| 风险 | 缓解措施 |
|
||||
|------|---------|
|
||||
| **换货链查询产生多次 DB 往返** | 每代一次 ExchangeOrder 查询 + 一次 Order 查询;链深度有限(≤10),总查询次数可控;可加 Redis 缓存换货链 |
|
||||
| **旧订单 asset_identifier 为空**(提案一之前创建的订单)| 本接口限定只查有 `asset_identifier` 快照的订单;旧订单通过 `iot_card_id`/`device_id` fallback 查询补充,明确标注"旧格式订单" |
|
||||
| **include_previous 导致响应体过大** | 分页仅对本代订单生效;前代订单默认返回最近 20 条,不支持前代分页(前代是历史归档,数量有限) |
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 依赖提案一完成(`Order.asset_identifier` 字段存在)
|
||||
2. 实现 `exchange_order_store.FindChainByNewAssetID()`
|
||||
3. 实现 `asset_service.GetOrders()`
|
||||
4. 注册路由和 Handler
|
||||
5. 更新文档生成器
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 无
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
当前系统的资产详情页面没有"历史订单"维度。管理员无法在查看某个资产时直接知道它卖给过谁、买过哪些套餐。换货流程(`ExchangeOrder`)已经在数据库层记录了旧资产→新资产的映射关系,但没有被利用来追溯前代订单。随着资产复用(换货后二次销售)场景增多,管理员需要一种方式能追溯到"这个资产的来源及历史"。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **[NEW]** 新增 B 端接口:`GET /api/admin/assets/:identifier/orders`,查询某资产本代的历史订单
|
||||
- **[NEW]** 支持查询参数 `include_previous=true`,通过换货链追溯并返回前代资产的订单(附带世代标注)
|
||||
- 接口路径遵循提案一中已确立的 `:identifier` 统一规范(依赖提案一完成后的注册表和路由规范)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `asset-historical-orders`:资产往期订单查询接口,支持本代/跨代两种视图,响应中明确标注每条订单所属世代
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(无——本提案仅新增接口,不修改现有接口行为)
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响的代码**:
|
||||
- `internal/handler/admin/asset.go`:新增 `Orders` handler
|
||||
- `internal/routes/asset.go`:注册新路由
|
||||
- `internal/service/asset/service.go`:新增 `GetOrders()` 方法,包含换货链追溯逻辑
|
||||
- `internal/store/postgres/order_store.go`:新增按 `asset_identifier` 精确查询(依赖提案一的 `asset_identifier` 快照字段)
|
||||
- `internal/store/postgres/exchange_order_store.go`:新增按资产 ID 查询换货链的方法
|
||||
- `internal/model/dto/asset_dto.go`:新增 `AssetOrdersRequest`、`AssetOrdersResponse`、`AssetOrderItem` DTO
|
||||
- `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:注册新 Handler
|
||||
|
||||
**前置依赖**:
|
||||
- 提案一(`asset-identifier-standardization`)必须先完成:本提案依赖 `Order.asset_identifier` 快照字段和统一的 `:identifier` 路由规范
|
||||
@@ -0,0 +1,108 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 查询资产本代历史订单
|
||||
|
||||
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
|
||||
|
||||
**API 端点**:`GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
**请求参数**:
|
||||
- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填
|
||||
- `page`(query):页码,默认 1
|
||||
- `page_size`(query):每页数量,默认 20,最大 100
|
||||
- `include_previous`(query):是否包含前代订单,布尔值,默认 false
|
||||
|
||||
**权限规则**:
|
||||
- 代理用户:只能查看数据权限范围内资产的订单
|
||||
- 平台/超管:可查看所有资产订单
|
||||
- 企业账号:不支持此接口(返回 403)
|
||||
|
||||
#### Scenario: 查询本代订单(默认)
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
|
||||
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
|
||||
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
|
||||
|
||||
#### Scenario: 资产无订单
|
||||
- **WHEN** 管理员查询一个从未购买过套餐的资产
|
||||
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限资产
|
||||
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
### Requirement: 查询资产跨代历史订单(含前代)
|
||||
|
||||
当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
|
||||
|
||||
**追溯逻辑**:
|
||||
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
|
||||
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
|
||||
3. 递归追溯,最多向前 10 代(安全上限)
|
||||
|
||||
**响应结构(AssetOrdersResponse)**:
|
||||
```json
|
||||
{
|
||||
"current_generation": {
|
||||
"generation": 2,
|
||||
"identifier": "DEV-001",
|
||||
"asset_type": "device",
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"items": [ ...订单列表... ]
|
||||
},
|
||||
"previous_generations": [
|
||||
{
|
||||
"generation": 1,
|
||||
"identifier": "DEV-OLD-001",
|
||||
"asset_type": "device",
|
||||
"exchange_no": "EXC20260101XXXXXX",
|
||||
"exchanged_at": "2026-01-01T00:00:00Z",
|
||||
"total": 3,
|
||||
"items": [ ...前代订单列表(最近20条)... ]
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**每条订单项(AssetOrderItem)包含**:
|
||||
- `order_no`:订单号
|
||||
- `order_type`:订单类型(single_card / device)
|
||||
- `payment_status`:支付状态
|
||||
- `payment_status_text`:支付状态文本
|
||||
- `total_amount`:订单金额(分)
|
||||
- `payment_method`:支付方式
|
||||
- `paid_at`:支付时间(可空)
|
||||
- `generation`:订单所属资产世代
|
||||
- `items`:套餐明细列表
|
||||
- `created_at`:订单创建时间
|
||||
|
||||
#### Scenario: 查询换货后资产的全代际订单
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代)
|
||||
- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2)
|
||||
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间
|
||||
- **THEN** `truncated=false`(未超出追溯上限)
|
||||
|
||||
#### Scenario: 资产本身就是第一代(无前代)
|
||||
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
|
||||
- **THEN** `previous_generations` 为空数组 `[]`
|
||||
- **THEN** `current_generation` 正常返回本代订单
|
||||
|
||||
#### Scenario: 换货链超过追溯上限
|
||||
- **WHEN** 换货链深度超过 10 代
|
||||
- **THEN** 追溯在第 10 代截断,`truncated=true`
|
||||
- **THEN** 已追溯到的前代数据正常返回
|
||||
|
||||
#### Scenario: 前代订单分页
|
||||
- **WHEN** 请求带 `include_previous=true`
|
||||
- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效
|
||||
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
|
||||
|
||||
#### Scenario: 无 include_previous 时响应不含前代字段
|
||||
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false)
|
||||
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽
|
||||
@@ -0,0 +1,54 @@
|
||||
## 1. 前置确认
|
||||
|
||||
- [x] 1.1 确认提案一(asset-identifier-standardization)已完成:`tb_order.asset_identifier` 字段存在,B 端资产路由已统一为 `:identifier`
|
||||
|
||||
## 2. 数据层:ExchangeOrder Store 扩展
|
||||
|
||||
- [x] 2.1 在 `internal/store/postgres/exchange_order_store.go` 新增 `FindByNewAssetID(ctx, assetType, assetID) (*model.ExchangeOrder, error)` 方法:查询 `WHERE new_asset_id = ? AND new_asset_type = ?`,返回该资产的换货来源记录(找不到则返回 nil,表示无前代)
|
||||
|
||||
## 3. DTO 层:新增响应结构
|
||||
|
||||
- [x] 3.1 在 `internal/model/dto/asset_dto.go` 新增以下结构体:
|
||||
- `AssetOrdersRequest`:路径参数 `identifier`,query 参数 `page`、`page_size`、`include_previous`
|
||||
- `AssetOrderItem`:单条订单项(order_no、order_type、payment_status、payment_status_text、total_amount、payment_method、paid_at、generation、items、created_at)
|
||||
- `GenerationOrders`:单代订单块(generation、identifier、asset_type、total、page、page_size、items)
|
||||
- `PreviousGenerationOrders`:前代订单块(generation、identifier、asset_type、exchange_no、exchanged_at、total、items)
|
||||
- `AssetOrdersResponse`:完整响应(current_generation、previous_generations、truncated)
|
||||
|
||||
## 4. 服务层:GetOrders 方法
|
||||
|
||||
- [x] 4.1 在 `internal/service/asset/service.go` 新增 `GetOrders(ctx, identifier, page, pageSize, includePrevious bool) (*dto.AssetOrdersResponse, error)` 方法:
|
||||
- 调用 `Resolve()` 得到 asset_type 和 asset_id
|
||||
- 查询本代订单:`orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)`
|
||||
- 若 `includePrevious=true`:循环调用 `exchangeOrderStore.FindByNewAssetID()` 追溯换货链(最多 10 代);每代查询前代 `old_asset_identifier` 对应的订单(最多 20 条,无分页)
|
||||
- 组装 `AssetOrdersResponse` 返回
|
||||
- [x] 4.2 在 `internal/store/postgres/order_store.go` 新增 `ListByAssetIdentifier(ctx, identifier, page, pageSize) ([]*model.Order, int64, error)` 方法:查询 `WHERE asset_identifier = ?`,分页,倒序
|
||||
|
||||
## 5. Handler 层:新增 Orders Handler
|
||||
|
||||
- [x] 5.1 在 `internal/handler/admin/asset.go` 新增 `Orders(c *fiber.Ctx) error` handler:
|
||||
- 从路径参数读取 `identifier`;从 query 读取 `page`、`page_size`、`include_previous`
|
||||
- 调用 `assetService.GetOrders()`
|
||||
- 返回 `response.Success(c, result)`
|
||||
- 注释:`// Orders 查询资产历史订单(支持跨代追溯)` + `// GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
## 6. 路由层:注册新路由
|
||||
|
||||
- [x] 6.1 在 `internal/routes/asset.go` 注册:`assets.Get("/:identifier/orders", h.Asset.Orders)`(注意路由顺序,避免与 `/resolve/:identifier` 冲突)
|
||||
|
||||
## 7. 文档生成器更新
|
||||
|
||||
- [x] 7.1 在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `handlers` 结构体中确认 `AssetHandler` 已包含新的 `Orders` 方法(检查是否需要更新 handler 注册)
|
||||
- [x] 7.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新接口体现
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- [x] 8.1 构建验证:`go build ./...` 无编译错误
|
||||
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告
|
||||
- [x] 8.3 接口验证(使用 PostgreSQL MCP + curl):
|
||||
- 查询有订单的资产:`GET /api/admin/assets/:identifier/orders` 返回正确订单列表,含 generation 字段
|
||||
- 查询无订单资产:返回 `items: [], total: 0`
|
||||
- 查询不存在的 identifier:返回 404
|
||||
- 查询换货资产(带 include_previous=true):`previous_generations` 包含前代订单,附带 exchange_no 和 exchanged_at
|
||||
- 查询无前代的资产(带 include_previous=true):`previous_generations` 为空数组
|
||||
- 代理查询无权限资产:返回 403
|
||||
Reference in New Issue
Block a user