Compare commits
6 Commits
43bcb0e4ae
...
208cb19f0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 208cb19f0e | |||
| 55bdc3a8d0 | |||
| 785907ce85 | |||
| 39cdbdcfef | |||
| cf5dcd28ea | |||
| ce69679b21 |
@@ -0,0 +1,18 @@
|
|||||||
|
# 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**What to build:** 业务用例可以通过统一 Audit Writer 写入不可变、多资源、可串联且默认脱敏的审计事件。成功事件能够与业务事实共用事务,失败或拒绝事件使用独立短事务保留;未注册动作、缺少主要资源或审计写入失败时按明确策略阻止错误事实落地。
|
||||||
|
|
||||||
|
**Blocked by:** `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Application + Port/Adapter,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 Audit Event、事件资源、动作注册表、统一 Sanitizer、内容哈希、大小控制和 Writer 可靠性,并用一个代表性敏感写操作验证公开接缝。明确不迁移其他旧 Service,不提供完整审计查询中心,不用 Audit Event 替代领域流水。
|
||||||
|
|
||||||
|
- [ ] Audit Event 支持稳定事件 ID、操作者与入口快照、结果、风险、前后数据、请求/关联/父事件 ID、HTTP 摘要、内容哈希和创建时间;事件资源支持 `primary/affected/reference`,且每个事件至少包含一个 `primary`。
|
||||||
|
- [ ] 动作注册表集中定义稳定动作编码、中文名称、类别、默认风险、允许资源类型和敏感字段规则;未经注册的动作不能写入生产审计。
|
||||||
|
- [ ] 统一 Sanitizer 删除禁止字段;`before_data`、`after_data`、`metadata` 分别执行 16KB 上限,超限后保存截断标志、原字节数、摘要和受控制品引用。
|
||||||
|
- [ ] `content_hash` 基于脱敏、标准化后的不可变内容生成,不包含数据库自增 ID;相同内容哈希稳定,Repository 不提供 Update/Delete 能力。
|
||||||
|
- [ ] `AppendWithTx` 与业务事实使用同一 GORM 事务,任一步失败整体回滚;已经回滚的 `failed/denied` 使用独立短事务,二次失败保留原业务错误并产生 critical 日志和指标。
|
||||||
|
- [ ] 真实 PostgreSQL 集成测试覆盖不可变约束、多资源、动作注册、风险默认值、哈希、截断、禁止字段、重复事件幂等和事务回滚。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**What to build:** 外部调用和未实际发出的同步尝试都能以稳定 Integration ID 建立可恢复事实,并通过受控条件更新进入明确终态。入站回调在业务处理前保存脱敏摘要和幂等标识,出站响应未知时保留未知结论而不伪装成普通失败。
|
||||||
|
|
||||||
|
**Blocked by:** `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为 Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 Integration Log 模型、Writer、执行态到终态的条件更新、入站摘要和未发送结果语义。明确不接管 Gateway、支付、企微或运营商的业务状态机,不把外部交互记录作为业务权威状态。
|
||||||
|
|
||||||
|
- [ ] Integration Log 支持 provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、计划与开始时间、结果、渠道摘要、脱敏请求响应摘要、耗时、状态变化标志和关联 ID。
|
||||||
|
- [ ] 方向固定为 `inbound/outbound`;公开终态至少覆盖成功、失败、未找到、无效载荷、忽略、合并、限频、提前完成和取消,并返回对应中文名称。
|
||||||
|
- [ ] 实际外部调用前持久化稳定尝试身份,完成后使用预期状态条件更新;并发完成、重复回调或重复消费不能改写既有终态。
|
||||||
|
- [ ] 请求已发出但响应未知时记录明确的未知结论和恢复策略,不自动把具有副作用的请求当成普通失败盲目重发。
|
||||||
|
- [ ] 入站回调先保存脱敏摘要、内容哈希和幂等标识;原始密文、完整正文、签名、附件和密钥不进入普通记录。
|
||||||
|
- [ ] PostgreSQL 集成测试覆盖出站成功、明确失败、响应未知、未发送终态、入站回调、重复回调、条件更新冲突和 Audit Event 关联。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 03 — 完成 Access Log 全路由敏感信息防泄漏
|
||||||
|
|
||||||
|
**What to build:** 当前项目所有敏感 HTTP 路由均使用统一的请求、响应、Query 和 Header 脱敏及路由级安全摘要策略。即使正文不是 JSON、解析失败或属于文件载荷,也不会把可复用凭证、完整回调或文件内容写入 Access Log。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- `.scratch/tech-public-foundation/issues/09-access-log-recursive-redaction.md` — 09 — 统一 Access Log 请求与响应递归脱敏
|
||||||
|
- `.scratch/tech-public-foundation/issues/10-sensitive-route-safe-summaries.md` — 10 — 为敏感接口提供安全摘要策略
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票将公共 Access Log 安全能力应用到当前仓库真实路由并建立固定回归矩阵。明确不修改业务响应,不创建审计事实,不把 Access Log 升级为业务权威存储。
|
||||||
|
|
||||||
|
- [ ] 登录、Token、支付与企微配置路由只记录字段存在性、长度和安全结果,不记录密码、验证码、Token、Secret、密钥或完整配置值。
|
||||||
|
- [ ] 支付、企微和运营商回调只记录事件类型、安全资源标识、大小、内容类型、摘要哈希与处理结果,不记录密文、完整正文、签名或附件。
|
||||||
|
- [ ] 上传、下载和导出路由不记录文件字节、Base64、multipart 正文、临时凭证或签名 URL,只保留脱敏文件元数据和任务标识。
|
||||||
|
- [ ] Query 和 Header 覆盖 token、secret、sign、nonce、authorization、cookie 等大小写变体;请求和响应均先脱敏再执行 50KB 截断。
|
||||||
|
- [ ] 非 JSON、XML、表单、二进制及解析失败场景均按路由策略安全降级,不能回退记录原文。
|
||||||
|
- [ ] 真实 Fiber 测试捕获最终 Access Log,覆盖敏感路由矩阵,并断言测试凭证、操作密码、回调原文、签名 URL、Authorization 和 Cookie 均未落盘。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 04 — 迁移账号、角色与权限敏感操作到统一审计
|
||||||
|
|
||||||
|
**What to build:** 账号、角色和权限敏感操作通过统一 Audit Writer 记录操作者、动作、目标资源、结果、风险和前后变化。关键成功审计与业务修改同事务,拒绝和失败审计可可靠保留,这些入口不再调用旧账号审计 Writer。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为旧 MVC Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口现有账号、角色和权限敏感写入口的统一审计接入。明确不重构整个账号模块,不迁移无关读取,不删除或回填旧账号历史表。
|
||||||
|
|
||||||
|
- [ ] 账号创建、修改、启停、删除、角色分配及权限变更使用注册动作和统一资源类型,成功、拒绝和失败语义明确。
|
||||||
|
- [ ] 账号角色或权限关键成功事件与业务事实使用同一 GORM 事务;审计失败时业务修改回滚。
|
||||||
|
- [ ] 权限拒绝与业务失败通过独立短事务记录,不向客户端泄露底层错误或资源是否存在。
|
||||||
|
- [ ] 事件包含操作者与入口快照、请求/关联标识、变更前后事实和直接受影响资源,敏感字段按统一规则删除或脱敏。
|
||||||
|
- [ ] 生产装配中的上述入口不再调用旧账号审计 Service,也不直接创建旧账号日志模型;静态检查和真实用例测试可证明该边界。
|
||||||
|
- [ ] 测试覆盖事务成功、审计失败回滚、拒绝、业务失败、重复请求和数据权限边界,不改变旧历史查询结果。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 05 — 迁移卡资产生命周期操作到统一审计
|
||||||
|
|
||||||
|
**What to build:** 卡分配、回收、删除、停复机、实名策略和状态变化等资产生命周期操作统一产生可关联的 Audit Event,并以多资源关系表达卡、设备、店铺、订单等直接影响对象。复杂卡状态规则仍由完整业务用例收口,不在审计 Adapter 中复制。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 复杂卡状态用例采用 Application/Domain;其他入口采用旧 MVC Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票迁移现有卡资产生命周期写入口的审计能力。明确不重新设计周期轮询、队列、卡资格或重排策略,不迁移本需求未触碰的资产规则,不删除旧资产历史表。
|
||||||
|
|
||||||
|
- [ ] 卡分配、回收、删除、手工停复机、实名策略和业务状态变化均映射到已注册动作,成功、拒绝和失败结果保持一致。
|
||||||
|
- [ ] 事件至少关联一个主要卡资源,并按实际影响关联设备、来源/目标店铺、订单或其他直接资源;不递归制造资源关系。
|
||||||
|
- [ ] 关键状态变更成功审计与业务事实同事务;失败或拒绝使用独立短事务,重复状态请求不制造重复业务副作用或虚假变化。
|
||||||
|
- [ ] `request_id/correlation_id/parent_event_id` 在 HTTP、业务用例和后续异步链路中按各自职责传播。
|
||||||
|
- [ ] 当前卡资产入口不再调用旧资产审计 Writer 或直接创建旧资产日志模型,但兼容历史读取仍可工作。
|
||||||
|
- [ ] 测试覆盖权限拒绝、状态条件更新、事务回滚、多资源时间线、敏感 ICCID 默认脱敏和重复请求。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 06 — 迁移设备与资产导入操作到统一审计
|
||||||
|
|
||||||
|
**What to build:** 设备分配、回收、绑定、控制操作以及卡和设备导入任务通过统一 Audit Writer 留下稳定、脱敏、可检索的事件。批量操作只在事件中保存命令摘要和计数,逐项明细继续由业务任务或受控制品承担。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application/旧 MVC Adapter,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口设备生命周期与现有资产导入入口的审计接入。明确不重构整个设备 Service,不建立通用批量任务表,不把大批量明细塞入 Audit Event JSON。
|
||||||
|
|
||||||
|
- [ ] 设备分配、回收、系列绑定、绑卡/解绑、停复机、限速、WiFi 设置、切卡、重启和恢复出厂映射为注册动作。
|
||||||
|
- [ ] 卡和设备导入任务创建、完成与失败事件关联任务、操作者、来源文件安全摘要和结果计数,不保存文件内容、签名 URL 或完整逐项数据。
|
||||||
|
- [ ] 批量事件超过大小限制时记录截断元数据、原始计数、摘要及受控任务/制品引用,查询仍能定位权威明细。
|
||||||
|
- [ ] 关键业务修改与成功审计同事务;拒绝和失败可靠保留,重复任务或重复控制请求不制造重复成功事件。
|
||||||
|
- [ ] 相关生产入口不再调用旧资产审计 Writer;自动检查覆盖直接旧模型 Create 和隐藏装配注入。
|
||||||
|
- [ ] 真实用例测试覆盖单项与批量、事务回滚、部分结果摘要、多资源关系、权限范围和敏感字段删除。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 07 — 迁移店铺、套餐和关键配置操作到统一审计
|
||||||
|
|
||||||
|
**What to build:** 店铺敏感归属、套餐管理以及支付、企微和关键系统配置的现有写操作统一记录注册动作、操作者、目标资源和前后变化。关键配置与权限相关成功审计和业务事实同事务,敏感配置原值永不进入审计正文。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为旧 MVC Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票迁移当前已有店铺、套餐和关键配置敏感入口。明确不接管各业务 PRD 尚未实现的状态机,不主动迁移无关 CRUD,不创建配置专用审计表。
|
||||||
|
|
||||||
|
- [ ] 店铺敏感业务员归属、关键账号关联和其他已识别敏感变更使用稳定动作及店铺/人员资源关系。
|
||||||
|
- [ ] 套餐创建、修改、上下架、授权或其他已存在敏感管理入口记录业务命令摘要和直接受影响资源。
|
||||||
|
- [ ] 支付、企微及关键系统配置变更只记录 Key、安全类型与脱敏前后摘要,不保存 Secret、Token、EncodingAESKey、私钥、公钥原文或签名材料。
|
||||||
|
- [ ] 关键配置和权限相关成功审计与业务事实同事务;拒绝、校验失败和持久化失败按公共失败策略记录。
|
||||||
|
- [ ] 相关旧账号/资产审计调用与零散配置日志不再承担这些操作的权威审计,生产装配可被自动检查验证。
|
||||||
|
- [ ] 测试覆盖配置审计失败回滚、店铺越权、套餐批量摘要、敏感字段删除、重复更新和统一中文错误。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 08 — 迁移现有资金与订单敏感操作到统一审计
|
||||||
|
|
||||||
|
**What to build:** 当前钱包变更、充值、退款和订单资金敏感操作通过统一 Audit Event 关联业务单、钱包、钱包流水、操作者和完整业务链路。金额结论继续以 Domain Ledger 为准,关键成功审计与资金事实同事务且只产生一次。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为复杂写 Application/Domain,辅助通道为 Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票迁移仓库当前存在的钱包、充值、退款和订单资金敏感入口,完整收口所触碰用例的金额、并发、幂等和可靠事件边界。明确不用 Audit Event 替代钱包流水、订单或退款事实,不重写未触碰资金用例。
|
||||||
|
|
||||||
|
- [ ] 钱包余额变更、代理钱包回退、线下充值入账、人工退款结果及现有订单资金操作使用注册动作和稳定 correlation。
|
||||||
|
- [ ] Audit Event 关联审批或业务单、钱包、钱包流水及直接受影响资产;每条投影明确领域流水才是金额权威。
|
||||||
|
- [ ] 余额、流水、成功审计和必要 Outbox 在同一 GORM 事务内提交;审计或 Outbox 写入失败时资金事实整体回滚。
|
||||||
|
- [ ] 状态条件更新、钱包版本或稳定业务键保证重复请求、Worker 重投和并发处理不重复改变余额、流水或成功审计。
|
||||||
|
- [ ] 明确失败、拒绝和结果异常使用独立短事务记录安全摘要,不把第三方支付密钥、完整凭证或底层错误返回客户端。
|
||||||
|
- [ ] PostgreSQL 集成测试覆盖正常资金变化、审计失败、Outbox 失败、乐观锁冲突、重复业务键、并发处理和金额事实对账。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 09 — 承接手动轮询状态并记录同步外部尝试
|
||||||
|
|
||||||
|
**What to build:** 现有手动触发、进度和监控接口保持用户契约,但运行状态由公共异步任务契约承接;Gateway 实际请求及合并、互斥、限频、提前完成等未发送尝试进入 Integration Log,只有状态变化、人工强制、连续失败或高风险异常进入 Audit Event。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
- `.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/06-unified-async-task-contract.md` — 06 — 冻结统一异步任务五态和查询契约
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Application + Infrastructure,辅助通道为 Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口旧手动轮询日志承担的运行状态替代、外部尝试记录和兼容接口。明确不改变轮询配置、间隔、Redis 分片队列、任务类型、卡级开关、并发控制或失败重排,不新建同步专用运行表。
|
||||||
|
|
||||||
|
- [ ] 手动触发、进度与监控公开契约继续可用,状态、计数、失败摘要和恢复行为映射到公共异步任务五态。
|
||||||
|
- [ ] 实际 Gateway 请求在调用前建立 Integration Log,完成后记录结果、耗时、状态变化和脱敏上游摘要。
|
||||||
|
- [ ] 合并、互斥、限频、已达预期和取消即使未产生 HTTP 请求也有可解释终态,不伪造 HTTP 状态。
|
||||||
|
- [ ] 同步序列传播 `request_id/correlation_id/series_id/attempt`;同序列查询可连续看到立即、3 分钟、5 分钟的全部尝试。
|
||||||
|
- [ ] 普通无变化成功只写 Integration Log;状态变化、人工强制、连续失败或高风险异常按注册规则关联 Audit Event。
|
||||||
|
- [ ] 回归测试固定现有轮询配置、队列、开关和监控基线,验证切换前后调度事实不变且旧手动轮询表不再承担新增运行状态。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 10 — 提供旧审计历史统一只读投影
|
||||||
|
|
||||||
|
**What to build:** 审计查询可以把旧账号、旧资产和旧手动轮询记录规范化为统一只读历史事件,并与新 Audit Event 稳定分页排序。现有资产操作日志兼容接口继续可用,但不再直接绑定旧资产日志表。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口三个旧历史来源的 `UNION ALL` 投影、确定性历史键和资产兼容读取。明确不在线回填新表,不修改或删除旧记录,不伪造历史中不存在的 correlation、风险或多资源关系。
|
||||||
|
|
||||||
|
- [ ] 旧账号、资产和手动轮询记录分别返回 `legacy_account/legacy_asset/legacy_polling` 来源及确定性历史事件键。
|
||||||
|
- [ ] 字段映射保留可证明的操作者、操作、资源、结果、时间和摘要;不存在的关联、风险或资源关系显式为空而非推测填充。
|
||||||
|
- [ ] 新旧记录使用稳定时间加 ID/历史键排序,分页跨越切换时间时无重复、漏项或顺序漂移。
|
||||||
|
- [ ] 资产操作日志兼容接口同时读取新 Audit Event 与旧资产投影,并保持原业务调用方所需响应兼容性。
|
||||||
|
- [ ] Query 不返回可用于业务写入的聚合对象,不对旧 JSON 做无索引模糊扫描,不产生 N+1 查询。
|
||||||
|
- [ ] PostgreSQL 投影测试覆盖三类历史样本、字段映射、确定性键、分页排序、新旧交界和兼容接口。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
|
||||||
|
**What to build:** 获得授权的平台用户可以分页查看全局审计事件与详情,先搜索业务资源候选,再打开包含新事件和历史投影的资源时间线。资源权限与字段脱敏由后端重新校验,代理或企业用户只能在原业务范围查看允许的资源轨迹。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- 05 — 迁移卡资产生命周期操作到统一审计
|
||||||
|
- 06 — 迁移设备与资产导入操作到统一审计
|
||||||
|
- 10 — 提供旧审计历史统一只读投影
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口事件列表/详情、资源候选搜索和资源时间线 API 及权限、分页、索引和 DTO 投影。明确不递归遍历资源图,不扫描 JSONB,不通过聚合根读取,不实现其他审计视角。
|
||||||
|
|
||||||
|
- [ ] 事件列表和详情支持注册动作、操作者、来源、结果、风险、时间、资源和请求标识等有索引过滤,默认 20、最大 100,并稳定按时间与 ID 排序。
|
||||||
|
- [ ] 资源搜索先查询业务读模型返回类型、ID、Key 和显示名;静态搜索路由先于动态资源路由注册。
|
||||||
|
- [ ] 资源时间线组合新 Audit Event 和旧投影,`include_related=true` 只展开事件直接关联资源,不递归遍历关系图。
|
||||||
|
- [ ] 超级管理员、平台角色、代理和企业按权限码与原数据范围取交集;越权查询不泄露资源是否存在。
|
||||||
|
- [ ] DTO 默认脱敏并返回结果、风险等中文名称;公共关键词不对 JSONB 执行无索引模糊扫描。
|
||||||
|
- [ ] 真实 Fiber、认证、GORM 和 PostgreSQL 测试覆盖路由顺序、分页、筛选、权限、历史交界、无 N+1 和常用查询索引;新增 Handler 同步两个文档生成器。
|
||||||
21
.scratch/tech-global-audit/issues/12-actor-risk-query.md
Normal file
21
.scratch/tech-global-audit/issues/12-actor-risk-query.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 12 — 交付人员行为和风险事件查询闭环
|
||||||
|
|
||||||
|
**What to build:** 获得相应权限的平台用户可以查看操作者列表、行为摘要、个人事件时间线以及风险概览和风险事件列表。所有聚合均由服务端完成,并严格隔离代理、企业和无对应权限的平台角色。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 04 — 迁移账号、角色与权限敏感操作到统一审计
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口人员与风险两个读取视角、服务端汇总和权限边界。明确不建设自动封禁、风险处置工单、实时行为分析或前端全量下载聚合。
|
||||||
|
|
||||||
|
- [ ] 操作者查询支持 actor kind、稳定 ID、名称快照、入口、动作、结果、风险和时间过滤,并提供摘要及稳定分页事件时间线。
|
||||||
|
- [ ] 风险概览按风险等级、结果、类别、动作和时间窗口进行有索引汇总,风险事件列表可进一步检索详情。
|
||||||
|
- [ ] `audit:actor:view` 与 `audit:risk:view` 独立授权;代理和企业账号不能进入人员或风险全局视角。
|
||||||
|
- [ ] 平台角色的权限与数据范围取交集,失败、拒绝、高风险和严重事件不会因缺失可选关联而被错误过滤。
|
||||||
|
- [ ] 汇总使用服务端 SQL 和批量投影,不下载全量事件、不对 JSONB 模糊扫描、不产生操作者或资源 N+1。
|
||||||
|
- [ ] HTTP 与性能测试覆盖超级管理员、不同平台角色、代理、企业、空态、筛选空态及代表性数据量;OpenAPI 文档同步更新。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
|
||||||
|
**What to build:** 运维人员可以按 request ID 或 correlation ID 查看同一请求或完整业务链路中的 Audit Event、Integration Log、Outbox 和异步任务摘要,并可按外部提供方、操作、方向、结果、资源、场景和序列检索外部尝试。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
- 09 — 承接手动轮询状态并记录同步外部尝试
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
- `.scratch/tech-public-foundation/issues/02-transactional-public-outbox-write.md` — 02 — 在业务事务中可靠写入公共 Outbox
|
||||||
|
- `.scratch/tech-public-foundation/issues/06-unified-async-task-contract.md` — 06 — 冻结统一异步任务五态和查询契约
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口请求时间线、关联时间线和 Integration Log 列表/详情查询。明确不在 API 请求中扫描本地 Access Log,不复制 Outbox/任务完整载荷,不把外部交互结果解释成业务权威状态。
|
||||||
|
|
||||||
|
- [ ] Request Timeline 组合可关联的审计事件、外部尝试、Outbox 和任务安全摘要,并返回 Access Log 检索标识而不读取日志文件。
|
||||||
|
- [ ] Correlation Timeline 以稳定 correlation 串联跨请求、回调和异步处理,`parent_event_id` 只表达直接因果,不替代 correlation。
|
||||||
|
- [ ] Integration 查询支持 provider、operation、方向、结果、资源、触发来源、场景、序列、尝试和时间过滤,列表默认 20、最大 100。
|
||||||
|
- [ ] 同一同步序列能够按尝试顺序连续展示立即、3 分钟、5 分钟结果,包括合并、限频和提前完成等未发送终态。
|
||||||
|
- [ ] 原始外部正文、密文、签名、附件、Outbox 载荷和任务敏感失败明细不进入普通查询 DTO。
|
||||||
|
- [ ] 真实链路测试覆盖单请求、多请求 correlation、异步传播、重复尝试、0/3/5 序列、权限隔离和常用查询索引;OpenAPI 文档同步更新。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 14 — 交付资金审计时间线
|
||||||
|
|
||||||
|
**What to build:** 财务人员可以按业务单、钱包、审批、资产和时间查看组合时间线,统一展示 Audit Event、钱包流水、订单、退款、充值和审批实例,并清楚区分每条记录来源及金额权威。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 08 — 迁移现有资金与订单敏感操作到统一审计
|
||||||
|
- 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口资金审计的只读组合投影、权限、分页和对账语义。明确不通过查询修改资金状态,不把 Audit Event 当作金额账本,不迁移未触碰的资金写用例。
|
||||||
|
|
||||||
|
- [ ] 时间线组合 Audit Event、钱包流水、订单、退款、充值和审批实例,每条返回明确 `record_source`、时间、业务标识和安全摘要。
|
||||||
|
- [ ] 金额、余额和资金结论始终取自 Domain Ledger;审计事件只解释操作者、动作、风险、前后变化和关联关系。
|
||||||
|
- [ ] 支持钱包、店铺、业务单、审批、资产、动作、结果、风险和时间等有索引过滤,并使用稳定时间加来源 ID 排序。
|
||||||
|
- [ ] `audit:finance:view` 独立授权,代理和企业不能进入全局资金视角;平台角色权限与原资金数据范围取交集。
|
||||||
|
- [ ] 金额、手机号、第三方交易号等字段默认按权限脱敏,不因某条关联缺失泄露其他资源存在性。
|
||||||
|
- [ ] 对账和性能测试覆盖多来源同链路、退款与充值、审批关联、无重复漏项、无 N+1 和代表性数据量;OpenAPI 文档同步更新。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 15 — 交付敏感值二次查看与自审计闭环
|
||||||
|
|
||||||
|
**What to build:** 审计详情默认返回掩码或安全状态;具有独立敏感查看权限的用户可以针对明确事件或资源二次请求允许展示的完整值,而该查看行为本身会产生高风险 Audit Event,并保留被查看对象、操作者和请求链路。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
- 12 — 交付人员行为和风险事件查询闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为简单写 Application。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口敏感字段受控读取、字段级授权和读取自审计。明确不恢复已被策略删除的原值,不允许超级管理员绕过二次审计,不授予导出权限。
|
||||||
|
|
||||||
|
- [ ] 普通详情对手机号、IP、ICCID、钱包金额和第三方交易号等受控字段默认脱敏;禁止字段始终不可恢复。
|
||||||
|
- [ ] 二次查看要求 `audit:sensitive:view`、明确目标和字段范围,并重新校验平台角色、数据范围与资源权限。
|
||||||
|
- [ ] 成功或被拒绝的敏感查看产生注册的高风险 Audit Event,关联被查看事件/资源、操作者、入口和 request ID。
|
||||||
|
- [ ] 审计写入失败时敏感值不返回;重复查看每次都保留独立读取事实,不因已有全局权限跳过。
|
||||||
|
- [ ] 无权限、历史字段不存在、原值已删除和资源越权返回明确但不泄密的统一错误或状态。
|
||||||
|
- [ ] 真实 Fiber 和 PostgreSQL 测试覆盖授权查看、越权、审计失败、禁止字段、历史缺失、并发请求及自审计事件查询;OpenAPI 文档同步更新。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 16 — 交付审计导出与字段授权快照
|
||||||
|
|
||||||
|
**What to build:** 获得审计导出权限的用户可以从支持的审计视角创建异步导出任务。任务创建时固化过滤条件、数据范围、字段授权和脱敏等级,后续角色变更或任务重试不会扩大导出内容。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
- 12 — 交付人员行为和风险事件查询闭环
|
||||||
|
- 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
- 14 — 交付资金审计时间线
|
||||||
|
- 15 — 交付敏感值二次查看与自审计闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query + Export DataSource,辅助通道为 Application。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口审计导出 Scene、字段能力、权限快照、异步任务和导出审计。明确不创建第二套导出框架,不把敏感查看权限等同敏感导出权限,不在权限解析失败时回退全字段。
|
||||||
|
|
||||||
|
- [ ] 审计导出复用统一 Export DataSource 和任务契约,支持经批准的事件、资源、链路、集成、风险和资金过滤条件。
|
||||||
|
- [ ] 创建任务时快照操作者、权限、数据范围、字段集合、脱敏等级和过滤条件;Worker 只使用快照执行。
|
||||||
|
- [ ] `audit:export` 与 `audit:sensitive:view` 独立;导出完整敏感字段需要额外字段授权,解析失败一律拒绝。
|
||||||
|
- [ ] 文件不包含禁止字段、完整外部报文、密钥、签名 URL 或未授权业务数据;下载继续遵守安全摘要和临时访问策略。
|
||||||
|
- [ ] 创建、完成、失败和下载等敏感导出行为产生统一 Audit Event,并能关联导出任务与请求链路。
|
||||||
|
- [ ] 测试覆盖权限快照、角色变更、字段空集、敏感权限分离、任务重试、导出审计、越权和文件内容检查。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 17 — 交付审计保留、清理和运行监控
|
||||||
|
|
||||||
|
**What to build:** 运维人员可以观察 Audit/Integration 写入、旧表意外新增、Integration Log 增长与清理、敏感读取和导出,并由受控维护身份按保留策略分批清理可清理记录。在线应用始终不能更新或删除 Audit Event。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为 Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口保留分类、Integration Log 分批清理、权限边界、指标与告警。明确不由在线应用删除 Audit Event,不预建尚无必要的复杂分区系统,不清理已有生产审计事实作为回滚手段。
|
||||||
|
|
||||||
|
- [ ] 审计事件按资金/权限/审批/关键配置与普通资产/业务分类表达 5 年或 2 年保留策略,在线应用账号无 Update/Delete 能力。
|
||||||
|
- [ ] Integration Log 默认保留 180 天,Gateway 无变化成功记录保留 30 天;清理按稳定时间与主键小批量执行并记录结果。
|
||||||
|
- [ ] 清理只允许受控维护身份执行,使用条件范围和可恢复进度,不影响异常、状态变化或仍被业务链路引用的记录。
|
||||||
|
- [ ] 指标和告警覆盖 Audit/Integration 写入失败、失败短事务失败、旧三表意外新增、Outbox 积压、Integration 增长/清理、敏感读取和导出次数。
|
||||||
|
- [ ] 日志与指标标签只使用安全 ID、结果和计数,不包含正文、敏感值或高基数未受控载荷。
|
||||||
|
- [ ] 测试覆盖保留边界、分批重跑、并发清理、在线账号拒绝、旧表新增告警和不删除未到期/受保护记录。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 18 — 冻结审计中心跨仓前端契约与验收包
|
||||||
|
|
||||||
|
**What to build:** 前端仓库获得稳定、框架无关的审计工作台契约和可执行验收数据,能够实现全局事件、人员行为、资源轨迹、请求/业务链路、资金审计、风险事件和外部集成七个视角,并正确处理权限、敏感查看、导出、空态与错误状态。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
- 12 — 交付人员行为和风险事件查询闭环
|
||||||
|
- 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
- 14 — 交付资金审计时间线
|
||||||
|
- 15 — 交付敏感值二次查看与自审计闭环
|
||||||
|
- 16 — 交付审计导出与字段授权快照
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query/API 契约。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口跨仓页面、状态、权限和验收契约以及 OpenAPI/样本数据。当前仓库不含前端源码,明确不虚构组件目录、状态库或技术栈,不在本票实现前端页面。
|
||||||
|
|
||||||
|
- [ ] 契约覆盖 `/operations/audit` 七个视角、权限控制 Tab、服务端筛选分页、稳定 URL 参数和详情抽屉字段分组。
|
||||||
|
- [ ] 资源轨迹定义先搜索候选再查看时间线;请求与 correlation 可相互跳转;Integration 序列连续展示立即、3 分钟、5 分钟尝试。
|
||||||
|
- [ ] 敏感字段默认掩码,二次查看重新请求受控接口;导出、敏感查看和各视角权限分别处理。
|
||||||
|
- [ ] 明确定义加载、真实空态、筛选空态、403、404/不可见、失败重试、历史字段不存在和数据已删除等状态。
|
||||||
|
- [ ] 提供不含真实敏感数据的七视角验收样本、权限矩阵和人工验收清单,后端 OpenAPI 与真实路由保持一致。
|
||||||
|
- [ ] 文档明确前端隐藏不是授权边界,第一版不实现自动封禁、风险工单或自由拖拽关系图。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 19 — 执行一次性审计切换与停机发布门禁
|
||||||
|
|
||||||
|
**What to build:** 发布负责人可以在一次停机窗口内完成新结构、Writer、现有敏感入口、历史查询、权限、监控和跨仓契约切换,并通过自动化门禁决定是否开放流量。任一关键检查失败时整体停止发布;一旦新系统产生生产事实,只能前向修复,不能恢复旧 Writer 制造分裂历史。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 03 — 完成 Access Log 全路由敏感信息防泄漏
|
||||||
|
- 04 — 迁移账号、角色与权限敏感操作到统一审计
|
||||||
|
- 05 — 迁移卡资产生命周期操作到统一审计
|
||||||
|
- 06 — 迁移设备与资产导入操作到统一审计
|
||||||
|
- 07 — 迁移店铺、套餐和关键配置操作到统一审计
|
||||||
|
- 08 — 迁移现有资金与订单敏感操作到统一审计
|
||||||
|
- 09 — 承接手动轮询状态并记录同步外部尝试
|
||||||
|
- 10 — 提供旧审计历史统一只读投影
|
||||||
|
- 11 — 交付全局事件和资源轨迹查询闭环
|
||||||
|
- 12 — 交付人员行为和风险事件查询闭环
|
||||||
|
- 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
- 14 — 交付资金审计时间线
|
||||||
|
- 15 — 交付敏感值二次查看与自审计闭环
|
||||||
|
- 16 — 交付审计导出与字段授权快照
|
||||||
|
- 17 — 交付审计保留、清理和运行监控
|
||||||
|
- 18 — 冻结审计中心跨仓前端契约与验收包
|
||||||
|
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为 Application/Query 验收。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口全局审计一次性停机切换、迁移验证、旧写收缩、对账、性能、安全、监控和发布回滚边界。明确不允许长期双写或局部放量,不删除旧历史,不在新事实产生后通过删表回滚。
|
||||||
|
|
||||||
|
- [ ] 停机顺序明确覆盖暂停流量与 Worker、前置检查、增量迁移、权限初始化、新 Writer 装配、旧写护栏、历史对账、Worker 恢复和开放流量。
|
||||||
|
- [ ] 自动切换清单证明生产产物不再调用旧账号/资产审计 Writer、不再直接 Create 旧三表、启动装配不再注入旧 Writer;旧表意外新增会立即告警或失败。
|
||||||
|
- [ ] 门禁覆盖关键业务与审计同事务、审计失败回滚、失败短事务、Integration 结果未知、历史新旧交界、Access Log 敏感矩阵和权限隔离。
|
||||||
|
- [ ] Query 性能验证常用过滤使用索引、无 JSONB 全表模糊扫描、无资源/操作者 N+1,并满足项目 P95/P99 目标。
|
||||||
|
- [ ] 任一迁移、旧写清单、样本对账、事务、安全、权限、性能、监控或跨仓验收失败均在开放流量前整体终止发布,不能让局部模块继续写旧表。
|
||||||
|
- [ ] 发布与回滚说明明确:未产生新事实时可回退兼容应用;已产生 Audit/Integration/Outbox 事实后保留全部记录、停止异常生产者并前向修复,禁止恢复旧 Writer 或删除事实。
|
||||||
|
- [ ] 中文功能总结覆盖关键流程、前后端契约、异常闭环、发布回滚、监控恢复和待决策项,README 增加索引;完整停机演练通过后方可放量。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
|
||||||
|
**What to build:** 业务事件携带稳定后台账号 ID 后,可以经公共 Outbox、Relay 和 Notification Worker 为该账号幂等生成一条纯文本站内通知;当前登录账号可以查询自己的未读数和分页列表,并将单条通知幂等标记为已读。重复投递不会重复写入,过期通知不进入用户视图,任何用户接口都不能指定或篡改接收人。
|
||||||
|
|
||||||
|
**Blocked by:** `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为 Infrastructure 与 Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口后台明确账号通知的存储、受控类型注册、Worker 幂等消费、未读数、基础列表和单条已读闭环。明确不实现角色或店铺动态接收人、个人客户通知、分类汇总、全部已读、目标解析、前端组件或具体业务场景触发规则,也不复制公共 Outbox 和 Relay。
|
||||||
|
|
||||||
|
- [ ] 通知事实包含稳定事件 ID、接收人类型与 ID、类别、类型、级别、纯文本标题正文、受控资源引用、已读与过期时间,并通过事件 ID、接收人类型和接收人 ID 唯一约束防止重复消费。
|
||||||
|
- [ ] 通知常量、中文说明、类型到类别、默认级别、模板和允许目标的注册关系统一管理;未注册类型、模板字段永久缺失或正文包含禁止敏感内容时不生成残缺通知。
|
||||||
|
- [ ] Worker 只接受结构化载荷,重复事件和并发消费最多为同一后台账号生成一条通知;瞬时数据库错误返回任务错误,原业务事务不因通知写入失败而回滚。
|
||||||
|
- [ ] 当前后台账号可以获得准确的 `count:int64` 和 `display_count:string`,其中 0、1~99、100 以上分别显示 `0`、十进制文本和 `99+`,且未读数只查询 PostgreSQL。
|
||||||
|
- [ ] 后台列表只读取当前认证账号的未过期通知,固定按创建时间和 ID 倒序,默认每页 20、最大 50,并返回统一响应与 ISO 8601 时间。
|
||||||
|
- [ ] 单条已读使用接收人条件和未读条件更新;别人通知、不存在通知和已读通知均幂等成功,首次写入的 `read_at` 在重复请求中保持不变。
|
||||||
|
- [ ] PostgreSQL、Worker 和真实后台认证 HTTP 集成测试覆盖唯一约束、重复消费、过期排除、分页排序、接收人篡改、越权隔离及重复已读。
|
||||||
|
- [ ] 新增后台 Handler 后完成路由、RouteSpec 和两个 OpenAPI 文档生成器注册,且静态路由顺序不会被动态通知 ID 路由吞掉。
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 02 — 交付后台通知筛选、分类汇总与全部已读
|
||||||
|
|
||||||
|
**What to build:** 当前后台账号可以按通知类别、类型、严重级别和已读状态分页查看自己的消息,获得总未读数及审批、临期、同步、系统四个固定类别的汇总,并将当前类别或全部未过期通知一次性标记为已读。筛选、汇总和更新始终绑定认证账号,不因管理员身份扩大到其他用户。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为简单写 Application。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口后台通知中心所需的筛选、固定分类汇总和批量已读用例。明确不实现个人客户接口、动态接收人、目标跳转、前端页面、Redis 未读计数或管理员查看他人通知能力。
|
||||||
|
|
||||||
|
- [ ] 列表支持类别、类型、严重级别、已读状态、页码和每页数量组合过滤,所有条件使用 AND 语义并保持创建时间、ID 倒序。
|
||||||
|
- [ ] 非法类别、严重级别、已读参数或越界分页返回统一参数错误,不向客户端拼接底层校验信息。
|
||||||
|
- [ ] 未读汇总固定返回 `total`、`approval`、`expiry`、`sync`、`system`,过期通知不计入任何分类。
|
||||||
|
- [ ] 全部已读在类别为空时更新当前账号全部未过期未读通知,在类别有效时只更新该类别,并返回实际更新数量。
|
||||||
|
- [ ] 批量更新使用当前接收人、未读状态、未过期和可选类别条件;重复调用返回零更新且保持成功,不覆盖既有 `read_at`。
|
||||||
|
- [ ] `/read-all` 等静态路由先于 `/{id}` 动态路由注册,生成的 OpenAPI 与真实路由、请求参数和响应结构一致。
|
||||||
|
- [ ] PostgreSQL 与真实后台认证 HTTP 集成测试覆盖组合筛选、固定汇总、最大分页、过期排除、非法类别、并发批量已读及无法操作他人通知。
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 03 — 向个人客户投递并提供简化通知中心
|
||||||
|
|
||||||
|
**What to build:** 业务事件携带稳定个人客户 ID 后,可以为该客户幂等生成与其订单、套餐或资产有关的站内通知;当前登录个人客户可以查询自己的未读数和分页列表,并执行单条或全部已读。个人客户永远看不到同步、系统等平台运维消息,也不能通过请求参数读取或修改其他客户通知。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为简单写 Application 与 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口个人客户通知的投递、读取和已读闭环,复用既有通知表、注册表和 Worker。明确不实现 C 端分类汇总、后台动态接收人、平台运维消息展示、C 端受控目标接口或前端组件。
|
||||||
|
|
||||||
|
- [ ] Worker 能以 `personal_customer` 接收人类型和稳定客户 ID 幂等生成通知,同一事件的后台账号与个人客户通知相互独立。
|
||||||
|
- [ ] 个人客户通知类型注册明确允许的业务类别和资源引用;`sync`、`system` 及未对 C 端开放的类型不会出现在个人客户查询中。
|
||||||
|
- [ ] C 端未读数遵循 0、1~99、100 以上的显示规则,列表固定倒序、默认每页 20、最大 50,并排除过期通知。
|
||||||
|
- [ ] 单条已读、全部已读只作用于当前认证客户;不存在、已删除、属于别人或已读的通知使用相同幂等安全语义。
|
||||||
|
- [ ] 请求 DTO 不接受接收人 ID,额外或恶意接收人参数不能改变查询与更新范围。
|
||||||
|
- [ ] 真实个人客户认证、Handler、Query、GORM 集成测试覆盖重复投递、运维类别隔离、跨客户越权、分页、过期排除、重复已读和批量已读。
|
||||||
|
- [ ] 新增 C 端 Handler 后同步个人客户路由、RouteSpec 与两个 OpenAPI 文档生成器,接口统一挂载在约定认证上下文中。
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 04 — 接入账号、角色与店铺动态接收人解析
|
||||||
|
|
||||||
|
**What to build:** Notification Worker 可以按业务场景把审批申请人、当前平台角色账号或目标店铺解析为一组稳定、去重且当前可用的后台账号接收人。店铺场景只包含当前启用的店铺主账号和当前仍可用的店铺业务员;账号停用、软删除或关系失效时跳过,上级代理不会因为可查看下级数据而自动收到通知。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
- `.scratch/ur96-shop-business-owner/issues/06-shop-business-owner-notification-recipient-release.md` — 06 — 提供业务员通知接收人解析并完成发布验证
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure Adapter,辅助通道为简单写 Application。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共通知 Worker 对明确申请人、平台角色和店铺接收人的解析、可用性复核、去重及无接收人语义。明确不实现 UR#33、UR#97 或企微审批的业务触发规则,不改变账号、角色、店铺层级或业务员归属,不自动转派历史通知,也不发送短信或企微消息。
|
||||||
|
|
||||||
|
- [ ] 明确申请人场景只使用业务事件携带的稳定系统账号 ID,并在消费时跳过已停用或软删除账号,不使用企微代提交身份替代真实业务提交人。
|
||||||
|
- [ ] 角色场景批量解析当前启用、未删除且仍持有指定平台角色的账号,结果按稳定账号 ID 去重,不按用户名或手机号投递。
|
||||||
|
- [ ] 店铺场景解析当前启用的店铺主账号,并复用 UR#96 接缝解析当前可用业务员;不沿父店铺、祖先店铺或代理数据权限向上扩散。
|
||||||
|
- [ ] 同一账号同时以主账号、业务员或角色命中时只生成一条通知,同一事件的其他接收人仍分别拥有独立已读状态。
|
||||||
|
- [ ] 暂无可用接收人记为 `no_recipient` 并成功结束,不进入无限重试;数据库等瞬时错误继续返回任务错误。
|
||||||
|
- [ ] 已生成通知不会因账号关系后续变化而转移给新接收人,历史接收人仍可在自身认证上下文中读取原通知。
|
||||||
|
- [ ] Application、Worker 和 PostgreSQL 集成测试覆盖申请人、角色批量解析、店铺主账号与业务员去重、停用、软删除、关系失效、无接收人及重复投递。
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 05 — 交付通知受控目标解析与权限复核
|
||||||
|
|
||||||
|
**What to build:** 当前后台账号点击自己的通知时,后端只返回白名单目标类型和结构化目标标识,不保存也不返回任意 URL。退款、代理充值、企微审批、卡、设备、临期资产、店铺资金、审计外部集成和系统配置等目标在解析时重新执行当前资源权限检查;目标不存在或权限已经变化时统一返回 `available=false`,不泄露资源详情。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
- `.scratch/tech-global-audit/issues/13-request-correlation-integration-timeline.md` — 13 — 交付请求、业务链路和外部集成时间线
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为 Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口后台通知受控目标注册、解析、当前权限复核和安全不可用语义。明确不返回 URL、不实现前端路由构造、不把通知所有权当成目标资源权限、不创建独立卡同步执行页,也不迁移各目标业务详情的既有授权规则。
|
||||||
|
|
||||||
|
- [ ] 通知只保存受控资源类型、数值 ID 或稳定 Key;目标响应只包含白名单 `target_type`、结构化 `target_id/target_key` 和 `available`,任何字段均不能承载任意 URL。
|
||||||
|
- [ ] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
|
||||||
|
- [ ] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。
|
||||||
|
- [ ] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。
|
||||||
|
- [ ] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。
|
||||||
|
- [ ] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。
|
||||||
|
- [ ] 契约与越权测试覆盖全部白名单、未知引用、通知越权、目标删除、权限变化、外部集成目标,并断言响应和持久化数据不存在任意 URL。
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 06 — 交付通知保留清理与失败可观测闭环
|
||||||
|
|
||||||
|
**What to build:** 系统可以按通知场景计算展示期限和数据保留期限,并在低峰按稳定主键和时间分批删除已超过保留期的通知。无接收人、模板错误、系统告警生成和投递失败具有可追踪、有限重试和安全摘要,既不会无限重试,也不会影响已经提交的资金、审批、套餐或其他业务事实。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 向明确后台账号可靠投递首条站内通知
|
||||||
|
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为简单写 Application。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口通知展示期限、数据保留、分批清理、失败分类和统一审计/外部集成可观测接缝。明确不删除 Audit Event、Integration Log、领域流水或 Outbox,不提供用户删除接口,不建设管理员查看他人消息入口,也不改变公共 Relay 的租约算法。
|
||||||
|
|
||||||
|
- [ ] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。
|
||||||
|
- [ ] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。
|
||||||
|
- [ ] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。
|
||||||
|
- [ ] 系统告警直接入队时必须携带预先生成的稳定事件 ID,重复执行仍由通知唯一键防重。
|
||||||
|
- [ ] 模板或接收人解析失败、系统告警生成和管理性排查写入统一 Audit/Integration 接缝;普通通知读取和已读只进入 Access Log。
|
||||||
|
- [ ] 日志、监控和审计只记录事件 ID、通知类型、失败类别、计数及安全资源标识,不记录敏感模板数据、完整回调、Token、Secret 或任意长期 URL。
|
||||||
|
- [ ] 测试覆盖各类期限边界、分批清理可重入、无接收人、瞬时失败、永久模板失败、最大重试、系统告警重复入队和业务事实不回滚。
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 07 — 冻结后台与 C 端通知前端契约及验收包
|
||||||
|
|
||||||
|
**What to build:** 前端仓库获得稳定、框架无关的后台铃铛、最近通知抽屉、通知中心和个人客户简化列表契约,以及可执行的验收数据。前端可以实现 30 秒未读轮询、页面隐藏暂停、失败保留旧值、筛选和全部已读,并按“先已读、再解析受控目标”的顺序处理点击,而无需猜测类别、级别或后端目标路径。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 交付后台通知筛选、分类汇总与全部已读
|
||||||
|
- 03 — 向个人客户投递并提供简化通知中心
|
||||||
|
- 05 — 交付通知受控目标解析与权限复核
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query/API 跨仓契约,辅助通道为前端验收契约。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口当前后端仓库能够交付的 OpenAPI、交互状态、目标白名单说明和验收数据,不在本仓库实现前端组件。明确不引入 WebSocket/SSE,不承诺 Redis 未读计数,不为未知目标提供自由 URL,也不代替前端仓库自身的组件测试。
|
||||||
|
|
||||||
|
- [ ] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。
|
||||||
|
- [ ] 徽标验收覆盖 0、1、99、100,0 时隐藏、1~99 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。
|
||||||
|
- [ ] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。
|
||||||
|
- [ ] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。
|
||||||
|
- [ ] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。
|
||||||
|
- [ ] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。
|
||||||
|
- [ ] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致,并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 08 — 完成公共通知发布门禁与下游接入契约
|
||||||
|
|
||||||
|
**What to build:** 发布负责人可以通过一套公共站内通知整体验收确认通知表、注册表、Worker、后台和 C 端接口、受控目标、清理任务及运行监控已经就绪。验收使用真实 PostgreSQL、Redis、公共 Outbox Relay 和 Asynq 接缝验证至少一次投递与重复消费,并向 UR#33、UR#97 和企微结果通知提供稳定的事件、接收人、模板和目标注册方式。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 交付后台通知筛选、分类汇总与全部已读
|
||||||
|
- 03 — 向个人客户投递并提供简化通知中心
|
||||||
|
- 04 — 接入账号、角色与店铺动态接收人解析
|
||||||
|
- 05 — 交付通知受控目标解析与权限复核
|
||||||
|
- 06 — 交付通知保留清理与失败可观测闭环
|
||||||
|
- 07 — 冻结后台与 C 端通知前端契约及验收包
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为 Application、Query 与跨仓契约。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共通知的迁移验证、端到端可靠性、OpenAPI、文档、发布回滚和下游接入说明。明确不实现 UR#33 套餐临期、UR#97 钱包低余额或企微审批结果的业务触发规则,不发送真实用户测试通知,不引入外部 Delivery 渠道,也不借发布验收迁移未触碰旧模块。
|
||||||
|
|
||||||
|
- [ ] 空数据库和兼容环境可执行通知正向迁移、索引校验与允许的结构回滚;已有通知事实后不得通过降级删表清理,应用回滚允许保留数据。
|
||||||
|
- [ ] PostgreSQL、Redis、公共 Relay 和 Asynq 端到端测试覆盖事务事件、至少一次投递、入队成功后重复、并发 Worker、多接收人、接收人去重和最终通知唯一性。
|
||||||
|
- [ ] 真实后台与个人客户认证测试覆盖所有公开接口、统一响应、静态路由顺序、接收人不可篡改、跨用户隔离、过期排除和受控目标权限变化。
|
||||||
|
- [ ] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。
|
||||||
|
- [ ] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用;调用统一队列客户端时禁止传预序列化字节。
|
||||||
|
- [ ] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者;下游不得在消费者和监控就绪前制造不可见积压。
|
||||||
|
- [ ] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。
|
||||||
|
- [ ] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项,README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 01 — 建立公共迁移所有权与检查门禁
|
||||||
|
|
||||||
|
**What to build:** 发布负责人能够通过统一清单确认公共数据库对象的唯一迁移所有者,并在迁移前后运行可重入检查。对象定义冲突、唯一键冲突、非法状态、必填字段空值、未完成任务、未投递事件、长租约或依赖版本不满足时,检查以中文安全摘要和非零状态阻断发布;正常结果给出可核对的计数与安全标识。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共数据库对象的所有权登记、迁移前置检查、后置检查、失败退出、可重入和安全输出契约。明确不创建下游业务表,不迁移历史业务数据,不接管审计、通知或业务 PRD 拥有的迁移。
|
||||||
|
|
||||||
|
- [ ] 公共 Outbox、系统配置及其公共索引、约束和初始化数据均有唯一迁移所有者,下游只能声明依赖,不能复制公共 DDL。
|
||||||
|
- [ ] 检查能够发现目标对象定义不一致、唯一键冲突、必填字段空值、非法枚举、未完成任务、未投递事件、长租约和依赖版本问题,并以非零状态阻断发布。
|
||||||
|
- [ ] 前置和后置检查可以重复运行;重复执行不产生新业务事实,`IF NOT EXISTS` 不会掩盖已有对象定义不一致。
|
||||||
|
- [ ] 后置检查验证约束、关键索引、异常计数和读写冒烟,输出仅包含计数、错误码与安全标识,不泄露敏感值。
|
||||||
|
- [ ] 发布说明明确哪些结构可安全回滚、哪些已有事实只能停止生产者后向前修复,以及迁移异常时的停止条件。
|
||||||
|
- [ ] 自动化验证覆盖空数据库、兼容存量数据库、异常数据和重复执行场景,不执行全库清理。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 02 — 在业务事务中可靠写入公共 Outbox
|
||||||
|
|
||||||
|
**What to build:** 业务开发者可以沿用现有 GORM 显式事务,在提交业务事实的同一事务中写入权威公共 Outbox 事件。事件身份和关联标识在事务内稳定持久化,业务写入或 Outbox 写入任一步失败都会整体回滚,事务中不会调用 Redis、Asynq 或外部系统。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 建立公共迁移所有权与检查门禁
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共 Outbox 模型、事件信封、事务内追加 Port 和一个可观察的示例写入链路。明确不定义下游业务事件含义,不实现业务消费者,不引入 UnitOfWork、事务工厂或全仓事务重构。
|
||||||
|
|
||||||
|
- [ ] 公共 Outbox 具有稳定唯一的事件 ID、事件类型、载荷版本、聚合与资源定位、请求与关联标识、结构化载荷、投递生命周期、重试、租约和安全错误摘要字段。
|
||||||
|
- [ ] Outbox 内部状态固定为 `1=待投递、2=投递中、3=已投递、4=投递失败`,常量、模型注释和公开说明保持一致。
|
||||||
|
- [ ] 业务事实和 Outbox 使用同一 GORM 事务句柄;任一写入失败时二者均不可见,未提交事件不会被投递侧读取。
|
||||||
|
- [ ] 事件 ID、业务键及必要快照在事务内生成并持久化,重试过程中不会重新生成事件身份。
|
||||||
|
- [ ] 事务内不执行 Redis、Asynq、HTTP、对象存储或其他外部调用,Domain 不依赖 GORM。
|
||||||
|
- [ ] PostgreSQL 集成测试覆盖事务成功、业务写入失败、Outbox 写入失败、事件 ID 唯一约束和回滚行为。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
|
||||||
|
|
||||||
|
**What to build:** 多个 Relay 实例可以并发领取到期 Outbox 事件,并通过有期限租约把公共事件信封可靠投递到 Asynq。瞬时失败会退避重试,进程崩溃后过期租约可恢复;入队成功但数据库标记前崩溃时允许重复投递,但始终传播原事件 ID 和关联标识。
|
||||||
|
|
||||||
|
**Blocked by:** 02 — 在业务事务中可靠写入公共 Outbox
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 Relay 的领取、租约、续租、投递、完成、失败、退避与恢复闭环,并通过公开 Asynq Handler 验证结构化信封。明确不实现业务消费者副作用,不承诺精确一次,不迁移未触碰的旧队列生产者。
|
||||||
|
|
||||||
|
- [ ] Relay 以小批量条件领取或跳锁方式取得处理权,领取、续租、完成和失败均校验当前状态与租约所有者。
|
||||||
|
- [ ] Relay 调用统一队列客户端时传 struct 或 map;传入 `[]byte` 被明确拒绝并有回归测试防止二次序列化为 Base64。
|
||||||
|
- [ ] 入队成功后事件标记为已投递;模拟入队成功但标记前崩溃时,恢复投递仍使用原事件 ID、载荷和关联标识。
|
||||||
|
- [ ] 瞬时失败按有上限的指数退避安排下次领取,达到最大重试或永久失败时保留记录并产生中文安全告警。
|
||||||
|
- [ ] 多 Relay 并发时同一时刻只有租约所有者能够完成事件;Worker 崩溃后其他实例可在租约过期后恢复领取。
|
||||||
|
- [ ] 真实 PostgreSQL、Redis 和 Asynq 链路测试覆盖事务写入、Relay、公开 Handler 与可观察消费结果,不依赖 Relay 私有函数断言。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 04 — 提供 Outbox 监控和受控恢复能力
|
||||||
|
|
||||||
|
**What to build:** 运维人员可以查看 Outbox 待投递量、最老积压、处理中和过期租约、成功率、重试分布、最终失败及按事件类型聚合的状态,并能对明确选择的失败或滞留事件执行受控重放或租约释放。恢复操作保留原事件内容和身份,并通过统一审计接缝记录操作者与原因。
|
||||||
|
|
||||||
|
**Blocked by:** 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 Outbox 运行状态查询、指标、告警和受控恢复用例。明确不实现 Audit Event 模型或查询,不删除 Outbox,不修改已投递事件内容,不跳过消费者幂等检查。
|
||||||
|
|
||||||
|
- [ ] 查询和指标覆盖待投递量、最老待投递年龄、处理中、过期租约、成功率、重试分布、最终失败和按事件类型的积压。
|
||||||
|
- [ ] 日志、指标和告警使用事件 ID、关联 ID 与安全资源标识串联,不把完整载荷或敏感值作为日志字段或指标标签。
|
||||||
|
- [ ] 受控重放只接受明确选择的失败或滞留事件,保留原事件 ID、载荷和关联标识,并记录操作者、原因和恢复批次。
|
||||||
|
- [ ] 租约释放只作用于符合状态与过期条件的事件,不能越过当前租约所有者直接修改正在有效处理的事件。
|
||||||
|
- [ ] 人工恢复通过统一审计 Port 记录;审计不可用时遵循明确的失败策略,但本票不创建独立审计表。
|
||||||
|
- [ ] 测试覆盖积压统计、阈值告警、最终失败、选择性重放、租约释放和越权/非法状态拒绝。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 05 — 提供创建命令幂等与并发职责契约
|
||||||
|
|
||||||
|
**What to build:** 创建类用例可以使用调用主体、操作类型和稳定请求 ID 建立幂等作用域,并对影响业务结果的规范化字段计算带版本的请求指纹。相同请求返回原结果,同一请求 ID 携带不同业务内容时返回明确冲突,并发首写最终由 PostgreSQL 唯一约束裁决;Redis 故障不会制造重复业务事实。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口请求指纹、幂等作用域、冲突分类和并发职责的可复用构件与公开示例。明确不建立万能幂等表,不迁移未触碰的旧订单、钱包或状态机,不用 Redis 替代数据库事实。
|
||||||
|
|
||||||
|
- [ ] 请求指纹只包含影响业务结果的规范化字段,排除时间戳、签名、Token 等易变传输字段,并携带算法版本。
|
||||||
|
- [ ] 同一作用域内相同请求 ID 和相同指纹返回原结果;不同指纹返回统一幂等冲突且不覆盖既有事实。
|
||||||
|
- [ ] 作用域至少区分调用主体与操作类型;不同主体使用相同请求 ID 不会相互污染。
|
||||||
|
- [ ] 并发首次提交由 PostgreSQL 唯一约束裁决,应用层预查仅用于友好返回;Redis 不可用、过期或主从切换不影响最终正确性。
|
||||||
|
- [ ] 文档和测试明确区分请求 ID、事件 ID、业务唯一键、状态条件更新、钱包版本、Worker 租约和 Redis 防并发的职责。
|
||||||
|
- [ ] 集成测试覆盖相同请求重放、指纹冲突、不同主体、并发首写和 Redis 故障,不修改未触碰业务模块。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 06 — 冻结统一异步任务五态和查询契约
|
||||||
|
|
||||||
|
**What to build:** 新接入的业务任务和前端可以复用固定五态、中文状态名、进度、结果计数、失败摘要、时间信息、租约恢复和轮询语义。任务到达业务处理终点时即为已完成,部分成功或全部业务项失败通过计数表达;只有整体无法执行时才进入已失败。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Application,辅助通道为 Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共任务状态、公开投影、领取与终态更新、结构化队列载荷及前端轮询契约,并以现有导出任务作为兼容基准。明确不创建万能任务表,不迁移批量订购、设备分配、导入任务或其他未触碰业务任务。
|
||||||
|
|
||||||
|
- [ ] 公共状态固定为 `1=待处理、2=处理中、3=已完成、4=已失败、5=已取消`,响应同时返回对应中文状态名。
|
||||||
|
- [ ] 公开查询契约至少包含任务 ID、状态与名称、总数、成功数、失败数、进度、安全失败摘要、开始、完成和更新时间。
|
||||||
|
- [ ] 业务项处理完成后满足 `total_count = success_count + failed_count`;部分成功和全部业务项失败均为已完成,不增加“部分成功”状态。
|
||||||
|
- [ ] 待处理领取、终态进入和取消均使用预期状态条件更新;重复 Handler 遇到终态不会重新制造业务副作用。
|
||||||
|
- [ ] 处理中任务具有租约或等价恢复记录,进程重启或重复投递后能从 PostgreSQL 事实恢复;队列载荷只包含最小结构化标识且禁止 `[]byte`。
|
||||||
|
- [ ] 契约测试覆盖全成功、部分成功、全部业务项失败、整体失败、取消、重复消费和过期任务恢复,并记录跨仓轮询、隐藏暂停与刷新恢复语义。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 07 — 交付受控系统配置注册与查询闭环
|
||||||
|
|
||||||
|
**What to build:** 超级管理员可以按模块查询系统注册的配置 Key,并获得脱敏值、类型、值域提示、中文说明、只读状态和更新时间。业务模块只需注册自己拥有的 Key、类型、默认值和校验规则;公共壳层负责注册冲突检查、持久化读取、Redis 缓存与 PostgreSQL 回退,未注册数据库记录最多只读展示。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 建立公共迁移所有权与检查门禁
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口系统配置表、代码注册表、启动校验、缓存读取和超级管理员列表 API。明确不注册支付等具体业务 Key,不允许创建任意 Key,不提供原始 JSON 自由编辑器;新增 Handler 必须同步文档生成器。
|
||||||
|
|
||||||
|
- [ ] 配置存储支持唯一 Key、字符串化值、`string/int/bool/json` 类型、模块、中文说明、只读与敏感属性、创建更新人与时间,且无外键或 GORM 关联标签。
|
||||||
|
- [ ] 注册表定义稳定 Key、模块、类型、值域或枚举、默认值、只读、敏感和控件提示;重复 Key、类型冲突或非法默认值在启动或验证阶段失败。
|
||||||
|
- [ ] 只有已认证超级管理员可按模块分页查询配置;权限不足返回统一 403,不伪装为空数据。
|
||||||
|
- [ ] 未注册数据库 Key 默认不可写,最多按只读、可诊断方式展示;敏感配置值按注册策略脱敏。
|
||||||
|
- [ ] PostgreSQL 是唯一事实来源;Redis 未命中、超时或不可用时回退数据库并尝试回填,缓存 Key 和默认 TTL 遵循公共常量。
|
||||||
|
- [ ] 真实 Fiber、认证、GORM 和 Redis 测试覆盖模块过滤、四种类型、缓存命中与回退、未注册 Key、敏感值和权限边界;OpenAPI 文档生成器同步更新。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 08 — 交付系统配置更新、权限和审计闭环
|
||||||
|
|
||||||
|
**What to build:** 超级管理员可以按 Key 更新单个已注册且允许修改的配置。系统依次完成授权、注册检查、类型解析和值域校验,在 GORM 事务中保存事实并通过统一审计接缝记录变更;提交成功后立即失效对应缓存,缓存失效失败不会回滚数据库事实但会产生可操作告警。
|
||||||
|
|
||||||
|
**Blocked by:** 07 — 交付受控系统配置注册与查询闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application 事务脚本,辅助通道为 Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口单 Key 更新、权限、类型和值域校验、事务写入、缓存失效和审计 Port。明确不实现全局 Audit Event 模型,不提供无约束批量覆盖,不接管各业务模块的具体值域或生效规则;新增 Handler 必须同步文档生成器。
|
||||||
|
|
||||||
|
- [ ] 只有超级管理员可以更新配置;未注册、只读、类型错误、非法 JSON、越界或枚举外值均返回统一中文错误且不修改事实。
|
||||||
|
- [ ] 配置更新使用现有 GORM 显式事务完成事实写入和审计接缝调用;任一步事务内写入失败时整体回滚。
|
||||||
|
- [ ] 审计信息包含操作者、操作类型、中文描述、变更前后事实和请求关联标识,但本票不创建独立配置审计表。
|
||||||
|
- [ ] 提交成功后失效对应 Redis 缓存;失效失败不回滚 PostgreSQL,产生包含组件、错误码、时间窗口和安全标识的中文告警。
|
||||||
|
- [ ] 数据库值不可解析或越界时不能静默使用错误值,按注册策略返回最后验证值或安全默认值并告警。
|
||||||
|
- [ ] 真实 Fiber、认证、GORM 和 Redis 测试覆盖更新成功、并发更新、权限、未注册、只读、四种类型、事务回滚、缓存失效失败和审计事实;OpenAPI 文档生成器同步更新。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 09 — 统一 Access Log 请求与响应递归脱敏
|
||||||
|
|
||||||
|
**What to build:** Access Log 对 query、请求体和响应体使用同一套大小写不敏感的递归脱敏能力,覆盖嵌套对象和数组。日志先脱敏再执行 50KB 截断,并继续保留请求 ID、方法、路径、安全 query、状态、耗时、用户与终端信息及明确截断标志,方便排障而不泄露可复用凭证。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共敏感字段注册、query/请求/响应 JSON 脱敏、截断与访问日志元数据。明确不修改 Audit Event 或 Integration Log 模型、Writer 与查询,不改变业务响应内容。
|
||||||
|
|
||||||
|
- [ ] 公共敏感字段至少覆盖密码、口令、Token、Authorization、Cookie、密钥、Secret、签名、Nonce、验证码、支付凭证和私密 URL,匹配大小写不敏感。
|
||||||
|
- [ ] query、请求 JSON 和响应 JSON 复用同一递归规则,嵌套对象、数组、非字符串敏感字段均被不可逆替换。
|
||||||
|
- [ ] 请求体和响应体分别先脱敏后按 50KB 截断,并输出可机器识别的截断状态,不因序列化失败回退记录未脱敏 JSON。
|
||||||
|
- [ ] 脱敏后仍保留方法、路径、安全 query、状态、耗时、请求 ID、IP、User-Agent、用户标识及请求/响应摘要。
|
||||||
|
- [ ] 日志、注释和告警均使用中文,用户可见响应继续使用统一错误与响应格式。
|
||||||
|
- [ ] 真实 Fiber 测试捕获最终 JSON 日志,覆盖嵌套结构、数组、大小写变体、query、请求与响应、超长 body 和无法序列化场景。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 10 — 为敏感接口提供安全摘要策略
|
||||||
|
|
||||||
|
**What to build:** 登录与 Token、支付、企微回调、文件上传下载和导出等敏感接口按路由策略记录安全摘要。无论载荷是 JSON、表单、XML、multipart、二进制还是解析失败,都不会回退记录原文,只保留事件类型、安全资源标识、大小、内容类型、摘要哈希、处理结果和截断信息等排障字段。
|
||||||
|
|
||||||
|
**Blocked by:** 09 — 统一 Access Log 请求与响应递归脱敏
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口路由级敏感策略、非 JSON 安全降级和固定回归矩阵。明确不实现登录、支付、企微、文件或导出业务逻辑,不记录完整回调正文、文件内容或临时访问能力。
|
||||||
|
|
||||||
|
- [ ] 登录和 Token 接口不记录密码、验证码、访问令牌、刷新令牌或会话标识,只保留成功状态和必要主体标识。
|
||||||
|
- [ ] 支付接口不记录支付凭证、银行卡敏感信息、二维码原文、跳转链接、渠道密钥或完整签名,只保留安全订单号、渠道类型、结果码和金额摘要。
|
||||||
|
- [ ] 企微回调不记录加密包、解密正文、签名、Nonce、通讯录敏感字段或完整响应,只保留事件类型、安全标识、大小、哈希和处理结果。
|
||||||
|
- [ ] 文件与导出接口不记录 multipart、二进制、Base64、文件字节、临时凭证或签名下载地址,只保留脱敏文件名、类型、大小、数量、任务标识和结果。
|
||||||
|
- [ ] 敏感路由解析失败时只记录字段存在性、长度、内容类型、安全哈希和截断标志;普通非敏感文本也必须经过明确路由策略才可记录。
|
||||||
|
- [ ] 真实 Fiber 回归矩阵覆盖 JSON、XML、表单、multipart、二进制、超长和不可解析载荷,并断言日志中不存在测试凭证、签名、回调原文或文件字节。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 11 — 验证公共对象迁移与数据安全回滚边界
|
||||||
|
|
||||||
|
**What to build:** 发布负责人可以在空数据库和带兼容存量数据的数据库上执行公共 Outbox 与系统配置的正向迁移、后置校验及允许的回滚。公共对象只创建一次且定义一致;存在重复身份、非法配置、处理中任务、未投递事件或长租约时,流程明确失败且不执行破坏性写入。
|
||||||
|
|
||||||
|
**Blocked by:** 02 — 在业务事务中可靠写入公共 Outbox;07 — 交付受控系统配置注册与查询闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共 Outbox 和系统配置对象的迁移链路、兼容数据验证、可重入回填、后置校验和回滚边界。明确不迁移下游业务表,不删除已产生的业务事实、Outbox、审计、通知或任务结果。
|
||||||
|
|
||||||
|
- [ ] 空数据库和兼容存量数据库均可执行正向迁移,公共表、索引、约束和初始化数据只创建一次且定义符合契约。
|
||||||
|
- [ ] 构造重复事件 ID、非法配置、唯一键冲突、处理中任务、未投递事件和长租约时,前置检查以非零状态失败且不执行破坏性写入。
|
||||||
|
- [ ] 需要回填时按稳定主键分批、记录进度并可中断重跑;重复执行不生成重复事实,最终行数守恒。
|
||||||
|
- [ ] 后置校验覆盖约束生效、异常计数归零、关键索引可用和读写冒烟,输出不包含敏感数据。
|
||||||
|
- [ ] 未产生业务数据的新增结构可在验证后回滚;已有 Outbox 或配置事实后,回滚流程停止生产者和 Relay、保留事实并向前修复,禁止删表清理。
|
||||||
|
- [ ] 迁移说明记录发布顺序、停止条件、恢复步骤和测试数据隔离策略,并通过相关迁移与集成测试。
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 12 — 建立公共基础发布门禁和下游接入契约
|
||||||
|
|
||||||
|
**What to build:** 发布负责人可以通过一套公共基础整体验收判断是否允许下游接入和放量。验收使用真实 Fiber、GORM、PostgreSQL、Redis、Relay 和 Asynq 接缝,覆盖事务可靠性、至少一次投递、幂等职责、任务恢复、配置读写和 Access Log 安全,并给出下游接入顺序、运行监控、故障恢复和前端交互契约。
|
||||||
|
|
||||||
|
**Blocked by:** 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环;04 — 提供 Outbox 监控和受控恢复能力;05 — 提供创建命令幂等与并发职责契约;06 — 冻结统一异步任务五态和查询契约;08 — 交付系统配置更新、权限和审计闭环;10 — 为敏感接口提供安全摘要策略;11 — 验证公共对象迁移与数据安全回滚边界
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure,辅助通道为 Application 与 Query 契约。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口公共能力的端到端发布门禁、运行手册、跨仓前端契约和下游接入说明。明确不实现前端代码、Audit Event、Integration Log、站内通知、业务事件消费者或任何下游领域规则,不借验收迁移未触碰旧模块。
|
||||||
|
|
||||||
|
- [ ] 整体验收通过公开接缝验证业务事务写入、Outbox Relay、Asynq Handler、重复投递、租约恢复和可观察消费结果。
|
||||||
|
- [ ] PostgreSQL、Redis 和 Asynq 测试使用隔离数据与唯一前缀,只清理本次创建的数据,不执行全库或全缓存清空。
|
||||||
|
- [ ] 发布顺序明确为迁移与检查、兼容 API、Relay/Worker、依赖消费者、前端;生产者不得在消费者和监控就绪前制造不可见积压。
|
||||||
|
- [ ] 停止条件覆盖迁移异常、Outbox 持续积压或租约大量过期、配置读写不一致、脱敏回归失败和关键任务无法恢复。
|
||||||
|
- [ ] 下游接入说明明确公共基础提供与不提供的能力,以及审计、通知和各业务 PRD 自行拥有的模型、状态机、业务唯一键、失败明细和消费者幂等。
|
||||||
|
- [ ] 前端跨仓契约记录加载、真实空态、筛选空态、403、失败重试、任务 ID 恢复、2/3/5 秒退避、最长 10 秒、页面隐藏暂停和恢复立即刷新。
|
||||||
|
- [ ] 中文功能总结覆盖关键流程、异常闭环、发布回滚、监控恢复和待决策项,README 增加索引;所有公共外部行为测试通过后方可标记基础就绪。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 01 — 建立回调 ICCID 精确唯一性发布门禁
|
||||||
|
|
||||||
|
**What to build:** 发布人员可以在启用运营商回调前检查未删除卡的 19 位和 20 位 ICCID 是否存在冲突,并在数据安全时把现有普通部分索引升级为部分唯一索引。若发现重复,迁移必须安全中止并给出修复依据,避免回调把观测应用到任意一张卡。
|
||||||
|
|
||||||
|
**Blocked by:** 无——可立即开始。
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口运营商回调所需的 ICCID 精确唯一性检查、迁移、回滚和发布门禁。明确不改变 ICCID 展示、导入、模糊查询或现有卡识别规则,不清理或自动合并生产重复数据。
|
||||||
|
|
||||||
|
- [ ] 向上迁移在建立唯一约束前分别检查未删除卡的 19 位和非空 20 位 ICCID 重复组,发现冲突时安全失败且不留下半完成结构。
|
||||||
|
- [ ] 未删除卡的 19 位和非空 20 位 ICCID 均由部分唯一索引保证精确唯一,软删除记录不阻塞合法新记录。
|
||||||
|
- [ ] 向下迁移只恢复本需求触碰的索引形态,不删除 ICCID 列、不修改卡数据,也不影响其他表索引。
|
||||||
|
- [ ] 迁移测试覆盖无冲突升级、19 位冲突、20 位冲突、软删除重复和 down/up 重放,并验证失败后可恢复。
|
||||||
|
- [ ] 发布说明明确生产检查、冲突修复责任、停止条件和回滚步骤,不把开发库无重复当作生产事实。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 02 — 交付实名观测的公共写入闭环
|
||||||
|
|
||||||
|
**What to build:** 轮询、手动同步和运营商回调可以提交类型明确的实名观测,由唯一公共用例锁定卡、应用首次实名与周期逆转规则,并在同一事务中保存卡状态、Audit Event 和 Outbox。有效但无变化的观测仍更新时间和 Integration Log,重复观测不会重复激活套餐或执行业务副作用。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- `.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-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 复杂写;主链路为 Handler/Worker → Application → Domain → Repository/Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票建立公共卡观测契约及实名观测的完整业务边界,包括首次实名、周期逆转确认、可靠事件和提交后缓存处理。明确不迁移流量、网络、套餐轮询、保护期轮询或未触碰的旧实名查询;运营商解除实名回调不进入逆转计数。
|
||||||
|
|
||||||
|
- [ ] 公共观测元数据和实名观测均使用类型明确的领域输入,包含来源、场景、观测时间、请求/关联标识及脱敏上游摘要,领域层不依赖 Gateway、Fiber、GORM、Redis 或 Asynq 类型。
|
||||||
|
- [ ] 应用用例在事务中锁定卡,首次从未实名变为已实名时只写一次首次实名时间,并可靠发布卡/设备套餐首次激活、设备实名投影和停复机评估所需事件。
|
||||||
|
- [ ] 周期查询的未实名观测保留连续三次确认和 10 分钟窗口,未达阈值不把已实名卡改为未实名;回调来源的解除实名观测不能增加该计数。
|
||||||
|
- [ ] 有效无变化观测更新时间并写 Integration Log,但不重复发布首次实名或状态变化事件;缺少关键字段的观测不以零值覆盖本地状态。
|
||||||
|
- [ ] 卡状态、Audit Event 和 Outbox 任一步失败均整体回滚;缓存失效或回填只在提交后执行,失败可观察且不伪造事务成功。
|
||||||
|
- [ ] 单元及 PostgreSQL 并发测试覆盖首次成功、重复成功、逆转阈值、窗口过期、无变化、无效载荷、同一卡串行、事务回滚和事件幂等。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 03 — 交付流量观测的公共写入闭环
|
||||||
|
|
||||||
|
**What to build:** 所有卡流量查询结果可以通过公共观测用例串行应用,完整保留运营商重置、跨月归档、异常下降保护、正增量累计、使用记录、套餐流量扣减和停复机评估。重复或无变化读数不会重复扣减,失败不会留下部分卡状态或业务事件。
|
||||||
|
|
||||||
|
**Blocked by:** 02 — 交付实名观测的公共写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 复杂写;主链路为 Handler/Worker → Application → Domain → Repository/Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票在已建立的公共卡观测边界内收口流量观测规则及副作用。明确不迁移套餐轮询、套餐领域其他用例、轮询调度或未触碰的旧流量读取接口。
|
||||||
|
|
||||||
|
- [ ] 流量观测使用类型明确的读数和公共元数据,同一卡实际应用保持串行,重复观测身份不会重复写入或扣减。
|
||||||
|
- [ ] 领域规则完整覆盖运营商重置日、跨月归档、非重置日读数下降保护、正增量累计和首次月份初始化,无效或异常读数不覆盖可信基线。
|
||||||
|
- [ ] 正增量可靠产生使用记录、套餐流量扣减和停复机评估事件;零增量和受保护的下降读数不发布重复扣减事件。
|
||||||
|
- [ ] 有效无变化观测仍更新时间和 Integration Log;卡流量事实、Audit Event 与 Outbox 保持事务一致,提交后缓存反映最终状态。
|
||||||
|
- [ ] 单元及 PostgreSQL 并发测试覆盖正增量、零增量、运营商重置、跨月、非重置日下降、并发观测、重复身份和事务失败回滚。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 04 — 交付网络观测的公共写入闭环
|
||||||
|
|
||||||
|
**What to build:** 所有 Gateway 网络状态结果可以通过公共观测用例安全写入卡状态、扩展原因和 IMEI。未知状态不会覆盖本地网络状态,真实状态变化可靠触发停复机评估,独立风险卡仍能关闭后续轮询。
|
||||||
|
|
||||||
|
**Blocked by:** 02 — 交付实名观测的公共写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 复杂写;主链路为 Handler/Worker → Application → Domain → Repository/Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票在公共卡观测边界内收口网络观测、运营商停机原因和风险卡行为。明确不重构停复机资格规则、保护期轮询、轮询调度或未触碰的设备状态写入。
|
||||||
|
|
||||||
|
- [ ] 网络观测使用类型明确的 Gateway 状态、扩展原因、IMEI 和公共元数据,领域层通过稳定映射决定是否可应用。
|
||||||
|
- [ ] 未知 Gateway 状态、缺少关键字段或无效结果不以零值覆盖网络状态;可安全保留的扩展字段按明确规则处理并记录失败结论。
|
||||||
|
- [ ] 网络状态变化时写入运营商停机原因并可靠发布停复机评估事件;状态不变时不重复发布网络变化事件。
|
||||||
|
- [ ] 独立卡命中风险停机或销户扩展状态后仍关闭轮询,绑定设备的卡和机卡分离停机不被错误终止。
|
||||||
|
- [ ] 卡状态、Audit Event 与 Outbox 保持事务一致,有效观测更新时间和 Integration Log,提交后缓存正确失效或回填。
|
||||||
|
- [ ] 单元及 PostgreSQL 并发测试覆盖全部已知映射、未知状态、扩展原因、IMEI、无变化、风险卡矩阵、重复身份和事务回滚。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 05 — 一次性切换三个卡状态轮询入口
|
||||||
|
|
||||||
|
**What to build:** 现有实名、流量和网络轮询在 Gateway 查询成功后统一调用公共观测边界,不再直接更新卡或自行触发套餐、扣减和停复机副作用。切换前后的轮询配置、资格判断、Redis 分片队列、重排、并发、卡级开关、风险卡终止和监控统计保持一致。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 交付实名观测的公共写入闭环
|
||||||
|
- 03 — 交付流量观测的公共写入闭环
|
||||||
|
- 04 — 交付网络观测的公共写入闭环
|
||||||
|
- `.scratch/tech-global-audit/issues/09-manual-polling-task-integration-cutover.md` — 09 — 承接手动轮询状态并记录同步外部尝试
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为复杂写,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票以一个不可分割的切换收口三个轮询 Handler 查询成功后的状态应用和业务联动。明确不迁移套餐轮询、保护期轮询、调度基础设施、查询前资格判断或现有轮询策略,不允许新旧路径双写。
|
||||||
|
|
||||||
|
- [ ] 三个轮询 Handler 只负责既有调度编排、资格判断和 Gateway 查询,查询成功后的持久化与副作用全部交给公共观测用例。
|
||||||
|
- [ ] 实名首次激活、实名逆转、流量扣减、停复机评估、缓存更新和风险卡终止在切换后仅有一个权威实现,不发生直接写库与公共用例双写。
|
||||||
|
- [ ] 每次实际 Gateway 请求建立 Integration Log 并传播请求、关联、来源和场景标识;失败仍按原轮询重排策略处理。
|
||||||
|
- [ ] 固定同一组轮询配置、Redis 队列和卡级开关的回归测试证明下次入队时间、失败重排、并发控制、卡资格、风险卡终止和监控统计不变。
|
||||||
|
- [ ] 手动刷新继续直接执行一次同步并保持路由、权限和响应契约,不额外生成 0/3/5 序列。
|
||||||
|
- [ ] 删除或封闭三个 Handler 中已迁移的状态写入和副作用分支,套餐轮询、保护期轮询和其他旧模块保持原状。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**What to build:** 内部业务调用方可以为资源和同步类型建立立即、3 分钟、5 分钟三次独立观测任务。序列按场景合并、按尝试幂等,单次失败不取消后续任务;达到明确预期时提前完成剩余尝试,实际 Gateway 请求仍通过公共观测边界应用结果。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 交付实名观测的公共写入闭环
|
||||||
|
- 03 — 交付流量观测的公共写入闭环
|
||||||
|
- 04 — 交付网络观测的公共写入闭环
|
||||||
|
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
|
||||||
|
- `.scratch/tech-public-foundation/issues/06-unified-async-task-contract.md` — 06 — 冻结统一异步任务五态和查询契约
|
||||||
|
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Application + Port/Adapter,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口通用观测序列的创建、合并、任务执行、预期判断、互斥和尝试留痕,并为实名、流量、网络及设备信息提供扩展点。明确不批量修改各业务入口,不新增公开同步 API、全局冷却或新的同步运行表。
|
||||||
|
|
||||||
|
- [ ] 一次触发恰好建立立即、3 分钟、5 分钟三次独立任务,三次任务均为零自动重试,载荷以 struct 或 map 入队并传播稳定 `series_id + attempt`。
|
||||||
|
- [ ] 同一场景、资源类型、资源 ID 和同步类型的未结束序列被合并;不同场景互不压制,OpenAPI 场景不包含调用方身份且不会因重复查询延长序列。
|
||||||
|
- [ ] 有明确预期时任务执行前读取本地快照,达到预期后把当前和剩余尝试标记完成且不访问 Gateway;回调确认实名后可提前完成同卡实名序列。
|
||||||
|
- [ ] 实际 Gateway 请求期间按单卡、运营商接入和同步类型互斥,流量复用卡级锁;互斥命中只跳过当前尝试,不取消后续任务。
|
||||||
|
- [ ] 合并、互斥、限频、提前完成和实际请求均写 Integration Log;超频不建立额外退避,事件任务失败不改变现有轮询重排。
|
||||||
|
- [ ] 并发及链路测试覆盖重复触发、不同场景、重复投递、单次失败、预期提前完成、互斥、限频、零重试和三次结果连续可查询。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 07 — 接入读取与实名入口的后台观测
|
||||||
|
|
||||||
|
**What to build:** C 端资产详情、后台资产实时状态、OpenAPI 卡和设备状态查询继续立即返回本地快照,同时 Best Effort 创建可合并的后台观测序列;后台和 C 端获取实名入口在运营商支持实名时创建以“已实名”为预期的序列。入队失败不改变原接口响应和耗时契约。
|
||||||
|
|
||||||
|
**Blocked by:** 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Query,辅助通道为 Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 PRD 指定的读取型入口和获取实名入口的触发接入。明确不迁移原查询到聚合根、不改变公开响应、不新增同步按钮或 API,也不改变现有手动刷新直接同步行为。
|
||||||
|
|
||||||
|
- [ ] C 端资产详情、后台实时状态以及 OpenAPI 卡/设备的流量、网络和实名查询按被查询资源与同步类型创建无预期序列,并先返回现有本地快照。
|
||||||
|
- [ ] 连续 OpenAPI 查询同一资源使用稳定、调用方无关的场景码合并到未结束序列,不会每次新增三任务或不断续建序列。
|
||||||
|
- [ ] 后台和 C 端获取实名入口仅在运营商实名能力不是 `none` 时创建实名序列,预期状态为已实名。
|
||||||
|
- [ ] Best Effort 入队失败只记录中文安全日志和关联标识,不改变原接口状态码、响应结构或业务结果。
|
||||||
|
- [ ] 现有后台和 C 端手动刷新仍直接执行一次同步,不生成后台序列;读接口不等待 Gateway,也不伪造已收敛状态。
|
||||||
|
- [ ] HTTP 集成与压测覆盖响应契约、延迟、序列合并、`realname_link_type=none`、入队失败降级和手动刷新不建序列。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 08 — 接入停复机与购包业务的状态收敛序列
|
||||||
|
|
||||||
|
**What to build:** 后台、C 端、OpenAPI 和自动任务的停复机成功后,系统可靠创建以目标网络状态为预期的观测序列;订单支付、钱包购包和套餐激活成功后,可靠创建实名、流量和网络观测序列。业务成功事实通过 Outbox 触发,不因即时队列故障永久丢失。
|
||||||
|
|
||||||
|
**Blocked by:** 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Application + Port/Adapter,辅助通道为复杂写。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票只把指定停复机和购包成功边界接入公共序列,不修改其领域资格、金额、库存、支付、套餐激活或 Gateway 调用规则,不借接入迁移未触碰的旧 Service。
|
||||||
|
|
||||||
|
- [ ] 后台、C 端、OpenAPI 和自动任务的停机成功创建网络观测序列并预期开机状态关闭,复机成功创建网络观测序列并预期开机状态正常。
|
||||||
|
- [ ] 订单支付、钱包购包和套餐激活成功创建实名、流量、网络三类无预期序列,失败或回滚的业务操作不产生同步触发。
|
||||||
|
- [ ] 写操作在原业务事务成功边界写入 Outbox,重复业务事件和至少一次投递不会创建重复未结束序列。
|
||||||
|
- [ ] 轮询观测到变化和序列执行结果不得反向创建新序列,停复机现有公开响应、权限和业务错误保持不变。
|
||||||
|
- [ ] 卡绑定、解绑、设备或卡分配与回收若没有实际调用上游,只保留业务事件,不创建 Gateway 观测序列。
|
||||||
|
- [ ] 集成测试覆盖各入口成功、失败、事务回滚、重复事件、明确预期提前完成及单次任务失败不影响后续尝试。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 09 — 接入设备控制业务的状态收敛序列
|
||||||
|
|
||||||
|
**What to build:** 设备切卡、切模式、重启、恢复出厂和 WiFi 设置成功后,系统为设备信息及相关卡创建准确的后台观测序列。切卡和切模式可以目标 ICCID 为预期提前完成,其余操作不阻塞原业务响应。
|
||||||
|
|
||||||
|
**Blocked by:** 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Application + Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口指定设备控制入口的观测触发,不迁移设备控制业务规则、绑定模型或 Gateway Adapter,不把没有上游调用的资产关系变化误建为同步序列。
|
||||||
|
|
||||||
|
- [ ] 切卡和切模式成功后创建设备信息、源卡与目标卡网络、目标卡流量序列,并以目标 ICCID 作为设备信息观测的明确预期。
|
||||||
|
- [ ] 设备重启、恢复出厂和 WiFi 设置成功后创建设备信息及绑定卡网络序列,不设置未经协议保证的预期状态。
|
||||||
|
- [ ] 失败、拒绝或事务回滚的设备操作不创建序列;重复事件按场景和资源合并,不重复产生三任务。
|
||||||
|
- [ ] 观测触发不增加原 Gateway 操作耗时,不改变现有路由、权限、响应结构和中文错误语义。
|
||||||
|
- [ ] 绑定、解绑、分配和回收等未实际调用上游的操作只记录业务事件,不创建设备或卡观测序列。
|
||||||
|
- [ ] 集成测试覆盖各控制入口、源卡/目标卡资源选择、预期 ICCID 提前完成、重复事件、入队恢复和无上游操作不建序列。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 10 — 接入电信实名回调
|
||||||
|
|
||||||
|
**What to build:** 电信 XML 回调可以按真实协议识别实名补录成功,并通过公共实名观测幂等更新卡;同一路由收到实名信息清除时只留痕忽略。无效报文、未找到、冲突和重复成功均按安全规则记录并返回运营商约定应答。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 建立回调 ICCID 精确唯一性发布门禁
|
||||||
|
- 02 — 交付实名观测的公共写入闭环
|
||||||
|
- 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure Adapter,辅助通道为复杂写。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票只实现已知电信 XML 报文的 Translator、成功应答、系统级精确查卡和实名观测接入。明确不创建电信独立解除实名路由,不验证来源真实性,不调用 Gateway 二次确认、旧第三方推送或删除实名接口。
|
||||||
|
|
||||||
|
- [ ] 回调仅在结果消息为成功且受理消息包含实名信息补录完成时生成已实名观测;实名信息清除只写 `ignored` Integration Log。
|
||||||
|
- [ ] ICCID 去除首尾空白后仅接受合法 19 或 20 位值,并分别精确查询对应列,禁止截断、补位、模糊匹配和跨列降级。
|
||||||
|
- [ ] 系统级 Repository 不应用登录账号数据范围;未找到记录 `not_found`,多匹配记录 `conflict`,均不任取卡写入。
|
||||||
|
- [ ] 重复成功回调幂等返回成功,不重复首次实名、套餐激活或领域事件,并提前完成同卡未执行实名序列。
|
||||||
|
- [ ] 报文级解析失败记录 `invalid_payload`;可安全识别的无效业务结果按运营商约定成功应答,日志和 Access Log 不保存完整报文或明文 ICCID。
|
||||||
|
- [ ] 新 Handler 完成路由注册和两个 OpenAPI 文档生成器接入;脱敏 XML fixture 契约测试覆盖成功、清除、业务失败、非法长度、未找到、冲突和重复成功。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 11 — 接入移动实名成功回调
|
||||||
|
|
||||||
|
**What to build:** 移动 JSON 回调可以按真实协议识别实名成功并通过公共观测幂等更新卡。ICCID 为空或载荷无效时安全留痕和应答,系统不会登录旧管理平台补查,也不会保留任何旧平台凭证或抓取逻辑。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 建立回调 ICCID 精确唯一性发布门禁
|
||||||
|
- 02 — 交付实名观测的公共写入闭环
|
||||||
|
- 06 — 交付可合并的 0/3/5 观测序列
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure Adapter,辅助通道为复杂写。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票只实现已知移动实名成功 JSON 报文的 Translator、成功应答、系统级精确查卡和实名观测接入。明确不实现移动解除实名,不验证来源真实性,不登录旧平台、不按 MSISDN 补查、不请求 Gateway 二次确认。
|
||||||
|
|
||||||
|
- [ ] 仅当外层状态为成功、消息为正确、首个结果实名状态为成功且携带 ICCID 时生成已实名观测。
|
||||||
|
- [ ] 空 ICCID 和无效业务载荷记录 `invalid_payload` 并按运营商约定应答,测试证明不会发起旧平台登录、MSISDN 查询或第三方抓取。
|
||||||
|
- [ ] ICCID 仅按合法 19/20 位精确查询对应列;未找到和冲突不更新卡,重复成功不重复首次实名、激活或领域事件。
|
||||||
|
- [ ] 成功回调可提前完成同卡未执行实名序列;回调本身不得反向创建新的 0/3/5 序列。
|
||||||
|
- [ ] Integration Log、应用日志和 Access Log 只保存脱敏摘要及必要解析字段,不保存完整报文、Cookie、Authorization、账号密码或明文 ICCID。
|
||||||
|
- [ ] 新 Handler 完成路由注册和两个 OpenAPI 文档生成器接入;脱敏 JSON fixture 契约测试覆盖成功、业务失败、空 ICCID、非法长度、未找到、冲突和重复成功。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 12 — 接入联通解除实名留痕回调
|
||||||
|
|
||||||
|
**What to build:** 联通解除实名 JSON 回调可以解析字符串字段中的 ICCID 和变更时间,建立脱敏 Integration Log 并按协议应答,但不会把已实名卡改为未实名、增加逆转计数或触发停机。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 建立回调 ICCID 精确唯一性发布门禁
|
||||||
|
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票只实现已有联通解除实名报文的解析、精确资源识别、忽略留痕和成功应答。明确不实现联通实名成功,不调用公共未实名观测、不修改卡状态、不验证来源真实性或请求 Gateway 二次确认。
|
||||||
|
|
||||||
|
- [ ] Adapter 能解析外层 JSON 字符串字段中的 ICCID 和变更时间,报文或嵌套数据解析失败时记录 `invalid_payload`。
|
||||||
|
- [ ] 合法 ICCID 仅按 19/20 位精确查询对应列;未找到和冲突分别记录明确结果,不任取卡,也不跨列降级。
|
||||||
|
- [ ] 可识别的解除实名回调统一记录 `ignored`,不更新实名状态、首次实名时间和检查时间,不增加周期逆转计数,不发布停机或套餐事件。
|
||||||
|
- [ ] 重复回调幂等留痕并按运营商约定应答,日志和 Access Log 不保存完整报文或明文 ICCID。
|
||||||
|
- [ ] 新 Handler 完成路由注册和两个 OpenAPI 文档生成器接入;脱敏 fixture 契约测试覆盖成功解析、嵌套数据无效、非法长度、未找到、冲突和重复回调。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 13 — 完成 UR#94 发布门禁与回归验收
|
||||||
|
|
||||||
|
**What to build:** 发布负责人可以通过一套端到端验收判断公共卡状态写入、0/3/5 观测序列和三个运营商回调是否可以灰度上线。验收对比改造前后的轮询事实,检查可靠事件、审计链路、敏感信息保护、运营商独立开关、停止条件和回滚路径。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 05 — 一次性切换三个卡状态轮询入口
|
||||||
|
- 07 — 接入读取与实名入口的后台观测
|
||||||
|
- 08 — 接入停复机与购包业务的状态收敛序列
|
||||||
|
- 09 — 接入设备控制业务的状态收敛序列
|
||||||
|
- 10 — 接入电信实名回调
|
||||||
|
- 11 — 接入移动实名成功回调
|
||||||
|
- 12 — 接入联通解除实名留痕回调
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Infrastructure 与集成验收,辅助通道为 Application、Domain 和 Query 契约。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口 UR#94 的端到端发布验证、文档、灰度和回滚门禁。明确不新增同步按钮、公开同步 API、活跃状态、调度状态表、独立同步监控页或轮询策略,不迁移套餐轮询和保护期轮询。
|
||||||
|
|
||||||
|
- [ ] 真实 Fiber、GORM、PostgreSQL、Redis、Relay 和 Asynq 链路验证实名、流量、网络观测及事件消费者,事务失败不留下部分卡状态、Audit Event 或 Outbox。
|
||||||
|
- [ ] 固定并记录发布前的启用轮询配置、队列深度、卡级开关、下次入队、失败重排、并发和监控统计;发布后对比证明调度事实未因 UR#94 改变。
|
||||||
|
- [ ] 事件验收覆盖恰好三次零重试任务、单次失败不取消后续、场景合并、OpenAPI 防轮询、互斥、限频和预期提前完成。
|
||||||
|
- [ ] 三个运营商 Adapter 使用脱敏真实 fixture 穿过 Fiber 路由和统一错误处理,验证 19/20 位精确匹配、重复回调、未找到、冲突、解除实名忽略和移动空 ICCID 不补查。
|
||||||
|
- [ ] Access Log、应用日志、Integration Log、Audit Event、Outbox 和任务安全摘要均不泄露完整运营商报文、Cookie、Authorization、账号密码或明文 ICCID。
|
||||||
|
- [ ] 测试环境按运营商原始样例验收后,电信、移动、联通 Adapter 可逐个启用和独立关闭;停止条件与回滚不影响事件序列和原轮询兜底。
|
||||||
|
- [ ] 中文功能总结覆盖关键流程、接口契约、异常闭环、发布回滚和待决策项,README 增加索引;全部门禁通过后方可配置生产回调 URL。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 01 — 建立店铺业务员归属持久化基线
|
||||||
|
|
||||||
|
**What to build:** 店铺可以持久保存一个可空的平台业务员账号 ID,并能通过普通索引支持后续精确筛选。上线迁移不回填存量店铺、不推断历史归属,也不建立数据库外键或账号关联标签;应用回滚时允许保留已经写入的归属事实。
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票只建立店铺业务员归属的数据承载和索引能力。明确不实现账号候选校验、创建继承、编辑权限、查询投影、通知发送或账号停用后的级联处理,也不迁移未触碰的店铺旧代码。
|
||||||
|
|
||||||
|
- [ ] 店铺数据结构支持可空业务员账号 ID,数据库类型、模型类型和空值语义一致。
|
||||||
|
- [ ] 业务员账号 ID 建立非唯一普通索引,迁移不包含数据库外键、级联动作或存量数据回填。
|
||||||
|
- [ ] 账号停用、软删除或重新启用不会自动清空或改写店铺保存的业务员 ID。
|
||||||
|
- [ ] 迁移向上、向下及再次向上均可验证,应用回滚策略允许新增字段及既有归属数据继续保留。
|
||||||
|
- [ ] PostgreSQL 集成测试验证空值保存、正 ID 保存、索引定义和软删除账号不影响历史关联。
|
||||||
|
- [ ] 迁移、模型及相关常量和注释遵守项目数据库、Model 与中文注释规范。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 02 — 平台创建店铺时设置或继承业务员
|
||||||
|
|
||||||
|
**What to build:** 超级管理员和平台账号创建店铺时,可以显式选择当前可用的平台业务员、显式创建为空归属,或在字段缺失时一次性继承所选上级店铺当前保存的业务员 ID。店铺、初始主账号、默认角色、主钱包、分佣钱包、业务员归属及成功审计作为一个完整创建用例原子提交。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 建立店铺业务员归属持久化基线
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为 Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票迁移并收口平台操作者的店铺创建完整用例。明确不迁移店铺删除、启停及其他无关 CRUD,不创建 Shop 聚合,不实现代理账号的请求字段拒绝规则。
|
||||||
|
|
||||||
|
- [ ] 创建命令真实区分业务员字段缺失、显式 `null`、零值和正整数,Handler 不自行猜测三态语义。
|
||||||
|
- [ ] 超级管理员和平台账号显式传正 ID 时,只能绑定当前启用、未删除的普通平台账号;超级管理员、代理、企业、停用和软删除账号均不可人工新绑定。
|
||||||
|
- [ ] 显式 `null` 创建为空归属;字段缺失且有上级时复制上级当前保存的原始 ID,字段缺失且无上级时创建为空归属。
|
||||||
|
- [ ] 上级保存的业务员即使当前停用或软删除,字段缺失时仍按历史 ID 原值继承,不把继承误作人工候选校验。
|
||||||
|
- [ ] 店铺、初始主账号、账号角色、店铺角色、两个钱包、业务员归属和关键成功审计在同一数据库事务内成功或回滚,任一步失败不留下半成品。
|
||||||
|
- [ ] 创建事件记录归属来源 `inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID,并符合统一审计写入契约。
|
||||||
|
- [ ] Application、PostgreSQL 与 HTTP 集成测试覆盖平台创建矩阵、字段四态、候选失效、事务回滚和统一中文错误。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 03 — 代理创建下级店铺时安全继承业务员
|
||||||
|
|
||||||
|
**What to build:** 代理账号创建其既有权限允许的下级店铺时,服务端从最终校验通过的直属上级店铺复制当时保存的业务员 ID;代理请求只要出现业务员字段,无论是相同 ID、其他 ID、零值或 `null`,均被安全拒绝。复制完成后父子店铺独立保存,后续互不级联。
|
||||||
|
|
||||||
|
**Blocked by:** 02 — 平台创建店铺时设置或继承业务员
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为既有权限 Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口代理操作者进入同一店铺创建用例时的权限与继承规则。明确不扩大代理原有可发展层级或数据范围,不允许代理人工选择业务员,不实现父子动态查询或批量级联。
|
||||||
|
|
||||||
|
- [ ] 代理创建前复用既有店铺层级和管理权限校验,所选上级必须处于调用者原本允许发展的范围。
|
||||||
|
- [ ] 请求 JSON 出现业务员字段即返回统一禁止访问错误,显式 `null`、零值、原值和其他正 ID 不得被静默忽略。
|
||||||
|
- [ ] 字段未出现时,上级有业务员则复制原始 ID,上级为空则新店铺为空;停用或软删除账号的历史 ID 仍可被复制。
|
||||||
|
- [ ] 复制是创建时快照:父店铺之后设置、清空或更换业务员,不改变既有子店铺或孙级店铺。
|
||||||
|
- [ ] 后续新建直属下级只读取其直属上级在本次创建时的当前保存值,不沿祖先动态追溯。
|
||||||
|
- [ ] 无权上级、企业操作者及资源不存在使用统一安全错误语义,不泄露资源是否存在。
|
||||||
|
- [ ] Application 与 HTTP 集成测试覆盖继承矩阵、恶意字段输入、无权上级、事务原子性及不级联回归。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 04 — 独立修改并审计店铺业务员归属
|
||||||
|
|
||||||
|
**What to build:** 超级管理员和平台账号编辑有权管理的店铺时,可以设置、清空或更换业务员;字段缺失则保持现状。代理可以继续更新其有权店铺的其他资料,但请求一旦出现业务员字段即被拒绝。每次实际归属变化只影响目标店铺,并留下可靠、脱敏的前后审计事实。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 01 — 建立店铺业务员归属持久化基线
|
||||||
|
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为简单写 Application,辅助通道为统一审计 Port/Adapter。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票迁移并收口店铺资料更新这一完整用例中的业务员归属变更。明确不迁移店铺删除、启停或其他无关 CRUD,不向父级、下级、孙级或同级传播变更,不承担公共审计基础设施建设。
|
||||||
|
|
||||||
|
- [ ] 更新命令真实区分字段缺失、显式 `null`、零值和正整数;字段缺失保持原归属,`null` 清空,正 ID 校验后替换,零值按非法参数处理。
|
||||||
|
- [ ] 超级管理员和平台账号只有在通过目标店铺既有资源权限检查后才能修改归属;企业、越权平台和资源不存在使用统一安全错误语义。
|
||||||
|
- [ ] 代理更新其他资料且字段缺失时保留原归属,请求一旦出现该字段即返回统一禁止访问错误。
|
||||||
|
- [ ] 设置正 ID 时在写事务内重新校验其仍为启用、未删除的普通平台账号,候选在提交前失效时不得更新店铺。
|
||||||
|
- [ ] 实际值未变化时不生成业务员变更成功审计;设置、清空和更换记录稳定动作、前后 ID、账号名摘要和可用状态。
|
||||||
|
- [ ] 店铺变化与关键成功审计同事务提交,审计失败时业务修改回滚;拒绝和失败按公共审计策略记录且不泄露底层错误。
|
||||||
|
- [ ] 测试覆盖四类操作者、字段四态、并发候选失效、无变化更新、父子不级联、审计失败回滚和中文错误契约。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 05 — 查询店铺业务员归属与候选账号
|
||||||
|
|
||||||
|
**What to build:** 有权用户可以在店铺列表和详情中看到相同的业务员归属摘要,按保存的业务员 ID 与其他条件组合筛选;超级管理员和平台账号还可以通过最小披露的候选接口搜索当前可人工绑定的平台业务员。停用或删除账号仍展示历史摘要及不可用状态,但不再进入候选结果。
|
||||||
|
|
||||||
|
**Blocked by:** 01 — 建立店铺业务员归属持久化基线
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** Query。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票收口店铺列表、店铺详情和业务员候选三个读取用例及其 API 契约。明确不通过聚合根读取,不执行写操作,不把候选查询结果当作写侧最终校验,不扩大任何账号的数据权限。
|
||||||
|
|
||||||
|
- [ ] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。
|
||||||
|
- [ ] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态;空归属返回空摘要,停用或软删除账号尽可能返回历史摘要并标记不可用。
|
||||||
|
- [ ] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。
|
||||||
|
- [ ] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。
|
||||||
|
- [ ] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。
|
||||||
|
- [ ] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要,不复用披露范围更大的账号列表响应。
|
||||||
|
- [ ] PostgreSQL 与 HTTP 集成测试覆盖索引筛选、AND 条件、分页总数、历史投影、手机号脱敏、无 N+1、权限和静态路由顺序。
|
||||||
|
- [ ] RouteSpec 和 OpenAPI 契约同步更新;若实现过程中新增 Handler 类型,则同步两个文档生成器,否则不得为此无谓新增 Handler。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 06 — 提供业务员通知接收人解析并完成发布验证
|
||||||
|
|
||||||
|
**What to build:** 后端业务可以按店铺当前保存的业务员 ID 稳定解析一个可用的平台业务员接收人,供公共站内通知 Worker 在套餐临期、钱包低余额等场景复用。解析只认当前店铺的独立归属,账号不可用时跳过,不沿店铺层级追溯;同时完成 UR#96 后端接口、文档、迁移与发布回滚的整体验收。
|
||||||
|
|
||||||
|
**Blocked by:**
|
||||||
|
|
||||||
|
- 02 — 平台创建店铺时设置或继承业务员
|
||||||
|
- 03 — 代理创建下级店铺时安全继承业务员
|
||||||
|
- 04 — 独立修改并审计店铺业务员归属
|
||||||
|
- 05 — 查询店铺业务员归属与候选账号
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
**架构通道:** 主通道为 Application + Port/Adapter,辅助通道为 Query/Infrastructure。
|
||||||
|
|
||||||
|
**完整业务边界:** 本票提供公共通知能力可调用的店铺业务员接收人解析接缝,并验证 UR#96 后端交付。明确不建设通知表、通知中心、铃铛或任何前端页面,不实现 UR#33 套餐临期和 UR#97 钱包低余额的触发规则,不发送外部渠道通知,也不建设自动转派。
|
||||||
|
|
||||||
|
- [ ] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。
|
||||||
|
- [ ] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。
|
||||||
|
- [ ] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。
|
||||||
|
- [ ] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。
|
||||||
|
- [ ] 集成测试覆盖可用、停用、软删除、重新启用、空归属、关系更换和父级变更不影响子店铺等场景。
|
||||||
|
- [ ] 后端验收覆盖平台显式设置和清空、代理继承、父级修改不级联、列表筛选、详情投影、候选权限、审计及创建事务原子性。
|
||||||
|
- [ ] 更新 UR#96 中文总结文档和 README 入口,记录 API 契约、架构通道、通知复用边界、上线核验及应用回滚保留字段和历史归属的策略。
|
||||||
|
- [ ] 发布前只读核验店铺层级异常和平台账号状态;发布后抽查显式设置、代理继承、不级联、历史不可用账号展示及接收人解析,不运行全量回填或级联脚本。
|
||||||
@@ -225,6 +225,7 @@ default:
|
|||||||
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md))
|
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md))
|
||||||
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md))
|
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md))
|
||||||
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.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)。
|
||||||
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
|
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
|
||||||
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
|
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
|
||||||
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
|
||||||
|
|||||||
@@ -3323,7 +3323,7 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
type: boolean
|
type: boolean
|
||||||
new_identifier:
|
new_identifier:
|
||||||
description: 新资产标识符,direct 流程必填(ICCID/虚拟号/IMEI/SN)
|
description: 新资产输入标识,direct 流程必填;卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
type: string
|
||||||
@@ -3331,7 +3331,7 @@ components:
|
|||||||
description: 旧资产类型 (iot_card:物联网卡, device:设备)
|
description: 旧资产类型 (iot_card:物联网卡, device:设备)
|
||||||
type: string
|
type: string
|
||||||
old_identifier:
|
old_identifier:
|
||||||
description: 旧资产标识符(ICCID/虚拟号/IMEI/SN)
|
description: 旧资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;响应快照使用权威标识
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
type: string
|
||||||
@@ -5175,7 +5175,7 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
type: integer
|
type: integer
|
||||||
new_asset_identifier:
|
new_asset_identifier:
|
||||||
description: 新资产标识符
|
description: 新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值
|
||||||
type: string
|
type: string
|
||||||
new_asset_type:
|
new_asset_type:
|
||||||
description: 新资产类型 (iot_card:物联网卡, device:设备)
|
description: 新资产类型 (iot_card:物联网卡, device:设备)
|
||||||
@@ -5185,7 +5185,7 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
type: integer
|
type: integer
|
||||||
old_asset_identifier:
|
old_asset_identifier:
|
||||||
description: 旧资产标识符
|
description: 旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值
|
||||||
type: string
|
type: string
|
||||||
old_asset_type:
|
old_asset_type:
|
||||||
description: 旧资产类型 (iot_card:物联网卡, device:设备)
|
description: 旧资产类型 (iot_card:物联网卡, device:设备)
|
||||||
@@ -5247,7 +5247,7 @@ components:
|
|||||||
description: 是否执行全量迁移 (true:执行, false:不执行)
|
description: 是否执行全量迁移 (true:执行, false:不执行)
|
||||||
type: boolean
|
type: boolean
|
||||||
new_identifier:
|
new_identifier:
|
||||||
description: 新资产标识符(ICCID/虚拟号/IMEI/SN)
|
description: 新资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
type: string
|
||||||
@@ -16195,6 +16195,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
description: 页码
|
description: 页码
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
nullable: true
|
||||||
type: integer
|
type: integer
|
||||||
- description: 每页数量
|
- description: 每页数量
|
||||||
in: query
|
in: query
|
||||||
@@ -16203,6 +16204,7 @@ paths:
|
|||||||
description: 每页数量
|
description: 每页数量
|
||||||
maximum: 100
|
maximum: 100
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
nullable: true
|
||||||
type: integer
|
type: integer
|
||||||
- description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
|
- description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
|
||||||
in: query
|
in: query
|
||||||
@@ -16222,11 +16224,18 @@ paths:
|
|||||||
- shipping
|
- shipping
|
||||||
- direct
|
- direct
|
||||||
type: string
|
type: string
|
||||||
- description: 资产标识符搜索(旧资产/新资产标识符模糊匹配)
|
- description: 旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与新资产关键词按 AND 组合
|
||||||
in: query
|
in: query
|
||||||
name: identifier
|
name: old_asset_keyword
|
||||||
schema:
|
schema:
|
||||||
description: 资产标识符搜索(旧资产/新资产标识符模糊匹配)
|
description: 旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与新资产关键词按 AND 组合
|
||||||
|
maxLength: 100
|
||||||
|
type: string
|
||||||
|
- description: 新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与旧资产关键词按 AND 组合
|
||||||
|
in: query
|
||||||
|
name: new_asset_keyword
|
||||||
|
schema:
|
||||||
|
description: 新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与旧资产关键词按 AND 组合
|
||||||
maxLength: 100
|
maxLength: 100
|
||||||
type: string
|
type: string
|
||||||
- description: 创建时间起始
|
- description: 创建时间起始
|
||||||
|
|||||||
72
docs/ur45-exchange-asset-search/功能总结.md
Normal file
72
docs/ur45-exchange-asset-search/功能总结.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# UR#45 换货资产快照与新旧资产独立搜索功能总结
|
||||||
|
|
||||||
|
## 本次交付范围
|
||||||
|
|
||||||
|
本次完成两张可独立发布的 Ticket:
|
||||||
|
|
||||||
|
- Ticket 01:统一物流换货创建、直接换货创建和物流发货三个写入入口的资产解析与权威快照。
|
||||||
|
- Ticket 02:将 `GET /api/admin/exchanges` 列表读取收口到独立 Query,提供新旧资产独立搜索、完整校验、权限、分页、错误转换、OpenAPI 和真实 PostgreSQL 验证。
|
||||||
|
|
||||||
|
未迁移换货详情、状态机、完成、取消、资料迁移、旧资产转新等旧用例;未回填历史快照;未实现 UR#86 资产前后代关系或 UR#98 店铺继承。
|
||||||
|
|
||||||
|
## 权威快照规则
|
||||||
|
|
||||||
|
- IoT 卡可通过 ICCID、接入号或虚拟号定位,但新写入的 `old_asset_identifier` 和 `new_asset_identifier` 始终保存数据库中的完整 ICCID。
|
||||||
|
- 设备可通过虚拟号、IMEI 或 SN 定位,快照按“虚拟号 → IMEI → SN”选择首个非空稳定标识。
|
||||||
|
- 物流创建的旧资产、直接换货的新旧资产、物流发货的新资产共享同一规范化能力。
|
||||||
|
- 标识解析继续应用既有店铺数据范围、资产类型、状态和并发校验。不存在、类型不匹配或数据库故障使用统一错误体系,不返回底层错误。
|
||||||
|
- 历史换货单保持原快照,不做自动清洗或回填;列表搜索按资产类型和资产主键命中,因此仍可通过资产当前支持的任一标识找到历史记录。
|
||||||
|
|
||||||
|
## 列表搜索契约
|
||||||
|
|
||||||
|
`GET /api/admin/exchanges` 使用以下可选参数:
|
||||||
|
|
||||||
|
- `old_asset_keyword`:最长 100 字符,只过滤旧资产一侧。
|
||||||
|
- `new_asset_keyword`:最长 100 字符,只过滤新资产一侧。
|
||||||
|
- 两者同时提交时按 AND 组合,并继续与 `status`、`flow_type`、`created_at_start`、`created_at_end` 和分页条件按 AND 组合。
|
||||||
|
- 空值不增加对应过滤;无候选或无换货单命中时返回成功空分页。
|
||||||
|
- 卡候选对 ICCID、接入号和虚拟号做包含匹配;设备候选对虚拟号、IMEI 和 SN 做包含匹配;GORM 默认排除软删除资产和换货单。
|
||||||
|
- 旧通用 `identifier` 已从新 DTO 与 OpenAPI 契约移除。
|
||||||
|
|
||||||
|
列表采用 `Handler → Query → GORM/DTO` 通道。候选资产通过数据库子查询参与最终换货单条件,不逐条读取资产;`total` 和 `items` 复用同一过滤链,按 `created_at DESC` 排序。最终换货单查询继续应用既有店铺范围,候选解析不会扩大平台、超级管理员或代理的可见数据。
|
||||||
|
|
||||||
|
## 校验与错误边界
|
||||||
|
|
||||||
|
Handler 对完整请求 DTO 执行校验。显式 `page=0`、`page_size=0`、超上限分页、非法状态、非法流程类型、超长关键词和非法时间格式统一返回 HTTP 400:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1001,
|
||||||
|
"msg": "参数验证失败",
|
||||||
|
"data": null,
|
||||||
|
"timestamp": "RFC3339 时间"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
候选或换货单查询失败返回脱敏 HTTP 500、`code=2002`、`msg=数据库错误`,不得将故障降级为空结果,也不向客户端暴露 SQL、候选数量或底层错误。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- 真实 PostgreSQL 写用例测试覆盖卡 ICCID、接入号、虚拟号和设备虚拟号、IMEI、SN 输入,并验证物流创建、直接创建和物流发货的持久化快照。
|
||||||
|
- 设备无虚拟号时的 IMEI、SN 回退规则由规范化单元测试覆盖;历史非规范快照保持原值。
|
||||||
|
- Query 集成测试覆盖六类标识、新旧独立、双关键词 AND、状态和时间组合、空结果、历史快照、软删除资产、店铺范围、分页排序与数据库故障。
|
||||||
|
- 代表性数据集在真实 PostgreSQL 中创建 120 条匹配换货单;连续执行 100 次第 2 页、每页 20 条查询,总数与排序保持一致,每次固定执行 2 条 SQL(计数 + 分页),并断言 P95 `<200ms`、P99 `<500ms`;测试同时执行 `EXPLAIN` 记录等价查询计划,证明没有逐行资产反查。
|
||||||
|
- HTTP 测试验证完整 DTO 校验、独立关键词传递、统一分页外层和脱敏数据库错误。
|
||||||
|
- OpenAPI 使用 `go run ./cmd/gendocs` 重新生成并核验新旧关键词、长度限制、AND 说明、权威快照响应语义以及旧 `identifier` 不再属于列表参数。
|
||||||
|
|
||||||
|
## 前端人工验收清单
|
||||||
|
|
||||||
|
- 将原单一资产输入框拆为“旧资产”和“新资产”两个输入框。
|
||||||
|
- 空值不提交;两个非空值同时提交并按 AND 展示结果。
|
||||||
|
- 表格分别展示旧资产类型/标识和新资产类型/标识,不混列。
|
||||||
|
- 前端不解析接入号、虚拟号或 ICCID,不在当前页本地过滤。
|
||||||
|
- 清空、分页、加载、空态和失败反馈沿用现有交互;失败时不得把旧结果伪装为新查询结果。
|
||||||
|
|
||||||
|
当前仓库没有可实施该页面的前端工程,因此前端部分以接口契约和本清单交付,等待同维护窗口的前端仓库实施与浏览器人工验收。
|
||||||
|
|
||||||
|
## 发布与回滚
|
||||||
|
|
||||||
|
- 发布时后端与前端在同一维护窗口切换:前端停止提交列表 `identifier`,改为提交 `old_asset_keyword` / `new_asset_keyword`。
|
||||||
|
- 本次无数据库迁移,不修改历史数据;后端回滚只需回退应用与 OpenAPI 产物。
|
||||||
|
- 若仅回滚前端,旧 `identifier` 不会产生筛选效果,因此不支持前后端跨版本长期混用;应整体回滚到上一版本。
|
||||||
|
- 发布后重点观察列表 HTTP 5xx、查询耗时和空结果比例;异常时按应用版本整体回滚,不执行数据修复。
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
|
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
|
||||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||||
|
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||||
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||||
@@ -109,7 +110,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||||
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
||||||
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
|
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
|
||||||
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ExchangeLister 定义换货列表读取用例。
|
||||||
|
type ExchangeLister interface {
|
||||||
|
List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeHandler 处理后台换货管理接口。
|
||||||
type ExchangeHandler struct {
|
type ExchangeHandler struct {
|
||||||
service *exchangeService.Service
|
service *exchangeService.Service
|
||||||
|
listQuery ExchangeLister
|
||||||
validator *validator.Validate
|
validator *validator.Validate
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
|
// NewExchangeHandler 创建后台换货管理 Handler。
|
||||||
return &ExchangeHandler{service: service, validator: validator}
|
func NewExchangeHandler(service *exchangeService.Service, listQuery ExchangeLister, validator *validator.Validate) *ExchangeHandler {
|
||||||
|
return &ExchangeHandler{service: service, listQuery: listQuery, validator: validator}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create 创建换货单。
|
||||||
|
// POST /api/admin/exchanges
|
||||||
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
|
||||||
var req dto.CreateExchangeRequest
|
var req dto.CreateExchangeRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
@@ -36,22 +49,37 @@ func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, data)
|
return response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 查询换货单列表。
|
||||||
|
// GET /api/admin/exchanges
|
||||||
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
|
||||||
var req dto.ExchangeListRequest
|
var req dto.ExchangeListRequest
|
||||||
if err := c.QueryParser(&req); err != nil {
|
if err := c.QueryParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
h.logListValidationFailure(c, err)
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
}
|
}
|
||||||
if err := h.validator.Struct(&req); err != nil {
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
h.logListValidationFailure(c, err)
|
||||||
return errors.New(errors.CodeInvalidParam)
|
return errors.New(errors.CodeInvalidParam)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.service.List(c.UserContext(), &req)
|
data, err := h.listQuery.List(c.UserContext(), &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return response.Success(c, data)
|
return response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) logListValidationFailure(c *fiber.Ctx, err error) {
|
||||||
|
logger.GetAppLogger().Warn("换货列表参数验证失败",
|
||||||
|
zap.String("method", c.Method()),
|
||||||
|
zap.String("path", c.Path()),
|
||||||
|
zap.String("query", c.Context().QueryArgs().String()),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 查询换货单详情。
|
||||||
|
// GET /api/admin/exchanges/:id
|
||||||
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -65,6 +93,8 @@ func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, data)
|
return response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ship 执行物流换货发货。
|
||||||
|
// POST /api/admin/exchanges/:id/ship
|
||||||
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -86,6 +116,8 @@ func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, data)
|
return response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Complete 确认换货完成。
|
||||||
|
// POST /api/admin/exchanges/:id/complete
|
||||||
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -98,6 +130,8 @@ func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, nil)
|
return response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel 取消换货单。
|
||||||
|
// POST /api/admin/exchanges/:id/cancel
|
||||||
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -118,6 +152,8 @@ func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, nil)
|
return response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renew 将已换出的旧资产转为新资产。
|
||||||
|
// POST /api/admin/exchanges/:id/renew
|
||||||
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
|
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
|
||||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
252
internal/handler/admin/exchange_test.go
Normal file
252
internal/handler/admin/exchange_test.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExchangeListValidatesCompleteRequest 验证列表 Handler 对全部查询字段执行统一校验。
|
||||||
|
func TestExchangeListValidatesCompleteRequest(t *testing.T) {
|
||||||
|
testCases := []string{
|
||||||
|
"page=0", "page_size=0", "page_size=101", "status=0", "status=6", "flow_type=invalid",
|
||||||
|
"old_asset_keyword=" + url.QueryEscape(strings.Repeat("旧", 101)),
|
||||||
|
"new_asset_keyword=" + url.QueryEscape(strings.Repeat("新", 101)),
|
||||||
|
"created_at_start=invalid",
|
||||||
|
}
|
||||||
|
for _, query := range testCases {
|
||||||
|
t.Run(query, func(t *testing.T) {
|
||||||
|
app, _ := newExchangeListTestApp(&exchangeListStub{})
|
||||||
|
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?"+query)
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("期望 HTTP 400,实际 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
assertExchangeErrorResponse(t, body, errors.CodeInvalidParam, "参数验证失败")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse 验证新旧关键词独立传入读取用例。
|
||||||
|
func TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse(t *testing.T) {
|
||||||
|
stub := &exchangeListStub{response: &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Total: 0, Page: 2, PageSize: 7}}
|
||||||
|
app, _ := newExchangeListTestApp(stub)
|
||||||
|
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?page=2&page_size=7&old_asset_keyword=old&new_asset_keyword=new")
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("期望 HTTP 200,实际 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
if stub.request == nil || stub.request.OldAssetKeyword != "old" || stub.request.NewAssetKeyword != "new" {
|
||||||
|
t.Fatalf("读取用例未收到独立关键词:%+v", stub.request)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Items []any `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if response.Code != errors.CodeSuccess || response.Data.Page != 2 || response.Data.Size != 7 || response.Data.Items == nil {
|
||||||
|
t.Fatalf("统一分页响应不符合契约:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeListSanitizesDatabaseErrors 验证数据库故障返回脱敏统一 500。
|
||||||
|
func TestExchangeListSanitizesDatabaseErrors(t *testing.T) {
|
||||||
|
stub := &exchangeListStub{err: errors.Wrap(errors.CodeDatabaseError, context.Canceled, "查询换货单数量失败")}
|
||||||
|
app, _ := newExchangeListTestApp(stub)
|
||||||
|
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?old_asset_keyword=UR45")
|
||||||
|
if status != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("期望 HTTP 500,实际 %d,响应:%s", status, body)
|
||||||
|
}
|
||||||
|
assertExchangeErrorResponse(t, body, errors.CodeDatabaseError, "数据库错误")
|
||||||
|
if strings.Contains(string(body), "context canceled") || strings.Contains(string(body), "查询换货单数量失败") {
|
||||||
|
t.Fatalf("数据库错误响应泄露内部细节:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeListHTTPIntegratesSearchAndAccountScopes 验证真实 Query 的 HTTP 搜索组合和三类账号范围。
|
||||||
|
func TestExchangeListHTTPIntegratesSearchAndAccountScopes(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
shopOne, shopTwo := uint(45201), uint(45202)
|
||||||
|
oldCard := createExchangeHTTPCard(t, tx, 1, shopOne)
|
||||||
|
newCard := createExchangeHTTPCard(t, tx, 2, shopOne)
|
||||||
|
otherCard := createExchangeHTTPCard(t, tx, 3, shopTwo)
|
||||||
|
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
|
||||||
|
visible := createExchangeHTTPOrder(t, tx, "UR45-HTTP-VISIBLE", oldCard, newCard, shopOne, createdAt)
|
||||||
|
createExchangeHTTPOrder(t, tx, "UR45-HTTP-HIDDEN", otherCard, otherCard, shopTwo, createdAt.Add(time.Minute))
|
||||||
|
|
||||||
|
handler := NewExchangeHandler(nil, exchangeQuery.NewListQuery(tx), validator.New())
|
||||||
|
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||||
|
app.Get("/api/admin/exchanges", func(c *fiber.Ctx) error {
|
||||||
|
ctx := c.UserContext()
|
||||||
|
if c.Get("X-UR45-Account") == "agent" {
|
||||||
|
ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{shopOne})
|
||||||
|
}
|
||||||
|
c.SetUserContext(ctx)
|
||||||
|
return handler.List(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
start, end := createdAt.Add(-time.Minute).Format(time.RFC3339), createdAt.Add(time.Minute).Format(time.RFC3339)
|
||||||
|
query := "old_asset_keyword=" + url.QueryEscape(oldCard.MSISDN) + "&new_asset_keyword=" + url.QueryEscape(newCard.VirtualNo) + "&status=4&flow_type=direct&created_at_start=" + url.QueryEscape(start) + "&created_at_end=" + url.QueryEscape(end)
|
||||||
|
for _, account := range []string{"super_admin", "platform", "agent"} {
|
||||||
|
t.Run(account, func(t *testing.T) {
|
||||||
|
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?"+query, account)
|
||||||
|
page := decodeExchangeHTTPPage(t, body)
|
||||||
|
if status != http.StatusOK || page.Total != 1 || len(page.Items) != 1 || uint(page.Items[0]["id"].(float64)) != visible.ID {
|
||||||
|
t.Fatalf("账号范围或组合搜索错误:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(oldCard.MSISDN), "platform")
|
||||||
|
page := decodeExchangeHTTPPage(t, body)
|
||||||
|
if status != http.StatusOK || page.Total != 1 || page.Items[0]["old_asset_identifier"] != "历史旧快照" {
|
||||||
|
t.Fatalf("仅旧关键词或历史旧快照响应错误:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword="+url.QueryEscape(newCard.VirtualNo), "platform")
|
||||||
|
page = decodeExchangeHTTPPage(t, body)
|
||||||
|
if status != http.StatusOK || page.Total != 1 || page.Items[0]["new_asset_identifier"] != "历史新快照" {
|
||||||
|
t.Fatalf("仅新关键词或历史新快照响应错误:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges", "platform")
|
||||||
|
page = decodeExchangeHTTPPage(t, body)
|
||||||
|
if status != http.StatusOK || !exchangeHTTPPageContains(page, "UR45-HTTP-VISIBLE") || !exchangeHTTPPageContains(page, "UR45-HTTP-HIDDEN") {
|
||||||
|
t.Fatalf("空参数列表响应错误:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(otherCard.ICCID), "agent")
|
||||||
|
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
|
||||||
|
t.Fatalf("代理关键词绕过店铺范围:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword=不存在", "platform")
|
||||||
|
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
|
||||||
|
t.Fatalf("无匹配应返回成功空分页:status=%d body=%s", status, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type exchangeListStub struct {
|
||||||
|
request *dto.ExchangeListRequest
|
||||||
|
response *dto.ExchangeListResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *exchangeListStub) List(_ context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
||||||
|
s.request = req
|
||||||
|
if s.response == nil && s.err == nil {
|
||||||
|
s.response = &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Page: constants.DefaultPage, PageSize: constants.DefaultPageSize}
|
||||||
|
}
|
||||||
|
return s.response, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExchangeListTestApp(stub ExchangeLister) (*fiber.App, *ExchangeHandler) {
|
||||||
|
handler := NewExchangeHandler(nil, stub, validator.New())
|
||||||
|
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||||
|
app.Get("/api/admin/exchanges", handler.List)
|
||||||
|
return app, handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeListRequest(t *testing.T, app *fiber.App, path string) (int, []byte) {
|
||||||
|
return exchangeListRequestWithAccount(t, app, path, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeListRequestWithAccount(t *testing.T, app *fiber.App, path, account string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
request, err := http.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建请求失败:%v", err)
|
||||||
|
}
|
||||||
|
if account != "" {
|
||||||
|
request.Header.Set("X-UR45-Account", account)
|
||||||
|
}
|
||||||
|
response, err := app.Test(request, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("执行请求失败:%v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取响应失败:%v", err)
|
||||||
|
}
|
||||||
|
return response.StatusCode, body
|
||||||
|
}
|
||||||
|
|
||||||
|
type exchangeHTTPPage struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeExchangeHTTPPage(t *testing.T, body []byte) exchangeHTTPPage {
|
||||||
|
t.Helper()
|
||||||
|
var response struct {
|
||||||
|
Data exchangeHTTPPage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析分页响应失败:%v", err)
|
||||||
|
}
|
||||||
|
return response.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeHTTPPageContains(page exchangeHTTPPage, exchangeNo string) bool {
|
||||||
|
for _, item := range page.Items {
|
||||||
|
if item["exchange_no"] == exchangeNo {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExchangeHTTPCard(t *testing.T, tx *gorm.DB, suffix int, shopID uint) *model.IotCard {
|
||||||
|
t.Helper()
|
||||||
|
iccid := "8986222222222222000" + strconv.Itoa(suffix)
|
||||||
|
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: "1360000000" + strconv.Itoa(suffix), VirtualNo: "UR45-HTTP-CARD-" + strconv.Itoa(suffix), ShopID: &shopID, AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if err := tx.Create(card).Error; err != nil {
|
||||||
|
t.Fatalf("创建 HTTP 测试卡失败:%v", err)
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExchangeHTTPOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldCard, newCard *model.IotCard, shopID uint, createdAt time.Time) *model.ExchangeOrder {
|
||||||
|
t.Helper()
|
||||||
|
newID := newCard.ID
|
||||||
|
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "历史旧快照", NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 HTTP 测试", Status: constants.ExchangeStatusCompleted, ShopID: &shopID}
|
||||||
|
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
|
||||||
|
if err := tx.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("创建 HTTP 测试换货单失败:%v", err)
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExchangeErrorResponse(t *testing.T, body []byte, expectedCode int, expectedMessage string) {
|
||||||
|
t.Helper()
|
||||||
|
var response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析错误响应失败:%v", err)
|
||||||
|
}
|
||||||
|
if response.Code != expectedCode || response.Msg != expectedMessage || response.Data != nil || !strings.Contains(string(body), "timestamp") {
|
||||||
|
t.Fatalf("错误响应不符合契约:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,30 +2,34 @@ package dto
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// CreateExchangeRequest 创建换货单请求。
|
||||||
type CreateExchangeRequest struct {
|
type CreateExchangeRequest struct {
|
||||||
OldAssetType string `json:"old_asset_type" validate:"required,oneof=iot_card device" required:"true" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
OldAssetType string `json:"old_asset_type" validate:"required,oneof=iot_card device" required:"true" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产标识符(ICCID/虚拟号/IMEI/SN)"`
|
OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;响应快照使用权威标识"`
|
||||||
FlowType string `json:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
FlowType string `json:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
||||||
NewIdentifier string `json:"new_identifier" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"新资产标识符,direct 流程必填(ICCID/虚拟号/IMEI/SN)"`
|
NewIdentifier string `json:"new_identifier" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"新资产输入标识,direct 流程必填;卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN"`
|
||||||
MigrateData *bool `json:"migrate_data" description:"是否执行全量迁移,direct 流程未传按 false 处理"`
|
MigrateData *bool `json:"migrate_data" description:"是否执行全量迁移,direct 流程未传按 false 处理"`
|
||||||
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"`
|
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"`
|
||||||
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExchangeListRequest 换货单列表请求。
|
||||||
type ExchangeListRequest struct {
|
type ExchangeListRequest struct {
|
||||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
Page *int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
PageSize *int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
|
||||||
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
|
||||||
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符搜索(旧资产/新资产标识符模糊匹配)"`
|
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与新资产关键词按 AND 组合"`
|
||||||
|
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN;与旧资产关键词按 AND 组合"`
|
||||||
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
||||||
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExchangeShipRequest 换货发货请求。
|
||||||
type ExchangeShipRequest struct {
|
type ExchangeShipRequest struct {
|
||||||
ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"`
|
ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"`
|
||||||
ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"`
|
ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"`
|
||||||
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产标识符(ICCID/虚拟号/IMEI/SN)"`
|
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产输入标识,卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识"`
|
||||||
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
|
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ type ClientShippingInfoParams struct {
|
|||||||
ClientShippingInfoRequest
|
ClientShippingInfoRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExchangeOrderResponse 换货单响应。
|
||||||
type ExchangeOrderResponse struct {
|
type ExchangeOrderResponse struct {
|
||||||
ID uint `json:"id" description:"换货单ID"`
|
ID uint `json:"id" description:"换货单ID"`
|
||||||
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
ExchangeNo string `json:"exchange_no" description:"换货单号"`
|
||||||
@@ -69,10 +74,10 @@ type ExchangeOrderResponse struct {
|
|||||||
FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"`
|
FlowTypeName string `json:"flow_type_name" description:"换货流程类型名称"`
|
||||||
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
|
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
|
||||||
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产标识符"`
|
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||||
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
|
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
|
||||||
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
|
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
|
||||||
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产标识符"`
|
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
|
||||||
RecipientName string `json:"recipient_name" description:"收件人姓名"`
|
RecipientName string `json:"recipient_name" description:"收件人姓名"`
|
||||||
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
|
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
|
||||||
RecipientAddress string `json:"recipient_address" description:"收货地址"`
|
RecipientAddress string `json:"recipient_address" description:"收货地址"`
|
||||||
|
|||||||
133
internal/query/exchange/list.go
Normal file
133
internal/query/exchange/list.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package exchange 提供换货读取用例。
|
||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListQuery 查询换货列表并完成权限、分页和响应投影。
|
||||||
|
type ListQuery struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type assetSide string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// oldAssetSide 表示旧资产查询侧。
|
||||||
|
oldAssetSide assetSide = "old"
|
||||||
|
// newAssetSide 表示新资产查询侧。
|
||||||
|
newAssetSide assetSide = "new"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewListQuery 创建换货列表查询。
|
||||||
|
func NewListQuery(db *gorm.DB) *ListQuery {
|
||||||
|
return &ListQuery{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 按新旧资产关键词和其他列表条件查询换货单。
|
||||||
|
func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
||||||
|
page := constants.DefaultPage
|
||||||
|
if req.Page != nil {
|
||||||
|
page = *req.Page
|
||||||
|
}
|
||||||
|
pageSize := constants.DefaultPageSize
|
||||||
|
if req.PageSize != nil {
|
||||||
|
pageSize = *req.PageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
query := q.db.WithContext(ctx).Model(&model.ExchangeOrder{})
|
||||||
|
query = middleware.ApplyShopFilter(ctx, query)
|
||||||
|
query = applyListFilters(query, req)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单数量失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var orders []*model.ExchangeOrder
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*dto.ExchangeOrderResponse, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
items = append(items, projectExchangeOrder(order))
|
||||||
|
}
|
||||||
|
return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyListFilters 组装列表计数和数据查询共用的全部过滤条件。
|
||||||
|
func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest) *gorm.DB {
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
if req.FlowType != "" {
|
||||||
|
query = query.Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, req.FlowType)
|
||||||
|
}
|
||||||
|
query = applyAssetKeyword(query, oldAssetSide, req.OldAssetKeyword)
|
||||||
|
query = applyAssetKeyword(query, newAssetSide, req.NewAssetKeyword)
|
||||||
|
if req.CreatedAtStart != nil {
|
||||||
|
query = query.Where("created_at >= ?", *req.CreatedAtStart)
|
||||||
|
}
|
||||||
|
if req.CreatedAtEnd != nil {
|
||||||
|
query = query.Where("created_at <= ?", *req.CreatedAtEnd)
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyAssetKeyword 使用子查询按资产类型和主键命中,避免依赖历史快照内容或逐行读取资产。
|
||||||
|
func applyAssetKeyword(query *gorm.DB, side assetSide, keyword string) *gorm.DB {
|
||||||
|
if keyword == "" {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
cardIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.IotCard{}).
|
||||||
|
Select("id").
|
||||||
|
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
|
||||||
|
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.Device{}).
|
||||||
|
Select("id").
|
||||||
|
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
|
||||||
|
prefix := string(side)
|
||||||
|
return query.Where(
|
||||||
|
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
|
||||||
|
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectExchangeOrder 将只读模型投影为列表响应,不用于后续写侧判断。
|
||||||
|
func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
|
||||||
|
var deletedAt *time.Time
|
||||||
|
if order.DeletedAt.Valid {
|
||||||
|
deletedAt = &order.DeletedAt.Time
|
||||||
|
}
|
||||||
|
return &dto.ExchangeOrderResponse{
|
||||||
|
ID: order.ID, ExchangeNo: order.ExchangeNo,
|
||||||
|
FlowType: effectiveFlowType(order.FlowType), FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
|
||||||
|
OldAssetType: order.OldAssetType, OldAssetID: order.OldAssetID, OldAssetIdentifier: order.OldAssetIdentifier,
|
||||||
|
NewAssetType: order.NewAssetType, NewAssetID: order.NewAssetID, NewAssetIdentifier: order.NewAssetIdentifier,
|
||||||
|
RecipientName: order.RecipientName, RecipientPhone: order.RecipientPhone, RecipientAddress: order.RecipientAddress,
|
||||||
|
ExpressCompany: order.ExpressCompany, ExpressNo: order.ExpressNo,
|
||||||
|
MigrateData: order.MigrateData, MigrationCompleted: order.MigrationCompleted, MigrationBalance: order.MigrationBalance,
|
||||||
|
ShippedAt: order.ShippedAt, CompletedAt: order.CompletedAt,
|
||||||
|
ExchangeReason: order.ExchangeReason, Remark: order.Remark,
|
||||||
|
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status),
|
||||||
|
ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt,
|
||||||
|
Creator: order.Creator, Updater: order.Updater,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveFlowType(flowType string) string {
|
||||||
|
if flowType == "" {
|
||||||
|
return constants.ExchangeFlowTypeShipping
|
||||||
|
}
|
||||||
|
return flowType
|
||||||
|
}
|
||||||
219
internal/query/exchange/list_test.go
Normal file
219
internal/query/exchange/list_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"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"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListQuerySearchesOldAndNewAssetsIndependently 验证六类资产标识、新旧独立和双条件 AND。
|
||||||
|
func TestListQuerySearchesOldAndNewAssetsIndependently(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
query := NewListQuery(tx)
|
||||||
|
oldCard := createListTestCard(t, tx, 1, nil)
|
||||||
|
newCard := createListTestCard(t, tx, 2, nil)
|
||||||
|
oldDevice := createListTestDevice(t, tx, 1, nil)
|
||||||
|
newDevice := createListTestDevice(t, tx, 2, nil)
|
||||||
|
|
||||||
|
cardOrder := createListTestOrder(t, tx, "UR45-Q-CARD", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, time.Now().Add(-time.Hour))
|
||||||
|
deviceOrder := createListTestOrder(t, tx, "UR45-Q-DEVICE", oldDevice.ID, constants.ExchangeAssetTypeDevice, newDevice.ID, constants.ExchangeAssetTypeDevice, time.Now())
|
||||||
|
|
||||||
|
for name, keyword := range map[string]string{"卡ICCID": oldCard.ICCID, "卡接入号": oldCard.MSISDN, "卡虚拟号": oldCard.VirtualNo} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: keyword}, []uint{cardOrder.ID})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for name, keyword := range map[string]string{"设备虚拟号": newDevice.VirtualNo, "设备IMEI": newDevice.IMEI, "设备SN": newDevice.SN} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assertListQueryIDs(t, query, &dto.ExchangeListRequest{NewAssetKeyword: keyword}, []uint{deviceOrder.ID})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.MSISDN, NewAssetKeyword: newCard.VirtualNo}, []uint{cardOrder.ID})
|
||||||
|
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.ICCID, NewAssetKeyword: newDevice.IMEI}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots 验证组合条件、历史快照和空结果契约。
|
||||||
|
func TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
query := NewListQuery(tx)
|
||||||
|
oldCard := createListTestCard(t, tx, 11, nil)
|
||||||
|
newCard := createListTestCard(t, tx, 12, nil)
|
||||||
|
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
||||||
|
order := createListTestOrder(t, tx, "UR45-Q-HISTORY", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, createdAt)
|
||||||
|
if err := tx.Model(order).Updates(map[string]any{
|
||||||
|
"old_asset_identifier": "历史旧快照", "new_asset_identifier": "历史新快照",
|
||||||
|
"status": constants.ExchangeStatusCompleted, "flow_type": constants.ExchangeFlowTypeDirect,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("更新历史快照失败:%v", err)
|
||||||
|
}
|
||||||
|
status := constants.ExchangeStatusCompleted
|
||||||
|
start, end := createdAt.Add(-time.Minute), createdAt.Add(time.Minute)
|
||||||
|
result, err := query.List(context.Background(), &dto.ExchangeListRequest{
|
||||||
|
OldAssetKeyword: oldCard.VirtualNo, NewAssetKeyword: newCard.MSISDN,
|
||||||
|
Status: &status, FlowType: constants.ExchangeFlowTypeDirect, CreatedAtStart: &start, CreatedAtEnd: &end,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询历史换货单失败:%v", err)
|
||||||
|
}
|
||||||
|
if result.Total != 1 || len(result.List) != 1 || result.List[0].OldAssetIdentifier != "历史旧快照" || result.List[0].NewAssetIdentifier != "历史新快照" {
|
||||||
|
t.Fatalf("历史快照查询结果错误:%+v", result)
|
||||||
|
}
|
||||||
|
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: "不存在"}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListQueryExcludesDeletedAssetsAndAppliesShopScope 验证候选软删除和最终换货单店铺范围。
|
||||||
|
func TestListQueryExcludesDeletedAssetsAndAppliesShopScope(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
query := NewListQuery(tx)
|
||||||
|
shopOne, shopTwo := uint(45101), uint(45102)
|
||||||
|
cardOne := createListTestCard(t, tx, 21, &shopOne)
|
||||||
|
cardTwo := createListTestCard(t, tx, 22, &shopTwo)
|
||||||
|
orderOne := createListTestOrder(t, tx, "UR45-Q-SCOPE-1", cardOne.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now())
|
||||||
|
orderTwo := createListTestOrder(t, tx, "UR45-Q-SCOPE-2", cardTwo.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Second))
|
||||||
|
if err := tx.Model(orderOne).Update("shop_id", shopOne).Error; err != nil {
|
||||||
|
t.Fatalf("更新店铺范围失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(orderTwo).Update("shop_id", shopTwo).Error; err != nil {
|
||||||
|
t.Fatalf("更新店铺范围失败:%v", err)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{shopOne})
|
||||||
|
result, err := query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: "UR45-Q-CARD"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("按店铺范围查询失败:%v", err)
|
||||||
|
}
|
||||||
|
if result.Total != 1 || result.List[0].ID != orderOne.ID {
|
||||||
|
t.Fatalf("店铺范围被关键词绕过:%+v", result)
|
||||||
|
}
|
||||||
|
if err := tx.Delete(cardOne).Error; err != nil {
|
||||||
|
t.Fatalf("软删除测试卡失败:%v", err)
|
||||||
|
}
|
||||||
|
result, err = query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: cardOne.ICCID})
|
||||||
|
if err != nil || result.Total != 0 {
|
||||||
|
t.Fatalf("软删除候选资产不应命中:result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListQueryUsesFixedQueriesAndConsistentPagination 验证大结果集仅执行计数和分页两条 SQL。
|
||||||
|
func TestListQueryUsesFixedQueriesAndConsistentPagination(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
card := createListTestCard(t, tx, 31, nil)
|
||||||
|
for index := 0; index < 120; index++ {
|
||||||
|
createListTestOrder(t, tx, fmt.Sprintf("UR45-Q-PERF-%03d", index), card.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Duration(index)*time.Second))
|
||||||
|
}
|
||||||
|
counter := &queryCounter{Interface: tx.Logger}
|
||||||
|
query := NewListQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||||
|
page, pageSize := 2, 20
|
||||||
|
req := &dto.ExchangeListRequest{OldAssetKeyword: card.ICCID, Page: &page, PageSize: &pageSize}
|
||||||
|
durations := make([]time.Duration, 100)
|
||||||
|
var result *dto.ExchangeListResponse
|
||||||
|
for index := range durations {
|
||||||
|
startedAt := time.Now()
|
||||||
|
var err error
|
||||||
|
result, err = query.List(context.Background(), req)
|
||||||
|
durations[index] = time.Since(startedAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("第 %d 次查询大结果集失败:%v", index+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if counter.count.Load() != 200 || result.Total != 120 || len(result.List) != 20 || result.List[0].ExchangeNo != "UR45-Q-PERF-099" {
|
||||||
|
t.Fatalf("查询次数或分页不一致:queries=%d total=%d items=%d first=%s", counter.count.Load(), result.Total, len(result.List), result.List[0].ExchangeNo)
|
||||||
|
}
|
||||||
|
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
|
||||||
|
p95, p99 := durations[94], durations[98]
|
||||||
|
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
|
||||||
|
t.Fatalf("列表查询超过性能目标:p95=%s p99=%s", p95, p99)
|
||||||
|
}
|
||||||
|
planSQL := query.db.ToSQL(func(db *gorm.DB) *gorm.DB {
|
||||||
|
return applyListFilters(db.Model(&model.ExchangeOrder{}), req).Order("created_at DESC").Offset(20).Limit(20).Find(&[]*model.ExchangeOrder{})
|
||||||
|
})
|
||||||
|
var planLines []string
|
||||||
|
if err := tx.Raw("EXPLAIN " + planSQL).Scan(&planLines).Error; err != nil || len(planLines) == 0 {
|
||||||
|
t.Fatalf("记录查询计划失败:plan=%v err=%v", planLines, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListQueryWrapsDatabaseErrors 验证数据库故障不会降级为空结果。
|
||||||
|
func TestListQueryWrapsDatabaseErrors(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
callbackName := "ur45:force_query_error"
|
||||||
|
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
|
||||||
|
db.AddError(fmt.Errorf("UR45 模拟数据库故障"))
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("注册数据库故障回调失败:%v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
|
||||||
|
_, err := NewListQuery(tx).List(context.Background(), &dto.ExchangeListRequest{})
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
if !ok || appErr.Code != errors.CodeDatabaseError {
|
||||||
|
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type queryCounter struct {
|
||||||
|
logger.Interface
|
||||||
|
count atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *queryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
l.count.Add(1)
|
||||||
|
l.Interface.Trace(ctx, begin, fc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createListTestCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
|
||||||
|
t.Helper()
|
||||||
|
iccid := fmt.Sprintf("8986111111111111%04d", suffix)
|
||||||
|
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1370000%04d", suffix), VirtualNo: fmt.Sprintf("UR45-Q-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if err := tx.Create(card).Error; err != nil {
|
||||||
|
t.Fatalf("创建测试卡失败:%v", err)
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func createListTestDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
|
||||||
|
t.Helper()
|
||||||
|
device := &model.Device{VirtualNo: fmt.Sprintf("UR45-Q-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86111111111%04d", suffix), SN: fmt.Sprintf("UR45-Q-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if err := tx.Create(device).Error; err != nil {
|
||||||
|
t.Fatalf("创建设备失败:%v", err)
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
func createListTestOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldID uint, oldType string, newID uint, newType string, createdAt time.Time) *model.ExchangeOrder {
|
||||||
|
t.Helper()
|
||||||
|
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: "历史旧快照", NewAssetType: newType, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 查询测试", Status: constants.ExchangeStatusPendingInfo}
|
||||||
|
if newID > 0 {
|
||||||
|
order.NewAssetID = &newID
|
||||||
|
}
|
||||||
|
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
|
||||||
|
if err := tx.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("创建换货单失败:%v", err)
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertListQueryIDs(t *testing.T, query *ListQuery, req *dto.ExchangeListRequest, expected []uint) {
|
||||||
|
t.Helper()
|
||||||
|
result, err := query.List(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查询换货单失败:%v", err)
|
||||||
|
}
|
||||||
|
if result.Total != int64(len(expected)) || len(result.List) != len(expected) {
|
||||||
|
t.Fatalf("命中数量错误:total=%d items=%d expected=%d", result.Total, len(result.List), len(expected))
|
||||||
|
}
|
||||||
|
for index, id := range expected {
|
||||||
|
if result.List[index].ID != id {
|
||||||
|
t.Fatalf("命中换货单错误:期望 %d,实际 %d", id, result.List[index].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
internal/service/exchange/asset_snapshot_test.go
Normal file
243
internal/service/exchange/asset_snapshot_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package exchange
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResolveAssetByIdentifierUsesAuthoritativeSnapshot 验证任意受支持标识都生成权威换货快照。
|
||||||
|
func TestResolveAssetByIdentifierUsesAuthoritativeSnapshot(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
service := &Service{
|
||||||
|
iotCardStore: postgres.NewIotCardStore(tx, nil),
|
||||||
|
deviceStore: postgres.NewDeviceStore(tx, nil),
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
card := &model.IotCard{ICCID: "89860012345678901234", ICCID19: "8986001234567890123", MSISDN: "13800138000", VirtualNo: "UR45-CARD", AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if err := tx.Create(card).Error; err != nil {
|
||||||
|
t.Fatalf("创建测试卡失败:%v", err)
|
||||||
|
}
|
||||||
|
for _, identifier := range []string{card.ICCID, card.MSISDN, card.VirtualNo} {
|
||||||
|
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeIotCard, identifier)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("通过 %s 解析测试卡失败:%v", identifier, err)
|
||||||
|
}
|
||||||
|
if asset.Identifier != card.ICCID {
|
||||||
|
t.Fatalf("卡快照应为 ICCID,输入 %s,实际 %s", identifier, asset.Identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
device := &model.Device{VirtualNo: "UR45-DEVICE", IMEI: "860000000000001", SN: "UR45-SN-1", AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if err := tx.Create(device).Error; err != nil {
|
||||||
|
t.Fatalf("创建设备失败:%v", err)
|
||||||
|
}
|
||||||
|
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeDevice, device.IMEI)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析设备失败:%v", err)
|
||||||
|
}
|
||||||
|
if asset.Identifier != device.VirtualNo {
|
||||||
|
t.Fatalf("设备应优先保存虚拟号,实际 %s", asset.Identifier)
|
||||||
|
}
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
device *model.Device
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{device: &model.Device{IMEI: "860000000000002", SN: "UR45-SN-2"}, expected: "860000000000002"},
|
||||||
|
{device: &model.Device{SN: "UR45-SN-3"}, expected: "UR45-SN-3"},
|
||||||
|
} {
|
||||||
|
if actual := newResolvedDeviceAsset(testCase.device).Identifier; actual != testCase.expected {
|
||||||
|
t.Fatalf("设备快照优先级错误,期望 %s,实际 %s", testCase.expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots 验证三个写入入口持久化卡的权威 ICCID。
|
||||||
|
func TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
identifier func(*model.IotCard) string
|
||||||
|
}{
|
||||||
|
{name: "ICCID", identifier: func(card *model.IotCard) string { return card.ICCID }},
|
||||||
|
{name: "接入号", identifier: func(card *model.IotCard) string { return card.MSISDN }},
|
||||||
|
{name: "虚拟号", identifier: func(card *model.IotCard) string { return card.VirtualNo }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
service := newExchangeSnapshotTestService(tx)
|
||||||
|
oldCard := createExchangeSnapshotCard(t, tx, index*10+1)
|
||||||
|
newCard := createExchangeSnapshotCard(t, tx, index*10+2)
|
||||||
|
|
||||||
|
direct, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||||
|
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||||
|
OldIdentifier: testCase.identifier(oldCard),
|
||||||
|
FlowType: constants.ExchangeFlowTypeDirect,
|
||||||
|
NewIdentifier: testCase.identifier(newCard),
|
||||||
|
ExchangeReason: "UR45 卡快照测试",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建直接换货失败:%v", err)
|
||||||
|
}
|
||||||
|
if direct.OldAssetIdentifier != oldCard.ICCID || direct.NewAssetIdentifier != newCard.ICCID {
|
||||||
|
t.Fatalf("直接换货卡快照错误:old=%s new=%s", direct.OldAssetIdentifier, direct.NewAssetIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
shippingOld := createExchangeSnapshotCard(t, tx, index*10+3)
|
||||||
|
shippingNew := createExchangeSnapshotCard(t, tx, index*10+4)
|
||||||
|
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||||
|
OldAssetType: constants.ExchangeAssetTypeIotCard,
|
||||||
|
OldIdentifier: testCase.identifier(shippingOld),
|
||||||
|
FlowType: constants.ExchangeFlowTypeShipping,
|
||||||
|
ExchangeReason: "UR45 物流换货快照测试",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建物流换货失败:%v", err)
|
||||||
|
}
|
||||||
|
if shipping.OldAssetIdentifier != shippingOld.ICCID {
|
||||||
|
t.Fatalf("物流换货旧卡快照应为 ICCID,实际 %s", shipping.OldAssetIdentifier)
|
||||||
|
}
|
||||||
|
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Updates(map[string]any{
|
||||||
|
"status": constants.ExchangeStatusPendingShip, "recipient_name": "测试用户",
|
||||||
|
"recipient_phone": "13800138000", "recipient_address": "测试地址",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("建立待发货测试前置状态失败:%v", err)
|
||||||
|
}
|
||||||
|
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||||
|
ExpressCompany: "测试快递", ExpressNo: "UR45-EXPRESS", NewIdentifier: testCase.identifier(shippingNew),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("物流换货发货失败:%v", err)
|
||||||
|
}
|
||||||
|
if shipped.NewAssetIdentifier != shippingNew.ICCID {
|
||||||
|
t.Fatalf("物流换货新卡快照应为 ICCID,实际 %s", shipped.NewAssetIdentifier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots 验证设备输入标识不影响稳定快照优先级。
|
||||||
|
func TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
identifier func(*model.Device) string
|
||||||
|
}{
|
||||||
|
{name: "虚拟号", identifier: func(device *model.Device) string { return device.VirtualNo }},
|
||||||
|
{name: "IMEI", identifier: func(device *model.Device) string { return device.IMEI }},
|
||||||
|
{name: "SN", identifier: func(device *model.Device) string { return device.SN }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
service := newExchangeSnapshotTestService(tx)
|
||||||
|
oldDevice := createExchangeSnapshotDevice(t, tx, index*10+1, true, true)
|
||||||
|
newDevice := createExchangeSnapshotDevice(t, tx, index*10+2, true, true)
|
||||||
|
order, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||||
|
OldAssetType: constants.ExchangeAssetTypeDevice,
|
||||||
|
OldIdentifier: testCase.identifier(oldDevice),
|
||||||
|
FlowType: constants.ExchangeFlowTypeDirect,
|
||||||
|
NewIdentifier: testCase.identifier(newDevice),
|
||||||
|
ExchangeReason: "UR45 设备快照测试",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建设备直接换货失败:%v", err)
|
||||||
|
}
|
||||||
|
if order.OldAssetIdentifier != oldDevice.VirtualNo || order.NewAssetIdentifier != newDevice.VirtualNo {
|
||||||
|
t.Fatalf("设备快照应优先使用虚拟号:old=%s new=%s", order.OldAssetIdentifier, order.NewAssetIdentifier)
|
||||||
|
}
|
||||||
|
persisted, err := service.Get(context.Background(), order.ID)
|
||||||
|
if err != nil || persisted.OldAssetIdentifier != oldDevice.VirtualNo || persisted.NewAssetIdentifier != newDevice.VirtualNo {
|
||||||
|
t.Fatalf("详情未读回设备权威快照:order=%+v err=%v", persisted, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, testCase := range testCases {
|
||||||
|
t.Run("物流"+testCase.name, func(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
service := newExchangeSnapshotTestService(tx)
|
||||||
|
oldDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+1, true, true)
|
||||||
|
newDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+2, true, true)
|
||||||
|
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
|
||||||
|
OldAssetType: constants.ExchangeAssetTypeDevice, OldIdentifier: testCase.identifier(oldDevice),
|
||||||
|
FlowType: constants.ExchangeFlowTypeShipping, ExchangeReason: "UR45 设备物流快照测试",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("创建设备物流换货失败:%v", err)
|
||||||
|
}
|
||||||
|
if shipping.OldAssetIdentifier != oldDevice.VirtualNo {
|
||||||
|
t.Fatalf("物流创建设备旧快照应使用虚拟号,实际 %s", shipping.OldAssetIdentifier)
|
||||||
|
}
|
||||||
|
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Update("status", constants.ExchangeStatusPendingShip).Error; err != nil {
|
||||||
|
t.Fatalf("建立待发货状态失败:%v", err)
|
||||||
|
}
|
||||||
|
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
|
||||||
|
ExpressCompany: "测试快递", ExpressNo: "UR45-DEVICE-EXPRESS", NewIdentifier: testCase.identifier(newDevice),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("设备物流换货发货失败:%v", err)
|
||||||
|
}
|
||||||
|
if shipped.OldAssetIdentifier != oldDevice.VirtualNo || shipped.NewAssetIdentifier != newDevice.VirtualNo {
|
||||||
|
t.Fatalf("设备物流快照应使用虚拟号:old=%s new=%s", shipped.OldAssetIdentifier, shipped.NewAssetIdentifier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExchangeSnapshotTestService(tx *gorm.DB) *Service {
|
||||||
|
iotCardStore := postgres.NewIotCardStore(tx, nil)
|
||||||
|
deviceStore := postgres.NewDeviceStore(tx, nil)
|
||||||
|
return New(
|
||||||
|
tx,
|
||||||
|
postgres.NewExchangeOrderStore(tx),
|
||||||
|
iotCardStore,
|
||||||
|
deviceStore,
|
||||||
|
postgres.NewAssetWalletStore(tx, nil),
|
||||||
|
postgres.NewAssetWalletTransactionStore(tx, nil),
|
||||||
|
postgres.NewPackageUsageStore(tx, nil),
|
||||||
|
postgres.NewPackageUsageDailyRecordStore(tx, nil),
|
||||||
|
postgres.NewResourceTagStore(tx),
|
||||||
|
customerBindingSvc.New(tx, iotCardStore, deviceStore),
|
||||||
|
zap.NewNop(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExchangeSnapshotCard(t *testing.T, tx *gorm.DB, suffix int) *model.IotCard {
|
||||||
|
t.Helper()
|
||||||
|
iccid := fmt.Sprintf("8986000000000000%04d", suffix)
|
||||||
|
card := &model.IotCard{
|
||||||
|
ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1390000%04d", suffix),
|
||||||
|
VirtualNo: fmt.Sprintf("UR45-CARD-%04d", suffix), AssetStatus: constants.AssetStatusInStock,
|
||||||
|
}
|
||||||
|
if err := tx.Create(card).Error; err != nil {
|
||||||
|
t.Fatalf("创建测试卡失败:%v", err)
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExchangeSnapshotDevice(t *testing.T, tx *gorm.DB, suffix int, withVirtualNo, withIMEI bool) *model.Device {
|
||||||
|
t.Helper()
|
||||||
|
device := &model.Device{SN: fmt.Sprintf("UR45-SN-%04d", suffix), AssetStatus: constants.AssetStatusInStock}
|
||||||
|
if withVirtualNo {
|
||||||
|
device.VirtualNo = fmt.Sprintf("UR45-DEVICE-%04d", suffix)
|
||||||
|
}
|
||||||
|
if withIMEI {
|
||||||
|
device.IMEI = fmt.Sprintf("86000000000%04d", suffix)
|
||||||
|
}
|
||||||
|
if err := tx.Create(device).Error; err != nil {
|
||||||
|
t.Fatalf("创建设备失败:%v", err)
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
@@ -112,47 +112,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
|
|||||||
return s.toExchangeOrderResponse(order), nil
|
return s.toExchangeOrderResponse(order), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
|
|
||||||
page := req.Page
|
|
||||||
page = max(page, 1)
|
|
||||||
pageSize := req.PageSize
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = constants.DefaultPageSize
|
|
||||||
}
|
|
||||||
if pageSize > constants.MaxPageSize {
|
|
||||||
pageSize = constants.MaxPageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
filters := make(map[string]any)
|
|
||||||
if req.Status != nil {
|
|
||||||
filters["status"] = *req.Status
|
|
||||||
}
|
|
||||||
if req.FlowType != "" {
|
|
||||||
filters["flow_type"] = normalizeExchangeFlowType(req.FlowType)
|
|
||||||
}
|
|
||||||
if req.Identifier != "" {
|
|
||||||
filters["identifier"] = req.Identifier
|
|
||||||
}
|
|
||||||
if req.CreatedAtStart != nil {
|
|
||||||
filters["created_at_start"] = *req.CreatedAtStart
|
|
||||||
}
|
|
||||||
if req.CreatedAtEnd != nil {
|
|
||||||
filters["created_at_end"] = *req.CreatedAtEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := make([]*dto.ExchangeOrderResponse, 0, len(orders))
|
|
||||||
for _, item := range orders {
|
|
||||||
list = append(list, s.toExchangeOrderResponse(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
|
||||||
order, err := s.exchangeStore.GetByID(ctx, id)
|
order, err := s.exchangeStore.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -433,7 +392,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
|
|||||||
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
|
||||||
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
|
return newResolvedDeviceAsset(device), nil
|
||||||
}
|
}
|
||||||
if err != gorm.ErrRecordNotFound {
|
if err != gorm.ErrRecordNotFound {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
@@ -446,7 +405,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
|
|||||||
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
|
||||||
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
|
return newResolvedIotCardAsset(card), nil
|
||||||
} else if err != gorm.ErrRecordNotFound {
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
}
|
}
|
||||||
@@ -684,7 +643,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
|
return newResolvedIotCardAsset(card), nil
|
||||||
}
|
}
|
||||||
if assetType == constants.ExchangeAssetTypeDevice {
|
if assetType == constants.ExchangeAssetTypeDevice {
|
||||||
device, err := s.deviceStore.GetByID(ctx, assetID)
|
device, err := s.deviceStore.GetByID(ctx, assetID)
|
||||||
@@ -694,7 +653,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
|
return newResolvedDeviceAsset(device), nil
|
||||||
}
|
}
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
|
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
|
||||||
}
|
}
|
||||||
@@ -710,7 +669,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
|
return newResolvedIotCardAsset(&card), nil
|
||||||
}
|
}
|
||||||
if assetType == constants.ExchangeAssetTypeDevice {
|
if assetType == constants.ExchangeAssetTypeDevice {
|
||||||
var device model.Device
|
var device model.Device
|
||||||
@@ -722,7 +681,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(&device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
|
return newResolvedDeviceAsset(&device), nil
|
||||||
}
|
}
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
|
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
|
||||||
}
|
}
|
||||||
@@ -738,7 +697,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
|
|||||||
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
|
||||||
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
|
return newResolvedDeviceAsset(&device), nil
|
||||||
} else if err != gorm.ErrRecordNotFound {
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||||
}
|
}
|
||||||
@@ -753,7 +712,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
|
|||||||
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
|
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
|
||||||
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
|
||||||
}
|
}
|
||||||
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
|
return newResolvedIotCardAsset(&card), nil
|
||||||
} else if err != gorm.ErrRecordNotFound {
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
|
||||||
}
|
}
|
||||||
@@ -761,6 +720,16 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
|
|||||||
return nil, errors.New(errors.CodeAssetNotFound)
|
return nil, errors.New(errors.CodeAssetNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newResolvedIotCardAsset 将卡的权威 ICCID 固化为换货快照,避免请求标识污染历史记录。
|
||||||
|
func newResolvedIotCardAsset(card *model.IotCard) *resolvedExchangeAsset {
|
||||||
|
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newResolvedDeviceAsset 按虚拟号、IMEI、SN 的稳定优先级生成设备换货快照。
|
||||||
|
func newResolvedDeviceAsset(device *model.Device) *resolvedExchangeAsset {
|
||||||
|
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
||||||
var count int64
|
var count int64
|
||||||
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
|
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"maps"
|
"maps"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
@@ -33,52 +32,6 @@ func (s *ExchangeOrderStore) GetByID(ctx context.Context, id uint) (*model.Excha
|
|||||||
return &order, nil
|
return &order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExchangeOrderStore) List(ctx context.Context, filters map[string]any, page, pageSize int) ([]*model.ExchangeOrder, int64, error) {
|
|
||||||
var orders []*model.ExchangeOrder
|
|
||||||
var total int64
|
|
||||||
|
|
||||||
query := s.db.WithContext(ctx).Model(&model.ExchangeOrder{})
|
|
||||||
query = middleware.ApplyShopFilter(ctx, query)
|
|
||||||
|
|
||||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
|
||||||
query = query.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
if flowType, ok := filters["flow_type"].(string); ok && flowType != "" {
|
|
||||||
query = query.Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType)
|
|
||||||
}
|
|
||||||
if identifier, ok := filters["identifier"].(string); ok && identifier != "" {
|
|
||||||
like := "%" + identifier + "%"
|
|
||||||
query = query.Where("old_asset_identifier LIKE ? OR new_asset_identifier LIKE ?", like, like)
|
|
||||||
}
|
|
||||||
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() {
|
|
||||||
query = query.Where("created_at >= ?", createdAtStart)
|
|
||||||
}
|
|
||||||
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() {
|
|
||||||
query = query.Where("created_at <= ?", createdAtEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := query.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = constants.DefaultPageSize
|
|
||||||
}
|
|
||||||
if pageSize > constants.MaxPageSize {
|
|
||||||
pageSize = constants.MaxPageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (page - 1) * pageSize
|
|
||||||
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return orders, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStatus, toStatus int, updates map[string]any) error {
|
func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStatus, toStatus int, updates map[string]any) error {
|
||||||
values := make(map[string]any, len(updates)+1)
|
values := make(map[string]any, len(updates)+1)
|
||||||
maps.Copy(values, updates)
|
maps.Copy(values, updates)
|
||||||
|
|||||||
39
internal/testutil/database.go
Normal file
39
internal/testutil/database.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Package testutil 提供项目集成测试共用的真实基础设施连接。
|
||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPostgresTransaction 创建自动回滚的 PostgreSQL 测试事务。
|
||||||
|
func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||||
|
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的集成测试")
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载测试配置失败:%v", err)
|
||||||
|
}
|
||||||
|
db, err := database.InitPostgreSQL(&cfg.Database, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||||
|
}
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = tx.Rollback().Error
|
||||||
|
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tx
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
|
||||||
AdminOrder: admin.NewOrderHandler(nil, nil),
|
AdminOrder: admin.NewOrderHandler(nil, nil),
|
||||||
AdminExchange: admin.NewExchangeHandler(nil, nil),
|
AdminExchange: admin.NewExchangeHandler(nil, nil, nil),
|
||||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil),
|
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(nil),
|
PollingConfig: admin.NewPollingConfigHandler(nil),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||||
|
|||||||
Reference in New Issue
Block a user