# UR#86 资产前代与后代换货标识功能总结

## 本次交付范围

本次按依赖顺序完成两张 Ticket：

- Ticket 01：统一资产详情增加稳定的 `exchange_trace` 对象，并提供当前资产作为新资产时的前代投影。
- Ticket 02：增加当前资产作为旧资产时的后代投影，使 A→B→C 的中间资产 B 同时返回 A 和 C。

主通道为 `Handler → Query → GORM/DTO`，数据库部分索引为辅助 Infrastructure。完整边界止于当前资产单节点的前代和后代；未迁移换货状态机、写侧 Service、资产详情其他旧读取逻辑，未新增关系表、递归链路接口或前端本地推导。

## 响应与权限契约

`GET /api/admin/assets/resolve/{identifier}` 的 `data` 始终包含：

```json
{
  "exchange_trace": {
    "previous_asset": null,
    "next_asset": null
  }
}
```

存在关联时，单项只返回 `asset_type`、可空 `asset_id`、`identifier`、`exchange_no`、`can_view`。其中 `asset_type` 使用换货模型原枚举 `iot_card` 或 `device`；卡资产详情顶层既有 `asset_type=card` 不变，由 Query 在边界转换。

- `previous_asset` 取当前资产作为新资产的已完成换货单旧资产快照。
- `next_asset` 取当前资产作为旧资产的已完成换货单新资产快照。
- `identifier` 始终直接使用换货单不可变快照，不回查关联资产当前标识。
- 当前资产先经过既有资产详情权限。无权限时继续返回原有不存在响应，换货查询不会暴露其存在。
- 换货关系查询不应用关联资产数据权限；可见性在关系确定后单独批量检查。
- 关联资产可见时返回真实 ID 和 `can_view=true`；不可见或已不存在时保留历史快照与换货单号，返回 `asset_id=null`、`can_view=false`。
- 不返回关联资产店铺、客户、套餐、钱包或状态等其他信息。

## 关系选择与可观测性

仅 `status=4`、未软删除的换货单形成关系。待填写、待发货、已发货待确认、已取消和软删除记录均忽略。

同一方向存在多条异常完成记录时，按 `completed_at DESC NULLS LAST, id DESC` 确定性选择最新记录，并记录中文 Warn 日志，包含方向、当前资产类型与 ID、候选数量、最终换货单 ID 和单号。数据库或可见性查询故障记录中文 Error 日志并返回统一数据库错误，不降级为“无换货关系”。

历史已完成换货单若缺少新资产 ID，仍返回后代类型、快照和换货单号，`asset_id=null`、`can_view=false`，并记录异常日志，避免把已存在的历史关系抹掉。

Query 每次固定执行两条关系查询，并按实际关联类型各执行一次批量可见性查询；卡链为三条 SQL，卡设备混合链最多四条 SQL，查询次数不随设备绑定卡数量或其他列表数据增长。
Query 同时提供按多个资产引用批量查询前代或后代已完成换货记录的能力，单资产详情复用该批量接口。

## 索引、发布与回滚

迁移 `000161_add_exchange_trace_indexes` 增加两条非唯一部分 B-tree 索引：

- `idx_exchange_trace_new_asset`：`new_asset_type, new_asset_id, completed_at DESC NULLS LAST, id DESC`。
- `idx_exchange_trace_old_asset`：`old_asset_type, old_asset_id, completed_at DESC NULLS LAST, id DESC`。

两条索引都只覆盖 `status=4 AND deleted_at IS NULL`。真实 PostgreSQL 已验证索引类型、非唯一性、部分谓词、排序列、代表性 `EXPLAIN` 计划，以及 `up → down → up` 可逆迁移。

发布顺序：先执行索引迁移，再发布 Query、Handler 和前端展示。同窗发布仅在 UR#45、UR#98 对应已完成 Ticket 被实际纳入发布计划时作为前置，不引入模糊跨需求依赖。回滚先回退应用响应字段，再执行 161 down 删除两条索引；不修改换货业务数据，不回填历史快照。

上线前抽样核验历史换货单的新旧资产 ID、资产类型和快照是否完整。异常只记录，不自动回填或改写。

## 验证证据

- Query 测试覆盖无关系、仅前代、仅后代、A→B→C、卡链、设备链、可见与不可见关联、非完成状态、软删除、重复完成记录和数据库故障。
- 真实 PostgreSQL 性能测试连续执行 30 次中间卡查询，每次固定三条 SQL，并断言 P95 `<200ms`、P99 `<500ms`。
- HTTP 集成测试使用真实 PostgreSQL、Redis 令牌和认证中间件，贯穿当前资产权限、资产解析、双向 Query 和统一响应；证明不可见关联保留快照但隐藏 ID，当前资产不可见时维持防枚举响应。
- OpenAPI 已重新生成，明确 `exchange_trace` 稳定对象、双向空值语义和仅 `can_view=true` 且 `asset_id` 非空时允许跳转。

## 前端人工验收清单

当前仓库没有资产详情前端页面工程，以下步骤交付给同维护窗口的前端仓库验收：

1. 无换货：不显示换货标签，不能因空对象报错。
2. 换货新资产：显示“换货新资产”、前代快照和换货单号。
3. 已换出旧资产：显示“已换出旧资产”、后代快照和换货单号。
4. A→B→C：中间资产同时显示前代和后代，互不覆盖。
5. `can_view=true` 且 `asset_id` 非空：关联标识可点击并进入对应卡或设备详情。
6. `can_view=false` 或 `asset_id=null`：只显示快照和换货单号，不渲染链接、按钮或可点击样式。
7. 点击目标是关联资产详情，换货单号仅作辅助文本；不得根据资产状态、generation 或本地缓存自行推导关系。

上述前端人工验收及维护窗口历史数据抽样尚未在本仓库执行，是 Ticket 02 的外部 blocker；完成后方可将该 Ticket 状态从 blocked 更新为 completed。
