feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s

- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-31

View File

@@ -0,0 +1,160 @@
## 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 自动执行。

View File

@@ -0,0 +1,40 @@
## Scope
- 迭代编号:`AUG26-010`
- 权威口径:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.11`111.md` §16PRD-08-012仅用于追溯未被 §2.11 确认的「不停机」、补偿/退款与异常记录页不实施。
## Why
客户容易遗漏套餐续费,套餐到期会中断服务;资产钱包余额充足时应在最终到期前自动续购当前有效主套餐,且不得与手动续购、钱包资金事实或运营商停复机混为同一个动作。
## What Changes
- 新增全局单例自动续费配置:总开关、适用全部或指定主套餐、到期前统一天数;仅超级管理员与平台账号可维护,保存记录操作者与前后值快照。
- 每日按上海自然日扫描独立卡与设备,对存在当前有效主套餐、最终到期进入窗口、不存在待生效主套餐且不存在在途人工订单的资产,以执行时当前渠道可售续费价从同一资产钱包可用余额续购一个周期的同一套餐商品。
- 每项资产每天至多一次尝试(含失败与跳过),由尝试记录唯一键保证;尝试记录保留资金、订单、复机与配置快照字段,并收敛中断留下的非终态记录。
- 失败按四类原因(余额不足、不可续费、订单失败、复机失败)向当前个人客户与资产所属店铺当时有效业务员各投递每日至多一条站内通知;业务员不存在不阻断续费与尝试记录。
- 续费成功后仅在资产处于可恢复停机且运营商状态不是风险停机或已销户时投递可靠复机任务;复机失败或未知保存结果并走既有恢复,不回滚续费事实。
- 手动续购优先:执行前重新读取资格事实,人工已完成续购或存在在途人工订单时跳过。
## 非目标
- 不实现「有余额就不停机」的特殊逻辑与任何运行时开关,不改变既有停机、复机判据。
- 不建补偿或退款路径;不做自动续费记录页与异常记录页。
- 不叠加多个续购周期(一个尝试只产生一个周期)。
- 不做当日重试(当天失败次日再评估),不按流量阈值或流量即将耗尽触发。
- 配置不按店铺、企业或个人客户分范围。
- 不改动人工路径的 Redis 锁命名空间,不接线未使用的资产钱包锁,不重构既有充值后自动购包实现与人工锁路径。
## Capabilities
### New Capabilities
- `asset-auto-renewal`: 资产钱包自动续费。
### Modified Capabilities
- 无。
## Impact
影响套餐购买校验与价格策略、套餐最终到期推算、资产钱包与钱包流水、订单与支付、异步任务与调度、Outbox、站内通知类型注册与个人客户可见性、统一审计注册、运营商复机、路由与 OpenAPI 文档,以及新增两处 Schema单行配置表与尝试记录表

View File

@@ -0,0 +1,166 @@
## Purpose
在套餐最终到期前的受控窗口内,仅以同一资产钱包可用余额自动续购当前有效主套餐,并使配置、每日尝试、资金闭合、通知与复机失败各自具有明确且可恢复的边界。
## ADDED Requirements
### Requirement: 自动续费配置与权限
系统 SHALL 只维护一份有效的全局自动续费配置,包含总开关、适用范围(全部主套餐或指定主套餐)、指定主套餐集合与到期前统一天数(正整数,上限 90。仅超级管理员与平台账号 SHALL 能读取或修改配置;代理、企业、个人客户 MUST NOT 有任何读取或修改入口,无权限与目标不存在 MUST NOT 形成可枚举差异。每次保存 SHALL 记录操作者、修改前后值快照与时间,并与配置保存同事务生效。指定范围时集合 MUST 非空且仅能选择当前可售主套餐;全部范围时集合 MUST 为空。每次保存 SHALL 递增配置版本;尝试记录 SHALL 保留触发时的配置版本快照。配置变更只影响后续扫描:已产生的尝试记录 MUST NOT 重算;关闭总开关后 MUST NOT 创建新的尝试或订单,既有尝试记录与通知保留。
#### Scenario: 无权限读取或修改配置
- **WHEN** 代理、企业或个人客户请求读取或修改自动续费配置
- **THEN** 系统拒绝请求、配置不变,且不产生任何执行任务或通知
#### Scenario: 关闭总开关后不再执行
- **WHEN** 总开关关闭后执行每日扫描
- **THEN** 系统不创建新的尝试记录、不创建订单、不扣款,既有尝试记录与通知保留
#### Scenario: 保存配置只影响后续扫描
- **WHEN** 管理员修改到期前天数或适用范围
- **THEN** 系统记录操作者、前后值与时间并递增配置版本,已产生的尝试记录保持原配置版本快照且不重算
### Requirement: 每日扫描与续购资格
系统 SHALL 按上海自然日执行自动续费扫描,处理对象为尚未到期的当前主套餐(主套餐记录、未退款、未删除、状态为生效或已用完,按优先级与创建时间取第一条),续购对象为其套餐商品。触发窗口 SHALL 为闭区间:最终到期剩余天数大于等于 0 且小于等于配置的到期前天数;剩余天数 SHALL 按上海自然日计算(固定东八区、无夏令时),并复用既有最终到期推算口径(含待生效主套餐顺延)。最终到期推算结果为「无有效主套餐」「等待激活」或「数据异常」时 MUST NOT 处理;剩余天数为负(已过期)时 MUST NOT 尝试。资产范围 SHALL 为独立卡与设备;已绑定设备的卡 MUST NOT 处理(与个人客户购买入口口径一致)。除窗口条件外,续购资格 MUST 同时满足:该资产当前不存在待生效主套餐(未退款、无主套餐归属且状态为待生效),当前主套餐商品在配置范围内(全部范围时不受此限),且该资产不存在未关闭(待支付)的个人资产钱包主套餐订单。系统 MUST NOT 按流量阈值或流量即将耗尽触发。
#### Scenario: 窗口闭区间边界
- **WHEN** 资产最终到期剩余天数分别为 0、等于配置天数、等于配置天数加一
- **THEN** 剩余天数为 0 与等于配置天数时进入执行,大于配置天数时不处理
#### Scenario: 已存在待生效主套餐
- **WHEN** 资产已存在待生效主套餐且当前主套餐进入窗口
- **THEN** 系统跳过该资产,不扣款、不创建订单,并在尝试记录标记跳过
#### Scenario: 无明确最终到期或已过期
- **WHEN** 资产无有效主套餐、最终到期推算为等待激活或数据异常,或最终到期已过期
- **THEN** 系统不进入执行,不创建尝试记录、不扣款、不通知
#### Scenario: 已绑定设备的卡不自动续费
- **WHEN** 卡已绑定设备
- **THEN** 系统只在设备维度按设备自身套餐判断续费,不对该卡执行自动续费
### Requirement: 每日一次尝试与尝试记录终态收敛
系统 SHALL 以(资产类型、资产 ID、触发日期唯一约束保证每项资产每天至多一次尝试资产类型取值域 SHALL 与资产钱包资源类型一致,卡与设备分别计数。尝试 SHALL 是执行级记录:只有进入执行并得出终态的资产才创建记录(成功、失败或跳过);未进入窗口或资格不足而未进入执行的资产 MUST NOT 创建记录、不构成尝试。扫描 SHALL 先以独立短事务写入当次尝试占位;唯一冲突即视为当日已尝试并跳过该资产。当天失败 MUST NOT 重试次日再评估MUST NOT 建立当日重试机制。跨日尝试次数 SHALL 以尝试序号字段累计。占位后进程中断留下的非终态记录 SHALL 由后续扫描收敛:触发日期早于当日的非终态记录 MUST 先被收敛为中断或未知,且当日不重试。扫描任务级失败 SHALL 返回错误交由既有任务重试机制重试;单个资产执行失败 MUST NOT 使扫描任务失败。
#### Scenario: 当日已存在尝试记录
- **WHEN** 该资产当日已存在成功、失败或跳过的尝试记录
- **THEN** 唯一约束冲突后该资产被跳过,当日不再尝试、不重复扣款
#### Scenario: 占位后进程中断
- **WHEN** 尝试占位写入后进程中断,记录停留在非终态,且当日扫描已结束
- **THEN** 次日扫描先将该记录收敛为中断或未知,且该资产当日至多仍只尝试一次
#### Scenario: 单资产失败不影响同批其他资产
- **WHEN** 同一批扫描中某个资产执行失败
- **THEN** 系统记录该资产失败原因并继续处理其余资产,扫描任务本身不因该资产失败而失败
### Requirement: 资金、价格与单事务闭合
自动续费 MUST 仅扣该续费资产钱包的可用余额余额减冻结余额MUST NOT 使用其他资产钱包、代理主钱包或外部支付渠道。续购价格 SHALL 取执行时该资产所属店铺渠道的当前可售续费价;资产无所属店铺时取平台价。续购 MUST 为同一套餐商品且 MUST 只产生一个周期的购买MUST NOT 在同一窗口内连续叠加多个周期。钱包扣款、订单与订单明细、支付记录、钱包流水、套餐生效事实与成功审计 MUST 在同一事务内闭合;任一步失败 MUST 整体回滚MUST NOT 产生「扣款已提交而套餐未生成」或其他部分成功状态。因此系统 MUST NOT 提供补偿或退款路径MUST NOT 建立自动续费异常记录页。当前条件不允许自动续购时,系统 MUST NOT 创建订单、MUST NOT 扣款,只记录失败原因并投递通知。不可续费至少覆盖:套餐商品被禁用;当前渠道下架且不满足续费豁免;生效零售价低于成本价;不在可购买范围或资产未关联套餐系列;资产钱包当前不可用于扣款同样记入不可续费。
#### Scenario: 可用余额不足
- **WHEN** 资产钱包可用余额小于执行时当前可售续费价
- **THEN** 系统不创建订单、不扣款,记录失败原因余额不足并投递通知
#### Scenario: 套餐不可续费
- **WHEN** 套餐商品被禁用,或当前渠道已下架且不满足续费豁免,或生效零售价低于成本价
- **THEN** 系统不创建订单、不扣款,记录失败原因不可续费并投递通知
#### Scenario: 成功续费的资金与套餐事实同时可见
- **WHEN** 自动续费成功
- **THEN** 续费订单、订单明细、已支付支付记录、钱包扣款流水(含扣款前后余额)与套餐使用记录在同一事务提交后同时可见,续购价格为该渠道执行时当前可售续费价
### Requirement: 失败通知与接收人
系统 SHALL 向当前个人客户与资产所属店铺当时有效业务员各创建站内通知,同一资产同一上海自然日同一原因对同一接收人至多一条。失败原因枚举 SHALL 固定为四类:余额不足、不可续费、订单失败、复机失败。通知幂等键 SHALL 内嵌资产类型与资产 ID、上海自然日、原因类型与接收人。复机失败通知 SHALL 沿用该次尝试的日期键,使同一尝试只通知一次且不跨日新增。资产所属店铺当时无有效业务员时 MUST NOT 阻断续费、失败记录或客户通知;资产无店铺归属时只创建客户通知。
#### Scenario: 余额不足的双接收人各一条
- **WHEN** 合格资产进入窗口但钱包可用余额不足,且资产所属店铺存在有效业务员
- **THEN** 系统为该客户与该业务员各创建一条余额不足通知,当日重复扫描不再新增
#### Scenario: 业务员不存在
- **WHEN** 资产所属店铺当时没有有效业务员
- **THEN** 系统不阻断续费流程与尝试记录,只创建客户通知
#### Scenario: 复机失败通知只发一次
- **WHEN** 续费成功但复机失败或结果未知,且恢复确认最终失败
- **THEN** 系统以该次尝试的日期键创建复机失败通知,同一尝试只通知一次且不跨日新增
### Requirement: 成功后的可靠复机
续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,系统 SHALL 通过可靠异步投递触发既有复机能力MUST NOT 使用提交后即发即弃的调用。复机失败或结果未知时,系统 MUST 保存执行结果含外部交互标识与失败原因并交由既有恢复机制查询确认MUST NOT 回滚已提交的订单、套餐生效、钱包扣款或续费成功事实。复机未投递(条件不成立)时 MUST NOT 创建复机失败通知。
#### Scenario: 复机条件不成立
- **WHEN** 续费成功但资产不满足可恢复停机条件,或运营商状态为风险停机或已销户
- **THEN** 系统记录复机跳过、不触发复机调用、不发送通知,续费事实保持不变
#### Scenario: 复机结果未知
- **WHEN** 复机调用后本地状态回写失败
- **THEN** 系统保存结果未知与外部交互标识,由恢复扫描查询确认最终结果,续费订单、套餐与钱包事实不变
#### Scenario: 复机失败不回滚续费
- **WHEN** 复机最终失败
- **THEN** 系统保留订单、套餐生效与钱包扣款事实,记录失败原因并投递复机失败通知
### Requirement: 尝试记录与可追溯字段
每次进入执行的尝试 SHALL 保留可追溯记录,至少包含:资产类型与资产 ID 与触发日期;触发时解析的当前个人客户与资产所属店铺快照;触发时配置版本与窗口快照;当前主套餐使用记录与当前套餐商品;待续购套餐商品与执行时续费价;钱包标识、钱包流水号、扣款金额与扣款前后余额;续费订单标识与订单号;尝试状态;失败原因;跳过原因;复机状态、复机外部交互标识与复机失败原因;操作者类型与标识(无人工处理入口,恒为系统任务);跨日尝试次数;时间戳。尝试状态 SHALL 为处理中、成功、失败、跳过四者之一。跳过原因 SHALL 至少覆盖人工已完成续购与人工订单在途。复机状态 SHALL 为未评估、跳过、已投递、成功、失败、未知之一。上述状态与原因枚举 SHALL 在规格与实现常量间共用同一份语义字面量。记录 MUST NOT 保存凭证、令牌或个人敏感信息。
#### Scenario: 成功尝试的可追溯内容
- **WHEN** 自动续费成功并按条件投递复机
- **THEN** 尝试记录包含扣款金额与扣款前后余额、钱包流水号、续费订单号、执行时续费价与复机状态
#### Scenario: 跳过尝试的可追溯内容
- **WHEN** 资产因已存在待生效主套餐或存在在途人工订单被跳过
- **THEN** 尝试记录状态为跳过并写明对应跳过原因,且不含订单号与扣款金额
### Requirement: 手动续购优先
自动任务 MUST 在提交资金事实前重新读取资格事实;人工续购已完成(存在待生效主套餐)时必须跳过,且 MUST NOT 产生第二笔订单或扣款。同一资产存在未关闭(待支付)的个人资产钱包主套餐订单时,自动任务 MUST 跳过并记录跳过原因人工订单在途MUST NOT 发送通知。手动续购优先的可观察定义:同一资产同一周期 MUST NOT 因自动任务产生两笔已支付续购。
#### Scenario: 人工成功续购后自动跳过
- **WHEN** 自动任务重新读取时发现该资产已由人工成功续购
- **THEN** 自动任务不创建第二笔订单、不扣款,并标记跳过
#### Scenario: 人工订单在途
- **WHEN** 自动任务执行时该资产存在未关闭的个人资产钱包主套餐订单
- **THEN** 自动任务跳过该资产、不扣款、不创建订单且不发送通知
### Requirement: 不得因钱包余额跳过停机判定
系统 MUST NOT 因资产钱包余额充足而跳过停机判定MUST NOT 为此新增任何运行时开关或配置项MUST NOT 改变既有停机与复机判据。服务不中断 SHALL 只由「窗口内提前续购使新主套餐待生效、到期由既有接续链生效、轮询在存在有效主套餐且流量未耗尽时自动复机」产生。
#### Scenario: 余额充足但命中既有停机条件
- **WHEN** 资产钱包可用余额充足且资产命中既有停机条件(无有效套餐或流量用尽)
- **THEN** 系统仍按既有规则执行停机判定,不因余额跳过停机,且不存在可开启的不停机开关
#### Scenario: 续购成功不立即改变停机状态
- **WHEN** 续购成功且新主套餐按既有规则为待生效
- **THEN** 系统不因续费成功提前改变停机状态,复机仍由既有接续与轮询判定决定

View File

@@ -0,0 +1,68 @@
## 1. 配置基座
- [x] 1.1 新增单行自动续费配置表成对迁移:主键恒为 1、开关、范围全部/指定)、指定套餐集合、到期前天数(上限 90、配置版本、创建人与更新人与时间指定范围集合非空、全部范围集合为空迁移预置一行且默认关闭
- [x] 1.2 新增配置模型与读写 Store读取单行、保存时以单行事务锁串行化并在保存事务内自增配置版本
- [x] 1.3 实现配置读写接口与 DTO范围枚举校验、指定范围只能选择当前可售主套餐、保存写入操作者与前后值快照
- [x] 1.4 注册路由与路由组级门禁(仅超级管理员与平台账号),并在应用层复核账号类型;代理、企业、个人客户无入口
- [x] 1.5 新增审计动作、操作与资源常量并在统一审计注册表登记定义(含操作者、来源、主资源与事务要求)
- [x] 1.6 同步 `cmd/api/docs.go``cmd/gendocs/main.go` 占位并运行 `go run cmd/gendocs/main.go` 核对路由与文档一致
- [x] 1.7 落实变更语义:配置变更只影响后续扫描,尝试记录只保留触发时配置版本快照、不做重算;关闭开关后当日不再创建尝试或订单
## 2. 尝试记录表
- [x] 2.1 新增尝试记录表成对迁移:唯一键(资产类型、资产 ID、触发日期、状态与原因字段、复机状态与外部交互号字段、资金与订单字段、配置与窗口快照字段、客户与店铺快照字段、操作者与跨日尝试次数字段
- [x] 2.2 新增模型与常量:尝试状态(处理中/成功/失败/跳过)、失败原因(余额不足/不可续费/订单失败/复机失败)、跳过原因(人工已完成续购/人工订单在途)、复机状态(未评估/跳过/已投递/成功/失败/未知),字面量与规格一致
- [x] 2.3 实现占位写入:扫描开始时以独立短事务写入当次尝试,唯一冲突即视为当日已尝试并跳过该资产
- [x] 2.4 实现终态写入:成功在续费事务内更新;失败与跳过在独立短事务条件更新,不复用已回滚事务的连接或事务
- [x] 2.5 实现单资产失败隔离:用例内捕获失败、落终态并继续处理其余资产,仅扫描级失败返回错误交既有任务重试
## 3. 候选与资格
- [x] 3.1 新增按窗口参数化的候选查询,复用既有最终到期推算口径(含待生效主套餐顺延),不改变既有临期列表行为
- [x] 3.2 实现资格判定:独立卡与设备、最终到期剩余天数为闭区间 0 至配置天数、按上海自然日比较、只接受明确推算结果、已过期不处理
- [x] 3.3 实现当前主套餐与续购对象取法:与既有临期口径同序取第一条主套餐,续购对象为其套餐商品
- [x] 3.4 实现范围判定:全部主套餐,或当前主套餐商品在指定集合内(运行时不因后来下架而拒绝,交由续费豁免判定)
- [x] 3.5 实现跳过判定:不存在待生效主套餐为资格前置;人工已完成续购与存在未关闭(待支付)个人资产钱包主套餐订单时跳过并记跳过原因,不发通知
- [x] 3.6 复用应用层购买校验与价格策略取得可续费判定与执行时续费价(含续费豁免下架、生效零售价与成本价比较),禁止依赖 handler 层续费价实现
## 4. 执行事务
- [x] 4.1 新增每日扫描任务与调度注册(上海时区每日一次),审计上下文固定为计划任务与 Worker 来源
- [x] 4.2 执行事务内先锁资产钱包行、后锁资产载体行,锁后重读最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额
- [x] 4.3 同一事务内闭合:按可用余额扣款、续购订单与明细、已支付支付记录、钱包流水、套餐使用记录(按既有规则排队)、佣金与卡观测 Outbox、统一审计、尝试记录置成功
- [x] 4.4 实现失败路径:不建订单、不扣款,按失败原因落终态并投递通知;订单失败随事务回滚后在独立短事务落失败事实
- [x] 4.5 实现条件复机:按可复机判定投递复机 Outbox条件不成立记录复机跳过且不投递、不通知
- [x] 4.6 边界遵守:不重构既有充值后自动购包实现与人工锁路径,不接线未使用的资产钱包锁常量,不统一人工路径的 Redis 锁命名空间,事务体在自有用例内实现
## 5. 通知
- [x] 5.1 注册受控通知类型与注册表定义:类别 expiry、级别 warning、接收人含账号与个人客户、允许引用资源类型、模板字段
- [x] 5.2 将新类型加入个人客户通知类型白名单的两处(通知列表与未读数范围),确保 H5 可见
- [x] 5.3 事件必带业务到期时间(类别为 expiry 时缺失会因参数非法投递失败)
- [x] 5.4 实现双接收人与幂等键:个人客户与资产所属店铺、键内嵌资产类型与资产 ID、上海自然日、原因与接收人业务员为空不阻断续费与尝试记录资产无店铺只发客户通知
- [x] 5.5 落实每日至多一条:同一资产同一自然日同一原因同一接收人只一条;复机失败沿用该次尝试的日期键,同一尝试只通知一次、不跨日新增
## 6. 复机可靠投递、恢复扫描与终态收敛
- [x] 6.1 新增导出的可复机判定与执行入口(返回成功/失败/未知分类),判定规则复用既有单一来源,不复制规则、不直接调用对跳过也返回成功的既有入口
- [x] 6.2 新增复机 Outbox 事件类型与消费者:条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因
- [x] 6.3 新增恢复扫描周期任务:只查询回填未知结果,不重复发起复机调用;仅最终失败按通知契约投递
- [x] 6.4 实现终态收敛:后续扫描先把触发日期早于当日且非终态的尝试记录收敛为中断或未知,且当日不重试
## 7. 验证
- [x] 7.1 在 `junhong_cmp_test` 与测试 Redis 库执行迁移 up/down/up只清理本 Change 自己创建的 fixture不重置测试库
- [x] 7.2 关闭总开关后扫描不产生新尝试与订单,既有记录保留
- [x] 7.3 窗口闭区间上下界与上海零点边界:剩余天数为 0 与等于配置天数进入执行,大于配置天数不处理
- [x] 7.4 已存在待生效主套餐时跳过(同时覆盖人工已完成续购与不叠加周期)
- [x] 7.5 已有当日尝试(成功/失败/跳过)时唯一冲突跳过,当日不重复尝试
- [x] 7.6 可用余额不足(含冻结占用后)记余额不足,客户与业务员各一条通知
- [x] 7.7 商品被禁用、渠道下架且不满足续费豁免、价格异常、钱包状态异常均记不可续费且不建订单不扣款
- [x] 7.8 人工成功续购后自动重读跳过,不产生第二笔订单
- [x] 7.9 人工待支付订单存在时跳过且不发送通知
- [x] 7.10 成功路径订单、支付记录、钱包流水与套餐使用记录在同一事务提交后同时可见,续费价等于同渠道人工续费价
- [x] 7.11 复机条件不成立时为跳过、无通知,续费事实不受影响
- [x] 7.12 复机本地回写失败记未知并保留外部交互号,恢复扫描回填;仅确认失败才通知
- [x] 7.13 占位后中断由次日扫描收敛为中断或未知,且当日不重试
- [x] 7.14 并发交叉验证:同一订单不重复扣款,同周期不产生两笔已支付续购
- [x] 7.15 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-asset-wallet-auto-renewal --strict``openspec doctor --json`;自动化测试按项目决策为 N/A