# PRD：UR#86 资产详情前代与后代换货标识

Status: ready-for-agent

---

## Problem Statement

卡和设备详情目前无法说明当前资产是否由换货产生、是否已经换出，也无法从 A→B→C 的连续换货中查看当前资产的前代和后代。运营只能人工搜索换货单，且直接返回关联资产主键会造成越权跳转风险。

## Solution

在统一资产详情响应中增加 `exchange_trace`，基于已完成换货单分别查询当前资产作为新资产时的前代，以及作为旧资产时的后代。关联资产仍在当前权限范围内时返回可跳转 ID；无权限时保留不可变换货快照标识，但隐藏内部 ID 并禁止跳转。

## User Stories

1. 作为运营人员，我希望看到当前资产是否是换货后的新资产，并查看其前代。
2. 作为运营人员，我希望看到当前资产是否已经换出，并查看其后代。
3. 作为运营人员，我希望链路中间资产同时显示前代和后代。
4. 作为受限账号，我希望仍能理解换货历史，但不能借此跳转或枚举无权限资产主键。
5. 作为维护人员，我希望直接复用换货单，不再维护一张可能与换货状态不一致的关系表。

## Implementation Decisions

### 响应契约

- 复用 `GET /api/admin/assets/resolve/{identifier}`，在 `AssetResolveResponse` 中增加稳定的 `exchange_trace` 对象。
- `exchange_trace` 包含 `previous_asset` 和 `next_asset`；不存在对应关系时字段为 null，不省略整个对象。
- 单个关联项包含 `asset_type`、可空 `asset_id`、`identifier`、`exchange_no`、`can_view`。
- `asset_type` 使用换货模型现有枚举 `iot_card` 或 `device`。
- `previous_asset` 来自“当前资产是新资产”的已完成换货单，返回该单旧资产快照。
- `next_asset` 来自“当前资产是旧资产”的已完成换货单，返回该单新资产快照。
- 只使用 `status=已完成` 且未软删除的换货单；待填写、待发货、已发货待确认和已取消均不形成资产链。
- A→B→C 时，B 同时返回 A 和 C。若异常历史数据在同一方向存在多条完成记录，选择完成时间与主键顺序最新的一条并记录异常日志。
- `identifier` 使用换货单保存的不可变快照，不回查当前资产可变字段；UR#45 上线后的卡快照为 ICCID，历史快照保持原样。

### 权限与防泄露

- 当前资产仍必须先通过资产详情现有数据权限；无权查看当前资产时维持现有不存在/禁止访问行为。
- 找到前代或后代关系后，分别按关联资产当前数据权限判断 `can_view`。
- 有权查看关联资产时返回真实 `asset_id`、`can_view=true`，前端可跳到对应卡或设备详情。
- 无权查看关联资产时返回 `asset_id=null`、`can_view=false`，但按标准评审稿保留换货单快照 `identifier` 和 `exchange_no`，前端仅作历史文本展示。
- 不返回关联资产店铺、客户、套餐、钱包、状态等额外信息。
- 不以“查不到受权限过滤的资产”抹掉换货关系；关系查询与关联资产可见性检查分开完成。
- 超级管理员、平台和代理继续使用现有资产权限规则，本需求不建立新的角色矩阵。

### Query 与索引

- 这是跨换货单和资产权限的读取用例，收口到 Asset/Exchange Query，直接使用 GORM/DTO 投影，不经过聚合根。
- 复用换货单作为关系权威来源，不新增换货链表或冗余前代/后代字段。
- 分别提供“按新资产查已完成换货”和“按旧资产查已完成换货”的批量查询能力；资产详情一次完成，不逐节点递归整条历史链。
- 新增两个非唯一部分 B-tree 索引：已完成且未软删除范围内的旧资产类型+旧资产 ID，以及新资产类型+新资产 ID；索引支持选择最新记录。
- 卡和设备走同一 Query，不为两种资产复制查询逻辑。
- 关联资产权限检查采用固定次数批量加载；详情增加换货信息后不得产生按列表或绑定卡数量增长的 N+1。
- 查询故障返回统一脱敏内部错误，不得把故障当作“没有换货关系”。

### 前端与文档

- `previous_asset` 存在时显示“换货新资产”标签、前代标识和换货单号。
- `next_asset` 存在时显示“已换出旧资产”标签、后代标识和换货单号。
- 两者同时存在时同时展示，不互相覆盖。
- `can_view=true` 且 `asset_id` 非空时允许点击进入关联卡或设备详情；否则只显示文本，不渲染链接或可点击样式。
- 点击目标是关联资产详情，不直接跳换货单；换货单号仅作辅助信息。
- 不根据当前资产状态、generation 或前端本地数据自行推导关系。
- 更新资产详情 OpenAPI、UR#86 中文总结和 README 索引。

### 发布与回滚

- 索引随维护窗口上线，回滚只删除新增索引和响应字段，不修改换货单业务数据。
- 不回填历史换货快照；上线前抽样核验历史新旧资产 ID 和标识快照完整性，并记录无法展示的异常。
- 与 UR#45、UR#98 同窗发布时，先完成数据库索引和写侧规范，再发布 Query 与前端。

## Testing Decisions

- Query 测试覆盖无关系、仅前代、仅后代、A→B→C 中间资产，以及卡/设备两种资产。
- 状态测试证明只有已完成换货进入链路，其他状态和软删除记录均忽略。
- 权限测试覆盖关联资产可见与不可见：不可见时仍有快照文本，但 ID 为 null 且不可跳转。
- 当前资产无权限时维持现有防枚举响应，不能因为换货查询泄露其存在。
- 构造同方向异常多条完成记录，验证确定性选择最新记录并产生可观测日志。
- HTTP 集成测试穿过真实认证、权限、资产解析、Query 和统一响应，使用真实 PostgreSQL/Redis/JWT 配置。
- 验证新增索引的结构、向下/向上迁移和代表性查询计划；查询满足项目性能目标且无 N+1。
- 前端人工验收四类页面：无换货、换货新资产、已换出旧资产、中间资产；另验收无权限不可点击。

## Out of Scope

- 不返回整条递归换货树或新增换货链列表接口。
- 不新建关系表，不修改换货状态机。
- 不回填或改写历史快照。
- 不向无权限用户返回关联资产内部 ID或其他业务详情。
- 不直接跳转换货单，不在前端自行拼接关系。
- 不改变资产详情现有数据权限。

## Further Notes

- 标准评审稿明确无权限时保留标识、隐藏可跳转 ID；该口径优先于禅道草稿中未定义算法的“脱敏后标识”。
- UR#45 负责未来快照规范，UR#98 保证完成换货后的归属一致；本需求只负责只读关系投影。
