6 Commits

Author SHA1 Message Date
c7f8b4c702 批量换货脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
2026-07-22 19:09:28 +09:00
c58773e35b 实现资产双向换货链路查询 2026-07-22 18:05:38 +09:00
7c8a4cd328 ur40issues 2026-07-22 17:49:25 +09:00
a278a80b34 ur38 issues 2026-07-22 17:25:12 +09:00
031a875ec1 ur36issues 2026-07-22 17:25:05 +09:00
86c38d46c5 ur37 issues 2026-07-22 16:58:26 +09:00
64 changed files with 3066 additions and 40 deletions

View File

@@ -0,0 +1,19 @@
# 01 — 统一资产标识的批量唯一解析
**What to build:** 批量订购可以通过系统唯一的资产解析能力,一次解析一组用户输入的资产标识,并稳定得到资产类型、资产 ID 和规范标识。ICCID、卡虚拟号、MSISDN、设备虚拟号、IMEI 和 SN 使用与其他业务相同的注册规则;未命中或历史脏数据导致多命中时明确失败,不能任取一条。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**架构通道:** Application + Port/Adapter。
**完整业务边界:** 本票深化公共 Asset 解析接缝,使单个和批量调用共享唯一匹配语义与规范标识选择规则。明确不实现批量订购任务、不迁移资产列表或生命周期逻辑、不在批量模块维护第二套标识白名单。
- [ ] 公共解析结果包含原始输入对应的资产类型、资产 ID、规范标识和稳定失败分类并能区分未命中与多命中。
- [ ] ICCID、卡虚拟号、唯一 MSISDN、设备虚拟号、IMEI 和 SN 均通过现有全局标识注册规则解析;新增调用方无需复制识别分支。
- [ ] 同一资产使用不同受支持标识时得到相同资产类型与 ID规范标识由公共能力统一决定。
- [ ] 历史脏数据造成多条候选时返回明确歧义,单个和批量调用均不得使用 `First` 或其他任取逻辑。
- [ ] 批量接口以集合查询或等价批处理方式完成解析,避免按行跨卡表、设备表和标识表形成 N+1。
- [ ] 测试覆盖全部支持标识、首尾空白、未命中、多命中、不同标识指向同一资产及批量查询次数边界。

View File

@@ -0,0 +1,19 @@
# 02 — 交付受控直传与对象归属证明
**What to build:** 操作员可以为批量订购 CSV 和线下凭证申请受控上传地址并直传真实私有 S3业务接口随后能够证明对象由当前账号按声明用途申请、已经真实上传且元数据符合约束。上传授权可原子绑定业务任务已绑定 Key 不能被其他账号或其他批次复用。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为简单写 Application。
**完整业务边界:** 本票收口预签名上传授权、对象 Head 元数据和业务绑定能力,并增加 `bulk_purchase` 受控用途。明确不解析 CSV、不创建批量任务、不改变其他对象存储消费者的业务语义、不实现内存 Provider。
- [ ] `bulk_purchase` 用途只签发批量订购目录下的 `.csv` Key并限制为允许的 CSV Content-Type附件继续复用现有 `attachment` 用途和文件类型规则。
- [ ] 上传授权稳定保存对象 Key、用途、申请账号、声明文件名、声明 Content-Type 和签发时间,不以目录前缀推断上传主体。
- [ ] 存储 Provider 可以查询对象是否存在、实际大小和实际 Content-Type并以统一安全错误区分不存在、类型不符、过大和基础设施失败。
- [ ] 授权绑定使用业务类型、业务 ID 和绑定时间形成持久事实;同一 Key 的并发绑定最终由数据库约束裁决。
- [ ] 相同业务幂等重试可以识别原绑定,其他账号、其他用途或其他批次复用同一 Key 时明确冲突。
- [ ] 真实 S3 集成测试覆盖预签名上传、Head、下载、精确删除、10MB 边界、伪造类型、对象不存在、跨账号、跨用途和重复绑定。

View File

@@ -0,0 +1,19 @@
# 03 — 建立批量任务真实依赖的集成测试 Harness
**What to build:** Agent 和开发者可以通过一个可复用的 Go 集成测试 Harness安全驱动真实 PostgreSQL、Redis DB 7、真实 S3、Fiber 后台认证和公开 Worker Handler。每次运行只创建、记录和清理带唯一运行标识的资源测试失败也不会清空或污染共享环境。
**Blocked by:** `.scratch/ur36-bulk-package-purchase/issues/02-controlled-upload-object-ownership.md` — 02 — 交付受控直传与对象归属证明
**Status:** ready-for-agent
**架构通道:** Infrastructure。
**完整业务边界:** 本票收口本需求及后续同类批量任务需要的安全集成测试基础设施。明确不替换全仓测试框架、不创建新 PostgreSQL 数据库、不使用 S3 内存替身、不通过等待真实后台 Worker 抢任务完成断言。
- [ ] Harness 启动时读取最终生效配置,并确认普通 Redis Client、Asynq Client 和 Worker Server 均使用 DB 7任一不符时在写入前失败。
- [ ] Harness 支持唯一 `run_id`、测试账号与真实认证、Fiber 请求、统一响应解码、真实 S3 上传及公开 Worker Handler 驱动。
- [ ] 待投递任务可在应用公开注入点被受控捕获,并以结构化 `task_id` 调用 Handler不依赖 `sleep` 或私有处理函数。
- [ ] PostgreSQL、Redis 和 S3 资源进入精确清理台账成功和失败清理只操作本次创建的主键、Key 和对象,不执行 TRUNCATE、FLUSHDB、前缀扫描或 Bucket 清理。
- [ ] 清理失败输出安全诊断和未清理资源标识不输出数据库密码、Token、对象存储密钥、预签名 URL 或完整敏感数据。
- [ ] 固定 `testdata` 覆盖 UTF-8、BOM、非法表头、重复资产、多代理、钱包不足和线下凭证并至少提供一个穿过公开 HTTP 与 Worker 边界的示例。

View File

@@ -0,0 +1,19 @@
# 04 — 统一后台与批量入口的套餐可售策略
**What to build:** 后台单笔订购和批量单行订购可以调用同一套餐可售策略,按写入时的资产、结算代理、套餐授权、渠道状态、价格和操作场景得到一致结论。批量或后台主体不能进入个人历史用户的下架续费例外,也不能绕过禁用、未授权或组合限制。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**架构通道:** 复杂写前置 Domain Policy。
**完整业务边界:** 本票收口 UR#36 实际触碰的后台套餐购买策略接缝,并与 UR#40 的统一策略语义兼容。明确不实现 C 端下架续费页面、不迁移套餐管理、不迁移未触碰的订单流程、不在 Query 快照上做最终写入判断。
- [ ] 策略输入显式包含操作主体、资产及当前世代、结算代理、套餐、销售渠道和操作场景,不依赖可被遗漏的隐式布尔开关。
- [ ] 套餐全局禁用、渠道下架、授权缺失或失效、价格异常、赠送及既有套餐组合限制均返回稳定业务失败。
- [ ] 后台单笔和批量场景遇到下架套餐固定拒绝,不能使用个人客户的 `renew_only` 历史资格。
- [ ] 结算金额来自结算代理当前有效授权成本和现有定价规则,前端或 CSV 无法提交价格覆盖策略结果。
- [ ] 写命令在事务前后按最新事实重新校验必要不变量,不能把查询接口先前返回的资格当作授权凭证。
- [ ] 测试覆盖平台及代理渠道、启用与禁用、上架与下架、授权删除或失效、价格低于成本、组合限制,以及后台与批量结果一致性。

View File

@@ -0,0 +1,23 @@
# 05 — 提供统一的单资产套餐订购命令
**What to build:** 现有后台单笔入口和批量任务中的一个业务行可以调用同一个单资产套餐订购命令,按资产当前归属确定结算代理,完成套餐可售校验、定价、订单及套餐激活编排。钱包支付通过统一 Wallet Application/Port 接缝完成,线下支付生成符合现有后台语义的已支付订单。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/04-unified-admin-package-purchasability.md` — 04 — 统一后台与批量入口的套餐可售策略
- `.scratch/ur38-agent-main-wallet-credit/issues/05-unified-agent-wallet-debit.md` — 05 — 统一代理订单、代购与钱包支付扣款
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写Application UseCase → Order/Package Domain → Repository/InfrastructureWallet Application/Port 为辅助通道。
**完整业务边界:** 本票迁移后台单资产套餐订购这一最小完整用例,收口资产归属、套餐可售与定价、订单、订单明细、套餐使用及激活编排。钱包领域只通过 UR#38 的稳定接缝协作。明确不迁移其他订单创建、支付、退款、佣金或资产钱包流程。
- [ ] 命令只接收规范资产、套餐、支付方式、凭证快照、操作者和稳定业务幂等标识,不接受可信 `shop_id`、结算价格或套餐可售结论。
- [ ] 每次执行按资产当前归属解析结算代理,并调用统一套餐可售策略重新校验授权、状态、定价及现有订单不变量。
- [ ] `offline` 创建已支付订单、订单明细和套餐使用并完成现有激活语义,不扣代理钱包;凭证关联作为业务快照保留。
- [ ] `wallet` 只调用 UR#38 提供的 Wallet Application/Port 接缝UR#36 不复制信用公式、不直接更新代理钱包模型、不调用旧钱包 Store 扣款条件、不重新实现钱包流水或信用并发规则。
- [ ] 单次成功订购中的订单、套餐使用与激活、结算代理和价格快照、明细关联、审计及可靠事件原子提交;任何失败不留下部分事实。
- [ ] 现有后台单笔 HTTP 契约保持不变并迁移为调用该命令;旧订单 Service 如保留,只能作为本用例的内部迁移门面。
- [ ] 测试覆盖钱包与线下、不同代理归属、未授权、下架、价格异常、资产状态与组合规则、幂等重试及事务回滚。

View File

@@ -0,0 +1,28 @@
# 06 — 幂等创建批量订购任务
**What to build:** 通过现有后台认证的操作员可以提交唯一 `request_id`、单个套餐、统一支付方式、CSV Key 和凭证 Key 创建批量订购任务。接口在返回已接收任务前验证对象归属和真实元数据,并在同一事务保存套餐与操作者快照、上传绑定、请求指纹、任务事实、创建审计和可靠投递事件。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/02-controlled-upload-object-ownership.md` — 02 — 交付受控直传与对象归属证明
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
- `.scratch/tech-public-foundation/issues/05-command-idempotency-contract.md` — 05 — 提供创建命令幂等与并发职责契约
- `.scratch/tech-public-foundation/issues/06-unified-async-task-contract.md` — 06 — 冻结统一异步任务五态和查询契约
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写 Application + Port/Adapter。
**完整业务边界:** 本票收口批量订购任务的可靠接收、幂等、上传绑定和投递事实。明确不下载或解析 CSV、不创建逐行明细、不执行订单、不增加批量权限码或任务创建人隔离。
- [ ] 请求只接受 `request_id``package_id``payment_method``file_key``voucher_keys`;显式提交 `shop_id` 返回统一参数错误,其他未知字段不参与业务决策。
- [ ] `wallet` 要求凭证为空;`offline` 要求 1 至 5 个有效附件;套餐存在性和 ID、编码、名称快照在创建时确定。
- [ ] CSV 与凭证必须由当前账号按正确用途申请、真实存在且元数据合法CSV 实际大小不超过 10MB所有 Key 在任务事务内完成绑定。
- [ ] 相同 `request_id`、操作者和规范化请求指纹返回原任务;改变套餐、支付方式、文件、凭证或操作者时返回冲突。
- [ ] 并发首写最终只产生一个任务、一次上传绑定和一个可靠事件Asynq 暂不可用不丢失已提交任务。
- [ ] 任务使用全局五态并返回中文状态名;队列载荷只包含结构化 `task_id`,不传 `[]byte`、文件内容、临时路径或认证上下文。
- [ ] 创建成功写公共审计,记录任务号、支付方式、文件安全摘要和凭证数量,不记录预签名 URL、Token 或环境密钥。
- [ ] 新 Handler 同步注册后台路由并更新两个 OpenAPI 文档生成入口,所有响应使用统一包装和中文错误码语义。

View File

@@ -0,0 +1,25 @@
# 07 — 完整校验 CSV 并固化逐行明细
**What to build:** Worker 可以领取待处理批量任务,下载完整 CSV 后先完成全部文件结构和行数校验,再批量解析资产并一次性持久化所有数据行。文件级错误使任务失败且不产生订单;文件有效时每个原始数据行都有稳定行号、解析结果、失败信息和幂等键,可供后续恢复处理。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/01-batch-unique-asset-resolution.md` — 01 — 统一资产标识的批量唯一解析
- `.scratch/ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md` — 03 — 建立批量任务真实依赖的集成测试 Harness
- `.scratch/ur36-bulk-package-purchase/issues/06-idempotent-bulk-purchase-creation.md` — 06 — 幂等创建批量订购任务
**Status:** ready-for-agent
**架构通道:** 主通道为 Application辅助通道为 Infrastructure。
**完整业务边界:** 本票收口任务领取、CSV 文件级校验、公共资产批量解析、文件内判重和明细初始化。明确不执行套餐订购、不扣钱包、不创建订单、不支持 Excel 或单行重试。
- [ ] Worker 仅领取待处理或租约已过期的处理中任务,终态任务不会重新解析;源对象下载失败按安全任务错误进入失败终态。
- [ ] CSV 仅接受 UTF-8 或 UTF-8 BOM、LF 或 CRLF、唯一单列表头 `资产标识`;空文件、只有表头、非法编码、引号语法错误、缺列、多列、重复或未知列均为文件级错误。
- [ ] 实际文件不得超过 10MB数据行不得超过 1000Worker 在任何订单写入前读完整文件并确认第 1001 行不存在。
- [ ] 文件有效后每个数据行都持久化并保留原始行号和原始标识;空值、控制字符、未命中及多命中作为行级失败计数,不被省略。
- [ ] 文件内重复在公共解析成功后按 `资产类型 + 资产ID` 判断;首次出现行继续处理,后续行失败并指出首次 CSV 行号,不同标识指向同一资产也能识别。
- [ ] 明细具有 `(任务, 行号)` 和稳定 `bulk_purchase:{task_id}:{row_no}` 幂等约束,并保存公共行处理状态、租约和安全失败码。
- [ ] 文件验证与明细初始化完成后Worker 重启直接读取已有明细,不重新生成行号或幂等键;汇总从明细事实聚合。
- [ ] 真实 S3 和 PostgreSQL 测试覆盖编码、换行、表头、10MB、1000/1001 行、伪装 Excel、空字段、控制字符、资产歧义和跨标识重复。

View File

@@ -0,0 +1,24 @@
# 08 — 逐行完成线下支付批量订购
**What to build:** 文件校验通过的线下支付任务可以严格按 CSV 行号逐行订购套餐。每行按资产当前归属确定结算代理,调用统一单资产订购命令创建已支付订单并激活套餐;一行业务失败只记录该行中文原因并继续,成功订单都能追溯批次及整批凭证。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/05-single-asset-package-purchase-command.md` — 05 — 提供统一的单资产套餐订购命令
- `.scratch/ur36-bulk-package-purchase/issues/07-validate-csv-persist-items.md` — 07 — 完整校验 CSV 并固化逐行明细
**Status:** ready-for-agent
**架构通道:** 复杂写Application → Order/Package Domain。
**完整业务边界:** 本票收口批量任务的 `offline` 逐行执行、结果记录和任务终态。明确不建设凭证金额核销、跨批唯一、自动财务对账、单行重试或钱包扣款。
- [ ] Worker 仅按 `row_no ASC` 串行领取待处理或过期处理中明细,不并发改变业务顺序,已有成功或失败终态不可被覆盖。
- [ ] 每行重新读取资产当前归属并解析结算代理,任务创建时不存在单一代理快照,也不接受前端提供的 `shop_id`
- [ ] 有效行调用统一单资产套餐订购命令,按结算代理当前授权、价格和套餐规则创建已支付订单并激活套餐,不扣任何代理钱包。
- [ ] 整批凭证 Key 快照关联到每个成功订单和批次审计;凭证可以被其他批次使用,不建立内容或金额唯一性。
- [ ] 资产、归属、授权、下架、价格、组合及订单业务错误写稳定失败码和中文原因后继续下一行,底层错误不原样返回。
- [ ] 成功行的订单、套餐使用与激活、明细成功状态、逐行关联、Audit Event 和 Outbox 原子提交;事务失败不会留下可重复执行的部分事实。
- [ ] 所有业务行处理完后任务进入已完成;全部成功、部分成功或全部业务失败只通过计数表达,`processed_count = success_count + fail_count = total_count`
- [ ] 测试覆盖多代理、凭证 1 个和 5 个、未授权、下架、全部失败、部分成功、重复 Worker 领取和单行事务回滚。

View File

@@ -0,0 +1,23 @@
# 09 — 逐行完成钱包批量订购与崩溃恢复
**What to build:** 钱包支付任务可以严格按 CSV 行号,分别使用每个资产结算代理的主钱包完成订购。同一代理资金不足只失败当前行,后续更便宜的行仍按当时可用金额判断;重复消息、并发 Worker 和进程中断恢复不会重复创建订单、扣款、写流水或激活套餐。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/05-single-asset-package-purchase-command.md` — 05 — 提供统一的单资产套餐订购命令
- `.scratch/ur36-bulk-package-purchase/issues/08-offline-bulk-row-purchase.md` — 08 — 逐行完成线下支付批量订购
**Status:** ready-for-agent
**架构通道:** 复杂写Application → Order/Package Domain辅助调用 Wallet Application/Port。
**完整业务边界:** 本票在已经验证的逐行任务闭环上增加 `wallet` 支付和故障恢复。它通过 UR#36 Ticket 05 间接消费 UR#38 的钱包能力,不直接依赖或重新实现 Wallet Domain。明确不预占整批资金、不并行结算、不迁移其他钱包用例。
- [ ] 每行按当前资产归属定位结算代理,并通过统一单资产订购命令调用 Wallet Application/Port批量代码不读取信用字段计算可用金额不直接更新钱包或创建钱包流水。
- [ ] 严格行序决定同一代理的资金使用优先级,不按代理汇总预占;当前行余额不足只失败该行,后续金额较小且资金足够时可以成功。
- [ ] 任务和明细使用状态条件、处理租约及稳定幂等键领取;只有租约所有者可以提交处理中状态,终态不可重复执行。
- [ ] 两个 Worker 同时领取、重复 Asynq 消息、任务租约过期和行租约过期时,订单、钱包扣款、真实金额流水、套餐激活及成功审计各最多一次。
- [ ] 单行事务提交前崩溃可安全重试;提交后但确认前崩溃可从订单、明细和幂等事实恢复,不得再次扣款。
- [ ] 钱包不足、无有效主钱包、信用关闭或额度不足等稳定业务失败记录中文安全原因数据库、Redis 或底层钱包错误不泄露给前端。
- [ ] 任务终态汇总从明细重新聚合并记录涉及代理数、成功金额、成功数和失败数,同时写任务终态公共审计。
- [ ] 并发与故障测试覆盖多代理各扣主钱包、现金与信用、无钱包、前贵后便宜、普通订单并发、双 Worker、重复消息及事务提交前后故障。

View File

@@ -0,0 +1,22 @@
# 10 — 提供任务汇总与逐行明细查询
**What to build:** 通过现有后台认证的调用方可以查询批量订购任务汇总及逐行结果,在刷新或重新打开页面后恢复进度。接口按固定行号顺序分页,支持状态和资产标识精确筛选,并返回套餐、结算代理、金额、订单及中文失败信息,不暴露存储凭证、钱包余额或底层错误。
**Blocked by:** `.scratch/ur36-bulk-package-purchase/issues/09-wallet-bulk-row-purchase-recovery.md` — 09 — 逐行完成钱包批量订购与崩溃恢复
**Status:** ready-for-agent
**架构通道:** Query。
**完整业务边界:** 本票收口批量订购任务详情和明细读取模型、DTO 与后台路由。明确不建立通用任务中心、不修改任务状态、不增加任务创建人隔离或新的 RBAC、不迁移其他任务查询。
- [ ] 任务详情返回任务号、请求 ID、套餐快照、支付方式、状态与中文名称、源文件摘要、凭证引用、操作者、计数、涉及代理数、成功金额、安全错误和起止时间。
- [ ] 明细查询默认第 1 页、每页 20 条且最大 100始终按 `row_no ASC`;前端不能改变业务处理顺序。
- [ ] `status` 只接受公共行处理状态,`asset_identifier` 去除首尾空白后仅在当前任务内对原始或规范标识精确匹配;多个筛选使用 AND。
- [ ] 每行返回行号、资产类型、原始标识、资产 ID、规范标识、结算代理快照、金额、状态与中文名称、订单、失败码、中文原因和处理时间。
- [ ] 汇总从明细事实计算或校准,始终满足 `processed_count = success_count + fail_count`;解析阶段允许总数为 0部分成功不增加私有状态。
- [ ] 查询只复用现有后台认证与公共数据范围,不新增批量权限码、账号类型拦截或按创建人隔离;无权与不存在遵循项目统一安全语义。
- [ ] 响应不包含 SQL、底层错误、永久对象访问凭证、Redis Key、预签名 URL、其他代理钱包余额或环境秘密。
- [ ] 新 Handler 同步后台路由及两个 OpenAPI 文档生成入口DTO 枚举说明从公共常量原文复制并包含所有 `status_name`
- [ ] HTTP 测试覆盖默认与最大分页、固定排序、状态与标识 AND 筛选、任务汇总、认证失效、跨创建人查询语义和安全字段边界。

View File

@@ -0,0 +1,24 @@
# 11 — 完成真实链路验收、文档与发布门禁
**What to build:** 发布负责人可以通过自动化测试、OpenAPI、静态 CSV 模板和中文联调契约,验证从后台认证、真实 S3 直传、任务创建、可靠投递、Worker 执行到任务查询的完整链路。发布和回滚步骤明确保护已经产生的订单、钱包流水、套餐使用、任务和审计事实。
**Blocked by:**
- `.scratch/ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md` — 03 — 建立批量任务真实依赖的集成测试 Harness
- `.scratch/ur36-bulk-package-purchase/issues/10-bulk-purchase-task-queries.md` — 10 — 提供任务汇总与逐行明细查询
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为 Application 与 Query 验收。
**完整业务边界:** 本票收口 UR#36 的真实依赖验收、接口文档、静态模板、中文功能总结、发布与回滚门禁。本仓库不实现前端页面;只提供前端交互所需的 API、OpenAPI、静态 CSV 模板和联调验收契约,不扩展为全仓测试治理。
- [ ] 完整示例穿过真实后台认证、预签名 URL、真实 S3 PUT 与 Head、创建 API、可靠任务投递、公开 Worker Handler、PostgreSQL 事实和查询 API。
- [ ] 自动化覆盖文件级零订单失败、行业务部分成功、全部行业务失败、多代理线下与钱包、信用与余额不足、严格行序、幂等 HTTP、双 Worker 和崩溃恢复。
- [ ] 审计验证任务创建、任务终态、成功订单和钱包事实可串联且响应、日志、审计和测试输出不包含密钥、Token、预签名 URL 或底层错误。
- [ ] 提供只有 `资产标识` 一列的 UTF-8 静态 CSV 模板并明确支持的标识、1000 行、10MB、不接受 Excel及长数字保护提示。
- [ ] OpenAPI 完整包含上传用途、创建任务、任务详情和明细查询契约;两个文档生成入口一致,生成校验无漂移。
- [ ] 前端交互联调契约覆盖单套餐、钱包或线下支付、1 至 5 个凭证、四阶段页面、重复点击复用 `request_id`、刷新恢复、解析中空进度、部分成功计数、失败默认筛选和精确搜索。
- [ ] 中文功能总结和 README 说明领域边界、接口、失败闭环、真实依赖测试、部署顺序、监控、停用入口及回滚限制。
- [ ] 发布门禁包含目标及相关包测试、全量 Go 测试、并发或竞态专项、静态检查、OpenAPI 生成、迁移升降级验证,以及 PostgreSQL、Redis/Asynq 和真实 S3 配置核验。
- [ ] 回滚先关闭创建入口并处置待处理或处理中任务;已经形成的标准订单、钱包流水、套餐使用、批量任务和审计作为业务事实保留,不执行反向删除。

View File

@@ -0,0 +1,18 @@
# 01 — 交付企微连接状态与安全 Adapter 闭环
**What to build:** 超级管理员可以查看企微连接、Token 获取和代理固定代提交成员是否就绪;企微 Token 与成员读取通过统一安全 Adapter 执行,每次真实外部尝试均可追踪,但任何接口、日志或审计都不会泄露 Secret、Token 或固定成员原始 `userid`
**Blocked by:** `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为 Query。
**完整业务边界:** 本票收口企微部署配置、Access Token 缓存与并发刷新、成员可用性读取、连接状态投影和外部尝试记录。明确不建立审批状态机,不创建审批实例,不提供敏感配置在线编辑,也不迁移退款或充值逻辑。
- [ ] 企微连接使用部署配置提供 CorpID、AgentID、AgentSecret、回调密钥、绑定回调地址、后台固定 Origin、代理固定代提交成员、请求超时和轮询间隔敏感值不进入通用配置表。
- [ ] Access Token 使用 Redis 缓存TTL 为企微 `expires_in` 减 300 秒并通过有期限锁避免多进程并发刷新Redis 异常时不会把空值或过期值伪装为有效 Token。
- [ ] 成员读取可以确认代理固定成员属于当前企业、已启用且在应用可见范围内,只向超级管理员返回就绪状态和显示名,不返回原始 `userid`
- [ ] 连接状态接口仅允许超级管理员访问,返回配置就绪、最近连通、最近安全错误摘要、回调最近成功和 Token 最近获取时间,使用统一响应结构。
- [ ] Token 和成员外部调用写 Integration Log记录操作、耗时、企微错误码和安全关联标识不记录令牌、密钥、完整响应或固定成员原始标识。
- [ ] WeCom Adapter 契约测试覆盖 Token 获取、缓存命中、并发刷新、企微非零错误、超时、成员失效、Redis 锁恢复及敏感信息不泄漏;新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,21 @@
# 02 — 完成平台账号本人扫码绑定闭环
**What to build:** 平台账号和超级管理员可以从本人会话发起官方企微 Web 登录扫码,安全完成本人绑定、查看结果、换绑或解绑;超级管理员可以查看平台绑定清单并强制解绑冲突成员,代理账号始终不能创建或管理企微绑定。
**Blocked by:**
- 01 — 交付企微连接状态与安全 Adapter 闭环
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写,采用 Application + Domain + Repository/WeCom Adapter绑定清单为辅助 Query。
**完整业务边界:** 本票收口系统账号与企微成员一对一绑定、一次性会话、本人解绑和超级管理员强制解绑。明确不改变账号认证、角色或店铺模型,不允许人工录入 `userid`,不为代理建立绑定。
- [ ] 只有当前已登录且启用的平台账号或超级管理员可以为自己创建绑定会话;随机 `state` 五分钟有效且原子读取删除,独立 `session_id` 十分钟内可查询结果。
- [ ] 回调只从服务端会话确定目标账号,调用 `auth/getuserinfo` 验证企业成员;前端不能传入或覆盖账号 ID、后台 Origin 或企微成员事实。
- [ ] 账号 ID 与企微 `userid` 均保持一对一唯一;非企业成员、过期或重复 state、成员已绑定其他账号均安全拒绝换绑必须由本人重新扫码。
- [ ] 回调成功页只向服务端配置的固定 Origin 发送结果,原页面可以通过 `session_id` 轮询兜底;响应和页面不泄露 Token 或其他账号信息。
- [ ] 平台账号只能查询和解绑本人;超级管理员可分页查看绑定状态并强制解绑,代理访问所有绑定接口均被拒绝。
- [ ] 绑定、换绑、解绑和强制解绑写统一 Audit Event测试覆盖并发唯一性、会话单次消费、权限、停用账号及历史审批身份快照不随绑定变化新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,22 @@
# 03 — 发布审批场景与首个模板版本
**What to build:** 超级管理员可以按稳定场景查看退款和线下充值审批配置,读取企微模板并通过可视化字段映射发布首个不可变模板版本;发布前由后端重新核对模板、控件类型和必填映射,业务代码不再依赖易变的模板 ID 或控件 ID。
**Blocked by:**
- 01 — 交付企微连接状态与安全 Adapter 闭环
- `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写,采用 Application + Domain + Repository/WeCom Adapter场景与版本列表为辅助 Query。
**完整业务边界:** 本票收口稳定场景、不可变模板版本、模板读取、字段映射校验和首次发布。明确不实现运行中安全换版,不创建业务审批实例,不接入退款或充值创建。
- [ ] 初始化稳定场景 `refund_approval``offline_recharge_approval`,场景状态固定为 `0=已暂停、1=启用、2=暂停中`,模板版本状态固定为 `0=已停用、1=启用、2=失效`
- [ ] 模板版本保存递增版本号、企微模板标识、名称、控件映射、模板快照、指纹、验证结果和发布快照;历史版本不可修改,每个场景至多一个启用版本。
- [ ] 模板读取接口只接受稳定场景和企微模板标识,向前端返回可映射控件事实;前端不能把控件名称、类型或原始映射 JSON 作为可信发布依据。
- [ ] 发布前重新从企微读取模板,验证可访问性、控件存在、类型匹配及全部必填业务字段映射;退款和线下充值字段集合符合 PRD金额字段只用于展示。
- [ ] 首次发布在单一事务内写不可变版本、设置当前版本并启用场景;失败时不留下半发布版本或错误的当前版本引用。
- [ ] 模板读取、验证和发布写 Integration Log/Audit Event真实 PostgreSQL 与可编程 Adapter 测试覆盖唯一约束、映射缺失、类型错误、指纹和事务回滚;新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,22 @@
# 04 — 提供审批创建前置检查与事务接入契约
**What to build:** 退款和线下充值创建用例可以通过稳定公共接缝确认场景、当前模板和实际企微发起身份可用,并在自己的业务事务中原子创建唯一审批实例及提交 Outbox任一前置失败时不会留下业务单、审批实例或待补提事件。
**Blocked by:**
- 02 — 完成平台账号本人扫码绑定闭环
- 03 — 发布审批场景与首个模板版本
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写 Application + Domain辅助通道为 Application Port/Adapter。
**完整业务边界:** 本票收口审批创建前置判定、真实业务提交人和企微发起身份快照、唯一审批实例以及事务内提交事件。明确不创建退款单或充值单,不定义其金额和活跃业务规则,不调用远程企微。
- [ ] 前置检查按稳定场景读取当前启用模板,并拒绝暂停中、已暂停、模板缺失或模板失效;失败发生在调用方写入任何业务事实前。
- [ ] 平台和超级管理员使用本人启用且成员可用的绑定;代理仅可在允许代理发起的退款场景使用部署配置固定成员,线下充值不允许代理发起。
- [ ] 审批实例冻结业务类型、业务 ID/编号、场景、模板版本与映射、业务快照、真实提交人账号/名称/角色/店铺快照、企微发起身份及 `self_binding/agent_proxy` 来源。
- [ ] `(biz_type,biz_id)` 唯一,非空 `sp_no` 全局唯一;状态初始为 `0=提交中`,不使用审批轮次,不建立数据库外键或 GORM 关联标签。
- [ ] 调用方可使用同一 GORM 事务创建业务事实、审批实例和结构化 `WeComApprovalSubmissionRequested` Outbox任一步失败整体回滚事务中不调用 Redis、Asynq、对象存储或企微。
- [ ] 公共状态常量、中文状态名及公开 DTO 描述保持唯一来源;集成测试以代表性测试业务事实验证前置失败零落库、事务回滚、并发唯一性和成功只产生一组审批事实。

View File

@@ -0,0 +1,22 @@
# 05 — 完成附件上传与企微异步提交闭环
**What to build:** 审批提交 Worker 可以从公共 Outbox 可靠领取审批实例,使用冻结的模板、业务快照和企微发起身份上传附件副本并调用企微发起审批;成功后绑定唯一 `sp_no`,明确失败可安全重试,已发送但响应未知时停止自动重发并等待人工恢复。
**Blocked by:**
- 04 — 提供审批创建前置检查与事务接入契约
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
**Status:** ready-for-agent
**架构通道:** 复杂写 Application + Domain外部调用位于 Infrastructure WeCom Adapter。
**完整业务边界:** 本票收口技术提交尝试、提交租约、附件副本上传、企微表单构造和提交结果分类。明确不处理企微审批终态,不执行退款或充值资金动作,不把临时企微资料变成永久业务事实。
- [ ] Worker 通过审批状态和 `submit_lease_until` 条件领取、续租并在所有退出路径释放租约;并发 Worker 同一时刻只有租约所有者可以提交。
- [ ] 本地私有对象 Key 是申请附件权威引用Worker 使用受控临时文件上传企微,临时 `media_id`、预签名 URL 和文件内容不进入永久业务快照、普通日志或响应。
- [ ] 企微表单严格使用实例冻结的模板映射、真实业务提交人和实际企微发起身份,审批人配置完全使用企微模板,不由系统计算或提交。
- [ ] 成功响应原子绑定唯一 `sp_no` 并进入审批中;企微明确未创建时进入提交失败,可在同一实例上技术重试且保留尝试历史。
- [ ] 可证明未发送的建连失败允许自动重试;请求已发送后超时或连接中断进入提交结果未知,持久化未知事实后禁止自动再次调用 `applyevent`
- [ ] 测试覆盖 Outbox 重投、并发领取、租约过期恢复、部分附件失败、明确失败、未发送失败、响应未知、成功绑定和结构化 Asynq 载荷,且不依赖真实企微网络。

View File

@@ -0,0 +1,21 @@
# 06 — 完成场景安全暂停、模板换版与失效检测
**What to build:** 超级管理员可以把场景安全切换到暂停中,停止新提交租约并等待已有租约释放后完成暂停,再发布新的不可变模板版本并恢复场景;后台定期发现启用模板不可访问或指纹变化时会自动暂停场景并告警。
**Blocked by:**
- 03 — 发布审批场景与首个模板版本
- 05 — 完成附件上传与企微异步提交闭环
**Status:** ready-for-agent
**架构通道:** 复杂写 Application + Domain模板验证调度为辅助 Infrastructure。
**完整业务边界:** 本票收口场景暂停状态机、租约排空、模板原子切换、恢复和定期有效性检查。明确不停止暂停前已经创建的审批同步或终态处理,不自动发现新的企微模板 ID。
- [ ] 状态接口只接受目标已暂停或启用以及必填原因;暂停中由系统维护,前端不能直接写入,非法转换或乐观锁冲突安全失败。
- [ ] 进入暂停中后立即拒绝新业务前置检查和新提交租约,但已有有效租约可以续租直至完成;所有未过期租约消失后才进入已暂停。
- [ ] 新版本发布前重新读取和验证企微模板;事务内停用旧版本、写新版本、切换当前版本并恢复场景,任一步失败保持场景暂停且旧事实不被改写。
- [ ] 后台按十分钟默认间隔验证启用模板;模板不可访问或指纹变化时标记版本失效、暂停对应场景并产生安全告警,不猜测替代模板 ID。
- [ ] 场景暂停和恢复不影响已创建实例继续接收回调、轮询和终态处理;历史实例始终使用创建时冻结的版本和映射。
- [ ] 测试覆盖暂停等待、停止发租约、租约续期/自然释放、首次与再次发布、发布回滚、模板失效、指纹变化、并发状态更新和 Audit Event 完整性。

View File

@@ -0,0 +1,22 @@
# 07 — 接入加密回调与权威详情同步
**What to build:** 企业微信可以完成审批回调 URL 校验并推送加密事件;系统验签、解密和核对企业身份后快速记录回调并触发统一同步,最终始终以 `getapprovaldetail` 保存审批状态、审批人、意见、附件元数据和时间线。
**Blocked by:**
- 05 — 完成附件上传与企微异步提交闭环
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
- `.scratch/tech-public-foundation/issues/10-sensitive-route-safe-summaries.md` — 10 — 为敏感接口提供安全摘要策略
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写 Application + Domain回调协议和企微详情位于 Infrastructure Adapter。
**完整业务边界:** 本票收口回调协议、安全入站记录和单实例权威状态同步。明确不实现批量轮询,不消费退款或充值终态,不把回调载荷中的状态当成业务权威。
- [ ] GET 回调按企微协议完成 URL 校验POST 回调校验 SHA1 签名、AES-CBC 解密并核对 `receiveID=CorpID`,错误签名、密文或企业身份安全拒绝。
- [ ] 入站回调先保存脱敏 Integration Log 与幂等标识只记录事件类型、安全资源标识、大小、哈希和结果不记录密文、明文、签名、Nonce 或密钥。
- [ ] 有效回调快速触发统一 `SyncApprovalStatus` 并按企微约定响应;重复事件、乱序事件和同步任务重复投递不直接改写状态。
- [ ] 同步用例调用 `getapprovaldetail` 作为唯一权威,保存审批详情、审批人名称快照、意见、附件元数据、时间线和最后同步时间。
- [ ] 审批实例以乐观锁和合法状态转换更新;详情未变化时只更新必要轮询事实,不产生第二个等价业务事件。
- [ ] 真实 Fiber 回调测试使用测试 Token/AES Key 构造签名密文,覆盖 URL 校验、签名/解密/CorpID 错误、重复事件、快速响应和权威同步;新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,21 @@
# 08 — 完成轮询补偿与终态只发布一次
**What to build:** 系统可以每两分钟并发安全地领取最久未同步的审批实例并查询企微权威详情;无论回调、轮询或重复任务谁先到,审批首次进入终态时都只发布一次对应业务终态事件,并对撤销、删除和通过后撤销保留明确异常语义。
**Blocked by:**
- 07 — 接入加密回调与权威详情同步
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
**Status:** ready-for-agent
**架构通道:** 复杂写 Application + Domain轮询调度和领取为辅助 Infrastructure。
**完整业务边界:** 本票收口轮询资格、并发领取、审批终态状态机及事务内业务事件发布。明确不实现退款退款、充值入账或其他下游消费者,不把审批通过等同于业务处理成功。
- [ ] 轮询默认每两分钟扫描提交中、审批中和需要恢复同步的实例,单批最多 100 条,按最久未轮询优先并以稳定主键打破并列。
- [ ] 多实例通过轮询租约或条件领取避免同一时刻重复占用;详情失败记录安全摘要并重新安排,进程中断后过期租约可恢复。
- [ ] 回调、轮询和手动同步复用同一状态同步用例,审批中、通过、驳回、撤销、通过后撤销、删除及提交异常均遵守公共状态机。
- [ ] 首次进入终态时在保存审批状态的同一 PostgreSQL 事务写稳定业务终态 Outbox状态未变化、重复终态或乐观锁冲突重试不会产生第二个事件。
- [ ] 审批状态与 `business_process_result/business_processed_at` 独立;通过后撤销在业务未执行时阻断后续处理,已执行时不自动冲正并产生 critical Audit Event 与告警事件。
- [ ] 测试覆盖轮询资格、批量上限、稳定排序、多实例领取、回调与轮询并发、重复终态、乱序状态、乐观锁冲突、撤销异常和每个业务终态最多一个 Outbox。

View File

@@ -0,0 +1,22 @@
# 09 — 交付审批运行查询、权限投影与立即同步
**What to build:** 超级管理员或具备企微审批运营权限的平台账号可以筛选审批运行记录、查看其原业务权限允许的完整审批详情并立即同步;代理和无业务权限的平台账号不能借企微运营接口看到审批人、意见、附件或其他业务资料。
**Blocked by:**
- 02 — 完成平台账号本人扫码绑定闭环
- 03 — 发布审批场景与首个模板版本
- 07 — 接入加密回调与权威详情同步
**Status:** ready-for-agent
**架构通道:** 主通道为 Query立即同步为辅助复杂写 Application。
**完整业务边界:** 本票收口审批运行列表、详情、专项权限、原业务权限复核和主体信息投影。明确不提供本地通过、驳回、退回或资金处理动作,不迁移退款/充值自己的列表和详情 Query。
- [ ] 运行列表支持业务类型、审批状态、`sp_no`、业务单号、业务处理结果和分页过滤,默认每页 20、最大 100按更新时间倒序并以 ID 稳定排序。
- [ ] 列表和详情返回公共状态及中文名称、模板版本、真实业务提交人、企微身份来源、同步时间、业务处理结果和异常标识,审批与业务处理状态独立展示。
- [ ] 只有超级管理员或具备独立企微审批运营权限的平台账号可访问运行接口;该权限不授予场景、模板、绑定管理或异常恢复能力。
- [ ] 完整详情必须再次通过对应退款或充值业务权限 Adapter无权、资源不存在和引用失效使用统一禁止访问语义企微运营权限不能绕过业务数据范围。
- [ ] 平台完整投影可以包含审批人、意见、时间线和审批附件元数据;供业务详情复用的代理投影只包含真实提交人、审批状态/时间和业务处理结果,不返回企微内部标识。
- [ ] 立即同步只调用统一权威详情同步不执行审批动作Query 和 Fiber 集成测试覆盖角色权限、业务权限、分页排序、无 N+1、附件权限和越权一致性新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,23 @@
# 10 — 完成提交结果未知的高风险恢复闭环
**What to build:** 超级管理员或具备企微审批异常恢复权限的平台账号可以为提交结果未知的实例安全绑定已存在的 `sp_no`,或在人工确认企微未创建后重新发送同一审批;系统完整保留恢复原因、校验依据、前后状态和每次技术尝试,避免重复企微审批。
**Blocked by:**
- 05 — 完成附件上传与企微异步提交闭环
- 07 — 接入加密回调与权威详情同步
- 09 — 交付审批运行查询、权限投影与立即同步
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写 Application + Domain企微核对位于 Infrastructure Adapter。
**完整业务边界:** 本票收口提交结果未知的两个明确恢复动作和高风险审计。明确不允许编辑业务申请、不创建业务审批轮次、不恢复已经进入明确终态的审批,退款驳回后的再次申请仍属于 UR#35 新业务单。
- [ ] 两个恢复接口只允许超级管理员或具备独立异常恢复权限的平台账号调用,请求必须包含非空恢复原因;企微运营权限本身不能执行恢复。
- [ ] 绑定已有 `sp_no` 前查询企微详情并核对企业、模板版本、实际企微发起身份及来源、业务场景、真实业务提交人字段和提交业务快照,任一不匹配均拒绝绑定。
- [ ] 成功绑定使用非空 `sp_no` 全局唯一约束和预期状态条件更新,随后进入统一权威同步;重复调用不能覆盖其他实例或制造第二次终态。
- [ ] 确认未创建后重新发送保留原尝试,并在同一审批实例记录新的技术提交尝试;不创建新业务 ID、审批实例或审批轮次。
- [ ] 已经进入通过、驳回、撤销、通过后撤销、删除等明确终态的实例禁止恢复;并发恢复、重复操作和状态变化时安全失败。
- [ ] 两种恢复均写高风险 Audit Event 并保留 Integration Log测试覆盖无权限、非法状态、`sp_no` 不存在、逐项快照不匹配、成功绑定、重新发送、重复并发和审计事务完整性;新增 Handler 同步路由、RouteSpec 和两个 OpenAPI 文档生成器。

View File

@@ -0,0 +1,21 @@
# 11 — 向真实业务提交人发送审批结果通知
**What to build:** 企微审批首次进入终态后,系统可以通过公共通知能力可靠通知真实业务提交人;代理由固定企微成员代提交时仍只把代理本人视为申请人,通过后撤销等高风险异常可按规则通知申请人和当前财务角色账号。
**Blocked by:**
- 08 — 完成轮询补偿与终态只发布一次
- `.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md` — 08 — 完成公共通知发布门禁与下游接入契约
**Status:** ready-for-agent
**架构通道:** Application + Port/Adapter复用公共 Outbox 和通知消费者。
**完整业务边界:** 本票收口企微审批结果通知事件、接收人语义、受控模板与目标引用。明确不复制通知模型、Worker、未读接口或前端消息中心不发送企微待办不承担退款到账通知。
- [ ] 通过、驳回、撤销、删除和通过后撤销使用受控通知类型、稳定事件 ID、审批实例/业务引用和最小结构化模板字段。
- [ ] 普通审批结果接收人固定为审批实例冻结的真实业务提交人账号 ID绝不使用实际企微发起 `userid` 或代理固定代提交账号替代。
- [ ] 通过后撤销且业务已经处理时,除申请人外按公共通知接缝解析当前可用财务角色账号;同一账号多重命中只生成一条通知。
- [ ] 重复回调、轮询、Outbox 和通知 Worker 消费不会生成重复通知;账号已停用或删除时遵循公共通知无接收人语义,不无限重试。
- [ ] 通知只保存受控审批或来源业务引用,目标解析时重新验证当前权限,不保存任意 URL、审批附件、意见或企微内部身份。
- [ ] 端到端测试覆盖平台本人发起、代理代提交、通过/驳回、通过后撤销、多接收人去重、停用提交人、重复事件和受控目标权限变化。

View File

@@ -0,0 +1,25 @@
# 12 — 交付历史审批与待处理数据迁移接缝
**What to build:** 发布人员可以幂等盘点退款和线下充值的历史本地审批,把已结束记录保留为只读 `legacy` 事实,并对仍待处理记录执行真实提交人、企微身份、模板和业务资料预检,输出可迁移项与明确待人工处理清单。
**Blocked by:**
- 02 — 完成平台账号本人扫码绑定闭环
- 03 — 发布审批场景与首个模板版本
- 04 — 提供审批创建前置检查与事务接入契约
- 05 — 完成附件上传与企微异步提交闭环
- 06 — 完成场景安全暂停、模板换版与失效检测
- 07 — 接入加密回调与权威详情同步
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为 Application。
**完整业务边界:** 本票收口公共审批迁移分类、预检、幂等实例创建接缝和回滚边界。明确不猜测退款或充值业务事实,不修改其金额、资金或处理状态;各业务表字段回填和旧路由下线分别由 UR#35、UR#34 的纵向切片负责。
- [ ] 历史已结束的本地审批只投影为 `approval_source=legacy`,保留原业务事实,不创建虚假 `sp_no`、企微审批人、意见、附件或时间线。
- [ ] 待处理记录按业务类型核对真实创建人、账号类型、本人绑定或固定代理身份、当前场景/模板以及业务提供的必需快照和附件事实。
- [ ] 满足条件的记录可通过公共事务接缝幂等创建唯一企微审批实例及提交事件;重复执行不会生成第二个实例、`sp_no` 或 Outbox。
- [ ] 缺少平台绑定、代理固定身份、真实提交人、模板、金额或附件等事实时进入结构化待处理清单,不继续调用旧审批动作,也不由迁移脚本猜测或补造。
- [ ] 应用回滚保留已经形成的企微实例、身份/模板快照、Outbox、Integration Log 和 Audit Event真实企微审批开始后不得恢复旧本地审批按钮。
- [ ] 迁移测试覆盖历史终态、平台/代理待处理、缺失事实、重复执行、并发唯一性、停止条件、安全输出和只清理本次隔离夹具。

View File

@@ -0,0 +1,23 @@
# 13 — 冻结企微后台与个人中心前端契约
**What to build:** 前后端可以依据稳定契约实现 `/system/wecom`、个人中心企微绑定、审批运行列表/详情和异常恢复交互;契约明确每种角色可见入口、服务端状态、加载与失败表现、高风险确认和禁止出现的本地审批动作。
**Blocked by:**
- 02 — 完成平台账号本人扫码绑定闭环
- 03 — 发布审批场景与首个模板版本
- 09 — 交付审批运行查询、权限投影与立即同步
- 10 — 完成提交结果未知的高风险恢复闭环
**Status:** ready-for-agent
**架构通道:** 跨仓前端契约与验收,后端接口分别保持 Query 或 Application 原通道。
**完整业务边界:** 本票收口 OpenAPI、页面状态、权限菜单、交互和人工验收包。当前仓库不包含前端源码因此明确不伪装为前端实现不修改退款或充值业务页面后续前端仓按本契约实施。
- [ ] `/system/wecom` 仅超级管理员可见,展示连接状态、代理固定成员、场景、模板版本/映射、平台绑定清单和异常入口,不提供 Secret 或固定 `userid` 编辑。
- [ ] 平台和超级管理员个人中心展示本人绑定状态、成员名称、最近验证时间及绑定/换绑/解绑;代理不展示绑定入口,扫码失败可通过会话轮询恢复。
- [ ] `/operations/wecom-approvals` 只对超级管理员或具备运营权限的平台账号显示,覆盖筛选、分页、详情、立即同步和业务处理结果;无业务权限时不显示敏感资料。
- [ ] 提交结果未知仅对具备恢复权限的主体展示两个高风险动作,均要求原因和二次确认;页面明确区分绑定已有单号与确认未创建后重发。
- [ ] 所有页面覆盖加载、真实空态、筛选空态、403、网络失败、场景暂停中、模板失效、绑定过期、提交失败、结果未知及通过后撤销告警。
- [ ] 页面和 OpenAPI 不出现本地通过、拒绝、退回、重提、审批金额修改或人工确认资金按钮完成中文验收说明、OpenAPI 校验和接口文档索引更新。

View File

@@ -0,0 +1,34 @@
# 14 — 以退款场景完成真实企微发布门禁
**What to build:** 发布负责人可以使用退款审批场景完成公共企微能力的真实端到端验收:平台本人或代理固定成员发起包含真实附件和真实提交人的审批,人工同意或拒绝,经加密回调或轮询同步到本地,并验证重复事件不会产生重复退款终态事件;验收结果形成不含敏感信息的发布与回滚报告。
**Blocked by:**
- 01 — 交付企微连接状态与安全 Adapter 闭环
- 02 — 完成平台账号本人扫码绑定闭环
- 03 — 发布审批场景与首个模板版本
- 04 — 提供审批创建前置检查与事务接入契约
- 05 — 完成附件上传与企微异步提交闭环
- 06 — 完成场景安全暂停、模板换版与失效检测
- 07 — 接入加密回调与权威详情同步
- 08 — 完成轮询补偿与终态只发布一次
- 09 — 交付审批运行查询、权限投影与立即同步
- 10 — 完成提交结果未知的高风险恢复闭环
- 11 — 向真实业务提交人发送审批结果通知
- 12 — 交付历史审批与待处理数据迁移接缝
- 13 — 冻结企微后台与个人中心前端契约
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
- UR#35 退款企微审批的“退款创建接入公共审批并消费终态事件”纵向 TicketUR#35 拆票后必须在本票实施前替换为其具体文件引用
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure 与端到端发布验收,辅助通道为跨 PRD Application 契约。
**完整业务边界:** 本票收口 UR#37 公共能力与首个退款业务切片的真实企微联合门禁。公共能力只验证审批实例、提交、同步和终态事件的正确性;退款金额、资金、佣金、套餐终结和业务幂等仍由 UR#35 对应纵向 Ticket 收口,不在本票重复实现。
- [ ] 使用真实退款模板和唯一隔离夹具,通过真实 HTTP 退款创建链路分别验证平台本人绑定发起与代理固定成员代发,企微表单正确展示真实业务提交人、退款快照和真实附件。
- [ ] 人工完成至少一次同意和一次拒绝,验证加密回调可以原样转发并完整验签解密;丢弃一次回调后由两分钟轮询恢复到同一权威结果。
- [ ] 重复回调、重复轮询和重复任务不会产生第二个审批终态 Outbox退款终态消费者的资金正确性与幂等结论引用 UR#35 具体 Ticket 的验收结果。
- [ ] 自动化 Adapter 继续覆盖超时、响应未知、重复/乱序、撤销、删除、通过后撤销和限流等不适合稳定人工制造的异常。
- [ ] 发布检查确认真实密钥已轮换且仅由部署配置提供,回调/可信域名、模板映射、平台绑定、代理固定成员、Worker、监控和通知均就绪任一门禁失败不得开放退款创建。
- [ ] 验收报告只包含运行 ID、业务安全标识、状态、计数和时间不包含密钥、Token、原始 `userid`、对象 Key、临时 `media_id` 或个人敏感信息;回滚说明保留已形成的全部审批事实并采用向前修复。

View File

@@ -0,0 +1,18 @@
# 01 — 扩展代理主钱包信用模型与领域不变量
**What to build:** 系统能够在不改变历史钱包行为的前提下表达代理主钱包信用额度,并由统一 Wallet Domain 安全计算现金可用金额、总可用金额和欠款。数据库与领域层共同拒绝非法开关组合、负额度、越界结果和算术溢出。
**Blocked by:** `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为复杂写 Domain。
**完整业务边界:** 本票收口代理主钱包信用字段、Wallet Domain 金额语义、数据库检查约束和历史数据兼容。明确不迁移具体扣款、冻结、充值或退款入口,不改变佣金钱包和资产钱包的业务边界。
- [ ] 迁移前置检查从目标 PostgreSQL 查询真实约束定义和异常数据;历史主钱包及佣金钱包均固化为关闭信用、额度为零,升级后原有现金行为不变。
- [ ] 代理主钱包支持信用开关和分单位 `int64` 额度;佣金钱包始终无信用并保持余额非负,数据库不存在外键或 GORM 关联标签。
- [ ] Wallet Domain 统一提供现金可用金额、有效信用额度、总可用金额、欠款状态和欠款金额,并保证关闭/0、开启/正数及总可用金额不小于零。
- [ ] 领域与数据库共同拒绝负额度、开启/0、关闭/非零、冻结金额为负、主钱包超出信用边界及所有加减法溢出场景。
- [ ] 真实 PostgreSQL 迁移测试覆盖升级、降级的安全条件、约束拒绝、重复执行检查和历史钱包兼容;出现负余额后的发布说明明确禁止回滚到旧钱包逻辑。
- [ ] 领域单元测试覆盖普通现金、部分使用信用、用尽信用、超过信用、冻结占用但未形成欠款,以及正负整数边界。

View File

@@ -0,0 +1,21 @@
# 02 — 配置客户角色的新建代理默认信用模板
**What to build:** 获得授权的平台人员可以为客户角色配置“新建代理默认信用”模板,并清楚获得该设置只影响未来新建店铺的接口契约。模板变更与不可变 Audit Event 同事务提交,任何审计失败都会回滚业务更新。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 简单写 Application 事务脚本。
**完整业务边界:** 本票收口角色默认信用模板的配置、权限、校验、读取响应和审计。明确不调整任何既有钱包实际额度,不扫描既有店铺,不让店铺角色关系成为运行时信用来源。
- [ ] 客户角色可通过独立接口保存合法的信用开关和额度组合;响应及角色管理所需读取契约明确这是新建代理模板。
- [ ] 平台角色只能保持关闭/0代理账号一律禁止配置模板平台账号仍需满足既有角色管理权限超级管理员按既有规则放行。
- [ ] 负额度、开启/0、关闭/非零、超出 `int64` 或分单位转换不精确的请求以统一中文错误拒绝Handler 不泄露底层校验信息。
- [ ] 模板更新和包含操作人、角色、开关及额度前后值的 Audit Event 在同一事务提交;审计失败时模板不变。
- [ ] 修改模板后,所有既有代理主钱包和后续店铺角色增删结果保持不变,并有集成测试证明不存在级联更新。
- [ ] 新接口完成路由注册、统一响应、OpenAPI 文档生成接入及权限回归测试。

View File

@@ -0,0 +1,18 @@
# 03 — 新建店铺原子复制角色信用模板
**What to build:** 创建代理店铺时,系统在一个 PostgreSQL 事务中重新读取唯一默认客户角色,并把当时的信用模板一次性复制到新主钱包。店铺、初始账号、角色关系和两个钱包任一步失败都不会留下半成品。
**Blocked by:** `.scratch/ur38-agent-main-wallet-credit/issues/02-role-default-credit-template.md` — 02 — 配置客户角色的新建代理默认信用模板
**Status:** ready-for-agent
**架构通道:** 主通道为简单写 Application 事务脚本,辅助通道为 Infrastructure。
**完整业务边界:** 本票收口现有店铺创建完整用例及信用模板快照。明确不迁移店铺编辑、查询、禁用和其他 CRUD不允许创建请求直接覆盖信用字段也不改变创建后的角色维护语义。
- [ ] 创建用例在事务内重新读取并校验请求中的唯一默认角色是启用的客户角色,不能使用事务外过期快照决定钱包信用。
- [ ] 店铺、初始主账号、账号角色、店铺角色、主钱包、佣金钱包和信用模板复制在同一 PostgreSQL 事务内全成全败。
- [ ] 主钱包复制角色当时的模板,佣金钱包固定关闭/0创建请求中的未知或显式信用字段不能改变快照结果。
- [ ] 模板更新与并发创建的事务快照语义稳定可验证,创建完成后的角色模板或店铺角色变化均不影响钱包。
- [ ] 故障注入集成测试逐步覆盖每个写入点,证明任一步失败均不残留店铺、账号、关系或钱包半成品。
- [ ] 现有店铺创建权限、校验、响应和非信用字段行为保持兼容,并更新相关中文功能文档。

View File

@@ -0,0 +1,21 @@
# 04 — 授权平台人员调整店铺实际信用额度
**What to build:** 超级管理员或同时具备独立授信权限和目标店铺数据范围的平台账号,可以安全调整代理主钱包的实际信用额度。旧版本、越权目标和无法覆盖当前资金占用的降额均被明确拒绝,且不会覆盖并发写入。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写Application UseCase → Wallet Domain → Repository/Infrastructure。
**完整业务边界:** 本票收口既有代理主钱包实际信用额度的授权调整、不变量、乐观锁和审计。明确不允许代理调整自己或任何下级,不复用角色模板作为运行时事实,不修改余额或冻结金额,不创建金额为零的钱包流水。
- [ ] 独立调额接口只允许超级管理员,或具备独立信用额度管理权限且满足目标店铺既有查看/数据范围的平台账号调用。
- [ ] 代理本人、直属下级、更深层下级、无独立权限的平台账号和企业账号均被后端独立拒绝,单独通过店铺管理检查不能获得调额权。
- [ ] Wallet Domain 校验开关组合、算术安全和调整后的总可用金额;欠款或冻结占用导致关闭或降额越界时保持原值并返回统一业务错误。
- [ ] 更新条件同时约束主钱包类型和请求版本,成功后版本递增;零受影响行重新读取并稳定区分并发冲突、无权/不存在及额度不足。
- [ ] 钱包更新与包含操作人、请求上下文、余额、冻结金额、开关、额度和版本前后值的 Audit Event 同事务提交,且不产生伪钱包交易流水。
- [ ] 权限矩阵、提高额度、安全降额、欠款关闭、冻结占用、旧版本并发及整数边界均有真实 PostgreSQL/API 集成测试;新 Handler 接入两个 OpenAPI 文档生成入口。

View File

@@ -0,0 +1,23 @@
# 05 — 统一代理订单、代购与钱包支付扣款
**What to build:** 所有本需求触碰的代理订单支付、代购和后台套餐订购都通过同一个代理主钱包扣款能力结算。扣款统一使用总可用金额,能够在现金不足时安全使用信用,并原子维护订单事实、钱包版本、真实金额流水和可靠事件。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写Application UseCase → Wallet Domain → Repository/InfrastructureOrder Application 为辅助编排通道。
**完整业务边界:** 本票收口代理主钱包扣款能力及现有被触碰的代理订单、代购、单资产后台订购调用点。它同时为 UR#36 的单资产套餐订购命令提供稳定钱包接缝。明确不迁移资产钱包支付、佣金钱包、不相关订单创建流程或整个订单模块。
- [ ] 统一扣款命令按代理主钱包加载聚合,以 `balance - frozen_balance + effective_credit` 判断资金边界,并安全允许账面余额在额度内变为负数。
- [ ] 钱包保存使用版本或等价行锁防止并发超额,成功后版本递增;并发请求不能让总可用金额小于零。
- [ ] 订单/业务单状态条件、钱包扣款、真实金额流水、Payment 事实、必要套餐处理、Audit Event 和 Outbox 在各完整用例的同一事务提交。
- [ ] 相同业务单重试不会重复扣款、重复建流水或重复激活;余额不足和版本冲突不会留下已支付订单或其他部分事实。
- [ ] 现有代理钱包支付、平台代理订购和代购调用点不再自行维护现金余额条件,均调用统一能力;旧 Service 如保留只能作为内部迁移门面。
- [ ] UR#36 可调用稳定的代理主钱包扣款 Application/Port 接缝,而无需复制信用公式、直接更新钱包模型或依赖旧 Store 条件。
- [ ] 集成测试逐一覆盖现金支付、部分信用、用尽信用、超额拒绝、代购付款钱包定位、并发支付、幂等重试、流水快照及 Outbox 回滚。

View File

@@ -0,0 +1,22 @@
# 06 — 统一代理主钱包冻结与解冻
**What to build:** 需要预占代理主钱包资金的完整用例可以统一冻结、解冻和完成冻结资金扣除,并与普通扣款维护同一个信用边界。并发冻结、取消和完成不会造成重复处理或总可用金额越界。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/ur38-agent-main-wallet-credit/issues/05-unified-agent-wallet-debit.md` — 05 — 统一代理订单、代购与钱包支付扣款
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
**Status:** ready-for-agent
**架构通道:** 复杂写Application UseCase → Wallet Domain → Repository/Infrastructure。
**完整业务边界:** 本票收口代理主钱包冻结、解冻和冻结资金最终扣除的完整用例及统一并发规则。明确不迁移佣金钱包提现、资产钱包冻结或没有触碰代理主钱包的预占流程。
- [ ] 冻结使用总可用金额作为边界,允许冻结占用信用但不把冻结金额直接计为欠款;冻结成功后版本递增。
- [ ] 解冻只减少合法冻结金额,完成冻结资金扣除同时减少余额和冻结金额,并保持冻结金额非负及主钱包总可用金额合法。
- [ ] 每个调用用例将业务状态、钱包版本、必要真实金额流水、Audit Event 和 Outbox 在同一事务维护,失败时整体回滚。
- [ ] 状态条件和稳定业务引用保证重复冻结、重复取消、重复完成不会二次改变钱包;并发零受影响行可区分业务已处理与真实资金冲突。
- [ ] 现有代理主钱包冻结相关调用点不再直接拼接现金条件或绕过 Wallet Domain佣金钱包原有提现边界保持不变。
- [ ] 测试覆盖纯现金冻结、占用信用、超额冻结、欠款状态下解冻、部分解冻、完成扣除、重复请求和并发竞态。

View File

@@ -0,0 +1,22 @@
# 07 — 统一代理主钱包充值入账与人工调整
**What to build:** 当前代理充值到账和人工余额调整通过统一 Wallet Application 写入主钱包,使正向入账能够正确清偿负余额并一致维护版本、真实金额流水、业务状态、审计和可靠事件。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写Application UseCase → Wallet Domain → Repository/Infrastructure。
**完整业务边界:** 本票收口现有代理主钱包充值入账和人工余额调整的资金写入边界。明确不建设 UR#34 的支付、企业微信审批或站内通知流程,不迁移佣金入账和资产钱包充值,只为这些外部流程提供稳定的主钱包入账能力。
- [ ] 正向入账通过 Wallet Domain 安全增加账面余额并递增版本,负余额钱包优先自然表现为欠款减少,不修改信用额度或冻结金额。
- [ ] 充值业务单状态或人工调整幂等键、钱包更新、唯一真实金额流水、Audit Event 和 Outbox 在同一事务提交。
- [ ] 重复回调、重复 Worker 或人工重试不重复入账;钱包或流水写入失败不会留下已完成业务单,已确认外部收款事实按其所属用例的处理状态保留并可重试。
- [ ] 充值和人工调整调用点不再直接更新代理主钱包余额,旧 Service 如保留只能调用统一 Application 能力。
- [ ] 金额为零、负数、加法溢出、错误钱包类型和不存在目标均以统一错误拒绝,客户端不接收底层数据库错误。
- [ ] 集成测试覆盖普通入账、负余额清偿、仍有欠款、重复入账、并发入账、流水唯一性、审计失败和 Outbox 失败回滚。

View File

@@ -0,0 +1,23 @@
# 08 — 统一代理订单退款回充
**What to build:** 代理钱包订单退款能够沿原扣款流水准确回充原主钱包,并通过统一 Wallet Application 幂等维护余额、版本、退款事实、真实金额流水和可靠事件。原付款使用信用不影响退款定位和金额口径。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/05-unified-agent-wallet-debit.md` — 05 — 统一代理订单、代购与钱包支付扣款
- `.scratch/ur38-agent-main-wallet-credit/issues/07-unified-agent-wallet-credit-posting.md` — 07 — 统一代理主钱包充值入账与人工调整
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** 复杂写Application UseCase → Wallet Domain → Repository/InfrastructureRefund Application 为辅助编排通道。
**完整业务边界:** 本票收口代理订单退款回充主钱包的完整资金用例及历史扣款流水兼容定位。明确不迁移资产钱包退款、佣金回扣、套餐失效和企微审批等非代理主钱包边界。
- [ ] 新订单退款以原扣款流水定位实际付款主钱包和代购关联店铺,历史缺失流水订单按已确定的兼容规则定位且不会退款到错误钱包。
- [ ] 退款状态条件、钱包回充、版本递增、唯一真实退款流水、Audit Event 和 Outbox 在同一事务提交。
- [ ] 退款增加账面余额并自然减少或清偿欠款,不修改冻结金额或信用额度;金额运算溢出时整个事务拒绝。
- [ ] 同一退款与原扣款业务引用形成稳定防重边界重复审批、Worker 重试和并发请求不会重复回充或重复写流水。
- [ ] 代理退款调用点不再直接增加钱包余额,资产钱包退款及其他退款后处理保持原有边界。
- [ ] 测试覆盖现金扣款退款、信用扣款退款、代购退款、历史流水兼容、重复退款、并发退款及任一事务写入失败回滚。

View File

@@ -0,0 +1,21 @@
# 09 — 资金概况返回信用、总可用金额与欠款
**What to build:** 平台和代理在现有资金概况范围内可以直接查看代理主钱包的账面余额、冻结金额、现金可用金额、信用额度、总可用金额、欠款和版本。所有派生金额由服务端统一投影,前端无需自行重算。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/01-agent-main-wallet-credit-foundation.md` — 01 — 扩展代理主钱包信用模型与领域不变量
- `.scratch/ur38-agent-main-wallet-credit/issues/04-manage-shop-credit-limit.md` — 04 — 授权平台人员调整店铺实际信用额度
**Status:** ready-for-agent
**架构通道:** Query。
**完整业务边界:** 本票收口现有资金概况读取模型的信用字段、派生金额、分页筛选和数据权限。明确不通过聚合根读取,不执行任何写操作,不新增绕过现有范围的全量接口,不把信用纳入 UR#97 的现金低余额预警。
- [ ] 现有资金概况接口对每个代理返回账面余额、冻结金额、现金可用金额、信用开关、额度、总可用金额、欠款状态、欠款金额和钱包版本,金额单位统一为分。
- [ ] 现金可用金额固定为余额减冻结金额,总可用金额只加启用后的额度;欠款只由负账面余额决定,冻结占用信用但余额非负时不能误报欠款。
- [ ] Query 延续现有分页、店铺和主账号筛选及平台/代理数据范围,批量投影主钱包信息且不存在按行钱包查询的 N+1。
- [ ] 代理和无调额权限的平台账号仍可在既有查看范围读取资金事实,但响应不暗示其具有调整权限;后端调额授权继续由写接口独立判断。
- [ ] UR#97 的现金低余额口径继续只消费现金可用金额,信用额度不能掩盖现金风险。
- [ ] API 集成测试覆盖关闭信用、使用信用、冻结信用、负余额欠款、分页筛选、数据范围和金额边界OpenAPI 契约明确前端展示及并发冲突刷新所需字段。

View File

@@ -0,0 +1,29 @@
# 10 — 完成信用钱包停机切换与发布门禁
**What to build:** 发布负责人能够在停机窗口确认代理主钱包所有已触碰写入端、查询接口和数据库约束已经同时理解信用额度,再安全开放授信配置。发布检查能阻止旧现金条件、异常钱包、未投递事件或不安全回滚进入生产。
**Blocked by:**
- `.scratch/ur38-agent-main-wallet-credit/issues/02-role-default-credit-template.md` — 02 — 配置客户角色的新建代理默认信用模板
- `.scratch/ur38-agent-main-wallet-credit/issues/03-atomic-shop-credit-snapshot.md` — 03 — 新建店铺原子复制角色信用模板
- `.scratch/ur38-agent-main-wallet-credit/issues/04-manage-shop-credit-limit.md` — 04 — 授权平台人员调整店铺实际信用额度
- `.scratch/ur38-agent-main-wallet-credit/issues/05-unified-agent-wallet-debit.md` — 05 — 统一代理订单、代购与钱包支付扣款
- `.scratch/ur38-agent-main-wallet-credit/issues/06-unified-agent-wallet-reservation.md` — 06 — 统一代理主钱包冻结与解冻
- `.scratch/ur38-agent-main-wallet-credit/issues/07-unified-agent-wallet-credit-posting.md` — 07 — 统一代理主钱包充值入账与人工调整
- `.scratch/ur38-agent-main-wallet-credit/issues/08-unified-agent-wallet-refund.md` — 08 — 统一代理订单退款回充
- `.scratch/ur38-agent-main-wallet-credit/issues/09-fund-summary-credit-query.md` — 09 — 资金概况返回信用、总可用金额与欠款
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为 Application 集成与 Query 验收。
**完整业务边界:** 本票收口 UR#38 的停机切换、旧写入口契约收缩、数据库验证、文档和发布回滚门禁。明确不迁移佣金钱包、资产钱包或未被本需求触碰的旧模块;不实现仓库外前端,只交付完整接口与交互验收契约。
- [ ] 静态盘点和集成测试证明代理主钱包扣款、冻结、解冻、充值、退款、人工调整及 UR#36 可消费的扣款接缝均使用统一 Wallet Domain/Application不再存在旧现金条件写入口。
- [ ] 停机迁移前后检查目标库真实约束、字段、索引、非法开关组合、冻结异常、总可用金额异常和历史信用关闭状态,任何异常以中文安全摘要阻断发布。
- [ ] 端到端验收覆盖普通现金支付、信用扣款、冻结与解冻、充值清偿、退款回充、调额、创建店铺快照、并发冲突、幂等和可靠事件投递。
- [ ] 开放普通访问前保持所有历史钱包信用关闭,并验证资金概况、角色模板、店铺调额接口权限及 OpenAPI 文档与真实路由一致。
- [ ] 中文功能总结、README、数据库发布步骤、监控指标、异常处理和联调清单齐全交互契约覆盖角色提示、按钮权限、金额精确显示、降额失败和版本冲突刷新。
- [ ] 回滚说明区分未产生负余额时的安全回退与已产生负余额后的禁止回退条件;后者只能先清偿欠款或继续保留理解信用的资金逻辑。
- [ ] 旧钱包写接缝仅在全部调用方完成迁移后收缩或删除CI、真实 PostgreSQL/Redis/Asynq 集成测试和迁移检查全部通过后方可完成本票。

View File

@@ -0,0 +1,21 @@
# 01 — 建立统一套餐可售决策与真实历史资格
**What to build:** 系统可以基于调用主体、资产当前归属与世代、套餐、销售渠道和操作场景,统一给出 `normal``renew_only``disabled` 的购买决策、稳定中文原因及当前有效价格。下架续费资格只来自当前客户在同一资产、当前世代、同一套餐上的真实有效购买历史,不会被退款、撤销、转手或异常使用记录误放行。
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写前置 Domain Policy辅助通道为 Infrastructure Port/Adapter查询侧只能消费同一策略能力进行投影。
**完整业务边界:** 本票收口 UR#40 的套餐可售决策、真实历史资格与当前渠道定价语义,为 C 端 Query、C 端创建订单及其他购买入口提供唯一接缝。明确不迁移未触碰的订单创建、支付、退款、资产生命周期或套餐管理用例,不新增资格表、缓存、快照或续费订单类型。
- [ ] 策略输入显式包含 actor、asset、当前 generation、package、sales channel 和 operation context不使用 `is_renewal` 等隐式布尔开关,也不信任调用方提供的客户、资产类型、店铺或上下架结论。
- [ ] 套餐全局禁用时固定返回 `disabled`;平台渠道读取套餐上下架状态,代理渠道要求当前资产店铺对应的分配记录存在、未软删除、已启用,并读取其独立上下架状态。
- [ ] 正常在售且满足既有系列、赠送套餐、主套餐/加油包组合及价格规则时返回 `normal`;渠道下架时仅个人客户本人在 C 端续费场景可以进入历史资格判断,其他主体和场景固定拒绝。
- [ ] 有效历史通过 `EXISTS` 或等价批量查询判断,只接受当前资产、当前世代、同一套餐、未软删除且状态为待生效、生效中、已用完或已过期的使用记录;已失效记录不产生资格。
- [ ] 历史资格同时联合订单与退款事实:待支付、取消、退款、已有 `refund_id`、撤销后失效、赠送、后台发放或其他无真实个人购买事实的记录均不得产生资格,不能只依赖单个可能滞后的状态字段。
- [ ] 平台渠道使用下单时套餐当前有效零售价;代理渠道使用当前有效分配零售价并校验不低于当前授权成本价;历史订单价格和使用记录实付金额不参与定价。
- [ ] 决策返回稳定原因,至少覆盖套餐已禁用、套餐已下架仅历史用户可续费、当前资产无该套餐有效历史、当前渠道未授权该套餐、套餐价格配置异常及既有主套餐/加油包限制,调用方不需要解析文案推导状态。
- [ ] 单元与 PostgreSQL 集成测试覆盖平台/代理、启用/禁用、上架/下架、分配缺失/禁用/软删除、价格异常、赠送,以及历史状态、退款撤销、资产、套餐、客户和世代隔离矩阵。
- [ ] 使用真实查询计划核验历史资格查询;只有确认现有生产结构缺少适用索引时,才通过可升降级迁移补充覆盖资产、世代、套餐、状态和软删除条件的普通或部分索引,且不建立外键。

View File

@@ -0,0 +1,18 @@
# 02 — 套餐商城只展示正常在售套餐
**What to build:** 当前资产所有人查看套餐商城时,只能看到当前销售渠道正常启用、上架且满足既有购买规则的套餐。每个返回项携带统一购买资格字段,下架套餐即使拥有历史续费资格也不会重新进入商城或成为新购入口。
**Blocked by:** 01 — 建立统一套餐可售决策与真实历史资格
**Status:** ready-for-agent
**架构通道:** Query。
**完整业务边界:** 本票只迁移和收口 `GET /api/c/v1/asset/packages` 的读取、权限、批量可售投影与 DTO 契约。明确不提供下架续费入口、不修改订单、不迁移资产信息或套餐历史查询、不经过聚合根。
- [ ] 查询继续执行个人客户认证和当前资产有效绑定校验,无权与资源不存在保持统一安全错误语义。
- [ ] 商城只返回统一策略判定为 `normal` 的套餐,并保持既有系列、赠送套餐、主套餐/加油包、价格、排序和空结果语义。
- [ ] 下架套餐无论是否存在当前资产历史资格都不会出现在商城;套餐全局禁用、代理分配缺失、禁用、软删除或价格异常时也不会返回。
- [ ] 每个正常返回项稳定包含 `can_purchase=true``purchase_mode=normal``disabled_reason=""`,现有套餐 ID、名称、类型、价格及流量字段保持兼容。
- [ ] Query 使用批量读取或等价集合策略解析套餐与代理分配,不在循环中逐项访问数据库,也不把查询结果用于后续写入授权。
- [ ] HTTP 集成测试穿过真实 Fiber 认证、Handler、Query、GORM/PostgreSQL 和统一响应,覆盖平台与代理渠道、下架历史用户、禁用、未授权、价格异常及正常上架新购不回归。

View File

@@ -0,0 +1,18 @@
# 03 — 当前套餐提供下架续费入口
**What to build:** 当前资产所有人查看资产信息时,可以获得当前套餐的稳定套餐 ID 和服务端购买资格。当前渠道已下架但存在真实有效历史时,当前套餐明确显示为仅限该资产续费;资格不成立时返回不可购买及稳定中文原因。
**Blocked by:** 01 — 建立统一套餐可售决策与真实历史资格
**Status:** ready-for-agent
**架构通道:** Query。
**完整业务边界:** 本票只迁移和收口 `GET /api/c/v1/asset/info` 中当前套餐的读取与 DTO 投影,新增稳定 `current_package_id` 及购买资格契约。明确不修改资产实时状态、钱包、Gateway 刷新或订单流程,不实现前端页面。
- [ ] 查询继续执行个人客户认证、资产解析、当前有效绑定和当前 generation 校验,不允许上一世代或其他客户的套餐事实进入投影。
- [ ] 当前套餐区域增加稳定 `current_package_id``can_purchase``purchase_mode``disabled_reason`;续费命令依赖套餐 ID不使用 `current_package_usage_id` 作为授权凭证。
- [ ] 当前渠道正常在售且满足既有规则时返回 `can_purchase=true, purchase_mode=normal`;渠道下架且历史资格成立时返回 `can_purchase=true, purchase_mode=renew_only`
- [ ] 套餐禁用、历史资格不存在、渠道未授权、价格异常或既有组合规则不成立时返回 `can_purchase=false, purchase_mode=disabled` 及稳定中文原因。
- [ ] 无当前套餐时保持现有空值兼容语义,不伪造套餐 ID 或续费入口,并返回明确不可购买决策。
- [ ] HTTP 集成测试覆盖在售当前套餐、平台与代理渠道下架续费、禁用、无历史、退款、解绑、转手及 generation 变化,验证统一响应和字段枚举。

View File

@@ -0,0 +1,19 @@
# 04 — 套餐历史提供下架续费入口
**What to build:** 当前资产所有人查看套餐历史时,每条历史记录都能获得基于当前事实计算的购买资格。待生效、生效中、已用完和已过期套餐均可在符合条件时提供下架续费入口;同一套餐的多条历史记录展示一致决策。
**Blocked by:** 01 — 建立统一套餐可售决策与真实历史资格
**Status:** ready-for-agent
**架构通道:** Query。
**完整业务边界:** 本票只迁移和收口 `GET /api/c/v1/asset/package-history` 的权限、分页、批量可售投影与 DTO 契约。明确不修改套餐使用状态、不把历史行作为写授权、不实现前端页面或跨世代历史续费。
- [ ] 查询继续限定当前资产与当前 generation并保持现有套餐类型、状态筛选、分页、排序和统一错误格式。
- [ ] 每条历史记录增加 `can_purchase``purchase_mode``disabled_reason`,现有 `package_id` 作为续费提交的稳定标识,`package_usage_id` 仅用于展示和追踪。
- [ ] 状态为待生效、生效中、已用完或已过期的真实购买记录均可形成下架续费资格;已过期记录具有可实际消费的 `renew_only` 契约。
- [ ] 同一套餐存在多条历史记录时,各条记录基于套餐级当前购买资格返回相同结果;即使某一行自身已失效,只要同套餐另有有效历史,仍按统一当前决策投影。
- [ ] 套餐当前正常在售时返回 `normal`;当前渠道下架且资格成立时返回 `renew_only`;禁用、无有效历史、未授权或价格异常时返回 `disabled` 和稳定原因。
- [ ] 套餐、渠道分配和历史资格按当前页套餐 ID 批量读取或使用等价集合查询,同套餐复用决策结果,不产生逐行或逐套餐 N+1。
- [ ] PostgreSQL 与 HTTP 集成测试覆盖四种有效历史状态、已失效、软删除、退款撤销、同套餐多记录、不同套餐、不同资产、不同世代、分页筛选及查询次数边界。

View File

@@ -0,0 +1,20 @@
# 05 — C 端创建订单按写时事实完成下架续费
**What to build:** 当前个人客户可以继续使用现有创建订单接口,为自己当前持有资产购买正常在售套餐,或续费同资产、当前世代、同套餐的历史下架套餐。下单始终按最新归属、渠道、历史和价格重新判定,展示后的任何事实变化都会安全拒绝过期资格。
**Blocked by:** 01 — 建立统一套餐可售决策与真实历史资格
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写,现有订单 Application/Service 调用 Domain Policy旧 Service 仅作为当前完整用例的迁移门面。
**完整业务边界:** 本票收口 `POST /api/c/v1/orders/create` 的个人客户套餐购买写用例,将下架续费纳入现有订单、支付、实名、幂等和套餐组合流程。明确不新增续费 API、续费订单类型、资格字段、资格缓存或第二套幂等机制不迁移未触碰的后台订单、自动购包、支付回调、退款和佣金流程。
- [ ] 请求继续只接受资产标识、套餐 ID 列表及现有支付相关字段,不新增或信任 `renewal`、客户 ID、资产类型、店铺、世代、上下架状态或价格字段。
- [ ] 写入前重新解析当前登录客户、资产当前有效绑定、资产类型与 ID、当前 generation、当前销售渠道、套餐、渠道分配、真实历史资格和当前价格不信任查询接口缓存结果。
- [ ] 每一个请求套餐都调用统一策略;正常在售套餐按 `normal` 继续购买,下架套餐只有个人客户本人在对应资产上得到 `renew_only` 时通过。
- [ ] 下架续费仍完整执行系列、赠送套餐、主套餐/加油包组合、实名顺序、强充、支付、订单状态和现有创建防重规则,不因历史资格绕过任何既有不变量。
- [ ] 平台自营按当前套餐有效零售价创建价格快照;代理渠道按当前有效分配零售价创建快照并校验不低于授权成本,不读取历史使用记录或历史订单价格。
- [ ] 套餐展示后被禁用或下架、代理分配被禁用/删除、资产解绑或转手、generation 变化、历史退款/失效及价格变为非法时,创建订单按最新事实拒绝并返回统一中文业务错误。
- [ ] 可售结果向后续订单编排保留 `purchase_mode`、资产世代、套餐、销售渠道和命中历史资格的安全摘要,供成功审计接缝消费,但不向请求方暴露敏感历史明细。
- [ ] 单元及 HTTP/PostgreSQL 集成测试覆盖正常新购、平台与代理下架续费、伪造续费字段、展示后事实变化、当前价格、组合规则、实名、并发重复提交和事务失败不留部分订单。

View File

@@ -0,0 +1,19 @@
# 06 — 封堵后台代购与自动购包的下架绕过
**What to build:** 平台后台订单、代理代购和充值后自动购包等现有非个人续费入口,都会使用统一套餐可售策略按写时事实校验。它们可以继续购买符合原有规则的正常在售套餐,但遇到下架套餐时固定拒绝,不能借用个人客户的历史续费资格。
**Blocked by:** 01 — 建立统一套餐可售决策与真实历史资格
**Status:** ready-for-agent
**架构通道:** 主通道为复杂写,辅助通道为 Infrastructure Worker。
**完整业务边界:** 本票只改造仓库中已经存在的平台后台普通订单、代理代购和自动购包入口,使其消费同一策略并关闭下架绕过。明确不实现 UR#36 批量订购、不迁移其他订单、钱包或佣金用例、不允许非个人主体进入 `renew_only`
- [ ] 后台单笔、平台代购、代理代购和自动购包使用显式 actor 与 operation context 调用统一策略,不再通过绕过代理渠道校验、强制平台渠道或只加载套餐价格的方式跳过上下架规则。
- [ ] 所有非个人续费场景遇到当前渠道下架套餐固定返回 `disabled`;即使资产存在同套餐有效历史,也不能获得 `renew_only`
- [ ] 套餐全局禁用、代理分配缺失/禁用/软删除、价格异常、赠送限制及现有主套餐/加油包组合规则在各入口得到一致执行。
- [ ] 正常上架的平台和代理渠道后台购买、代购及自动购包保持原业务语义、结算方式和价格来源,不因本需求扩大迁移范围。
- [ ] 自动购包任务在实际扣款和创建订单前按最新资产、generation、渠道、套餐及价格重新校验不能使用充值单创建时的旧资格快照放行。
- [ ] 各入口返回或记录统一安全业务原因Handler、Worker 和 Service 不向客户端或日志直接暴露底层数据库错误。
- [ ] 测试形成入口矩阵,验证个人 C 端下架续费可通过,而平台后台、平台代理、代理代购和自动购包对同一下架套餐全部拒绝,同时正常上架路径不回归。

View File

@@ -0,0 +1,22 @@
# 07 — 接入下架续费成功与拒绝审计
**What to build:** 下架续费订单成功创建后,审计人员可以通过统一 Audit Event 追踪购买模式、资产、世代、套餐、渠道和历史资格摘要;构造请求因资格、归属或渠道事实被拒绝时,也能看到稳定失败原因,且审计中不包含客户敏感明细。
**Blocked by:**
- 05 — C 端创建订单按写时事实完成下架续费
- 06 — 封堵后台代购与自动购包的下架绕过
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**架构通道:** Application + Audit Port/Adapter。
**完整业务边界:** 本票只把 UR#40 的成功 `renew_only` 决策和被拒绝的构造购买请求接入公共 Audit Event不创建独立续费日志表不迁移套餐上下架、普通订单或其他旧审计日志不用审计替代订单和套餐使用业务事实。
- [ ] 成功创建下架续费订单时写统一成功事件,至少记录 `purchase_mode=renew_only`、资产类型与 ID、当前 generation、套餐 ID、平台或代理销售渠道、订单引用及命中历史资格的脱敏摘要。
- [ ] 成功事件与订单事实遵循公共 Audit Writer 的事务契约;审计失败时不得留下缺失必需审计的错误成功事实。
- [ ] 归属伪造、跨资产/跨世代历史、下架非个人购买、套餐禁用、渠道未授权、退款失效及价格异常等拒绝结果,按公共失败审计规则使用独立短事务保存稳定动作、结果和业务失败分类。
- [ ] 审计资源能够串联客户购买动作、资产、套餐、订单及销售渠道但不记录手机号、OpenID、完整请求体、历史订单金额、底层错误或其他客户敏感明细。
- [ ] 普通 `normal` 购买沿用现有审计语义,不因本票被重复记录为下架续费;后台与自动购包拒绝事件能够明确表明其 operation context 不具备历史续费例外。
- [ ] PostgreSQL 集成测试覆盖成功事务一致性、业务回滚、拒绝事件独立落库、重复请求、脱敏与资源关联,并验证审计失败策略符合公共契约。

View File

@@ -0,0 +1,30 @@
# 08 — 完成接口契约、跨入口验收与发布门禁
**What to build:** 发布负责人可以通过自动化测试、OpenAPI、中文联调说明和发布检查验证套餐商城不暴露下架套餐当前套餐与套餐历史正确提供续费资格个人客户可以按当前价格续费而后台、代理、自动购买和批量订购均无法绕过下架限制。前端仅消费后端资格契约不承担资格推导。
**Blocked by:**
- 02 — 套餐商城只展示正常在售套餐
- 03 — 当前套餐提供下架续费入口
- 04 — 套餐历史提供下架续费入口
- 05 — C 端创建订单按写时事实完成下架续费
- 06 — 封堵后台代购与自动购包的下架绕过
- 07 — 接入下架续费成功与拒绝审计
- `.scratch/ur36-bulk-package-purchase/issues/04-unified-admin-package-purchasability.md` — 04 — 统一后台与批量入口的套餐可售策略
**Status:** ready-for-agent
**架构通道:** 主通道为 Infrastructure辅助通道为 Query 与 Application 验收。
**完整业务边界:** 本票收口 UR#40 的后端接口契约、真实链路验收、性能验证、中文功能总结、README、发布与回滚门禁。本仓库不实现前端页面前端内容只描述如何消费 `can_purchase``purchase_mode``disabled_reason``current_package_id` 以及资格失效后的刷新交互,不扩展为跨仓前端任务。
- [ ] OpenAPI 完整描述套餐商城、资产信息、套餐历史和创建订单的最终契约,包含 `current_package_id``can_purchase``purchase_mode=normal|renew_only|disabled`、稳定中文原因及统一响应包装,生成校验无漂移。
- [ ] 真实 Fiber、认证、Handler、Query/Application、GORM/PostgreSQL 集成验收覆盖商城永不返回下架套餐、当前套餐下架续费、待生效/生效中/已用完/已过期历史续费、禁用和无历史资格。
- [ ] 入口矩阵验证个人 C 端可以续费,而平台后台普通订单、平台或代理代购、自动购包及 UR#36 批量订购对同一下架套餐全部拒绝;各入口正常上架购买不回归。
- [ ] 竞态验收覆盖展示后套餐禁用、渠道分配禁用/删除、资产解绑/转手、generation 变化、历史退款/失效和价格非法,创建订单始终按写时事实拒绝。
- [ ] 价格验收证明平台和代理渠道使用下单时当前零售价,不复用历史价格;代理零售价低于当前成本时拒绝。
- [ ] PostgreSQL 查询计划验证历史资格使用适用索引,商城与历史列表批量投影没有逐行查询;目标接口满足项目分页与性能要求。
- [ ] 后端联调文档明确前端只根据资格字段渲染:商城只展示后端返回项,`normal` 使用原动作,`renew_only` 显示“续费”和下架提示,`disabled` 隐藏或禁用并展示原因;提交只携带资产标识和套餐 ID失败后展示后端中文错误并刷新数据。
- [ ] 中文功能总结和 README 说明策略边界、接口契约、错误闭环、索引或迁移结论、测试方式、监控、发布顺序和回滚限制;不承诺前端页面实现。
- [ ] 发布前只读核验套餐与代理分配状态、当前世代使用记录、订单退款一致性及查询计划;发现退款完成但使用记录仍有效的异常时必须修复或由策略显式排除后才能发布。
- [ ] 回滚只恢复“下架全部拒绝”的应用行为,保留已合法产生的订单、套餐使用和审计事实,不回填资格、不删除历史数据,也不反向修改套餐上下架状态。

View File

@@ -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 中文总结中记录前代查询的架构通道、权限防泄露、异常选择规则、索引发布与回滚方式,并准备无换货、前代可见和前代不可见的人工验收步骤。

View File

@@ -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 文件路径和标题更新本票,不能使用模糊依赖描述。

View File

@@ -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和 H5Agent、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和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户

View File

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

View File

@@ -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。

View File

@@ -0,0 +1,49 @@
# 批量换货脚本功能总结
## 功能说明
新增 Python 运维脚本 `scripts/batch_exchange/batch_exchange.py`,用于读取旧资产标识、新资产标识两列 CSV并逐行调用后台 `POST /api/admin/exchanges` 接口。
脚本复用现有换货业务事务,不直接操作数据库,不新增换货接口,也不改变现有 Handler、Service 或数据模型。
## 固定业务约束
- 换货流程固定为 `flow_type=direct`,不支持物流换货。
- 数据迁移固定为 `migrate_data=true`,不提供关闭选项。
- 每组资产调用一次创建换货接口;现有接口会在同一事务内创建并立即完成直接换货。
- 资产类型通过 `--asset-type` 按批次指定为 `iot_card``device`,新旧资产类型仍由接口校验一致性。
- 换货原因默认使用 `批量直接换货`,支持按批次覆盖原因和备注。
## 数据迁移范围
数据迁移完全复用现有换货服务,包含:
- 资产钱包余额。
- 套餐使用记录。
- 累计充值字段。
- 资产标签。
此外,直接换货原有流程仍会处理客户绑定切换以及新旧资产状态更新。
## 安全控制
- 默认仅预演,显式增加 `--execute` 后才会真实换货。
- 请求前校验 CSV 必须正好两列且新旧标识非空。
- 拦截同一行新旧资产相同、重复旧资产、重复新资产。
- 拦截同一资产在批次内同时作为旧资产和新资产,避免顺序执行改变后续行的资产状态。
- 接口返回成功时仍要求响应中的 `migration_completed=true`,否则结果记为失败并要求人工核对。
- 每行结果立即写入结果 CSV中断后保留已处理记录。
- POST 请求不自动重试,避免接口已成功但客户端未收到响应时产生误操作。
- Token 和密码支持环境变量传入,不写入结果文件。
## 输出
结果 CSV 包含:
- CSV 原始行号、新旧资产标识。
- 成功或失败状态。
- HTTP 状态码、业务错误码和接口消息。
- 换货单 ID、换货单号。
- 迁移完成状态和迁移余额。
全部成功时退出码为 `0`;存在失败或认证失效导致中途停止时退出码为 `2`参数、CSV 或登录错误时退出码为 `1`

View File

@@ -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
}(),

View File

@@ -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)
}

View File

@@ -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 设备绑定的卡信息

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
-- 回滚换货链双向查询索引,不修改换货单业务数据。
DROP INDEX IF EXISTS idx_exchange_trace_old_asset;
DROP INDEX IF EXISTS idx_exchange_trace_new_asset;

View File

@@ -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;

View File

@@ -97,6 +97,15 @@ const (
UserTypePersonalCustomer = 5 // 个人客户C端用户
)
const (
// AssetResolveTypeCard 表示统一资产详情中的卡类型。
AssetResolveTypeCard = "card"
// ExchangeTraceDirectionPrevious 表示换货链前代查询方向。
ExchangeTraceDirectionPrevious = "previous"
// ExchangeTraceDirectionNext 表示换货链后代查询方向。
ExchangeTraceDirectionNext = "next"
)
// RBAC 角色类型常量
const (
RoleTypePlatform = 1 // 平台角色(适用于平台用户)

View File

@@ -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),

View File

@@ -0,0 +1,114 @@
# 批量换货脚本
该脚本读取两列 CSV逐行调用 `POST /api/admin/exchanges` 执行换货。脚本固定使用:
- `flow_type=direct`:只支持直接换货,接口创建换货单后会立即完成换货。
- `migrate_data=true`:必须执行全量数据迁移,不能通过参数关闭。
全量迁移由现有换货接口在同一事务中执行,包括钱包余额、套餐使用记录、累计充值字段和资产标签。脚本仅使用 Python 标准库,不需要安装依赖。
默认只预演,必须增加 `--execute` 才会真实换货。
## CSV 格式
首行表头可选,第一列填写旧资产标识,第二列填写新资产标识:
```csv
old_identifier,new_identifier
89860000000000000001,89860000000000000101
89860000000000000002,89860000000000000102
```
支持表头:
- 英文:`old_identifier,new_identifier``old_asset_identifier,new_asset_identifier`
- 中文:`旧资产标识,新资产标识``旧资产,新资产`
旧、新资产标识均使用换货接口已有的识别规则:物联网卡支持 ICCID、接入号、虚拟号设备支持虚拟号、IMEI、SN。
脚本会在请求前拦截:
- 列数不是两列,或任一列为空。
- 同一行新旧资产相同。
- 旧资产重复,或新资产重复。
- 同一资产在本批次中既作为旧资产又作为新资产。该情况会受到执行顺序影响,因此整批终止。
## 预演
物联网卡示例:
```bash
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv scripts/batch_exchange/exchanges.example.csv \
--asset-type iot_card
```
设备批次将 `--asset-type` 改为 `device`。同一个 CSV 批次只能使用一种资产类型;新资产必须与旧资产类型一致,否则接口会拒绝该行。
预演只校验 CSV 并展示最多五条请求示例,不需要 Token也不会调用接口。输出中会明确显示 `direct``migrate_data=true`
## 使用已有 Token 执行
推荐通过环境变量传递 Token避免进入命令历史
```bash
JUNHONG_ADMIN_TOKEN='<后台Access Token>' \
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--execute
```
## 使用账号自动登录后执行
未提供 Token 时,可以使用后台账号调用 `/api/admin/login` 自动获取 Token
```bash
JUNHONG_ADMIN_USERNAME='<后台账号>' \
JUNHONG_ADMIN_PASSWORD='<后台密码>' \
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--execute
```
也可以使用 `--token``--username``--password` 参数。密码优先通过环境变量或交互输入,避免保存在 Shell 历史中。
## 换货原因和备注
脚本默认使用 `批量直接换货` 作为换货原因。可按整批覆盖原因和备注:
```bash
python3 scripts/batch_exchange/batch_exchange.py \
--base-url https://cmp-api.example.com \
--csv /path/to/exchanges.csv \
--asset-type iot_card \
--exchange-reason '故障卡批量换货' \
--remark '2026年7月批次' \
--execute
```
## 结果文件
默认在输入 CSV 同目录生成:
```text
原文件名_换货结果_YYYYMMDD_HHMMSS.csv
```
结果包含新旧资产标识、成功或失败状态、HTTP 状态码、业务错误码、错误消息、换货单 ID、换货单号、迁移完成状态和迁移余额。每处理一条都会立即刷新文件中途中断时已完成的结果不会丢失。
为避免覆盖历史结果,`--output` 指定的文件已经存在时脚本会直接报错。
## 其他参数和执行约束
- `--timeout`:单次请求超时秒数,默认 `30`
- `--interval`:每次换货后的等待秒数,默认 `0.2`
- `--execute`:显式开启真实换货。
每组资产单独调用一次接口,脚本不会自动重试 POST 请求。接口可能已经成功提交事务但客户端没有收到响应,自动重试可能造成误判;失败项应先查询换货单或资产状态,再决定是否单独重跑。
脚本不会回滚前面已经成功的行。执行前应先预演并确认完整映射;执行后根据结果 CSV 逐条核对失败项。

View File

@@ -0,0 +1,613 @@
#!/usr/bin/env python3
"""批量直接换货脚本:读取双列 CSV逐行创建必须迁移数据的直接换货单。
默认只执行预演。只有显式传入 --execute 时,才会调用
POST /api/admin/exchanges。脚本仅使用 Python 标准库,不需要安装第三方依赖。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from getpass import getpass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
EXCHANGE_PATH = "/api/admin/exchanges"
LOGIN_PATH = "/api/admin/login"
AUTH_ERROR_CODES = {1002, 1003, 1004}
ASSET_TYPES = {"iot_card", "device"}
DEFAULT_EXCHANGE_REASON = "批量直接换货"
OLD_HEADER_NAMES = {
"old_identifier",
"old_asset_identifier",
"旧资产标识",
"旧资产",
}
NEW_HEADER_NAMES = {
"new_identifier",
"new_asset_identifier",
"新资产标识",
"新资产",
}
@dataclass(frozen=True)
class ExchangeInput:
"""保存 CSV 中的一组新旧资产标识及原始行号。"""
line_no: int
old_identifier: str
new_identifier: str
@dataclass(frozen=True)
class HTTPResult:
"""保存一次 HTTP 请求的响应信息。"""
status: int
body: dict[str, Any] | None
raw_body: str
@dataclass(frozen=True)
class ExchangeOutcome:
"""保存单组资产的换货结果及结果文件行。"""
row: dict[str, object]
success: bool
http_status: int | None
code: int | str | None
message: str
exchange_no: str
class RequestFailedError(Exception):
"""表示请求尚未获得可解析的 HTTP 响应。"""
class AdminAPIClient:
"""调用后台认证和换货接口的轻量客户端。"""
def __init__(self, base_url: str, timeout: float) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def login(self, username: str, password: str) -> str:
"""使用后台账号登录并返回 Access Token。"""
result = self._post_json(
LOGIN_PATH,
{"username": username, "password": password, "device": "web"},
token=None,
)
code = response_code(result.body)
if not is_success(result.status, code):
raise RequestFailedError(
f"登录失败HTTP {result.status}code={display_value(code)}"
f"msg={response_message(result.body, result.raw_body)}"
)
data = result.body.get("data") if result.body else None
token = data.get("access_token") if isinstance(data, dict) else None
if not isinstance(token, str) or not token.strip():
raise RequestFailedError("登录响应中缺少 data.access_token")
return token.strip()
def create_exchange(
self,
token: str,
exchange: ExchangeInput,
asset_type: str,
exchange_reason: str,
remark: str | None,
) -> HTTPResult:
"""创建并立即完成一组必须迁移数据的直接换货。"""
payload = build_exchange_payload(exchange, asset_type, exchange_reason, remark)
return self._post_json(EXCHANGE_PATH, payload, token=token)
def _post_json(self, path: str, payload: dict[str, Any], token: str | None) -> HTTPResult:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "junhong-batch-exchange/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(
url=self.base_url + path,
data=body,
headers=headers,
method="POST",
)
try:
with urlopen(request, timeout=self.timeout) as response:
raw_body = response.read().decode("utf-8", errors="replace")
return HTTPResult(
status=response.status,
body=parse_json_object(raw_body),
raw_body=raw_body,
)
except HTTPError as exc:
raw_body = exc.read().decode("utf-8", errors="replace")
return HTTPResult(
status=exc.code,
body=parse_json_object(raw_body),
raw_body=raw_body,
)
except (URLError, TimeoutError, OSError) as exc:
raise RequestFailedError(f"请求失败:{exc}") from exc
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="读取双列 CSV逐行调用 /api/admin/exchanges 执行直接换货和数据迁移",
)
parser.add_argument(
"--base-url",
default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""),
help="接口 Base URL例如 https://cmp-api.example.com也可使用 JUNHONG_ADMIN_BASE_URL",
)
parser.add_argument(
"--csv",
required=True,
help="双列资产 CSV 文件路径,依次为旧资产标识、新资产标识,首行可有表头",
)
parser.add_argument(
"--asset-type",
required=True,
choices=sorted(ASSET_TYPES),
help="本批资产类型iot_card物联网卡或 device设备",
)
parser.add_argument(
"--exchange-reason",
default=DEFAULT_EXCHANGE_REASON,
help=f"本批换货原因(默认:{DEFAULT_EXCHANGE_REASON}",
)
parser.add_argument("--remark", default="", help="本批换货备注;默认不传")
parser.add_argument(
"--token",
default=os.getenv("JUNHONG_ADMIN_TOKEN", ""),
help="后台 Access Token也可使用 JUNHONG_ADMIN_TOKEN",
)
parser.add_argument(
"--username",
default=os.getenv("JUNHONG_ADMIN_USERNAME", ""),
help="未提供 Token 时用于自动登录;也可使用 JUNHONG_ADMIN_USERNAME",
)
parser.add_argument(
"--password",
default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""),
help="后台登录密码;建议使用 JUNHONG_ADMIN_PASSWORD避免进入命令历史",
)
parser.add_argument("--output", default="", help="结果 CSV 路径;默认输出到输入文件同目录")
parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30")
parser.add_argument("--interval", type=float, default=0.2, help="每次换货后的间隔秒数(默认 0.2")
parser.add_argument(
"--execute",
action="store_true",
help="真实调用接口换货;不传时只校验 CSV 并预览请求",
)
return parser.parse_args()
def load_exchanges(csv_path: Path) -> list[ExchangeInput]:
"""读取双列 CSV并在执行前拦截可能破坏批次映射的数据。"""
if not csv_path.exists():
raise ValueError(f"找不到 CSV 文件:{csv_path}")
if not csv_path.is_file():
raise ValueError(f"CSV 路径不是文件:{csv_path}")
exchanges: list[ExchangeInput] = []
old_line_by_identifier: dict[str, int] = {}
new_line_by_identifier: dict[str, int] = {}
errors: list[str] = []
first_nonempty_seen = False
with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
reader = csv.reader(file)
for line_no, row in enumerate(reader, start=1):
if not any(value.strip() for value in row):
continue
if len(row) != 2:
errors.append(f"{line_no} 行必须正好有两列,实际读取到 {len(row)}")
continue
old_identifier, new_identifier = (value.strip() for value in row)
if not first_nonempty_seen:
first_nonempty_seen = True
if is_header_row(old_identifier, new_identifier):
continue
if not old_identifier or not new_identifier:
errors.append(f"{line_no} 行的旧资产标识和新资产标识均不能为空")
continue
if len(old_identifier) > 100 or len(new_identifier) > 100:
errors.append(f"{line_no} 行的资产标识不能超过 100 个字符")
continue
if old_identifier == new_identifier:
errors.append(f"{line_no} 行的新旧资产标识相同:{old_identifier}")
continue
if old_identifier in old_line_by_identifier:
errors.append(
f"{line_no} 行旧资产与第 {old_line_by_identifier[old_identifier]} 行重复:"
f"{old_identifier}"
)
continue
if new_identifier in new_line_by_identifier:
errors.append(
f"{line_no} 行新资产与第 {new_line_by_identifier[new_identifier]} 行重复:"
f"{new_identifier}"
)
continue
old_line_by_identifier[old_identifier] = line_no
new_line_by_identifier[new_identifier] = line_no
exchanges.append(
ExchangeInput(
line_no=line_no,
old_identifier=old_identifier,
new_identifier=new_identifier,
)
)
for identifier in old_line_by_identifier.keys() & new_line_by_identifier.keys():
errors.append(
f"资产 {identifier} 同时作为第 {old_line_by_identifier[identifier]} 行旧资产和"
f"{new_line_by_identifier[identifier]} 行新资产,批次执行顺序会改变其状态"
)
if errors:
preview = "\n".join(f" - {error}" for error in errors[:20])
if len(errors) > 20:
preview += f"\n - 其余 {len(errors) - 20} 个错误已省略"
raise ValueError(f"CSV 校验失败,请修正后重试:\n{preview}")
if not exchanges:
raise ValueError("CSV 中没有有效的换货资产映射")
return exchanges
def is_header_row(old_identifier: str, new_identifier: str) -> bool:
"""判断首个非空行是否为支持的双列表头。"""
return old_identifier.lower() in OLD_HEADER_NAMES and new_identifier.lower() in NEW_HEADER_NAMES
def parse_json_object(raw_body: str) -> dict[str, Any] | None:
"""尝试把响应正文解析为 JSON 对象。"""
if not raw_body.strip():
return None
try:
value = json.loads(raw_body)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def response_code(body: dict[str, Any] | None) -> int | str | None:
"""读取统一响应中的业务错误码。"""
if not body:
return None
code = body.get("code")
if isinstance(code, bool):
return int(code)
if isinstance(code, int):
return code
if isinstance(code, str):
stripped = code.strip()
return int(stripped) if stripped.isdigit() else stripped
return None
def response_message(body: dict[str, Any] | None, raw_body: str) -> str:
"""读取统一响应消息,非 JSON 响应则保留截断后的正文。"""
if body:
message = body.get("msg", body.get("message", ""))
if message is not None and str(message).strip():
return str(message).strip()
text = raw_body.strip().replace("\r", " ").replace("\n", " ")
return text[:500] if text else "接口未返回错误信息"
def is_success(http_status: int, code: int | str | None) -> bool:
"""同时校验 HTTP 状态码和业务响应码。"""
return 200 <= http_status < 300 and str(code) == "0"
def display_value(value: object) -> str:
"""把可能为空的字段转为适合日志和 CSV 的文本。"""
return "" if value is None else str(value)
def resolve_output_path(input_path: Path, output_arg: str) -> Path:
"""生成本批次的结果文件路径。"""
if output_arg.strip():
return Path(output_arg).expanduser().resolve()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return input_path.with_name(f"{input_path.stem}_换货结果_{timestamp}.csv")
def resolve_token(args: argparse.Namespace, client: AdminAPIClient) -> str:
"""优先使用现有 Token否则使用后台账号自动登录。"""
token = args.token.strip()
if token:
return token
username = args.username.strip()
if not username:
raise ValueError(
"真实执行需要 --token或同时提供 --username/--password"
"也可通过 JUNHONG_ADMIN_TOKEN 等环境变量配置"
)
password = args.password
if not password and sys.stdin.isatty():
password = getpass("请输入后台登录密码:")
if not password:
raise ValueError("使用账号登录时必须提供密码")
print(f"正在使用后台账号 {username!r} 获取 Access Token...")
return client.login(username, password)
def build_exchange_payload(
exchange: ExchangeInput,
asset_type: str,
exchange_reason: str,
remark: str | None,
) -> dict[str, Any]:
"""构造预演和真实执行共同使用的固定直接换货请求。"""
payload: dict[str, Any] = {
"old_asset_type": asset_type,
"old_identifier": exchange.old_identifier,
"flow_type": "direct",
"new_identifier": exchange.new_identifier,
"migrate_data": True,
"exchange_reason": exchange_reason,
}
if remark is not None:
payload["remark"] = remark
return payload
def preview(
exchanges: list[ExchangeInput],
base_url: str,
asset_type: str,
exchange_reason: str,
remark: str | None,
) -> int:
"""输出预演信息,不发送任何 HTTP 请求。"""
print("预演完成:未发送任何 HTTP 请求。")
print(f"接口地址:{base_url.rstrip('/')}{EXCHANGE_PATH}")
print(f"换货数量:{len(exchanges)}")
print(f"资产类型:{asset_type}")
print("换货流程direct直接换货")
print("数据迁移true必须迁移")
print(f"换货原因:{exchange_reason}")
print("请求示例:")
for exchange in exchanges[:5]:
payload = build_exchange_payload(exchange, asset_type, exchange_reason, remark)
print(f"{exchange.line_no} 行:{json.dumps(payload, ensure_ascii=False)}")
if len(exchanges) > 5:
print(f" 其余 {len(exchanges) - 5} 条已省略")
print("确认资产映射无误后,增加 --execute 才会真实换货。")
return 0
def exchange_asset(
client: AdminAPIClient,
token: str,
exchange: ExchangeInput,
asset_type: str,
exchange_reason: str,
remark: str | None,
) -> ExchangeOutcome:
"""调用一次换货接口,并转换为统一的结果记录。"""
try:
result = client.create_exchange(token, exchange, asset_type, exchange_reason, remark)
code = response_code(result.body)
message = response_message(result.body, result.raw_body)
data = result.body.get("data") if result.body else None
exchange_data = data if isinstance(data, dict) else {}
request_succeeded = is_success(result.status, code)
migration_completed = exchange_data.get("migration_completed") is True
success = request_succeeded and migration_completed
if request_succeeded and not migration_completed:
message = "接口返回成功,但响应未确认数据迁移完成,请人工核对该换货单"
exchange_no = display_value(exchange_data.get("exchange_no"))
return ExchangeOutcome(
row={
"line_no": exchange.line_no,
"old_identifier": exchange.old_identifier,
"new_identifier": exchange.new_identifier,
"status": "成功" if success else "失败",
"http_status": result.status,
"code": display_value(code),
"msg": message,
"exchange_id": display_value(exchange_data.get("id")),
"exchange_no": exchange_no,
"migration_completed": display_value(exchange_data.get("migration_completed")),
"migration_balance": display_value(exchange_data.get("migration_balance")),
},
success=success,
http_status=result.status,
code=code,
message=message,
exchange_no=exchange_no,
)
except RequestFailedError as exc:
message = str(exc)
return ExchangeOutcome(
row={
"line_no": exchange.line_no,
"old_identifier": exchange.old_identifier,
"new_identifier": exchange.new_identifier,
"status": "失败",
"http_status": "",
"code": "",
"msg": message,
"exchange_id": "",
"exchange_no": "",
"migration_completed": "",
"migration_balance": "",
},
success=False,
http_status=None,
code=None,
message=message,
exchange_no="",
)
def execute(
exchanges: list[ExchangeInput],
client: AdminAPIClient,
token: str,
asset_type: str,
exchange_reason: str,
remark: str | None,
output_path: Path,
interval: float,
) -> int:
"""顺序执行换货,并把每条结果立即写入 CSV。"""
output_path.parent.mkdir(parents=True, exist_ok=True)
success_count = 0
failed_count = 0
total = len(exchanges)
with output_path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(
file,
fieldnames=[
"line_no",
"old_identifier",
"new_identifier",
"status",
"http_status",
"code",
"msg",
"exchange_id",
"exchange_no",
"migration_completed",
"migration_balance",
],
)
writer.writeheader()
file.flush()
for index, exchange in enumerate(exchanges, start=1):
outcome = exchange_asset(
client,
token,
exchange,
asset_type,
exchange_reason,
remark,
)
writer.writerow(outcome.row)
if outcome.success:
success_count += 1
print(
f"[{index}/{total}] 成功:{exchange.old_identifier} -> "
f"{exchange.new_identifier},换货单号={outcome.exchange_no}"
)
else:
failed_count += 1
print(
f"[{index}/{total}] 失败:{exchange.old_identifier} -> "
f"{exchange.new_identifier}HTTP={display_value(outcome.http_status)}"
f"code={display_value(outcome.code)}msg={outcome.message}",
file=sys.stderr,
)
file.flush()
if outcome.http_status == 401 or outcome.code in AUTH_ERROR_CODES:
print("认证已失效,停止后续换货;已处理结果已保存。", file=sys.stderr)
break
if interval > 0 and index < total:
time.sleep(interval)
print()
print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。")
print(f"结果文件:{output_path}")
return 0 if failed_count == 0 and success_count == total else 2
def main() -> int:
"""校验参数,执行预演或真实批量换货。"""
args = parse_args()
try:
base_url = args.base_url.strip()
if not base_url:
raise ValueError("必须通过 --base-url 或 JUNHONG_ADMIN_BASE_URL 配置接口地址")
if not base_url.startswith(("http://", "https://")):
raise ValueError("base-url 必须以 http:// 或 https:// 开头")
exchange_reason = args.exchange_reason.strip()
if not exchange_reason:
raise ValueError("exchange-reason 不能为空")
if len(exchange_reason) > 100:
raise ValueError("exchange-reason 不能超过 100 个字符")
remark = args.remark.strip() or None
if remark is not None and len(remark) > 500:
raise ValueError("remark 不能超过 500 个字符")
if args.timeout <= 0:
raise ValueError("timeout 必须大于 0")
if args.interval < 0:
raise ValueError("interval 不能小于 0")
input_path = Path(args.csv).expanduser().resolve()
exchanges = load_exchanges(input_path)
if not args.execute:
return preview(
exchanges,
base_url,
args.asset_type,
exchange_reason,
remark,
)
output_path = resolve_output_path(input_path, args.output)
if output_path == input_path:
raise ValueError("结果文件不能与输入 CSV 使用同一路径")
if output_path.exists():
raise ValueError(f"结果文件已存在,请更换 --output 路径:{output_path}")
client = AdminAPIClient(base_url, args.timeout)
token = resolve_token(args, client)
print(
f"即将真实换货:{len(exchanges)} 组,资产类型={args.asset_type}"
"流程=direct迁移数据=true"
)
print(f"接口地址:{base_url.rstrip('/')}{EXCHANGE_PATH}")
print(f"结果文件:{output_path}")
return execute(
exchanges=exchanges,
client=client,
token=token,
asset_type=args.asset_type,
exchange_reason=exchange_reason,
remark=remark,
output_path=output_path,
interval=args.interval,
)
except (ValueError, RequestFailedError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("\n用户中断执行;已写入的结果会保留。", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,38 @@
old_identifier,new_identifier
99840868838,89861590172420400342
99840868841,89861590172420400343
99840868842,89861590172420400344
99840868843,89861590172420400345
99840868844,89861590172420400346
99840868845,89861590172420400347
99840868847,89861590172420400348
99840868848,89861590172420400349
99840868850,89861590172420400351
99840868851,89861590172420400352
99840868852,89861590172420400353
99840868854,89861590172420400354
99840868856,89861590172420400356
99840868863,89861590172420400363
99840868865,89861590172420400364
99820416468,89861590172420400365
99840868917,89861590172420400319
99840868918,89861590172420400320
99840868919,89861590172420400321
99840868920,89861590172420400322
99840868922,89861590172420400323
99840868923,89861590172420400324
99840868924,89861590172420400325
99840868925,89861590172420400326
99840868938,89861590172420400327
99840868939,89861590172420400328
99840868940,89861590172420400329
99840868941,89861590172420400330
99840868942,89861590172420400331
99840868943,89861590172420400332
99840868944,89861590172420400333
99840868945,89861590172420400334
99840868946,89861590172420400335
99840868948,89861590172420400336
99840868949,89861590172420400337
99840868951,89861590172420400338
99840868952,89861590172420400339
1 old_identifier new_identifier
2 99840868838 89861590172420400342
3 99840868841 89861590172420400343
4 99840868842 89861590172420400344
5 99840868843 89861590172420400345
6 99840868844 89861590172420400346
7 99840868845 89861590172420400347
8 99840868847 89861590172420400348
9 99840868848 89861590172420400349
10 99840868850 89861590172420400351
11 99840868851 89861590172420400352
12 99840868852 89861590172420400353
13 99840868854 89861590172420400354
14 99840868856 89861590172420400356
15 99840868863 89861590172420400363
16 99840868865 89861590172420400364
17 99820416468 89861590172420400365
18 99840868917 89861590172420400319
19 99840868918 89861590172420400320
20 99840868919 89861590172420400321
21 99840868920 89861590172420400322
22 99840868922 89861590172420400323
23 99840868923 89861590172420400324
24 99840868924 89861590172420400325
25 99840868925 89861590172420400326
26 99840868938 89861590172420400327
27 99840868939 89861590172420400328
28 99840868940 89861590172420400329
29 99840868941 89861590172420400330
30 99840868942 89861590172420400331
31 99840868943 89861590172420400332
32 99840868944 89861590172420400333
33 99840868945 89861590172420400334
34 99840868946 89861590172420400335
35 99840868948 89861590172420400336
36 99840868949 89861590172420400337
37 99840868951 89861590172420400338
38 99840868952 89861590172420400339

View File

@@ -0,0 +1,172 @@
"""批量换货脚本的输入校验测试。"""
from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).with_name("batch_exchange.py")
SPEC = importlib.util.spec_from_file_location("batch_exchange", SCRIPT_PATH)
assert SPEC is not None and SPEC.loader is not None
batch_exchange = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = batch_exchange
SPEC.loader.exec_module(batch_exchange)
class LoadExchangesTest(unittest.TestCase):
"""验证 CSV 映射校验不会把危险批次交给接口执行。"""
def write_csv(self, content: str) -> Path:
"""在临时目录中创建待解析的 CSV。"""
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
path = Path(directory.name) / "exchanges.csv"
path.write_text(content, encoding="utf-8")
return path
def test_loads_supported_header_and_rows(self) -> None:
"""支持标准表头并保留原始行号。"""
path = self.write_csv("old_identifier,new_identifier\nold-1,new-1\nold-2,new-2\n")
exchanges = batch_exchange.load_exchanges(path)
self.assertEqual(
exchanges,
[
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
batch_exchange.ExchangeInput(3, "old-2", "new-2"),
],
)
def test_rejects_duplicate_old_identifier(self) -> None:
"""同一旧资产不能在一批中换出两次。"""
path = self.write_csv("old-1,new-1\nold-1,new-2\n")
with self.assertRaisesRegex(ValueError, "旧资产.*重复"):
batch_exchange.load_exchanges(path)
def test_rejects_duplicate_new_identifier(self) -> None:
"""同一新资产不能接收两次换货。"""
path = self.write_csv("old-1,new-1\nold-2,new-1\n")
with self.assertRaisesRegex(ValueError, "新资产.*重复"):
batch_exchange.load_exchanges(path)
def test_rejects_same_identifier_in_one_row(self) -> None:
"""禁止资产换给自身。"""
path = self.write_csv("old-1,old-1\n")
with self.assertRaisesRegex(ValueError, "新旧资产标识相同"):
batch_exchange.load_exchanges(path)
def test_rejects_cross_row_asset_reuse(self) -> None:
"""禁止资产在同批中同时作为旧资产和新资产。"""
path = self.write_csv("old-1,new-1\nnew-1,new-2\n")
with self.assertRaisesRegex(ValueError, "同时作为.*旧资产.*新资产"):
batch_exchange.load_exchanges(path)
def test_rejects_missing_column_value(self) -> None:
"""双列中的空值必须在请求前被拦截。"""
path = self.write_csv("old_identifier,new_identifier\nold-1,\n")
with self.assertRaisesRegex(ValueError, "均不能为空"):
batch_exchange.load_exchanges(path)
class BuildExchangePayloadTest(unittest.TestCase):
"""验证脚本不能关闭直接换货或数据迁移。"""
def test_forces_direct_flow_and_migration(self) -> None:
"""请求固定为 direct 且 migrate_data 为 true。"""
exchange = batch_exchange.ExchangeInput(2, "old-1", "new-1")
payload = batch_exchange.build_exchange_payload(
exchange,
"iot_card",
"批量直接换货",
None,
)
self.assertEqual(payload["flow_type"], "direct")
self.assertIs(payload["migrate_data"], True)
self.assertEqual(payload["old_asset_type"], "iot_card")
class ExchangeAssetTest(unittest.TestCase):
"""验证结果必须同时满足接口成功和迁移完成。"""
class FakeClient:
"""返回指定响应的换货客户端替身。"""
def __init__(self, result: object) -> None:
self.result = result
def create_exchange(self, *args: object) -> object:
"""返回测试预设的接口响应。"""
return self.result
def test_marks_completed_migration_as_success(self) -> None:
"""业务成功且迁移完成时结果为成功。"""
result = batch_exchange.HTTPResult(
status=200,
body={
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EX123",
"migration_completed": True,
"migration_balance": 100,
},
},
raw_body="",
)
outcome = batch_exchange.exchange_asset(
self.FakeClient(result),
"token",
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
"iot_card",
"批量直接换货",
None,
)
self.assertTrue(outcome.success)
self.assertEqual(outcome.exchange_no, "EX123")
def test_rejects_success_response_without_completed_migration(self) -> None:
"""业务成功但未确认迁移完成时结果仍为失败。"""
result = batch_exchange.HTTPResult(
status=200,
body={
"code": 0,
"msg": "success",
"data": {
"id": 123,
"exchange_no": "EX123",
"migration_completed": False,
"migration_balance": 0,
},
},
raw_body="",
)
outcome = batch_exchange.exchange_asset(
self.FakeClient(result),
"token",
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
"iot_card",
"批量直接换货",
None,
)
self.assertFalse(outcome.success)
self.assertIn("未确认数据迁移完成", outcome.message)
if __name__ == "__main__":
unittest.main()