From c58773e35b903af1110119514c5e258d27b4e2b6 Mon Sep 17 00:00:00 2001 From: break Date: Wed, 22 Jul 2026 18:05:38 +0900 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B5=84=E4=BA=A7=E5=8F=8C?= =?UTF-8?q?=E5=90=91=E6=8D=A2=E8=B4=A7=E9=93=BE=E8=B7=AF=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01-asset-previous-exchange-trace.md | 28 +- .../issues/02-asset-next-exchange-trace.md | 28 +- README.md | 1 + docs/admin-openapi.yaml | 32 +- docs/ur86-asset-exchange-trace/功能总结.md | 78 ++++ internal/bootstrap/handlers.go | 3 +- internal/handler/admin/asset.go | 28 +- internal/model/dto/asset_dto.go | 20 +- internal/query/asset/exchange_trace.go | 228 +++++++++ internal/query/asset/exchange_trace_test.go | 435 ++++++++++++++++++ internal/routes/asset.go | 2 +- .../asset_exchange_trace_integration_test.go | 268 +++++++++++ ...000161_add_exchange_trace_indexes.down.sql | 3 + .../000161_add_exchange_trace_indexes.up.sql | 8 + pkg/constants/constants.go | 9 + pkg/openapi/handlers.go | 2 +- 16 files changed, 1133 insertions(+), 40 deletions(-) create mode 100644 docs/ur86-asset-exchange-trace/功能总结.md create mode 100644 internal/query/asset/exchange_trace.go create mode 100644 internal/query/asset/exchange_trace_test.go create mode 100644 internal/routes/asset_exchange_trace_integration_test.go create mode 100644 migrations/000161_add_exchange_trace_indexes.down.sql create mode 100644 migrations/000161_add_exchange_trace_indexes.up.sql diff --git a/.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md b/.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md index 1c5e566..2d2a6aa 100644 --- a/.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md +++ b/.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md @@ -4,18 +4,18 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** completed -- [ ] 卡和设备通过统一资产详情查询时始终返回 `exchange_trace` 对象;没有前代时 `previous_asset` 为 `null`,不省略整个对象。 -- [ ] 当前资产作为新资产出现在未软删除且状态为已完成的换货单中时,`previous_asset` 返回旧资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记。 -- [ ] 前代标识直接使用换货单保存的不可变快照,不回查或改写关联资产的当前标识。 -- [ ] 关联前代资产通过现有卡或设备数据权限检查;有权限时返回真实 ID 和 `can_view=true`,无权限时返回 `asset_id=null` 和 `can_view=false`,且不返回店铺、客户、套餐、钱包、状态等额外信息。 -- [ ] 关系查询不应用关联资产的数据权限过滤,不会因关联资产不可见而抹掉换货关系;当前资产本身仍先经过现有权限校验,未授权请求维持原有防枚举响应。 -- [ ] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成前代关系。 -- [ ] 查询存储故障返回统一脱敏内部错误,不会被降级为“无换货关系”,并记录包含查询方向和当前资产上下文的中文错误日志。 -- [ ] 增加已完成且未软删除范围内支持“新资产类型 + 新资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。 -- [ ] 同一资产存在多条“当前资产为新资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。 -- [ ] Query 测试覆盖无关系、卡前代、设备前代、可见前代、不可见前代、非完成状态、软删除记录和异常重复完成记录。 -- [ ] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、前代 Query 与统一响应格式,并证明当前资产无权限时不会因换货关系泄露其存在。 -- [ ] 更新资产详情 OpenAPI 的前代响应契约,明确 `exchange_trace` 始终存在、`previous_asset` 可为 `null`,以及 `can_view` 与可空 `asset_id` 的跳转规则。 -- [ ] 在 UR#86 中文总结中记录前代查询的架构通道、权限防泄露、异常选择规则、索引发布与回滚方式,并准备无换货、前代可见和前代不可见的人工验收步骤。 +- [x] 卡和设备通过统一资产详情查询时始终返回 `exchange_trace` 对象;没有前代时 `previous_asset` 为 `null`,不省略整个对象。 +- [x] 当前资产作为新资产出现在未软删除且状态为已完成的换货单中时,`previous_asset` 返回旧资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记。 +- [x] 前代标识直接使用换货单保存的不可变快照,不回查或改写关联资产的当前标识。 +- [x] 关联前代资产通过现有卡或设备数据权限检查;有权限时返回真实 ID 和 `can_view=true`,无权限时返回 `asset_id=null` 和 `can_view=false`,且不返回店铺、客户、套餐、钱包、状态等额外信息。 +- [x] 关系查询不应用关联资产的数据权限过滤,不会因关联资产不可见而抹掉换货关系;当前资产本身仍先经过现有权限校验,未授权请求维持原有防枚举响应。 +- [x] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成前代关系。 +- [x] 查询存储故障返回统一脱敏内部错误,不会被降级为“无换货关系”,并记录包含查询方向和当前资产上下文的中文错误日志。 +- [x] 增加已完成且未软删除范围内支持“新资产类型 + 新资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。 +- [x] 同一资产存在多条“当前资产为新资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。 +- [x] Query 测试覆盖无关系、卡前代、设备前代、可见前代、不可见前代、非完成状态、软删除记录和异常重复完成记录。 +- [x] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、前代 Query 与统一响应格式,并证明当前资产无权限时不会因换货关系泄露其存在。 +- [x] 更新资产详情 OpenAPI 的前代响应契约,明确 `exchange_trace` 始终存在、`previous_asset` 可为 `null`,以及 `can_view` 与可空 `asset_id` 的跳转规则。 +- [x] 在 UR#86 中文总结中记录前代查询的架构通道、权限防泄露、异常选择规则、索引发布与回滚方式,并准备无换货、前代可见和前代不可见的人工验收步骤。 diff --git a/.scratch/ur86-asset-exchange-trace/issues/02-asset-next-exchange-trace.md b/.scratch/ur86-asset-exchange-trace/issues/02-asset-next-exchange-trace.md index e77ad4a..9bfe323 100644 --- a/.scratch/ur86-asset-exchange-trace/issues/02-asset-next-exchange-trace.md +++ b/.scratch/ur86-asset-exchange-trace/issues/02-asset-next-exchange-trace.md @@ -4,20 +4,20 @@ **Blocked by:** `.scratch/ur86-asset-exchange-trace/issues/01-asset-previous-exchange-trace.md` — 01 — 资产详情展示可控前代换货信息 -**Status:** ready-for-agent +**Status:** blocked — 后端实现与自动化验证已完成;等待前端仓库人工验收及维护窗口历史数据抽样。 -- [ ] 当前资产作为旧资产出现在未软删除且状态为已完成的换货单中时,`next_asset` 返回新资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记;没有后代时为 `null`。 -- [ ] 卡和设备共用同一套 Query 投影逻辑,不为两种资产复制换货链查询流程。 -- [ ] 后代资产有权限时返回真实 ID 和 `can_view=true`;无权限时保留快照标识及换货单号、返回 `asset_id=null` 和 `can_view=false`,不泄露其他业务信息。 -- [ ] A→B→C 场景查询 B 时同时返回 A 和 C,`previous_asset` 与 `next_asset` 互不覆盖;查询 A 和 C 时分别只返回存在的方向。 -- [ ] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成后代关系。 -- [ ] 增加已完成且未软删除范围内支持“旧资产类型 + 旧资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。 -- [ ] 前代和后代的关联资产可见性采用固定次数批量加载;查询次数不随设备绑定卡数量或其他列表数据增长,不产生 N+1。 -- [ ] 同一资产存在多条“当前资产为旧资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。 -- [ ] Query 测试覆盖仅后代、A→B→C 中间资产、卡链、设备链、关联后代可见与不可见、非完成状态、软删除记录和异常重复完成记录。 -- [ ] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、双向换货 Query 与统一响应格式,覆盖无换货、仅前代、仅后代、中间资产及关联资产不可见场景。 -- [ ] 使用代表性 PostgreSQL 数据验证两条部分索引的结构和查询计划,资产详情增加双向换货投影后仍满足项目数据库查询与 API 性能目标,且无 N+1。 -- [ ] 完成资产详情 OpenAPI 双向契约,明确 `previous_asset`、`next_asset` 的空值语义,以及仅在 `can_view=true` 且 `asset_id` 非空时允许跳转。 +- [x] 当前资产作为旧资产出现在未软删除且状态为已完成的换货单中时,`next_asset` 返回新资产类型、换货单快照标识、换货单号、可空资产 ID 和可见性标记;没有后代时为 `null`。 +- [x] 卡和设备共用同一套 Query 投影逻辑,不为两种资产复制换货链查询流程。 +- [x] 后代资产有权限时返回真实 ID 和 `can_view=true`;无权限时保留快照标识及换货单号、返回 `asset_id=null` 和 `can_view=false`,不泄露其他业务信息。 +- [x] A→B→C 场景查询 B 时同时返回 A 和 C,`previous_asset` 与 `next_asset` 互不覆盖;查询 A 和 C 时分别只返回存在的方向。 +- [x] 待填写、待发货、已发货待确认、已取消及软删除换货单均不会形成后代关系。 +- [x] 增加已完成且未软删除范围内支持“旧资产类型 + 旧资产 ID”最新记录查询的非唯一部分 B-tree 索引,并验证向上迁移、向下迁移和代表性查询计划。 +- [x] 前代和后代的关联资产可见性采用固定次数批量加载;查询次数不随设备绑定卡数量或其他列表数据增长,不产生 N+1。 +- [x] 同一资产存在多条“当前资产为旧资产”的异常完成记录时,按 `completed_at` 降序、主键降序确定性选择最新一条,并记录包含当前资产、候选数量和最终换货单的中文异常日志。 +- [x] Query 测试覆盖仅后代、A→B→C 中间资产、卡链、设备链、关联后代可见与不可见、非完成状态、软删除记录和异常重复完成记录。 +- [x] HTTP 集成测试使用真实 PostgreSQL、Redis、JWT 和认证中间件,贯穿当前资产权限、资产解析、双向换货 Query 与统一响应格式,覆盖无换货、仅前代、仅后代、中间资产及关联资产不可见场景。 +- [x] 使用代表性 PostgreSQL 数据验证两条部分索引的结构和查询计划,资产详情增加双向换货投影后仍满足项目数据库查询与 API 性能目标,且无 N+1。 +- [x] 完成资产详情 OpenAPI 双向契约,明确 `previous_asset`、`next_asset` 的空值语义,以及仅在 `can_view=true` 且 `asset_id` 非空时允许跳转。 - [ ] 完成 UR#86 中文总结并更新 README 索引,记录完整权限边界、异常选择规则、发布顺序和回滚方式;上线前抽样核验历史新旧资产 ID 与快照完整性,只记录异常、不回填数据。 - [ ] 前端人工验收覆盖无换货、换货新资产、已换出旧资产和 A→B→C 中间资产;无权限关联项只显示快照标识和换货单号,不渲染链接或可点击样式。 -- [ ] 与 UR#45、UR#98 同窗发布时,仅将已实际完成的对应 Ticket 纳入发布前置检查;若形成跨 PRD 实施阻塞,必须先引用其已发布的具体 issue 文件路径和标题更新本票,不能使用模糊依赖描述。 +- [x] 与 UR#45、UR#98 同窗发布时,仅将已实际完成的对应 Ticket 纳入发布前置检查;若形成跨 PRD 实施阻塞,必须先引用其已发布的具体 issue 文件路径和标题更新本票,不能使用模糊依赖描述。 diff --git a/README.md b/README.md index 0a8d103..858768a 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ default: - **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)) - **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。 - **UR#45 换货资产快照与新旧资产独立搜索**:新换货单将卡快照统一为完整 ICCID、设备按虚拟号/IMEI/SN 固化稳定标识;列表使用 `old_asset_keyword` 与 `new_asset_keyword` 独立搜索并按 AND 组合;详见 [功能总结](docs/ur45-exchange-asset-search/功能总结.md)。 +- **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID;详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。 - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 76cb665..53b29bd 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -981,6 +981,34 @@ components: description: 目标所有者类型 type: string type: object + DtoAssetExchangeTrace: + properties: + next_asset: + $ref: '#/components/schemas/DtoAssetExchangeTraceItem' + previous_asset: + $ref: '#/components/schemas/DtoAssetExchangeTraceItem' + type: object + DtoAssetExchangeTraceItem: + nullable: true + properties: + asset_id: + description: 关联资产数据库 ID;无权限时为 null + minimum: 0 + nullable: true + type: integer + asset_type: + description: 资产类型 (iot_card:物联网卡, device:设备) + type: string + can_view: + description: 是否有权跳转查看关联资产 + type: boolean + exchange_no: + description: 换货单号 + type: string + identifier: + description: 换货单保存的不可变资产标识快照 + type: string + type: object DtoAssetInfoResponse: properties: activated_at: @@ -1643,6 +1671,8 @@ components: enable_virtual_data: description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回) type: boolean + exchange_trace: + $ref: '#/components/schemas/DtoAssetExchangeTrace' gateway_card_imei: description: 插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看) type: string @@ -12264,7 +12294,7 @@ paths: - 资产管理 /api/admin/assets/resolve/{identifier}: get: - description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。 + description: 通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。exchange_trace 始终存在,previous_asset/next_asset 无关系时为 null;关联项仅在 can_view=true 且 asset_id 非空时允许跳转。企业账号禁止调用。 parameters: - description: 是否返回当前世代流量汇总字段(total_virtual_used_mb / total_virtual_remaining_mb),默认 false in: query diff --git a/docs/ur86-asset-exchange-trace/功能总结.md b/docs/ur86-asset-exchange-trace/功能总结.md new file mode 100644 index 0000000..30756f0 --- /dev/null +++ b/docs/ur86-asset-exchange-trace/功能总结.md @@ -0,0 +1,78 @@ +# 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。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index dd937d7..e5a5296 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/handler/callback" openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" + assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" @@ -128,7 +129,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { deps.Logger, svc.AssetAudit, ) - h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc) + h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger)) h.SetLifecycleService(svc.AssetLifecycle) return h }(), diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index b7e0255..2f7284a 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -1,6 +1,7 @@ package admin import ( + "context" "strconv" "strings" "time" @@ -29,6 +30,12 @@ type AssetHandler struct { iotCardStopResume *iotCardService.StopResumeService assetPolling *pollingSvc.AssetPollingService assetLifecycleService AssetLifecycleService + exchangeTraceQuery AssetExchangeTraceResolver +} + +// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 +type AssetExchangeTraceResolver interface { + Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) } // NewAssetHandler 创建资产管理处理器 @@ -39,14 +46,16 @@ func NewAssetHandler( iotCardSvc *iotCardService.Service, iotCardStopResume *iotCardService.StopResumeService, assetPolling *pollingSvc.AssetPollingService, + exchangeTraceQuery AssetExchangeTraceResolver, ) *AssetHandler { return &AssetHandler{ - assetService: assetSvc, - assetAuditService: assetAuditService, - deviceService: deviceSvc, - iotCardService: iotCardSvc, - iotCardStopResume: iotCardStopResume, - assetPolling: assetPolling, + assetService: assetSvc, + assetAuditService: assetAuditService, + deviceService: deviceSvc, + iotCardService: iotCardSvc, + iotCardStopResume: iotCardStopResume, + assetPolling: assetPolling, + exchangeTraceQuery: exchangeTraceQuery, } } @@ -78,6 +87,13 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error { if err != nil { return err } + result.ExchangeTrace = &dto.AssetExchangeTrace{} + if h.exchangeTraceQuery != nil { + result.ExchangeTrace, err = h.exchangeTraceQuery.Resolve(c.UserContext(), result.AssetType, result.AssetID) + if err != nil { + return err + } + } return response.Success(c, result) } diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index f5a945c..a89f242 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -69,8 +69,24 @@ type AssetResolveResponse struct { GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` // 流量汇总字段(仅在 ?include_usage_summary=true 时返回非 null 值) - TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"` - TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"` + TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"` + TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"` + ExchangeTrace *AssetExchangeTrace `json:"exchange_trace" description:"换货链路;对象始终存在,前代或后代不存在时对应字段为 null"` +} + +// AssetExchangeTrace 资产单节点双向换货链路。 +type AssetExchangeTrace struct { + PreviousAsset *AssetExchangeTraceItem `json:"previous_asset" nullable:"true" description:"当前资产的换货前代,不存在时为 null"` + NextAsset *AssetExchangeTraceItem `json:"next_asset" nullable:"true" description:"当前资产的换货后代,不存在时为 null"` +} + +// AssetExchangeTraceItem 换货链路中的单个关联资产。 +type AssetExchangeTraceItem struct { + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID *uint `json:"asset_id" description:"关联资产数据库 ID;无权限时为 null"` + Identifier string `json:"identifier" description:"换货单保存的不可变资产标识快照"` + ExchangeNo string `json:"exchange_no" description:"换货单号"` + CanView bool `json:"can_view" description:"是否有权跳转查看关联资产"` } // BoundCardInfo 设备绑定的卡信息 diff --git a/internal/query/asset/exchange_trace.go b/internal/query/asset/exchange_trace.go new file mode 100644 index 0000000..10c775e --- /dev/null +++ b/internal/query/asset/exchange_trace.go @@ -0,0 +1,228 @@ +// Package asset 提供资产详情读取投影。 +package asset + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ExchangeTraceQuery 查询资产单节点换货链路并投影关联资产可见性。 +type ExchangeTraceQuery struct { + db *gorm.DB + logger *zap.Logger +} + +// ExchangeTraceAssetRef 标识批量换货关系查询中的当前资产。 +type ExchangeTraceAssetRef struct { + // AssetType 为统一资产详情或换货模型中的资产类型。 + AssetType string + // AssetID 为资产数据库 ID。 + AssetID uint +} + +// NewExchangeTraceQuery 创建资产换货链路查询。 +func NewExchangeTraceQuery(db *gorm.DB, logger *zap.Logger) *ExchangeTraceQuery { + if logger == nil { + logger = zap.NewNop() + } + return &ExchangeTraceQuery{db: db, logger: logger} +} + +// Resolve 查询当前资产的前代与后代换货投影。 +func (q *ExchangeTraceQuery) Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) { + trace := &dto.AssetExchangeTrace{} + assetType = normalizeExchangeAssetType(assetType) + ref := ExchangeTraceAssetRef{AssetType: assetType, AssetID: assetID} + previousBatch, err := q.FindPreviousCompleted(ctx, []ExchangeTraceAssetRef{ref}) + if err != nil { + return nil, q.wrapQueryError(constants.ExchangeTraceDirectionPrevious, assetType, assetID, err) + } + nextBatch, err := q.FindNextCompleted(ctx, []ExchangeTraceAssetRef{ref}) + if err != nil { + return nil, q.wrapQueryError(constants.ExchangeTraceDirectionNext, assetType, assetID, err) + } + + previousOrders := previousBatch[ref] + nextOrders := nextBatch[ref] + previous := q.selectLatest(constants.ExchangeTraceDirectionPrevious, assetType, assetID, previousOrders) + next := q.selectLatest(constants.ExchangeTraceDirectionNext, assetType, assetID, nextOrders) + visibility, err := q.loadVisibility(ctx, previous, next) + if err != nil { + q.logger.Error("查询换货关联资产可见性失败", + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Error(err)) + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货关联资产可见性失败") + } + + if previous != nil { + key := relatedAssetKey{assetType: previous.OldAssetType, assetID: previous.OldAssetID} + previousAssetID := previous.OldAssetID + trace.PreviousAsset = newTraceItem(previous.OldAssetType, &previousAssetID, previous.OldAssetIdentifier, previous.ExchangeNo, visibility[key]) + } + if next != nil { + var canView bool + if next.NewAssetID != nil { + key := relatedAssetKey{assetType: next.NewAssetType, assetID: *next.NewAssetID} + canView = visibility[key] + } else { + q.logger.Warn("已完成换货记录缺少新资产 ID", + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Uint("exchange_id", next.ID), + zap.String("exchange_no", next.ExchangeNo)) + } + trace.NextAsset = newTraceItem(next.NewAssetType, next.NewAssetID, next.NewAssetIdentifier, next.ExchangeNo, canView) + } + return trace, nil +} + +func normalizeExchangeAssetType(assetType string) string { + if assetType == constants.AssetResolveTypeCard { + return constants.ExchangeAssetTypeIotCard + } + return assetType +} + +type relatedAssetKey struct { + assetType string + assetID uint +} + +// FindPreviousCompleted 批量查询当前资产作为新资产时的已完成换货记录。 +func (q *ExchangeTraceQuery) FindPreviousCompleted(ctx context.Context, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) { + return q.findRelatedOrdersBatch(ctx, constants.ExchangeTraceDirectionPrevious, refs) +} + +// FindNextCompleted 批量查询当前资产作为旧资产时的已完成换货记录。 +func (q *ExchangeTraceQuery) FindNextCompleted(ctx context.Context, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) { + return q.findRelatedOrdersBatch(ctx, constants.ExchangeTraceDirectionNext, refs) +} + +func (q *ExchangeTraceQuery) findRelatedOrdersBatch(ctx context.Context, direction string, refs []ExchangeTraceAssetRef) (map[ExchangeTraceAssetRef][]*model.ExchangeOrder, error) { + result := make(map[ExchangeTraceAssetRef][]*model.ExchangeOrder, len(refs)) + if len(refs) == 0 { + return result, nil + } + var orders []*model.ExchangeOrder + query := q.db.WithContext(ctx).Where("status = ?", constants.ExchangeStatusCompleted) + assetConditions := q.db.Session(&gorm.Session{NewDB: true}).Where("1 = 0") + for _, ref := range refs { + ref.AssetType = normalizeExchangeAssetType(ref.AssetType) + if direction == constants.ExchangeTraceDirectionPrevious { + assetConditions = assetConditions.Or("new_asset_type = ? AND new_asset_id = ?", ref.AssetType, ref.AssetID) + } else { + assetConditions = assetConditions.Or("old_asset_type = ? AND old_asset_id = ?", ref.AssetType, ref.AssetID) + } + } + err := query.Where(assetConditions). + Order("completed_at DESC NULLS LAST, id DESC"). + Find(&orders).Error + if err != nil { + return nil, err + } + for _, order := range orders { + ref := ExchangeTraceAssetRef{AssetType: order.OldAssetType, AssetID: order.OldAssetID} + if direction == constants.ExchangeTraceDirectionPrevious && order.NewAssetID != nil { + ref = ExchangeTraceAssetRef{AssetType: order.NewAssetType, AssetID: *order.NewAssetID} + } + result[ref] = append(result[ref], order) + } + return result, nil +} + +func (q *ExchangeTraceQuery) selectLatest(direction string, assetType string, assetID uint, orders []*model.ExchangeOrder) *model.ExchangeOrder { + if len(orders) == 0 { + return nil + } + selected := orders[0] + if len(orders) > 1 { + q.logger.Warn("检测到同方向多条已完成换货记录", + zap.String("direction", string(direction)), + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Int("candidate_count", len(orders)), + zap.Uint("selected_exchange_id", selected.ID), + zap.String("selected_exchange_no", selected.ExchangeNo)) + } + return selected +} + +func (q *ExchangeTraceQuery) loadVisibility(ctx context.Context, previous, next *model.ExchangeOrder) (map[relatedAssetKey]bool, error) { + result := make(map[relatedAssetKey]bool, 2) + cardIDs, deviceIDs := relatedAssetIDs(previous, next) + if len(cardIDs) > 0 { + var cards []*model.IotCard + if err := q.db.WithContext(ctx).Select("id", "shop_id").Where("id IN ?", cardIDs).Find(&cards).Error; err != nil { + return nil, err + } + for _, card := range cards { + result[relatedAssetKey{assetType: constants.ExchangeAssetTypeIotCard, assetID: card.ID}] = canViewShopAsset(ctx, card.ShopID) + } + } + if len(deviceIDs) > 0 { + var devices []*model.Device + if err := q.db.WithContext(ctx).Select("id", "shop_id").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil { + return nil, err + } + for _, device := range devices { + result[relatedAssetKey{assetType: constants.ExchangeAssetTypeDevice, assetID: device.ID}] = canViewShopAsset(ctx, device.ShopID) + } + } + return result, nil +} + +func canViewShopAsset(ctx context.Context, shopID *uint) bool { + if middleware.IsUnrestricted(ctx) { + return true + } + return shopID != nil && middleware.ContainsShopID(ctx, *shopID) +} + +func relatedAssetIDs(previous, next *model.ExchangeOrder) ([]uint, []uint) { + cardIDs := make([]uint, 0, 2) + deviceIDs := make([]uint, 0, 2) + appendID := func(assetType string, assetID uint) { + if assetType == constants.ExchangeAssetTypeIotCard { + cardIDs = append(cardIDs, assetID) + } else if assetType == constants.ExchangeAssetTypeDevice { + deviceIDs = append(deviceIDs, assetID) + } + } + if previous != nil { + appendID(previous.OldAssetType, previous.OldAssetID) + } + if next != nil && next.NewAssetID != nil { + appendID(next.NewAssetType, *next.NewAssetID) + } + return cardIDs, deviceIDs +} + +func (q *ExchangeTraceQuery) wrapQueryError(direction string, assetType string, assetID uint, err error) error { + q.logger.Error("查询资产换货关系失败", + zap.String("direction", string(direction)), + zap.String("asset_type", assetType), + zap.Uint("asset_id", assetID), + zap.Error(err)) + return errors.Wrap(errors.CodeDatabaseError, err, "查询资产换货关系失败") +} + +func newTraceItem(assetType string, assetID *uint, identifier, exchangeNo string, canView bool) *dto.AssetExchangeTraceItem { + item := &dto.AssetExchangeTraceItem{ + AssetType: assetType, + Identifier: identifier, + ExchangeNo: exchangeNo, + CanView: canView, + } + if canView && assetID != nil { + item.AssetID = assetID + } + return item +} diff --git a/internal/query/asset/exchange_trace_test.go b/internal/query/asset/exchange_trace_test.go new file mode 100644 index 0000000..4978632 --- /dev/null +++ b/internal/query/asset/exchange_trace_test.go @@ -0,0 +1,435 @@ +package asset + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// TestExchangeTraceQueryProjectsPreviousAsset 验证卡和设备前代均使用换货快照,并始终返回稳定对象。 +func TestExchangeTraceQueryProjectsPreviousAsset(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + query := NewExchangeTraceQuery(tx, zap.NewNop()) + + card := createTraceCard(t, tx, 1, nil) + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID) + if err != nil { + t.Fatalf("查询无换货关系资产失败:%v", err) + } + if trace == nil || trace.PreviousAsset != nil || trace.NextAsset != nil { + t.Fatalf("无关系时应返回稳定空对象:%+v", trace) + } + + oldCard := createTraceCard(t, tx, 2, nil) + createTraceOrder(t, tx, "UR86-PREV-CARD", constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", constants.ExchangeAssetTypeIotCard, card.ID, "新卡快照", constants.ExchangeStatusCompleted, time.Now()) + trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, card.ID) + if err != nil { + t.Fatalf("查询卡前代失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldCard.ID, "历史卡快照", "UR86-PREV-CARD", true) + + oldDevice := createTraceDevice(t, tx, 3, nil) + newDevice := createTraceDevice(t, tx, 4, nil) + createTraceOrder(t, tx, "UR86-PREV-DEVICE", constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "新设备快照", constants.ExchangeStatusCompleted, time.Now()) + trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, newDevice.ID) + if err != nil { + t.Fatalf("查询设备前代失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeDevice, oldDevice.ID, "历史设备快照", "UR86-PREV-DEVICE", true) +} + +// TestExchangeTraceQueryHidesInvisiblePreviousAsset 验证关系查询不受权限过滤,但关联资产 ID 按当前权限隐藏。 +func TestExchangeTraceQueryHidesInvisiblePreviousAsset(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + visibleShop, hiddenShop := uint(86101), uint(86102) + oldCard := createTraceCard(t, tx, 11, &hiddenShop) + newCard := createTraceCard(t, tx, 12, &visibleShop) + createTraceOrder(t, tx, "UR86-PREV-HIDDEN", constants.ExchangeAssetTypeIotCard, oldCard.ID, "不可见历史快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "当前资产快照", constants.ExchangeStatusCompleted, time.Now()) + + ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop}) + trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeIotCard, newCard.ID) + if err != nil { + t.Fatalf("查询不可见前代失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false) + + emptyScope := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{}) + trace, err = NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(emptyScope, constants.ExchangeAssetTypeIotCard, newCard.ID) + if err != nil { + t.Fatalf("查询空权限范围前代失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, 0, "不可见历史快照", "UR86-PREV-HIDDEN", false) +} + +// TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations 验证仅未软删除的已完成换货形成前代。 +func TestExchangeTraceQueryIgnoresIncompleteAndDeletedPreviousRelations(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + query := NewExchangeTraceQuery(tx, zap.NewNop()) + oldCard := createTraceCard(t, tx, 21, nil) + statuses := []int{ + constants.ExchangeStatusPendingInfo, + constants.ExchangeStatusPendingShip, + constants.ExchangeStatusShipped, + constants.ExchangeStatusCancelled, + } + for index, status := range statuses { + newCard := createTraceCard(t, tx, 22+index, nil) + createTraceOrder(t, tx, fmt.Sprintf("UR86-PREV-STATUS-%d", status), constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", status, time.Now()) + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID) + if err != nil || trace.PreviousAsset != nil { + t.Fatalf("状态 %d 不应形成前代:trace=%+v err=%v", status, trace, err) + } + } + + deletedNew := createTraceCard(t, tx, 30, nil) + deletedOrder := createTraceOrder(t, tx, "UR86-PREV-DELETED", constants.ExchangeAssetTypeIotCard, oldCard.ID, "旧快照", constants.ExchangeAssetTypeIotCard, deletedNew.ID, "新快照", constants.ExchangeStatusCompleted, time.Now()) + if err := tx.Delete(deletedOrder).Error; err != nil { + t.Fatalf("软删除换货单失败:%v", err) + } + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, deletedNew.ID) + if err != nil || trace.PreviousAsset != nil { + t.Fatalf("软删除换货单不应形成前代:trace=%+v err=%v", trace, err) + } +} + +// TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly 验证重复完成记录按完成时间和主键确定性选择。 +func TestExchangeTraceQuerySelectsLatestPreviousRelationAndLogsAnomaly(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + core, logs := observer.New(zap.WarnLevel) + query := NewExchangeTraceQuery(tx, zap.New(core)) + newCard := createTraceCard(t, tx, 41, nil) + oldOne := createTraceCard(t, tx, 42, nil) + oldTwo := createTraceCard(t, tx, 43, nil) + completedAt := time.Now().Truncate(time.Second) + createTraceOrder(t, tx, "UR86-PREV-OLDER", constants.ExchangeAssetTypeIotCard, oldOne.ID, "较旧快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt) + createTraceOrder(t, tx, "UR86-PREV-LATEST", constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", constants.ExchangeAssetTypeIotCard, newCard.ID, "新快照", constants.ExchangeStatusCompleted, completedAt) + + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, newCard.ID) + if err != nil { + t.Fatalf("查询重复完成记录失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, oldTwo.ID, "最新快照", "UR86-PREV-LATEST", true) + if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 { + t.Fatalf("应记录重复换货异常日志:%v", logs.All()) + } +} + +// TestExchangeTraceQueryWrapsDatabaseErrors 验证查询故障不会降级为空关系。 +func TestExchangeTraceQueryWrapsDatabaseErrors(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + callbackName := "ur86:force_trace_query_error" + if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) { + db.AddError(fmt.Errorf("UR86 模拟数据库故障")) + }); err != nil { + t.Fatalf("注册数据库故障回调失败:%v", err) + } + t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) }) + + _, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, 1) + appErr, ok := err.(*errors.AppError) + if !ok || appErr.Code != errors.CodeDatabaseError { + t.Fatalf("数据库故障应转换为统一错误,实际:%v", err) + } +} + +// TestExchangeTraceQueryProjectsBidirectionalChain 验证 A→B→C 中间资产同时返回前代和后代。 +func TestExchangeTraceQueryProjectsBidirectionalChain(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + query := NewExchangeTraceQuery(tx, zap.NewNop()) + a := createTraceCard(t, tx, 51, nil) + b := createTraceCard(t, tx, 52, nil) + c := createTraceCard(t, tx, 53, nil) + createTraceOrder(t, tx, "UR86-CHAIN-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute)) + createTraceOrder(t, tx, "UR86-CHAIN-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now()) + + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID) + if err != nil { + t.Fatalf("查询双向换货链失败:%v", err) + } + assertTraceAsset(t, trace.PreviousAsset, constants.ExchangeAssetTypeIotCard, a.ID, "快照A", "UR86-CHAIN-AB", true) + assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, c.ID, "快照C", "UR86-CHAIN-BC", true) + + trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, a.ID) + if err != nil || trace.PreviousAsset != nil { + t.Fatalf("链首不应有前代:trace=%+v err=%v", trace, err) + } + assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, b.ID, "快照B", "UR86-CHAIN-AB", true) +} + +// TestExchangeTraceQueryHidesInvisibleNextAsset 验证后代不可见时保留快照但隐藏 ID。 +func TestExchangeTraceQueryHidesInvisibleNextAsset(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + visibleShop, hiddenShop := uint(86201), uint(86202) + oldDevice := createTraceDevice(t, tx, 61, &visibleShop) + newDevice := createTraceDevice(t, tx, 62, &hiddenShop) + createTraceOrder(t, tx, "UR86-NEXT-HIDDEN", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧设备快照", constants.ExchangeAssetTypeDevice, newDevice.ID, "不可见新设备快照", constants.ExchangeStatusCompleted, time.Now()) + ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{visibleShop}) + + trace, err := NewExchangeTraceQuery(tx, zap.NewNop()).Resolve(ctx, constants.ExchangeAssetTypeDevice, oldDevice.ID) + if err != nil { + t.Fatalf("查询不可见后代失败:%v", err) + } + assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, 0, "不可见新设备快照", "UR86-NEXT-HIDDEN", false) +} + +// TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing 验证历史完成单缺少新资产 ID 时仍保留后代文本。 +func TestExchangeTraceQueryKeepsNextSnapshotWhenAssetIDMissing(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + core, logs := observer.New(zap.WarnLevel) + oldCard := createTraceCard(t, tx, 63, nil) + completedAt := time.Now() + order := &model.ExchangeOrder{ + ExchangeNo: "UR86-NEXT-MISSING-ID", FlowType: constants.ExchangeFlowTypeDirect, + OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "旧快照", + NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetIdentifier: "缺失 ID 的后代快照", + ExchangeReason: "UR86 历史异常测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt, + } + if err := tx.Create(order).Error; err != nil { + t.Fatalf("创建缺少新资产 ID 的历史换货单失败:%v", err) + } + + trace, err := NewExchangeTraceQuery(tx, zap.New(core)).Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, oldCard.ID) + if err != nil { + t.Fatalf("查询缺少新资产 ID 的后代失败:%v", err) + } + assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeIotCard, 0, "缺失 ID 的后代快照", "UR86-NEXT-MISSING-ID", false) + if logs.FilterMessage("已完成换货记录缺少新资产 ID").Len() != 1 { + t.Fatalf("应记录历史换货单缺少新资产 ID 的异常:%v", logs.All()) + } +} + +// TestExchangeTraceQueryProvidesBatchRelationshipQueries 验证前代和后代关系支持批量查询。 +func TestExchangeTraceQueryProvidesBatchRelationshipQueries(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + a := createTraceCard(t, tx, 64, nil) + b := createTraceCard(t, tx, 65, nil) + c := createTraceDevice(t, tx, 66, nil) + d := createTraceDevice(t, tx, 67, nil) + createTraceOrder(t, tx, "UR86-BATCH-CARD", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now()) + createTraceOrder(t, tx, "UR86-BATCH-DEVICE", constants.ExchangeAssetTypeDevice, c.ID, "快照C", constants.ExchangeAssetTypeDevice, d.ID, "快照D", constants.ExchangeStatusCompleted, time.Now()) + query := NewExchangeTraceQuery(tx, zap.NewNop()) + + previousRefs := []ExchangeTraceAssetRef{{AssetType: constants.AssetResolveTypeCard, AssetID: b.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: d.ID}} + previous, err := query.FindPreviousCompleted(context.Background(), previousRefs) + if err != nil || len(previous[ExchangeTraceAssetRef{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: b.ID}]) != 1 || len(previous[previousRefs[1]]) != 1 { + t.Fatalf("批量前代查询错误:result=%+v err=%v", previous, err) + } + nextRefs := []ExchangeTraceAssetRef{{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: a.ID}, {AssetType: constants.ExchangeAssetTypeDevice, AssetID: c.ID}} + next, err := query.FindNextCompleted(context.Background(), nextRefs) + if err != nil || len(next[nextRefs[0]]) != 1 || len(next[nextRefs[1]]) != 1 { + t.Fatalf("批量后代查询错误:result=%+v err=%v", next, err) + } +} + +// TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation 验证后代过滤、确定性选择和异常日志。 +func TestExchangeTraceQueryIgnoresIncompleteDeletedAndSelectsLatestNextRelation(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + core, logs := observer.New(zap.WarnLevel) + query := NewExchangeTraceQuery(tx, zap.New(core)) + oldDevice := createTraceDevice(t, tx, 71, nil) + statuses := []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, constants.ExchangeStatusCancelled} + for index, status := range statuses { + candidate := createTraceDevice(t, tx, 72+index, nil) + createTraceOrder(t, tx, fmt.Sprintf("UR86-NEXT-STATUS-%d", status), constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, candidate.ID, "候选快照", status, time.Now()) + } + deletedCandidate := createTraceDevice(t, tx, 80, nil) + deletedOrder := createTraceOrder(t, tx, "UR86-NEXT-DELETED", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, deletedCandidate.ID, "软删除快照", constants.ExchangeStatusCompleted, time.Now()) + if err := tx.Delete(deletedOrder).Error; err != nil { + t.Fatalf("软删除后代换货单失败:%v", err) + } + + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID) + if err != nil || trace.NextAsset != nil { + t.Fatalf("非完成和软删除记录不应形成后代:trace=%+v err=%v", trace, err) + } + + newOne := createTraceDevice(t, tx, 81, nil) + newTwo := createTraceDevice(t, tx, 82, nil) + completedAt := time.Now().Truncate(time.Second) + createTraceOrder(t, tx, "UR86-NEXT-OLDER", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newOne.ID, "较旧后代快照", constants.ExchangeStatusCompleted, completedAt) + createTraceOrder(t, tx, "UR86-NEXT-LATEST", constants.ExchangeAssetTypeDevice, oldDevice.ID, "旧快照", constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", constants.ExchangeStatusCompleted, completedAt) + trace, err = query.Resolve(context.Background(), constants.ExchangeAssetTypeDevice, oldDevice.ID) + if err != nil { + t.Fatalf("查询重复后代记录失败:%v", err) + } + assertTraceAsset(t, trace.NextAsset, constants.ExchangeAssetTypeDevice, newTwo.ID, "最新后代快照", "UR86-NEXT-LATEST", true) + if logs.FilterMessage("检测到同方向多条已完成换货记录").Len() != 1 { + t.Fatalf("应记录后代重复换货异常日志:%v", logs.All()) + } +} + +// TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets 验证 SQL 次数固定且读取性能满足项目目标。 +func TestExchangeTraceQueryUsesFixedQueriesAndMeetsPerformanceTargets(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + a := createTraceCard(t, tx, 91, nil) + b := createTraceCard(t, tx, 92, nil) + c := createTraceCard(t, tx, 93, nil) + createTraceOrder(t, tx, "UR86-PERF-AB", constants.ExchangeAssetTypeIotCard, a.ID, "快照A", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeStatusCompleted, time.Now().Add(-time.Minute)) + createTraceOrder(t, tx, "UR86-PERF-BC", constants.ExchangeAssetTypeIotCard, b.ID, "快照B", constants.ExchangeAssetTypeIotCard, c.ID, "快照C", constants.ExchangeStatusCompleted, time.Now()) + counter := &traceQueryCounter{Interface: tx.Logger} + query := NewExchangeTraceQuery(tx.Session(&gorm.Session{Logger: counter}), zap.NewNop()) + + durations := make([]time.Duration, 30) + for index := range durations { + startedAt := time.Now() + trace, err := query.Resolve(context.Background(), constants.ExchangeAssetTypeIotCard, b.ID) + durations[index] = time.Since(startedAt) + if err != nil || trace.PreviousAsset == nil || trace.NextAsset == nil { + t.Fatalf("第 %d 次性能查询失败:trace=%+v err=%v", index+1, trace, err) + } + } + if counter.count.Load() != 90 { + t.Fatalf("卡链每次应固定执行 3 条 SQL,实际总数:%d", counter.count.Load()) + } + sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] }) + p95, p99 := durations[28], durations[29] + if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond { + t.Fatalf("换货链 Query 超过 API 性能目标:p95=%s p99=%s", p95, p99) + } +} + +// TestExchangeTraceIndexDefinitionAndPlans 验证双向部分索引定义及代表性查询计划。 +func TestExchangeTraceIndexDefinitionAndPlans(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + indexNames := []string{"idx_exchange_trace_new_asset", "idx_exchange_trace_old_asset"} + for _, indexName := range indexNames { + var count int64 + if err := tx.Raw("SELECT COUNT(*) FROM pg_class WHERE relname = ?", indexName).Scan(&count).Error; err != nil { + t.Fatalf("查询换货链索引数量失败:%v", err) + } + if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" { + if count != 0 { + t.Fatalf("回滚后索引 %s 仍然存在", indexName) + } + continue + } + if count != 1 { + t.Fatalf("换货链索引 %s 数量异常:%d", indexName, count) + } + + var index struct { + AccessMethod string `gorm:"column:access_method"` + IsUnique bool `gorm:"column:is_unique"` + Definition string `gorm:"column:definition"` + Predicate string `gorm:"column:predicate"` + } + err := tx.Raw(` + SELECT am.amname AS access_method, + ix.indisunique AS is_unique, + pg_get_indexdef(ix.indexrelid) AS definition, + pg_get_expr(ix.indpred, ix.indrelid) AS predicate + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_am am ON am.oid = i.relam + WHERE i.relname = ? + `, indexName).Scan(&index).Error + if err != nil { + t.Fatalf("查询换货链索引定义失败:%v", err) + } + if index.AccessMethod != "btree" || index.IsUnique || !strings.Contains(index.Definition, "completed_at DESC NULLS LAST") || !strings.Contains(index.Definition, "id DESC") || !strings.Contains(index.Predicate, "status = 4") || !strings.Contains(index.Predicate, "deleted_at IS NULL") { + t.Fatalf("换货链索引定义不符合契约:%+v", index) + } + } + if os.Getenv("UR86_EXPECT_INDEX_ABSENT") == "1" { + return + } + + for direction, sql := range map[string]string{ + "previous": "SELECT * FROM tb_exchange_order WHERE new_asset_type = 'iot_card' AND new_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1", + "next": "SELECT * FROM tb_exchange_order WHERE old_asset_type = 'iot_card' AND old_asset_id = 1 AND status = 4 AND deleted_at IS NULL ORDER BY completed_at DESC NULLS LAST, id DESC LIMIT 1", + } { + var planLines []string + if err := tx.Exec("SET LOCAL enable_seqscan = off").Error; err != nil { + t.Fatalf("配置查询计划测试失败:%v", err) + } + if err := tx.Raw("EXPLAIN " + sql).Scan(&planLines).Error; err != nil { + t.Fatalf("记录 %s 查询计划失败:%v", direction, err) + } + plan := strings.Join(planLines, "\n") + expectedIndex := "idx_exchange_trace_old_asset" + if direction == "previous" { + expectedIndex = "idx_exchange_trace_new_asset" + } + if !strings.Contains(plan, expectedIndex) { + t.Fatalf("%s 查询计划未使用预期索引 %s:%s", direction, expectedIndex, plan) + } + } +} + +func createTraceCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard { + t.Helper() + iccid := fmt.Sprintf("8986222222222222%04d", suffix) + card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock} + if err := tx.Create(card).Error; err != nil { + t.Fatalf("创建换货链测试卡失败:%v", err) + } + return card +} + +func createTraceDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device { + t.Helper() + device := &model.Device{VirtualNo: fmt.Sprintf("UR86-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86222222222%04d", suffix), SN: fmt.Sprintf("UR86-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock} + if err := tx.Create(device).Error; err != nil { + t.Fatalf("创建换货链测试设备失败:%v", err) + } + return device +} + +func createTraceOrder(t *testing.T, tx *gorm.DB, exchangeNo, oldType string, oldID uint, oldIdentifier, newType string, newID uint, newIdentifier string, status int, completedAt time.Time) *model.ExchangeOrder { + t.Helper() + order := &model.ExchangeOrder{ + ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, + OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: oldIdentifier, + NewAssetType: newType, NewAssetID: &newID, NewAssetIdentifier: newIdentifier, + ExchangeReason: "UR86 换货链测试", Status: status, + } + if status == constants.ExchangeStatusCompleted { + order.CompletedAt = &completedAt + } + if err := tx.Create(order).Error; err != nil { + t.Fatalf("创建换货链测试单失败:%v", err) + } + return order +} + +func assertTraceAsset(t *testing.T, actual *dto.AssetExchangeTraceItem, assetType string, assetID uint, identifier, exchangeNo string, canView bool) { + t.Helper() + if actual == nil { + t.Fatal("换货关联项不应为空") + } + if actual.AssetType != assetType || actual.Identifier != identifier || actual.ExchangeNo != exchangeNo || actual.CanView != canView { + t.Fatalf("换货关联项错误:%+v", actual) + } + if canView { + if actual.AssetID == nil || *actual.AssetID != assetID { + t.Fatalf("可见关联资产应返回真实 ID:%+v", actual) + } + } else if actual.AssetID != nil { + t.Fatalf("不可见关联资产不应返回 ID:%+v", actual) + } +} + +type traceQueryCounter struct { + logger.Interface + count atomic.Int64 +} + +func (l *traceQueryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + l.count.Add(1) + l.Interface.Trace(ctx, begin, fc, err) +} diff --git a/internal/routes/asset.go b/internal/routes/asset.go index 13c23da..6bbc04d 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -14,7 +14,7 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Register(assets, doc, groupPath, "GET", "/resolve/:identifier", handler.Resolve, RouteSpec{ Summary: "解析资产", - Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。企业账号禁止调用。", + Description: "通过虚拟号/ICCID/IMEI/SN/MSISDN 解析设备或卡的完整详情。exchange_trace 始终存在,previous_asset/next_asset 无关系时为 null;关联项仅在 can_view=true 且 asset_id 非空时允许跳转。企业账号禁止调用。", Tags: []string{"资产管理"}, Input: new(dto.AssetResolveRequest), Output: new(dto.AssetResolveResponse), diff --git a/internal/routes/asset_exchange_trace_integration_test.go b/internal/routes/asset_exchange_trace_integration_test.go new file mode 100644 index 0000000..c7de9b7 --- /dev/null +++ b/internal/routes/asset_exchange_trace_integration_test.go @@ -0,0 +1,268 @@ +package routes + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "strconv" + "testing" + "time" + + "github.com/bytedance/sonic" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/bootstrap" + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" + assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/auth" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/database" + "github.com/break/junhong_cmp_fiber/pkg/errors" + pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。 +func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) { + env := newAssetTraceHTTPEnv(t) + visibleShop := env.createShop(t, "UR86 可见店铺", nil, 1) + hiddenShop := env.createShop(t, "UR86 不可见店铺", nil, 1) + oldCard := env.createCard(t, 1, &hiddenShop.ID) + middleCard := env.createCard(t, 2, &visibleShop.ID) + nextCard := env.createCard(t, 3, &visibleShop.ID) + env.createOrder(t, "UR86-HTTP-PREV", oldCard, middleCard, "HTTP 历史前代快照", "HTTP 中间快照", time.Now().Add(-time.Minute)) + env.createOrder(t, "UR86-HTTP-NEXT", middleCard, nextCard, "HTTP 中间快照", "HTTP 历史后代快照", time.Now()) + token := env.newAgentToken(t, visibleShop.ID) + + status, body := env.request(t, "/api/admin/assets/resolve/"+middleCard.ICCID, token) + if status != http.StatusOK { + t.Fatalf("查询中间资产失败,状态 %d:%s", status, body) + } + var success struct { + Code int `json:"code"` + Data struct { + AssetID uint `json:"asset_id"` + ExchangeTrace struct { + PreviousAsset *struct { + AssetID *uint `json:"asset_id"` + Identifier string `json:"identifier"` + ExchangeNo string `json:"exchange_no"` + CanView bool `json:"can_view"` + } `json:"previous_asset"` + NextAsset *struct { + AssetID *uint `json:"asset_id"` + Identifier string `json:"identifier"` + ExchangeNo string `json:"exchange_no"` + CanView bool `json:"can_view"` + } `json:"next_asset"` + } `json:"exchange_trace"` + } `json:"data"` + } + if err := sonic.Unmarshal(body, &success); err != nil { + t.Fatalf("解析资产详情响应失败:%v", err) + } + if success.Code != errors.CodeSuccess || success.Data.AssetID != middleCard.ID || success.Data.ExchangeTrace.PreviousAsset == nil || success.Data.ExchangeTrace.NextAsset == nil { + t.Fatalf("双向换货响应不完整:%s", body) + } + previous := success.Data.ExchangeTrace.PreviousAsset + if previous.CanView || previous.AssetID != nil || previous.Identifier != "HTTP 历史前代快照" || previous.ExchangeNo != "UR86-HTTP-PREV" { + t.Fatalf("不可见前代未按契约隐藏 ID:%s", body) + } + next := success.Data.ExchangeTrace.NextAsset + if !next.CanView || next.AssetID == nil || *next.AssetID != nextCard.ID || next.Identifier != "HTTP 历史后代快照" || next.ExchangeNo != "UR86-HTTP-NEXT" { + t.Fatalf("可见后代未按契约返回:%s", body) + } + + noTraceCard := env.createCard(t, 4, &visibleShop.ID) + status, body = env.request(t, "/api/admin/assets/resolve/"+noTraceCard.ICCID, token) + assertHTTPTraceDirections(t, status, body, false, false) + + previousOnlyOld := env.createCard(t, 5, &visibleShop.ID) + previousOnlyNew := env.createCard(t, 6, &visibleShop.ID) + env.createOrder(t, "UR86-HTTP-PREV-ONLY", previousOnlyOld, previousOnlyNew, "仅前代旧快照", "仅前代新快照", time.Now()) + status, body = env.request(t, "/api/admin/assets/resolve/"+previousOnlyNew.ICCID, token) + assertHTTPTraceDirections(t, status, body, true, false) + + nextOnlyOld := env.createCard(t, 7, &visibleShop.ID) + nextOnlyNew := env.createCard(t, 8, &visibleShop.ID) + env.createOrder(t, "UR86-HTTP-NEXT-ONLY", nextOnlyOld, nextOnlyNew, "仅后代旧快照", "仅后代新快照", time.Now()) + status, body = env.request(t, "/api/admin/assets/resolve/"+nextOnlyOld.ICCID, token) + assertHTTPTraceDirections(t, status, body, false, true) + + status, body = env.request(t, "/api/admin/assets/resolve/"+oldCard.ICCID, token) + if status != http.StatusNotFound { + t.Fatalf("当前资产无权限时应维持防枚举响应,状态 %d:%s", status, body) + } + var denied struct { + Code int `json:"code"` + Data any `json:"data"` + } + if err := sonic.Unmarshal(body, &denied); err != nil || denied.Code != errors.CodeNotFound || denied.Data != nil { + t.Fatalf("当前资产无权限响应不符合原契约:%s", body) + } +} + +func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("资产详情状态错误,实际 %d:%s", status, body) + } + var response struct { + Data struct { + ExchangeTrace struct { + PreviousAsset any `json:"previous_asset"` + NextAsset any `json:"next_asset"` + } `json:"exchange_trace"` + } `json:"data"` + } + if err := sonic.Unmarshal(body, &response); err != nil { + t.Fatalf("解析换货方向响应失败:%v", err) + } + if (response.Data.ExchangeTrace.PreviousAsset != nil) != hasPrevious || (response.Data.ExchangeTrace.NextAsset != nil) != hasNext { + t.Fatalf("换货方向空值语义错误:previous=%v next=%v body=%s", hasPrevious, hasNext, body) + } +} + +type assetTraceHTTPEnv struct { + app *fiber.App + db *gorm.DB + redis *redis.Client + tokenManager *auth.TokenManager +} + +func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv { + t.Helper() + if os.Getenv("JUNHONG_DATABASE_HOST") == "" || os.Getenv("JUNHONG_REDIS_ADDRESS") == "" { + t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 和 Redis 的资产换货链 HTTP 集成测试") + } + cfg, err := config.Load() + if err != nil { + t.Fatalf("加载测试配置失败:%v", err) + } + log := zap.NewNop() + db, err := database.InitPostgreSQL(&cfg.Database, log) + if err != nil { + t.Fatalf("连接 PostgreSQL 失败:%v", err) + } + tx := db.Begin() + if tx.Error != nil { + t.Fatalf("开启资产换货链测试事务失败:%v", tx.Error) + } + redisClient, err := database.NewRedisClient(database.RedisConfig{ + Address: cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port), Password: cfg.Redis.Password, DB: cfg.Redis.DB, + PoolSize: cfg.Redis.PoolSize, MinIdleConns: cfg.Redis.MinIdleConns, + DialTimeout: cfg.Redis.DialTimeout, ReadTimeout: cfg.Redis.ReadTimeout, WriteTimeout: cfg.Redis.WriteTimeout, + }, log) + if err != nil { + t.Fatalf("连接 Redis 失败:%v", err) + } + t.Cleanup(func() { + _ = tx.Rollback().Error + _ = redisClient.Close() + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + }) + + shopStore := postgres.NewShopStore(tx, redisClient) + deviceStore := postgres.NewDeviceStore(tx, redisClient) + cardStore := postgres.NewIotCardStore(tx, redisClient) + assetSvc := assetService.New( + tx, deviceStore, cardStore, + postgres.NewPackageUsageStore(tx, redisClient), postgres.NewPackageStore(tx), postgres.NewPackageSeriesStore(tx), + postgres.NewDeviceSimBindingStore(tx, redisClient), shopStore, redisClient, nil, nil, + postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient), + postgres.NewExchangeOrderStore(tx), nil, + ) + handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log)) + tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL) + authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{ + TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) { + info, validateErr := tokenManager.ValidateAccessToken(context.Background(), token) + if validateErr != nil { + return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期") + } + return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, Username: info.Username, ShopID: info.ShopID}, nil + }, + ShopStore: shopStore, + }) + app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)}) + RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin") + return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager} +} + +func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop { + t.Helper() + shop := &model.Shop{ShopName: name, ShopCode: fmt.Sprintf("UR86-%d", time.Now().UnixNano()), ParentID: parentID, Level: level, Status: constants.ShopStatusEnabled} + if err := e.db.Create(shop).Error; err != nil { + t.Fatalf("创建资产换货链测试店铺失败:%v", err) + } + return shop +} + +func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *model.IotCard { + t.Helper() + iccid := fmt.Sprintf("8986333333333333%04d", suffix) + card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], VirtualNo: fmt.Sprintf("UR86-HTTP-CARD-%04d", suffix), ShopID: shopID, Status: constants.IotCardStatusInStock, AssetStatus: constants.AssetStatusInStock} + if err := e.db.Create(card).Error; err != nil { + t.Fatalf("创建资产换货链 HTTP 测试卡失败:%v", err) + } + return card +} + +func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) { + t.Helper() + newID := newCard.ID + order := &model.ExchangeOrder{ + ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, + OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: oldSnapshot, + NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: newSnapshot, + ExchangeReason: "UR86 HTTP 测试", Status: constants.ExchangeStatusCompleted, CompletedAt: &completedAt, + } + if err := e.db.Create(order).Error; err != nil { + t.Fatalf("创建资产换货链 HTTP 测试单失败:%v", err) + } +} + +func (e *assetTraceHTTPEnv) newAgentToken(t *testing.T, shopID uint) string { + t.Helper() + info := &auth.TokenInfo{UserID: uint(time.Now().UnixNano() % 1_000_000_000), UserType: constants.UserTypeAgent, ShopID: shopID, Username: "UR86测试代理"} + accessToken, refreshToken, err := e.tokenManager.GenerateTokenPair(context.Background(), info) + if err != nil { + t.Fatalf("创建资产换货链测试令牌失败:%v", err) + } + t.Cleanup(func() { + _ = e.tokenManager.RevokeToken(context.Background(), accessToken) + _ = e.tokenManager.RevokeToken(context.Background(), refreshToken) + _ = e.redis.Del(context.Background(), constants.RedisUserTokensKey(info.UserID)).Err() + }) + return accessToken +} + +func (e *assetTraceHTTPEnv) request(t *testing.T, path, token string) (int, []byte) { + t.Helper() + request, err := http.NewRequest(http.MethodGet, path, nil) + if err != nil { + t.Fatalf("创建资产换货链 HTTP 请求失败:%v", err) + } + request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token) + response, err := e.app.Test(request, -1) + if err != nil { + t.Fatalf("执行资产换货链 HTTP 请求失败:%v", err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatalf("读取资产换货链 HTTP 响应失败:%v", err) + } + return response.StatusCode, body +} diff --git a/migrations/000161_add_exchange_trace_indexes.down.sql b/migrations/000161_add_exchange_trace_indexes.down.sql new file mode 100644 index 0000000..6528e9b --- /dev/null +++ b/migrations/000161_add_exchange_trace_indexes.down.sql @@ -0,0 +1,3 @@ +-- 回滚换货链双向查询索引,不修改换货单业务数据。 +DROP INDEX IF EXISTS idx_exchange_trace_old_asset; +DROP INDEX IF EXISTS idx_exchange_trace_new_asset; diff --git a/migrations/000161_add_exchange_trace_indexes.up.sql b/migrations/000161_add_exchange_trace_indexes.up.sql new file mode 100644 index 0000000..afcd3ce --- /dev/null +++ b/migrations/000161_add_exchange_trace_indexes.up.sql @@ -0,0 +1,8 @@ +-- 为已完成且未软删除的换货链双向最新记录查询增加非唯一部分索引。 +CREATE INDEX idx_exchange_trace_new_asset + ON tb_exchange_order USING btree (new_asset_type, new_asset_id, completed_at DESC NULLS LAST, id DESC) + WHERE status = 4 AND deleted_at IS NULL; + +CREATE INDEX idx_exchange_trace_old_asset + ON tb_exchange_order USING btree (old_asset_type, old_asset_id, completed_at DESC NULLS LAST, id DESC) + WHERE status = 4 AND deleted_at IS NULL; diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index de8e2be..3caa2a6 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -97,6 +97,15 @@ const ( UserTypePersonalCustomer = 5 // 个人客户(C端用户) ) +const ( + // AssetResolveTypeCard 表示统一资产详情中的卡类型。 + AssetResolveTypeCard = "card" + // ExchangeTraceDirectionPrevious 表示换货链前代查询方向。 + ExchangeTraceDirectionPrevious = "previous" + // ExchangeTraceDirectionNext 表示换货链后代查询方向。 + ExchangeTraceDirectionNext = "next" +) + // RBAC 角色类型常量 const ( RoleTypePlatform = 1 // 平台角色(适用于平台用户) diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index ba5dcba..5219757 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -58,7 +58,7 @@ func BuildDocHandlers() *bootstrap.Handlers { PollingAlert: admin.NewPollingAlertHandler(nil), PollingCleanup: admin.NewPollingCleanupHandler(nil), PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), - Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil), + Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil, nil), AssetLifecycle: admin.NewAssetLifecycleHandler(nil), AssetWallet: admin.NewAssetWalletHandler(nil), WechatConfig: admin.NewWechatConfigHandler(nil),