Files
junhong_cmp_fiber/openspec/changes/add-package-silence-period/design.md
邹进 af57de0d4d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
fix: 店铺分配覆盖为实名即生效的套餐纳入沉默期配置口径
存量中套餐本身购买即生效、店铺分配覆盖为实名即生效的正式套餐,购买后同样等实名,
但原口径只看套餐本身:无法配置默认沉默期、不进未配置清单与打开前检查,
待实名记录无法回填导致总开关打不开,新购买也绕过沉默期。

- 未配置清单与打开前检查纳入存在实名即生效分配覆盖的套餐
- 购买即生效正式套餐允许选填默认沉默期,加油包仍禁止
- 分配覆盖购买即生效 → 实名即生效在套餐已配置默认沉默期时放行
- 同步 OpenSpec design D11、spec、tasks 3.4/3.5 与前端说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:56:29 +09:00

11 KiB
Raw Blame History

Context

实名即生效套餐(最终生效条件 from_activation)在资产实名前保持 status = 0、pending_realname_activation = true,现在没有任何期限。业务确认需要沉默期、过期失效与延期收费,规则与 31 项已确定决定见技术方案 docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md(下称"技术方案",章节号以 §n 引用)。本设计只记录实施时必须遵守的结构性决定;字段清单、接口字段、错误码、示例与测试场景以技术方案为准。

现状要点:

  • 激活统一经过 ActivationService.canActivatePendingUsage(internal/service/package/activation_service.go);排队取队首 getNextPendingMainPackage;孤儿扫描 findAndActivateOrphanPackages(internal/polling/package_activation_handler.go,10-27 已加"未实名不选中"的条件)。
  • 订单支付后发放套餐只有两个入口:订单服务 activatePackage(6 处调用)与自动购包 activatePackages。
  • 在线支付已有商户池路由(merchantpayment.RuntimeLoader.SelectForNewPaymentWithTx + FreezeRoute)、渠道适配器、支付恢复任务;代理充值的在线创建、线下登记、查单补偿(internal/application/agentrecharge/)是延期订单的参照实现。
  • 企微审批按业务类型分发(internal/application/approval/),业务类型受 chk_wecom_approval_scene_business 白名单约束(最近一次扩展在 000216)。
  • 换货迁移套餐记录的条件为 status IN (0,1,2)(internal/service/exchange/migration.go:105),旧资产世代 +1(exchange/service.go)。
  • 迁移编号当前到 000258;测试环境是唯一验证面,迁移用 ~/go/bin/migrate 与 .env 的 DB_*。

Goals / Non-Goals

Goals:

  • 每笔实名即生效套餐有沉默期;过期后不能激活、不能退款;可按四种方式延期。
  • 总开关默认关闭:上线、配置、回填期间不改变任何现有行为;打开前自动检查。
  • 延期订单与线上订单同一套支付(商户池、回调、查单、原路退款),交易可追溯。
  • 卡、设备首次购买套餐时间与首次购买套餐记录。

Non-Goals:

  • 通知提醒(短信、公众号、弹窗)。
  • 延期费分佣、延期费计入代理业绩。
  • 运营直接修改截止时间的任何接口。
  • 老系统无订单资产的首次购买套餐时间补数(后续另行回填)。

Decisions

D1 沉默期建在套餐记录上,过期不改 status

字段加在 tb_package_usage,新增独立 silence_status(空 / waiting / expired / ended),过期时 status 保持 0,由激活闸门拦截。

  • 备选 A:过期改 status = 4。否决:4 已表示退款、资产失效;延期时要把终态恢复回来,且会被换货迁移条件排除。
  • 备选 B:延期时新建一笔套餐记录(技术方案 v2 的做法)。否决:订单、退款、换货都要处理两条记录的关系,且容易重复计收入。
  • 代价:所有"查 status = 0 待生效套餐"的地方都要考虑 silence_status = expired。已知入口:激活闸门、排队队首、孤儿扫描、退款申请、展示。实施时 MUST 全局搜索 PackageUsageStatusPending 的查询逐一确认。

D2 判定集中在一个领域函数与一个应用用例

internal/domain/package/silence.go 放纯函数:SilenceDeadline、判定(wait / end / expire / noop)、延期报价(overdue、min/max、ceil、金额、新截止)、政策匹配与重叠校验、免审批判断。应用层 ResolveUsageSilence 负责加锁、读实名事实、条件更新、写流水与审计,所有入口只调用它。

  • 理由:时间与金额规则多处使用(激活、扫描、回填、资格、执行),只能有一份实现;纯函数便于在测试环境之外推演边界(日期跨月、ceil)。

D3 总开关只决定"执行",不决定"记录"

package.silence.enabled 关闭时仍写沉默期字段,只是不拦截、不扫描、不展示、延期接口拒绝。

  • 备选:关闭时完全不写字段。否决:回填后到打开开关之间的新订单会留下缺口,回填永远补不完。
  • 开关读取走现有系统配置缓存;应急关闭后,已发生的支付回调与审批回调 MUST 照常执行(避免收钱不延期),只拒绝新申请。
  • 打开前检查放在开关的写接口里,不满足返回未满足项与数量,前端直接展示。

D4 延期订单单独建表,支付复用线上订单链路

tb_package_silence_extension_order + tb_payment(order_type = silence_extension)。商户池路由、冻结、回调、查单、原路退款全部复用;在线创建参照 agentrecharge/online_creation.go,查单参照 recover_online_payment.go,线下登记参照 offline_creation.go(收款方式字典 EmployeeCollectionPaymentMethod)。

  • 备选:作为一种套餐订单写入 tb_order。否决:会进入订单分类、佣金、退款、导出等全部订单逻辑,延期费不分佣、不退款与之冲突。
  • 回调分发:internal/handler/callback/payment.go 按 order_type 增加分支。tb_payment.order_type 无数据库约束,只加代码常量。

D5 批次 + 明细统一四种路径

每次延期都建批次(单个即 1 条明细),路径 online / offline / special,approval_required 由政策快照与类型决定;执行统一走"延期执行"用例(技术方案 §8.6),以明细状态条件更新保证幂等。这样在线付款、线下审批、特殊审批、批量导入共用一个执行入口与异常重试。

D6 一个审批场景,按关联业务记录分发

只新增业务类型 package_silence_approval,模板用"申请类型"控件区分三种申请。审批实例关联到调整申请 / 延期批次 / 延期订单,回调按关联记录分发,不解析表单文本。申请人 creator_userid 为发起人绑定的企微成员,审批人 use_template_approver = 1。

D7 免审批是政策上的多选项

approval_exempt_types 放在延期政策里,提交时匹配并快照。免审批只跳过审批,其余校验与留痕不变。在线付款始终不审批,不在可选项。

D8 截止与计费全部按天、按 30 天向上取整

截止 = 当日 23:59:59(Asia/Shanghai);延期按输入天数 ceil(days / 30) 计费,延长天数 = 计费天数;新截止从原截止日加。最少月数 floor(overdue/30) + 1 保证新截止晚于今天。特殊延期只受最少月数约束。

D9 首次购买套餐时间写在发放套餐的入口

在 activatePackage 与 activatePackages 的事务里写,条件 first_purchase_at IS NULL AND generation = 订单世代,影响行数为 1 时插入首次购买套餐记录;当前绑定在设备上的卡跳过。换货在世代 +1 的同一事务里清空并结束旧记录;换货后的新资产不因换货写入(套餐记录迁移不算购买),在当前世代真正支付套餐订单时才按首次购买写入(10-27 与业务确认,替代技术方案 D28)。

D10 历史回填是独立工具,先出清单

回填(沉默期、首次购买套餐时间)放在 cmd/ 下的一次性工具,支持"只出清单"与"执行"两种模式,分批、只处理空值、可重复执行。生产执行前 MUST 由业务确认清单。生产只读拉数遵守项目约束(dbhub 只读)。

D11 分配覆盖为实名即生效的套餐纳入配置口径

最终生效条件 = 店铺分配覆盖值 ?? 套餐值。存量数据中有套餐本身 from_purchase、但店铺分配覆盖为 from_activation 的正式套餐,购买后同样等实名;若只按套餐本身判断,这类套餐既不能配置默认沉默期,也不进打开前检查,待实名记录永远无法回填,总开关打不开,新购买也绕过沉默期(10-27 测试环境验证发现)。

口径:正式套餐本身 from_activation,或存在未删除的分配覆盖为 from_activation,即视为"需要默认沉默期"。后者计入未配置清单与打开前检查。购买即生效正式套餐允许选填默认沉默期(不强制,只在分配覆盖为实名即生效时使用;否则第一次覆盖前无法配置,形成死锁),加油包仍禁止。分配覆盖 from_purchase → from_activation 由一律阻断改为"套餐已配置默认沉默期时放行",与"套餐 from_purchase → from_activation 须同时提供默认沉默期"保持同一原则。购买时的沉默期设置仍按记录的 expiry_base_snapshot 与套餐默认值判断,不变(替代技术方案 §3.3 第 4 行"一律阻断")。

Risks / Trade-offs

  • [打开总开关后一批历史套餐立即失效,过期满 180 天的无法付费延期] → 回填清单单独列出"将过期""将过期且无法付费延期"数量,业务确认后再打开;特殊延期可兜底;可应急关闭。
  • [遗漏某个查询 status = 0 的入口,过期套餐被激活或挡住队列] → D1 要求全局搜索;测试场景覆盖激活、排队、孤儿扫描、退款、换货。
  • [生产存在 first_realname_at 为空的已实名卡,被误判过期] → 判定时视为期内已实名并补写时间;回填清单单列数量。
  • [跨天支付导致新截止不晚于当天] → 进入异常并原路退款;下单时 expire_at 不跨天可降低发生率。
  • [客户 H5 待支付订单与运营二维码并发] → 新建前查单旧支付单,已支付则不新建;部分唯一索引兜底。
  • [企微模板未配置导致需审批的申请无法提交] → 打开前检查包含模板配置;提交时返回明确错误。
  • [扫描与实名事件并发] → 条件更新 + 影响行数判断,审计只写一次。
  • [down 迁移会删除业务事实] → 只在测试环境演练,生产不用 down 回滚业务,应急用总开关。

Migration Plan

迁移①~⑨见技术方案 §13.1:tb_package_usage 加 10 个字段与部分索引;tb_package 加 2 个字段;新建 5 张表;默认政策;系统配置(总开关默认关闭);扩展 chk_wecom_approval_scene_business 加入 package_silence_approval;tb_iot_card、tb_device 加 first_purchase_at 与索引;新建 tb_asset_first_purchase_record。加列使用常量默认值(PostgreSQL 11+ 不重写表),建索引放低峰。

上线顺序(技术方案 §13.3):测试环境 up → down → up 与全量场景验证 → 生产迁移 → 部署(总开关关闭)→ 配置企微模板与权限 → 业务补齐默认沉默期 → 核查新订单字段 → 回填(沉默期、首次购买套餐时间)→ 超级管理员打开总开关并观察。

回滚:业务层面用总开关关闭;代码回滚前先关闭总开关;生产不执行 down。

Open Questions

无。31 项决定已在技术方案 §15.1 确认。