Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/design.md
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

161 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
动机与边界见 `proposal.md`。本设计只记录塑造实现方式的现状约束:
- 续购是既有能力的编排:套餐最终到期推算、个人套餐购买校验与取价、资产钱包扣款、订单/支付/钱包流水、套餐使用记录激活、站内通知 Outbox、运营商停复机、统一审计已各自成立。
- `internal/task/auto_purchase.go:208-318` 是全库唯一把「钱包扣款 → 订单与明细 → 支付记录 → 钱包流水 → 套餐激活 → 佣金与卡观测 Outbox → 审计」闭合在一个 GORM 事务里的先例。
- 到期口径唯一来源为 `internal/query/packageexpiry/query.go``ResolveBatch:70` / `Calculate:158`;当前主套餐取法唯一先例为 `internal/query/packageexpiry/list.go:222``loadFinalUsages`
- 可售与价格唯一来源为 `internal/service/purchase_validation``ValidatePersonalCardPurchase:64` / `ValidatePersonalDevicePurchase:116``validatePackages:162``loadRenewablePackageIDs:279`handler 层 `internal/handler/app/client_asset.go:311` 的续费价是同一应用层分支的拷贝。
- 资产钱包扣款只有乐观版本锁(`internal/store/postgres/asset_wallet_store.go:65`),人工下单侧预占为 `internal/service/order/service.go:1606``pkg/constants/wallet.go:205` 的资产钱包分布式锁常量全库无调用。人工购买锁分裂为 `order:create:lock:*``internal/service/order/service.go:242`)与 `client:purchase:lock:*``internal/service/client_order/service.go:213`)两个命名空间。
- 可靠副作用的既有形态为 Outbox 事件 + 消费者 + 独立恢复扫描(`internal/application/carrierthreshold/event.go:39/74/121``internal/infrastructure/carrierthreshold/task.go:52``internal/service/order/service.go:2141` 的支付后复机是即发即弃 goroutine不满足本项要求。
- 站内通知类型为代码内受控注册(`internal/infrastructure/notification/registry.go`),个人客户可见性由两处类型白名单决定(`internal/application/notification/read.go:216-222``internal/query/notification/query.go:190-199`);统一审计动作为 fail-closed 注册(`internal/infrastructure/audit/writer.go:838`)。
## Goals / Non-Goals
**Goals:**
- 让「提前续购」在资金、套餐、通知与复机四个面上各自闭合,且每个失败都能在数据上被定位与恢复。
- 复用既有单一事实源(到期推算、购买校验与价格、钱包扣款、套餐激活、通知、审计),只在缺失处新增最小结构。
**Non-Goals:**
- 不新增停机判据、不停机开关或复机判据;不改造人工路径的锁实现与既有自动购包实现。
- 不建补偿/退款路径、记录页与异常记录页;不叠加多周期;不建当日重试。
## Decisions
### 1. 不实现「有余额就不停机」
理由:
1. PRD §2.11 未确认 `111.md` §16.1 的「有余额自动续费就不停机」,且 PRD 领域语言明确要求把续费订单、钱包扣款、运营商停复机与轮询同步区分开——该措辞本身是被要求拆分的对象。
2. 既有机制已产生等价结果:窗口内提前续购后新主套餐为待生效(`internal/task/auto_purchase.go:636-665`),到期由既有接续链生效,轮询在「有有效主套餐且流量未耗尽」时自动复机。服务不中断来自**提前续购**,不来自改停机判据。
3. 实现该措辞等于让未支付资金决定不停机,并绕过风险停机/销户判定与通道阈值锁。
规格以禁止性条款固定(见 `specs/asset-auto-renewal/spec.md` 的「不得因钱包余额跳过停机判定」);实现不新增该路径上的任何代码。
### 2. 续费事务单事务闭合,不建补偿路径
钱包扣款(`:220`)、订单(`:228`)、订单明细、支付记录 `status=paid`、钱包流水(`:254-270`)、套餐激活(`:274`)、佣金与卡观测 Outbox、审计`:317`)在 `internal/task/auto_purchase.go:208-318` 同一个 `db.Transaction` 内闭合;任一步失败整体回滚,因此不存在「扣款已提交而套餐未生成」的中间态。本项沿用该形态:续费成功、尝试记录置成功与成功审计同事务;失败/跳过事实与失败审计按 ENG-TX-001 例外走独立短事务(先例 `markAutoPurchaseFailedIfFinalRetry:399-412`),条件更新依据尝试记录仍处于非终态。
外部副作用一律出事务走 OutboxENG-OUTBOX-001事务内不持有外部 I/OENG-TX-001。因此**不设补偿、不设退款、不做异常记录页**:异常只表达为「失败原因 + 尝试记录」。
### 3. 单周期与「人工已完成」由同一不变式保证
资格不变式:该资产**不存在待生效主套餐**`status=待生效` 且无主套餐归属且未退款)。
- 只续购一个周期:`HasCurrentMainPackageForQueue``internal/service/package/addon_main_package.go:43/72`)决定新主套餐是否排队,`activateMainPackage` 在存在当前主套餐时把新记录写成待生效——不变式使续购最多领先一个周期。
- 人工已完成即跳过:人工成功续购必经套餐激活写入待生效记录,自动任务重读即跳过。
**备选与否决**:仅用「最终到期时间是否出窗口」判定不可行——周期长度不大于窗口天数的套餐会在同一窗口内反复续购(例如 7 天周期、窗口 15 天,续购后最终到期仍在窗口内),既违反单周期也不满足「人工已完成即跳过」的稳定性。
### 4. 每日一次与尝试状态机
```
扫描开始
├─ 收敛:触发日期 < 今日 且非终态 → 中断/未知 (独立短事务)
└─ 逐资产:
候选(窗口/范围/资格)
├─ 未进入执行 ────────────────→ 不建记录、不构成尝试
└─ 进入执行 → 占位写入(唯一键冲突 = 当日已尝试 → 跳过)
├─ 跳过(人工已完成 / 人工订单在途)→ 终态 skipped独立短事务不通知
├─ 失败(余额不足 / 不可续费) → 终态 failed独立短事务 + 通知 Outbox
├─ 失败(订单失败,事务回滚) → 终态 failed独立短事务 + 通知 Outbox
└─ 成功 → 续费事务内终态 succeeded+ 条件复机 Outbox
```
- 尝试 = 执行级记录:未进入执行不建行(避免与库内全量资产量级绑定),因此「每天至多一次」的唯一键只约束真正进入执行的资产。
- 终态收敛保证占位后中断不会让资产永久停在非终态当日不重试次日再评估PRD 每日一次、不引入重试机制)。
- 单资产失败在用例内捕获并落终态后继续;只有扫描级失败(候选读取、数据库不可用)返回错误交既有任务重试,重试对已占位资产天然幂等。
- 多实例调度器重复入队安全:唯一键 + 执行事务内的行锁共同收敛。
### 5. 窗口与候选来源
- 口径:`Calculate``query.go:158`)——主套餐、未退款、未删除、状态为生效/已用完且至多一条,后续待生效主套餐按时间顺延;只接受推算结果为明确值,按上海自然日(`dateInShanghai`,固定东八区)比较;窗口闭区间 `0 ≤ 剩余天数 ≤ N`,剩余天数为负不尝试。**不得另写到期推算。**
- 候选来源:既有临期候选查询绑定固定 15 天窗口(`list.go:17`)并叠加数据范围(`applyStrictShopScope:279`N 可配置时不得复用该常量。本项新增按窗口参数化的候选查询,口径仍调用同一推算函数,且**不改变既有临期列表行为**。
### 6. 资格、价格与资产范围
- 当前主套餐与续购对象:与 `loadFinalUsages:222` 同口径取第一条,续购对象为其套餐商品。
- 范围:全部主套餐,或指定主套餐且当前主套餐商品在配置集合内;保存时校验只能选择当前可售主套餐,运行时**不因后来下架而拒绝**(交由续费豁免判定)。
- 价格与可售:复用应用层购买校验与价格策略(个人卡/设备购买校验、续费豁免下架、生效零售价),禁止依赖 handler 层续费价实现。
- 渠道店铺:取资产所属店铺,为空即平台价(与 `validateCardPurchase:86` 解析一致)。
- **资产范围:独立卡与设备;绑定设备的卡排除**,与人工入口对绑定卡的拒绝口径(`validateCardPurchase:82`)一致,避免自动比人工更宽松。
- 不可续费来源归一为四类(商品被禁用、范围外或渠道下架且不满足续费豁免、生效零售价低于成本价、不在可购买范围或未关联套餐系列)+**钱包状态异常归因**:资产钱包状态非「正常」时按「不可续费」落失败尝试,不新增第五类原因枚举,通知语义为「当前条件不允许自动续购」。
### 7. 并发与锁序
- **锁序**:执行事务内先锁资产钱包行,后锁资产载体行(`lockPackageCarrier``SELECT ... FOR UPDATE``internal/task/auto_purchase.go:761`)。人工下单路径同样先冻结钱包、后激活套餐,锁序一致可避免与人工事务的锁序反转死锁。
- 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价、可用余额;重读结果决定执行或跳过。
- **残留竞态与代数说明**:自动与人工共享的序列化只有数据库行锁。人工下单(`client_order/service.go:213` 持 Redis 锁)与支付(`PayOrder:1416`)是两次请求,下单即冻结钱包(`order/service.go:1272`)。若自动事务先提交,人工支付**仍会成功**——人工支付只要求「余额足够且冻结额足够」(`order/service.go:1928-1930`),而自动只扣可用余额、不侵占冻结额(`asset_wallet_store.go:65``balance - frozen_balance >= amount`)。因此该竞态的结果不是同一笔资金重复扣减,而是**同一周期产生两笔已支付续购**;本设计以「存在未关闭(待支付)的个人资产钱包主套餐订单即跳过」消除该情形,窗口由既有订单超时释放预占保证有界。
- 不接线未使用的资产钱包锁常量,不统一人工路径的两个 Redis 锁命名空间,不重构既有自动购包实现与人工锁路径。
### 8. 复机落点与结果分类
照抄第 13 项形态,**不复用其锁表**
| 落点 | 内容 |
| --- | --- |
| 可复机判定(新增导出入口) | 非风险停机/销户 + 停因为可轮询复机 + 复机前置条件(有效主套餐、流量未耗尽、实名)+ 不受通道阈值锁限制;判定规则复用既有单一来源,不复制 |
| 执行(新增导出入口,返回结果分类) | 复用既有复机重试、Integration Log 与统一审计,返回成功/失败/未知分类 |
| Outbox 事件与消费者 | 与续费事务同事务写事件;消费者条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因 |
| 恢复扫描(新增周期任务) | 只查询回填未知结果,不重复发起;终态收敛同批完成 |
| 通知 | 仅最终失败或确认失败时按通知契约投递 |
两点事实:
1. **不得直接调用既有「若已停机则复机」入口**:它对持锁、已开机、非轮询停因、条件不满足四类跳过都以成功返回(`internal/service/iot_card/stop_resume_service.go:565`),无法区分结果,不能作为尝试记录的结果来源。
2. **既有判定函数为包内私有**,跨包使用须新增导出入口,不得复制规则。
**现实观察(实现与验收都需按此预期)**:资格要求存在未过期的当前主套餐,而续购总把新主套餐写为待生效,因此复机前置条件多数不成立、复机多为跳过;真正会触发的只有「读取资格后、执行事务前当前主套餐刚好过期」这一窄窗口(此时新主套餐直接生效)。跳过不发通知。
### 9. 通知注册与个人客户可见性
- 新增一个受控通知类型与一份注册表定义:类别 `expiry`、级别 `warning`、接收人含账号与个人客户、允许引用的资源类型覆盖资产与卡/设备,模板字段含资产标识、当前套餐、续费套餐、原因与最终到期日。
- **必须同时加入个人客户通知类型白名单的两处**`internal/application/notification/read.go:216-222``internal/query/notification/query.go:190-199`),否则 H5 列表与未读数静默不可见。
- **因类别为 `expiry`,通知事件必须携带业务到期时间**,否则投递因参数非法失败(`internal/application/notification/delivery.go:284-290`)。
- 幂等键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人复机失败沿用该次尝试的日期键。由「每资产每天至多一次尝试」直接推出「每资产每天至多一条失败通知」。
- 店铺通知复用既有卡/设备详情跳转目标,不新增前端目标类型(不做记录页)。
- 接收人解析复用既有绑定解析与店铺解析(业务员为空解析为空列表,不阻断投递流程)。
### 10. 配置存储、权限与审计
- **使用新增单行配置表,不使用系统配置键值表。** 先例引用纠正H5 弹窗配置表是多行加优先级(`migrations/000225`),不是单行先例,仓库当前没有单行配置表先例。
- 表约束到单行(主键恒为 1迁移预置一行且默认关闭字段为开关、范围全部/指定)、指定套餐集合、到期前天数(上限 90、配置版本、创建人与更新人与时间指定范围时集合非空全部范围时集合为空。
- 配置版本在保存事务内自增供尝试记录快照追溯并发保护用单行事务锁不用乐观锁ENG-CONC-001 面向余额与状态机,配置用行锁即可)。
- 权限:路由组级门禁仅超级管理员与平台账号(先例 `internal/routes/package_traffic_alert.go:15-23`并在应用层复核账号类型ENG-AUTHZ-001
- 审计:保存写前后值快照,走统一审计并与保存同事务;**必须注册审计动作与资源常量及注册表定义**——审计写入 fail-closed未注册会使保存事务整体失败。
- 变更语义:只影响后续扫描,尝试记录保留原配置版本快照、不重算;关闭后当日不再创建新尝试或订单。
### 11. 尝试记录字段与枚举
唯一键(资产类型、资产 ID、触发日期。字段客户与店铺快照、配置版本与窗口快照、当前主套餐使用记录与当前商品、待续购商品与执行时价格、钱包标识与流水号与扣款金额与扣款前后余额、订单标识与订单号、尝试状态、失败原因、跳过原因、复机状态与复机外部交互号与失败原因、操作者类型与 ID恒为系统任务、跨日尝试次数、时间戳。
枚举(与规格共用同一份语义字面量):
- 尝试状态:处理中 / 成功 / 失败 / 跳过(生命周期状态按 ENG-STATE-001 以整数编码,顺序与语义如上)。
- 失败原因:余额不足 / 不可续费 / 订单失败 / 复机失败(复机失败记录在复机字段,不复用失败原因字段)。
- 跳过原因:人工已完成续购 / 人工订单在途。
- 复机状态:未评估 / 跳过 / 已投递 / 成功 / 失败 / 未知。
不存凭证、令牌或个人敏感信息ENG-LOG-001
### 执行契约
- **配置维护**`GET /asset-auto-renewal-config``PUT /asset-auto-renewal-config` 仅超级管理员与平台账号;保存记录操作者、前后快照与时间;代理、企业、个人客户无读取或修改入口。配置变更只影响后续扫描,已产生尝试记录不重算;关闭开关后不再创建新尝试或订单。
- **每日扫描与尝试**Worker 在上海自然日执行一次,先收敛历史非终态尝试,再按资产扫描候选;对每项候选以独立短事务占位,占位成功后执行;执行失败与跳过各以独立短事务写终态,失败按原因投递通知。
- **续费与副作用**:同一事务内闭合资金、订单、套餐与成功审计;成功后按条件写复机 Outbox复机结果由消费者与恢复扫描回填失败或未知永不回滚续费事实。
## Risks / Trade-offs
- [自动与人工在同一资产上并发] → 共享序列化只有数据库行锁;以「人工已完成」与「人工订单在途」两个跳过条件消除同周期两笔已支付续购,残留窗口由订单超时释放预占限制;极端交叉下人工下单可能因钱包版本冲突失败并需重试(与既有两个并发下单情形相同)。
- [复机几乎总是跳过] → 属既有套餐排队语义的必然结果,不做额外补偿;规格与尝试记录显式区分「跳过」与「失败」,跳过不通知。
- [尝试记录只覆盖进入执行的资产] → 换取与全量资产解耦的记录量级;「每天至多一次」的唯一键仍严格成立。
- [配置单行表无乐观锁] → 保存串行化依赖单行事务锁;冲突表现为保存等待而非静默覆盖。
- [新增通知类型可能静默不可见] → 规格要求两处白名单同时更新,并在验证中检查 H5 列表与未读数可见性。
- [审计未注册导致保存整体失败] → 属 fail-closed 的预期行为,任务中显式要求先注册动作与资源定义再接入。
## Migration Plan
新增成对迁移:单行自动续费配置表(预置一行且默认关闭)与尝试记录表(唯一键与状态字段)。验证在 `junhong_cmp_test` 与测试 Redis 库执行迁移 up/down/up并只清理本 Change 自己创建的 fixture生产迁移按生产运行说明由维护者手工执行不由本 Change 自动执行。