- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230) - 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合 - 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试 - 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单 - 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用 - 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计 - tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
18 KiB
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. 不实现「有余额就不停机」
理由:
- PRD §2.11 未确认
111.md§16.1 的「有余额自动续费就不停机」,且 PRD 领域语言明确要求把续费订单、钱包扣款、运营商停复机与轮询同步区分开——该措辞本身是被要求拆分的对象。 - 既有机制已产生等价结果:窗口内提前续购后新主套餐为待生效(
internal/task/auto_purchase.go:636-665),到期由既有接续链生效,轮询在「有有效主套餐且流量未耗尽」时自动复机。服务不中断来自提前续购,不来自改停机判据。 - 实现该措辞等于让未支付资金决定不停机,并绕过风险停机/销户判定与通道阈值锁。
规格以禁止性条款固定(见 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),条件更新依据尝试记录仍处于非终态。
外部副作用一律出事务走 Outbox(ENG-OUTBOX-001),事务内不持有外部 I/O(ENG-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 事件与消费者 | 与续费事务同事务写事件;消费者条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因 |
| 恢复扫描(新增周期任务) | 只查询回填未知结果,不重复发起;终态收敛同批完成 |
| 通知 | 仅最终失败或确认失败时按通知契约投递 |
两点事实:
- 不得直接调用既有「若已停机则复机」入口:它对持锁、已开机、非轮询停因、条件不满足四类跳过都以成功返回(
internal/service/iot_card/stop_resume_service.go:565),无法区分结果,不能作为尝试记录的结果来源。 - 既有判定函数为包内私有,跨包使用须新增导出入口,不得复制规则。
现实观察(实现与验收都需按此预期):资格要求存在未过期的当前主套餐,而续购总把新主套餐写为待生效,因此复机前置条件多数不成立、复机多为跳过;真正会触发的只有「读取资格后、执行事务前当前主套餐刚好过期」这一窄窗口(此时新主套餐直接生效)。跳过不发通知。
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 自动执行。