## 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`),条件更新依据尝试记录仍处于非终态。 外部副作用一律出事务走 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 事件与消费者 | 与续费事务同事务写事件;消费者条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因 | | 恢复扫描(新增周期任务) | 只查询回填未知结果,不重复发起;终态收敛同批完成 | | 通知 | 仅最终失败或确认失败时按通知契约投递 | 两点事实: 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 自动执行。