feat: 实名即生效套餐沉默期、延期办理与资产出库时间
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s

- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-27 16:32:58 +09:00
parent d2b0d2f534
commit 909f4d0ec2
112 changed files with 10473 additions and 82 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27

View File

@@ -0,0 +1,106 @@
## 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` 的事务里写,条件 `outbound_at IS NULL AND generation = 订单世代`,影响行数为 1 时插入出库记录;当前绑定在设备上的卡跳过。换货在世代 +1 的同一事务里清空并结束旧记录,新资产写换货完成时间。
### D10 历史回填是独立工具,先出清单
回填(沉默期、出库时间)放在 `cmd/` 下的一次性工具,支持"只出清单"与"执行"两种模式,分批、只处理空值、可重复执行。生产执行前 MUST 由业务确认清单。生产只读拉数遵守项目约束(dbhub 只读)。
## 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` 加 `outbound_at` 与索引;新建 `tb_asset_outbound_record`。加列使用常量默认值(PostgreSQL 11+ 不重写表),建索引放低峰。
上线顺序(技术方案 §13.3):测试环境 `up → down → up` 与全量场景验证 → 生产迁移 → 部署(总开关关闭)→ 配置企微模板与权限 → 业务补齐默认沉默期 → 核查新订单字段 → 回填(沉默期、出库时间)→ 超级管理员打开总开关并观察。
回滚:业务层面用总开关关闭;代码回滚前先关闭总开关;生产不执行 down。
## Open Questions
无。31 项决定已在技术方案 §15.1 确认。

View File

@@ -0,0 +1,40 @@
## Why
实名即生效套餐在资产实名前一直处于待生效,现在没有任何期限:客户买了套餐长期不实名,套餐永远挂着,既不能失效也不能收取延期费用。业务已确认《实名即生效套餐沉默期规则需求确认稿_10.16》及 10-27 的补充决定,需要为这类套餐加上沉默期:期限内实名正常生效,过期失效,失效后可付费或经审批延期。同时卡与设备需要记录出库时间(首次购买套餐时间)用于展示。技术方案见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(以下简称"技术方案"),规则以技术方案为准。
## What Changes
- **沉默期按每笔套餐计算**:实名即生效主套餐在资产未实名时购买,从支付成功起计算沉默期,截止日当天 23:59:59 到期;字段建在套餐记录上,资产上不建沉默期字段。
- **套餐配置**:实名即生效套餐必填默认沉默期(1~90 天,新建预填 30)和"是否支持延期";禁止把实名即生效套餐改成购买即生效(套餐与店铺分配两处);存量套餐由业务在后台补齐。
- **后台购买时调整沉默期**:默认值以内直接生效;超过默认值默认需企微审批(先按默认值购买,审批中不过期,通过后重算),不设上限。
- **过期与激活拦截**:截止前实名正常激活(或排队);截止后仍未实名标记为"沉默期已过期",套餐状态保持待生效,由激活入口统一拦截;排队与孤儿套餐恢复跳过过期套餐;过期套餐不能退款。每 10 分钟扫描到期套餐。
- **沉默期总开关**:默认关闭;关闭时只记录沉默期、不执行任何规则;参数配置与历史回填完成、通过打开前检查后由超级管理员打开;可应急关闭。
- **延期政策(可配置)**:适用范围(全部 / 系列 / 指定套餐)、每月单价、免费延期次数、付费累计与单次最多月数、免审批场景(多选)、生效时间、启停;预置默认政策(5 元/月、免费 1 次、累计 6 个月)。
- **延期计费**:从原截止日往后加;客户按天输入,按每月 30 天向上取整计费,延长天数等于计费天数;系统计算最少 / 最多天数,最少超过最多则不能付费延期。
- **四种延期办理**:H5 自助与后台二维码(在线支付、商户池路由、不审批);线下付款登记(默认审批,可配免审批);特殊延期(免费、优惠、补偿、特殊客户、批量;默认审批,可配免审批,不设上限)。延期订单单独建表,记录支付方式、收款商户、交易流水号或线下收款信息;延期费不分佣、不退(执行异常除外)。
- **企微审批**:新增一个审批场景,用"申请类型"区分购买时调整、特殊延期、线下付费延期。
- **换货**:沉默期字段随套餐迁移,不重新起算。
- **历史回填**:按购买时间起算,不给上线宽限;截止前已实名的记为已结束;先出影响清单,业务确认后执行。
- **出库时间**:卡、设备新增出库时间(当前世代首次购买套餐的支付时间)与出库记录表;设备上绑定的卡不记;换货新资产取换货完成时间;只做记录展示,不受总开关控制。
- 不做通知提醒(确认稿第七节不实现)。
## Capabilities
### New Capabilities
- `package-silence-period`:实名即生效套餐沉默期的设置、购买时调整、实名与过期判定、激活拦截、扫描、总开关与历史回填。
- `package-silence-extension`:延期政策、延期资格与计费、四种延期办理、延期订单与支付、企微审批与免审批、延期执行与异常处理。
- `asset-outbound-time`:卡与设备的出库时间与出库记录。
### Modified Capabilities
- `package-queue-activation`:排队接续与孤儿待生效套餐恢复必须跳过沉默期已过期的套餐。
- `order-refund-exchange`:沉默期已过期的套餐不能申请退款;换货迁移时沉默期随套餐迁移、不重新起算。
## Impact
- 数据库:迁移 9 项(技术方案 §13.1)——`tb_package_usage` 新增 10 个沉默期字段与扫描索引;`tb_package` 新增 2 个字段;新建延期政策、沉默期流水、延期订单、延期批次、延期明细 5 张表;默认政策与系统配置(含总开关,默认关闭);扩展企微审批场景业务类型白名单;`tb_iot_card`、`tb_device` 新增出库时间与出库记录表。总开关关闭前不改变任何现有行为。
- 代码:套餐服务与分配、订单支付后发放套餐(`internal/service/order/service.go`、`internal/task/auto_purchase.go`)、激活服务(`internal/service/package/activation_service.go`)、排队与孤儿扫描(`internal/polling/package_activation_handler.go`)、退款、换货、支付回调(`internal/handler/callback/payment.go`)、商户池与支付渠道适配、企微审批(`internal/application/approval/`)、新应用层 `internal/application/packagesilence/`、领域规则 `internal/domain/package/`、定时任务、后台与 H5 Handler、导出、财务流水。
- 接口:新增延期政策、延期资格、延期下单、线下登记、特殊延期、延期订单查询、总开关、出库记录等接口;套餐、购买、资产套餐、卡与设备列表和详情增加字段。H5 新增延期入口。
- 数据:上线后先补齐套餐默认沉默期,再执行历史回填与出库时间回填;打开总开关后,截止已过且期内未实名的历史套餐会失效(不给宽限)。
- 运营配置:延期政策、企微审批模板、权限分配、商户池与线下收款方式检查(技术方案 §18.3)。

View File

@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: 卡与设备出库时间
系统 SHALL 为 IoT 卡与设备记录出库时间 `outbound_at` = 资产当前世代第 1 次购买套餐(主套餐或加油包)的订单支付成功时间。卡取单卡订单,设备取设备订单;**当前绑定在设备上的卡不记录出库时间**(套餐在设备上)。出库时间写入后 MUST NOT 被后续订单覆盖,首笔订单退款也不清空。出库时间只用于展示、筛选与导出,MUST NOT 参与业务判断,不受沉默期总开关影响。
写入发生在订单支付后发放套餐的两个入口(订单 `activatePackage`、自动购包 `activatePackages`)的同一事务内:`UPDATE ... SET outbound_at = :paid_at WHERE id = :asset_id AND generation = :order_generation AND outbound_at IS NULL`,影响行数为 1 时插入出库记录。
#### Scenario: 首次购买写入
- **WHEN** 新卡当前世代第一次支付套餐订单
- **THEN** 卡 `outbound_at` 为该订单支付时间,并新增一条出库记录(来源 `order`,带订单号)
#### Scenario: 后续订单不覆盖
- **WHEN** 同一世代再次购买套餐,或首笔订单被退款
- **THEN** `outbound_at` 不变
#### Scenario: 设备上绑定的卡
- **WHEN** 设备订单支付成功,设备上绑定了两张卡
- **THEN** 只写设备的出库时间,两张卡不写
### Requirement: 出库记录
系统 SHALL 用 `tb_asset_outbound_record` 保存每个世代的出库记录:资产类型与 ID、资产标识快照、世代、出库时间、来源(`order` / `exchange` / `backfill`)、订单号或换货单号、店铺、结束时间与原因。同一资产同一世代只有一条(唯一约束);记录只新增与填写结束信息,不修改出库时间、不删除。系统 SHALL 提供按资产查询各世代出库记录的接口。
#### Scenario: 换货后历史可查
- **WHEN** 资产换货回库后再次销售并购买套餐
- **THEN** 能查到两条记录:旧世代带结束时间与原因 `exchange_return`,新世代为新的出库时间
### Requirement: 换货时的出库时间
换货时旧资产世代 +1、回到在库的同一事务内,系统 MUST 清空旧资产 `outbound_at` 并为旧世代记录填写结束时间与原因;换货后的新资产若当前世代出库时间为空,MUST 写入换货完成时间并插入出库记录(来源 `exchange`,带换货单号)。
#### Scenario: 换货新资产
- **WHEN** 卡 A 换成卡 B 完成
- **THEN** 卡 A 出库时间清空,卡 B 出库时间为换货完成时间
### Requirement: 出库时间历史回填与展示
系统 SHALL 提供出库时间回填工具:按资产当前世代最早一笔已支付或已退款的套餐订单回填(排除当前绑定在设备上的卡),分批执行、只更新为空的、可重复执行,并插入来源 `backfill` 的出库记录;回填前输出可回填数与无订单数。老系统迁移、新系统无订单的资产保持为空,后续另行回填。卡列表、设备列表返回出库时间并支持按时间范围筛选;卡详情、设备详情与资产解析返回出库时间;卡、设备导出增加"出库时间"列。字段与记录只读。
#### Scenario: 无订单资产
- **WHEN** 老系统迁移的卡在新系统没有任何套餐订单
- **THEN** 回填后出库时间仍为空

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 沉默期已过期的套餐不能退款
沉默期总开关打开时,退款申请创建 MUST 拦截 `silence_status = expired` 的套餐,返回 `silence_expired_not_refundable`。沉默期延期费 MUST NOT 退款,仅在延期执行异常时按支付单冻结的商户原路退回。
#### Scenario: 申请退款过期套餐
- **WHEN** 为一笔沉默期已过期的套餐发起退款申请
- **THEN** 系统拒绝,返回 `silence_expired_not_refundable`
### Requirement: 换货时沉默期随套餐迁移
换货迁移套餐记录时,沉默期字段 MUST 随记录迁移,不重新起算、不重新给沉默期;现有迁移条件 `status IN (0,1,2)` 已包含 `waiting` 与 `expired` 记录,无需修改。迁移后:`waiting` 记录按新资产实名事实判定;`expired` 记录仍为过期,可在新资产上延期。延期执行按套餐记录当前载体,审批在换货前提交、换货后通过的,延期作用于迁到新资产的同一笔套餐。迁移 MUST 写沉默期流水(换货单号、前后资产、截止时间)。
#### Scenario: 过期套餐换货后延期
- **WHEN** 卡 A 上一笔沉默期已过期的套餐随换货迁到卡 B
- **THEN** 套餐在卡 B 上仍为过期,截止日不变,可在卡 B 上办理延期

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 排队与孤儿恢复跳过沉默期已过期套餐
沉默期总开关打开时,排队取下一个主套餐(`getNextPendingMainPackage`)MUST 跳过 `silence_status = expired` 的套餐记录,避免过期套餐挡住同资产后续套餐;孤儿待生效套餐扫描(`findAndActivateOrphanPackages`)的候选查询 MUST 排除 `silence_status = expired` 的记录,不得为其重复提交激活任务。总开关关闭时行为与现状一致。
#### Scenario: 队首是过期套餐
- **WHEN** 当前主套餐到期,同资产队首待生效套餐 `silence_status = expired`,其后还有一笔正常待生效套餐
- **THEN** 系统跳过过期套餐,激活其后的正常套餐
#### Scenario: 孤儿扫描不选中过期套餐
- **WHEN** 资产没有生效中主套餐,唯一的待生效主套餐已沉默期过期
- **THEN** 孤儿扫描不选中该记录,不提交激活任务

View File

@@ -0,0 +1,157 @@
## ADDED Requirements
### Requirement: 延期政策可配置
系统 SHALL 提供延期政策 `tb_package_silence_extension_policy` 的列表、详情、新增、编辑、启停与"查询某套餐当前匹配政策",需 `package_silence:policy` 权限,变更写审计。政策字段:名称、适用范围(`all` 全部 / `series` 系列 / `package` 指定套餐)与范围 ID、每月单价(分)、免费延期次数、付费延期累计最多月数、单次最多月数(可空)、免审批场景(多选)、生效与失效时间、启停。配置按月填写,1 个月 = 30 天。
匹配规则:候选为启用且在生效期内的政策,优先级"指定套餐 > 套餐系列 > 全部套餐";同一优先级的适用范围与生效时间段 MUST NOT 重叠,保存时重叠返回 `silence_policy_overlap`。上线预置一条"全部套餐、长期有效、500 分/月、免费 1 次、累计 6 个月、单次不限、无免审批场景"的默认政策。
延期订单、批次与明细 MUST 冻结政策快照(政策 ID 与名称、单价、免审批场景、免费次数、付费累计与单次上限);修改或停用政策只影响之后发起的延期。
#### Scenario: 按优先级匹配
- **WHEN** 存在全部套餐政策(5 元/月)与"年卡"系列政策(3 元/月),为年卡系列套餐查询延期资格
- **THEN** 单价返回 300 分
#### Scenario: 同级重叠
- **WHEN** 新建一条与已有系列政策同系列、生效时间重叠的政策
- **THEN** 保存失败,返回 `silence_policy_overlap`
#### Scenario: 改价不影响已创建订单
- **WHEN** 延期订单创建后政策单价从 5 元改为 8 元
- **THEN** 该订单仍按 5 元计价
### Requirement: 延期资格与计费
系统 SHALL 提供按套餐记录查询延期资格的接口(后台与 H5 同语义),返回:能否付费延期 `can_extend` 与原因、能否申请特殊延期、原截止日、已过期天数、最少与最多可输入天数、最少与最多月数、已累计与已付费延期天数、能否申请免费延期、单价、各月数选项(计费天数、金额、新截止日)。计算 MUST 由后端完成,不接受前端传入金额:
```text
overdue_days = 今天日期 − 套餐截止日期
min_months = floor(overdue_days / 30) + 1
remain = 政策.paid_max_months − silence_paid_extended_days / 30
max_months = min(remain, 政策.single_max_months)(单次上限为空时取 remain)
min_days = overdue_days + 1;max_days = max_months × 30
months = ceil(输入天数 / 30);计费天数 = 延长天数 = months × 30
amount = months × 政策单价
新截止日 = (原截止日 + 延长天数) 当日 23:59:59
```
前置条件:套餐 `silence_status = expired`(`waiting` 但已到期的先判定为过期)。未到期、无沉默期、已激活、已退款的拒绝。套餐 `silence_extension_enabled = false` 或未匹配到政策时不能付费延期,但可申请特殊延期。`min_months > max_months` 时不能付费延期。总开关关闭时返回 `silence_feature_disabled`。
#### Scenario: 过期 75 天的资格
- **WHEN** 套餐截止日 2026-10-01,2026-12-15 查询,默认政策,未延期过
- **THEN** 返回 `overdue_days = 75`、`min_days = 76`、`max_days = 180`、`min_months = 3`、`max_months = 6`、单价 500
#### Scenario: 按天向上取整计费
- **WHEN** 客户输入 1、30、31、60 天
- **THEN** 分别按 30、30、60、60 天计费并延长相同天数
#### Scenario: 超过可延期期限
- **WHEN** 截止日 2026-10-01,2027-04-05 查询(已过期 186 天),默认政策
- **THEN** `can_extend = false`,返回 `min_months = 7`、`max_months = 6`,特殊延期仍可申请
### Requirement: 在线付费延期
客户在 H5 自助下单,或运营在后台创建订单并生成支付二维码由客户扫码支付,MUST 支付成功后自动延期,不审批。
- 创建延期订单(`tb_package_silence_extension_order`)与支付单 `tb_payment`(`order_type = silence_extension`);商户按支付方式经 `merchantpayment` 商户池路由选择并冻结在支付单上;后台二维码复用渠道适配器生成支付内容存入 `qr_content`。
- 下单时冻结输入天数、月数、单价、金额、下单时截止日与预计新截止日;按当天重算 min/max 校验。
- 同一笔套餐同时最多一笔未结束订单:新建前若已有待支付订单,先对旧支付单查单,确认未支付则关闭旧订单再新建,已支付则按已支付处理并不再新建。
- 支付回调按 `order_type` 分发,条件更新 `pending_payment → paid → completed`,重复回调幂等;回调金额不一致进入 `exception`。未支付订单复用支付恢复任务查单,过期关闭。
- 支付时新截止日不再晚于当天的,明细进入 `exception`,按冻结的商户原路退款。
- 回调、查单、退款按支付单冻结的商户加载凭证,不按当前商户池推断。
- 延期费不参与分佣,不计入代理累计充值与业绩。不从代理钱包扣款。
#### Scenario: H5 付款后自动延期
- **WHEN** 客户在 H5 输入 76 天,微信支付 15 元成功
- **THEN** 订单 `completed`,套餐截止日延长 90 天,回到 `waiting`,不产生审批与佣金
- **AND** 订单列表显示支付方式、收款商户名称、交易流水号
#### Scenario: 后台二维码与客户旧订单冲突
- **WHEN** 客户已有一笔未支付的 H5 延期订单,运营为同一套餐生成二维码
- **THEN** 系统先查单旧支付单,未支付则关闭旧订单后生成新二维码
#### Scenario: 重复支付回调
- **WHEN** 同一支付单的成功回调到达两次
- **THEN** 只延期一次
### Requirement: 线下付费延期
运营(`package_silence:offline`)登记线下付款时 SHALL 输入延期天数(换算后月数在 min~max 内),填写线下收款方式(复用现有线下收款方式字典)、交易单号、实收金额、付款时间,上传收款凭证与客户申请依据。实收金额 MUST 等于系统计算金额,否则返回 `silence_offline_amount_mismatch`;同一收款方式下交易单号 MUST 唯一,重复返回 `silence_offline_trade_no_duplicate`。
政策未勾选免审批场景 `offline_paid` 时,订单 `pending_approval` 并提交企微审批(申请类型"线下付费延期"):通过后执行延期,驳回或撤回订单 `rejected`、不延期、系统不退款。勾选时登记即执行。线下付费计入付费累计天数,受政策上限与套餐"是否支持延期"约束。
#### Scenario: 默认需审批
- **WHEN** 运营登记一笔 15 元线下付款,政策未设免审批
- **THEN** 订单待审批,审批通过后延期生效
#### Scenario: 交易单号重复
- **WHEN** 同一收款方式下再次登记相同交易单号
- **THEN** 系统拒绝
### Requirement: 特殊延期
有权限的运营 SHALL 可提交特殊延期:免费、优惠价、客诉补偿、系统异常补偿、特殊客户政策;批量延期需上传明细且仅限 `package_silence:extend_batch`。申请字段包括资产、套餐与订单号、原截止日(系统带出)、延期天数(按天向上取整到 30 的倍数)、原因、客户申请记录或截图、费用(免费为 0)、免费或优惠依据;优惠价有收款的同时登记线下收款信息。
特殊延期 MUST NOT 受政策付费上限与套餐"是否支持延期"约束,只要求新截止日晚于今天(月数 ≥ min_months),天数不计入付费累计。免费延期次数 MUST 受政策 `free_extension_limit`(默认 1)约束:提交与执行时都校验,执行成功后计数加 1;驳回与撤回不占次数;同一笔套餐同时只能有一个进行中的免费延期申请。
政策未勾选对应免审批场景(批量看 `batch`,否则看延期类型)时提交企微审批(申请类型"特殊延期"),通过后自动执行,驳回或撤回不产生任何变化;勾选时提交即执行。需审批的场景 MUST NOT 先延期后补审批。批量延期逐条执行,部分失败不影响其他行,记录成功数、失败数与失败原因。
#### Scenario: 免费延期次数用完
- **WHEN** 套餐已成功使用 1 次免费延期,政策免费次数为 1,再次申请免费延期
- **THEN** 拒绝,返回 `silence_free_extension_used_up`
#### Scenario: 付费办不了仍可特殊延期
- **WHEN** 套餐已过期 186 天、付费延期不可办理
- **THEN** 仍可提交特殊延期,月数不受上限限制
#### Scenario: 批量部分失败
- **WHEN** 批量特殊延期 10 条,审批通过时其中 2 条已被退款
- **THEN** 8 条延期成功,2 条记失败及原因
### Requirement: 延期执行
所有延期路径 MUST 在同一事务内执行:锁载体与套餐记录并重读;校验仍为 `expired`、新截止日晚于当天;付费延期另校验单次与累计上限(按政策快照);更新 `silence_status = waiting`、`silence_deadline_at = SilenceDeadline(原截止日期, 延长天数)`、累计延期天数、付费延期天数(付费延期)、延期次数、免费次数(免费延期);同事务写明细状态、批次计数、流水与审计;提交后执行沉默期判定,期内已实名则激活或排队。执行以明细状态条件更新保证幂等。执行失败进入 `exception`,有 `package_silence:exception` 权限者可重试或关闭,MUST NOT 直接改库时间。事务内 MUST NOT 调用支付渠道、Gateway 或企微接口。
延期只延长实名等待时间,不增加套餐使用时长;延期后再次到期未实名可再次延期。
#### Scenario: 延期时资产已实名
- **WHEN** 卡在过期后、延期前已实名,延期后新截止日晚于实名时间
- **THEN** 延期执行后套餐立即激活(或排队)
#### Scenario: 重复审批通过事件
- **WHEN** 同一审批单的通过事件到达两次
- **THEN** 只执行一次延期
### Requirement: 沉默期企微审批场景
系统 SHALL 新增一个企微审批业务类型 `package_silence_approval`,用模板中的"申请类型"控件区分"购买时调整沉默期""特殊延期""线下付费延期"。申请人为发起人绑定的企微成员(`creator_userid`),审批人按模板配置(`use_template_approver = 1`);发起人未绑定企微成员时拒绝提交。回调按审批单关联的业务记录(调整申请 / 延期批次 / 延期订单)分发,不依赖表单文本。复用现有场景配置、模板指纹校验、可靠提交、回调与轮询。勾选为免审批的场景不提交到本场景。
#### Scenario: 发起人未绑定企微
- **WHEN** 未绑定企微成员的运营提交特殊延期
- **THEN** 系统拒绝提交
### Requirement: 延期订单查询
系统 SHALL 提供延期订单列表、详情与导出:后台按套餐、资产、订单号、状态、支付方式、收款商户、交易流水号、支付时间筛选,叠加店铺数据范围;在线订单返回 `payment_method`、`payment_merchant_name`、`third_party_trade_no`(取已支付或已退款支付单快照),线下订单返回收款方式与交易单号;H5 客户查看自己的延期订单。延期订单纳入财务交易流水查询与导出。交易记录不删除。
#### Scenario: 查询在线延期订单
- **WHEN** 后台查询一笔已完成的微信延期订单
- **THEN** 返回收款商户名称与交易流水号,与线上套餐订单字段一致

View File

@@ -0,0 +1,178 @@
## ADDED Requirements
### Requirement: 实名即生效套餐沉默期按每笔套餐计算
系统 SHALL 在订单支付成功、创建套餐记录的同一事务内,为满足条件的套餐记录设置沉默期:主套餐(`master_usage_id IS NULL`)、生效条件快照为 `from_activation`、购买时资产尚未实名(`pending_realname_activation = true`)、所购套餐已配置默认沉默期。沉默期字段 MUST 保存在套餐记录上,资产上 MUST NOT 保存沉默期字段。同一资产的多笔实名即生效套餐 MUST 各自计算。
截止时间 MUST 按天计算:`(支付成功日期 + 沉默期天数) 当日 23:59:59`,时区 `Asia/Shanghai`,由唯一领域函数计算。
#### Scenario: 资产未实名时购买实名即生效套餐
- **WHEN** 2026-09-24 10:00 为未实名的卡购买默认沉默期 30 天的实名即生效主套餐并支付成功
- **THEN** 套餐记录 `silence_status = waiting`、`silence_start_at` 为支付成功时间、`silence_period_days = 30`
- **AND** `silence_deadline_at = 2026-10-24 23:59:59`
- **AND** 写入一条沉默期流水(set),含默认值快照、是否调整、操作人、原因
#### Scenario: 不进入沉默期的套餐
- **WHEN** 购买的是加油包、购买即生效套餐,或购买时资产已实名,或套餐未配置默认沉默期
- **THEN** 套餐记录 `silence_status` 为空,行为与现状一致
#### Scenario: 同一资产多笔套餐各自计算
- **WHEN** 同一张未实名卡在 09-01 与 09-20 各购买一笔实名即生效套餐
- **THEN** 两笔套餐的截止日分别按各自的支付日期计算,一笔过期不影响另一笔
### Requirement: 套餐默认沉默期与是否支持延期配置
实名即生效套餐 MUST 配置默认沉默期 `silence_period_days`(1~90 天,新建时预填 30)与 `silence_extension_enabled`(默认支持);普通套餐 MUST NOT 写入沉默期。修改默认值 MUST 只影响之后的新购买,并写审计。系统 SHALL 提供"未配置沉默期的实名即生效套餐"清单(筛选与导出)。
#### Scenario: 实名即生效套餐未填默认沉默期
- **WHEN** 新建或编辑 `from_activation` 套餐时未提供 `silence_period_days`,或值不在 1~90
- **THEN** 系统拒绝保存,返回 `silence_period_out_of_range`
#### Scenario: 修改默认值不影响已购买记录
- **WHEN** 将套餐默认沉默期从 30 改为 45
- **THEN** 已有套餐记录的截止日不变,之后的新购买按 45 天计算
### Requirement: 禁止绕过沉默期的生效条件变更
系统 MUST 在套餐服务与店铺分配(单个、批量、系列授权)写入路径的共用校验中阻断:套餐 `from_activation` 改为 `from_purchase`、分配覆盖把 `from_activation` 套餐改为 `from_purchase`、分配覆盖把 `from_purchase` 套餐改为 `from_activation`。套餐 `from_purchase` 改为 `from_activation` 时同一请求 MUST 提供默认沉默期。
#### Scenario: 实名即生效改为购买即生效
- **WHEN** 管理员把实名即生效套餐改为购买即生效,或在店铺分配上覆盖为购买即生效
- **THEN** 系统拒绝,返回 `package_expiry_base_change_forbidden`
### Requirement: 后台购买时调整沉默期
后台给资产购买会进入沉默期的套餐时,有 `package_silence:override` 权限的运营 SHALL 可以设置本次沉默期(≥ 1 天,不设上限),必须填写原因;优先级为本次设置值高于套餐默认值。
- 设置值不超过默认值:直接生效。
- 设置值超过默认值:按匹配到的延期政策判断;未勾选免审批场景 `purchase_adjust`(含未匹配到政策)时,系统 MUST 先按默认值创建套餐记录,同时提交企微审批(申请类型"购买时调整沉默期"),并标记 `silence_adjust_pending = true`;勾选时直接按设置值生效。
- 审批中的套餐 MUST NOT 因到期而过期。审批通过后按 `起算日期 + 审批天数` 重算截止日;驳回或撤回保持默认值。审批结束后立即重新判定沉默期。
- 自动购包、钱包自动续费等无人参与的路径 MUST 只用默认值。
#### Scenario: 无权限传入调整值
- **WHEN** 无 `package_silence:override` 权限的账号在购买时传入沉默期
- **THEN** 系统拒绝,返回 `silence_override_forbidden`
#### Scenario: 超默认值需审批
- **WHEN** 默认 30 天的套餐在后台购买时设置为 120 天,政策未设免审批
- **THEN** 套餐记录按 30 天设置截止日,`silence_adjust_pending = true`,并提交企微审批
- **AND** 审批期间超过 30 天截止日也不会过期
- **AND** 审批通过后截止日改为购买日期 + 120 天;若重算后已过截止且期内未实名,立即过期
#### Scenario: 超默认值且政策免审批
- **WHEN** 匹配到的延期政策勾选了 `purchase_adjust`
- **THEN** 套餐记录直接按设置值计算截止日,不提交审批
### Requirement: 实名与过期判定
系统 SHALL 用统一判定处理 `waiting` 的套餐记录:期内已实名则沉默期结束(`ended`),之后按现有逻辑激活;同资产已有生效主套餐时继续排队,排队期间不再受沉默期限制。已到期、期内未实名且无调整审批的,MUST 标记为 `expired`,`status` 保持 0,记录 `silence_expired_at`,写流水与审计。
实名时间取实际业务时间:卡取 `first_realname_at`;设备取所有已绑定且已实名卡中 `max(卡实名时间, 绑定时间)` 的最小值。卡当前已实名但 `first_realname_at` 为空时 MUST 视为期内已实名,并补写 `first_realname_at` 为判定时间。
判定入口包括:运营商实名观测与人工实名、卡绑定设备、排队接续、指定激活、孤儿套餐扫描、延期生效、调整审批结束、沉默期扫描。
#### Scenario: 截止日当天实名
- **WHEN** 截止日为 10-24 23:59:59,卡在 10-24 23:30 实名
- **THEN** 套餐 `silence_status = ended` 并按现有逻辑激活,有效期从激活时间起算
#### Scenario: 截止后实名
- **WHEN** 卡在 10-25 08:00 才实名
- **THEN** 套餐标记为 `expired`,不激活
#### Scenario: 截止后才绑定已实名卡的设备
- **WHEN** 设备套餐截止后,才绑定一张早已实名的卡
- **THEN** 设备实名时间取绑定时间,晚于截止日,套餐过期
#### Scenario: 期内实名但需排队
- **WHEN** 资产在截止前实名,但同资产已有生效主套餐
- **THEN** 这笔套餐 `silence_status = ended`、`status` 仍为 0 排队,之后按排队接续激活,不再因沉默期过期
#### Scenario: 实名时间为空的已实名卡
- **WHEN** 卡 `real_name_status` 为已实名但 `first_realname_at` 为空
- **THEN** 判定为期内已实名,不判过期,并补写 `first_realname_at`
### Requirement: 沉默期已过期的套餐不能激活
`silence_status = expired` 的套餐 MUST NOT 被任何激活入口激活。`ActivationService.canActivatePendingUsage` MUST 在现有实名判断前先执行沉默期判定,过期时返回不可激活。对外展示 MUST 显示"沉默期已过期(失效)"并返回截止日与过期时间。任何人 MUST NOT 通过修改时间或状态把 `expired` 恢复为 `waiting`;只能通过延期。
#### Scenario: 过期套餐实名后仍不激活
- **WHEN** 已过期套餐所属卡完成实名
- **THEN** 套餐不激活,仍为 `expired`
#### Scenario: 已到期但尚未被扫描
- **WHEN** 套餐仍为 `waiting` 但已过截止日,此时触发任一激活入口
- **THEN** 入口先判定为过期,不激活
### Requirement: 沉默期扫描
系统 SHALL 每 10 分钟运行 `package:silence:scan`,查询 `status = 0 AND silence_status = 'waiting' AND silence_deadline_at < now() AND silence_adjust_pending = false` 的记录(单批 200,最多 20 批,Unique 9 分钟,MaxRetry 3),逐条执行统一判定。状态更新 MUST 带期望状态条件并检查影响行数,重复执行幂等。扫描只在总开关打开时运行。
#### Scenario: 扫描与实时判定一致
- **WHEN** 扫描和实名事件同时处理同一条到期记录
- **THEN** 只有一方更新成功,结果一致,审计只写一次
### Requirement: 沉默期总开关
系统 SHALL 提供系统配置 `package.silence.enabled`,默认关闭,仅超级管理员可切换,每次切换写审计 `package.silence_switch_changed`(操作人、前后值、检查结果)。
- 关闭时:新购买照常写入沉默期字段(只记录);不拦截激活、不扫描、不拦截退款、不展示沉默期与延期入口;延期资格、下单、线下登记、特殊延期申请返回 `silence_feature_disabled`;套餐与政策配置、审批模板配置、历史回填工具可用;购买时调整照常记录与提交审批。
- 打开前检查:实名即生效套餐全部已配置默认沉默期;没有"待实名、实名即生效主套餐、沉默期字段为空"的记录;存在启用中的"全部套餐"延期政策;企微审批场景 `package_silence_approval` 已配置启用。任一项不满足 MUST 拒绝打开,返回 `silence_switch_precheck_failed` 及未满足项与数量。
- 打开后:所有规则同时生效,扫描开始运行。
- 打开后再关闭:规则暂停、数据保留;已发生的付款回调、查单补偿与审批回调 MUST 照常处理完;关闭期间不接受新的延期申请。
#### Scenario: 默认关闭时行为不变
- **WHEN** 总开关关闭,未实名卡购买实名即生效套餐后超过截止日才实名
- **THEN** 套餐记录有截止日,但实名后照常激活,不判过期
#### Scenario: 检查未通过拒绝打开
- **WHEN** 仍有 3 个实名即生效套餐未配置默认沉默期时尝试打开
- **THEN** 系统拒绝,返回未满足项"未配置默认沉默期的套餐:3"
#### Scenario: 应急关闭时处理进行中的付款
- **WHEN** 总开关打开后又关闭,此时一笔延期订单的支付回调到达
- **THEN** 回调照常处理并执行延期
### Requirement: 历史数据回填
系统 SHALL 提供沉默期回填工具,可在总开关关闭时运行。前提是相关套餐的默认沉默期已补齐。回填范围为 `status = 0 AND pending_realname_activation = true` 的实名即生效主套餐记录;起算为该笔套餐的购买(支付成功)时间;天数取套餐默认沉默期;未配置的套餐不回填并列入清单。回填时 MUST 同时按实名时间判定,截止前已实名的直接记为 `ended`。不给上线宽限:截止早于上线日且期内未实名的,打开总开关后过期。
回填 MUST 先生成影响清单(套餐记录数、按代理与套餐分布、期内已实名数、实名时间为空的已实名数、将过期数、将过期且无法付费延期数),经业务确认后执行;每条写流水(backfill);可重复执行。
#### Scenario: 回填时已实名
- **WHEN** 历史套餐按购买时间算出的截止日之前,卡已实名
- **THEN** 回填记为 `ended`,不会过期
#### Scenario: 回填后截止日已过
- **WHEN** 历史套餐购买于 2026-03-01、默认 30 天,上线日为 2026-11-15,卡未实名
- **THEN** 回填截止日为 2026-03-31 23:59:59,打开总开关后第一轮扫描标记为过期
### Requirement: 沉默期变更留痕
套餐沉默期的设置、调整、过期、结束、延期、换货迁移、回填 MUST 写入不可变流水 `tb_package_silence_record`(套餐记录、事件类型、变更前后状态与截止时间、天数、默认值快照、是否调整、操作人、原因、审批单号、延期明细、时间),并写对应审计事件。系统 SHALL 提供按套餐查询流水的接口。
#### Scenario: 查询一笔套餐的沉默期历史
- **WHEN** 查询某笔经历过购买、过期、延期的套餐流水
- **THEN** 依次返回 set、expired、extend 三条记录及前后截止时间

View File

@@ -0,0 +1,121 @@
> 规则细节以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(下称"技术方案")为准,任务中的 §n 指技术方案章节。测试环境是唯一验证面(PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration` 分支部署、`cmp-test` 日志);迁移用 `~/go/bin/migrate` 与 `.env` 的 `DB_*`;fixture 只创建 / 删除本变更自己的记录;只读诊断用 dbhub。
## 1. 数据层(§3、§13.1、§17.2)
- [x] 1.1 迁移①:`tb_package_usage` 新增 10 个沉默期字段(`silence_status`、`silence_start_at`、`silence_period_days`、`silence_deadline_at`、`silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used`、`silence_expired_at`、`silence_adjust_pending`),计数类 `NOT NULL DEFAULT 0`,布尔 `NOT NULL DEFAULT false`,带列注释;成对 down
- [x] 1.2 迁移②:部分索引 `idx_package_usage_silence_scan (silence_deadline_at, id) WHERE deleted_at IS NULL AND status = 0 AND silence_status = 'waiting'`
- [x] 1.3 迁移③:`tb_package` 新增 `silence_period_days`(可空)、`silence_extension_enabled`(`NOT NULL DEFAULT true`)
- [x] 1.4 迁移④:新建 `tb_package_silence_extension_policy`、`tb_package_silence_record`、`tb_package_silence_extension_order`、`tb_package_silence_extension_batch`、`tb_package_silence_extension`,含 §7.1 / §7.2 的唯一约束与部分唯一索引(同一套餐最多一笔未结束订单、线下收款方式 + 交易单号唯一、`UNIQUE(batch_id, package_usage_id)`、同一套餐最多一条未结束明细)
- [x] 1.5 迁移⑤⑥:插入默认延期政策(全部套餐、长期、500 分/月、免费 1 次、累计 6 个月、单次不限、无免审批场景);插入系统配置 `package.silence.enabled`(false)、`package.silence.default_days`(30)、`package.silence.extension_unit_price`(500)
- [x] 1.6 迁移⑦:扩展 `tb_wecom_approval_scene.chk_wecom_approval_scene_business`,在 `000216` 现有白名单基础上加入 `package_silence_approval`;down 恢复原白名单
- [x] 1.7 迁移⑧⑨:`tb_iot_card`、`tb_device` 新增 `outbound_at timestamptz NULL` 与索引;新建 `tb_asset_outbound_record`(`UNIQUE(asset_type, asset_id, generation)`)
- [x] 1.8 测试库执行 `up → down → up`,确认约束、索引、默认数据正确
- [x] 1.9 模型:`PackageUsage`、`Package`、`IotCard`、`Device` 增加字段;新增 6 个表的模型
- [x] 1.10 Store:延期政策、沉默期流水、延期订单、批次、明细、出库记录的 Store(条件更新方法返回影响行数)
- [x] 1.11 常量与错误码:`silence_status` 取值、延期路径与类型、免审批场景枚举、`order_type = silence_extension`、审批业务类型 `package_silence_approval`、审计动作(§11.2)、权限点(§11.1)、错误码(§12.5)
## 2. 领域规则(§4.2、§5.2、§8.2、§3.6)
- [x] 2.1 `internal/domain/package/silence.go`:`SilenceDeadline(date, days)`(当日 23:59:59,Asia/Shanghai)
- [x] 2.2 沉默期判定:输入当前状态、截止时间、实名时间、是否有调整审批、当前时间,输出 wait / end / expire / noop
- [x] 2.3 延期报价:输入截止日、今天、输入天数、政策快照、已付费延期天数、延期类型,输出 overdue、min/max 天数与月数、计费月数与天数、金额、新截止日、能否办理与原因
- [x] 2.4 政策匹配(指定套餐 > 系列 > 全部,启用且在生效期)与同级重叠校验;免审批判断
- [x] 2.5 用 §8.2 示例表四种情况、输入 1/30/31/60 天、跨月跨年日期在测试环境调用路径中核对一次结果(不写自动化测试)
- 核对结果(2026-10-27,临时程序调用领域函数,已删除):截止 10-01 时 10-20 / 12-15 / 次年 03-20 / 次年 04-05 分别为 overdue 19/75/170/186、min 1/3/6/7、max 6/6/6/6,选最少新截止 10-31 / 12-30 / 03-30,第四种 `exceeds_limit`;输入 1/30/31/60 天计费 30/30/60/60;09-24 10:00 + 30 → 10-24 23:59:59;跨年 12-20 + 30 → 01-19,跨月 01-31 + 30 → 03-02;判定:截止当天 23:30 实名 → end、截止后实名 → expire、已实名无时间 → end 并补写、调整审批中 → 不过期。
## 3. 套餐配置(§3.2、§3.3)
- [x] 3.1 套餐新增 / 编辑 DTO 与校验:实名即生效套餐必填 `silence_period_days`(1~90)、`silence_extension_enabled`;普通套餐禁止写入;修改默认值写审计 `package.silence_default_changed`
- [x] 3.2 生效条件变更约束:套餐服务与店铺分配(单个、批量、系列授权)共用校验函数,阻断 §3.3 的三种变更,`from_purchase → from_activation` 要求同时提供默认沉默期
- [x] 3.3 "未配置沉默期的实名即生效套餐"列表接口(筛选、导出)
## 4. 沉默期设置、判定与激活拦截(§4、§5)
- [x] 4.1 应用层 `internal/application/packagesilence/resolve.go`:`ResolveUsageSilence`(加锁、读实名事实、条件更新、写流水与审计);实名时间按 §5.1(卡 `first_realname_at`;设备取绑定卡 `max(实名, 绑定)` 的最小值;已实名但时间为空的视为期内并补写)
- [x] 4.2 订单 `activatePackage` 与自动购包 `activatePackages`:创建套餐记录时按 §4.1 设置沉默期并写流水(总开关关闭也写)
- 备注:除订单 `activatePackage` 与自动购包 `activatePackages` 外,钱包自动续费(`internal/application/assetautorenewal/renew.go`)也创建套餐记录,同样接入(无人参与,只用默认值);三处都在创建记录的同一事务内调用 `packagesilence.Setter.ApplyOnUsageCreatedInTx`。
- [x] 4.3 后台购买:DTO 增加 `silence_period_days`、`silence_reason`;权限 `package_silence:override`;超默认值按政策决定直接生效或提交调整审批(`silence_adjust_pending = true`);无人参与路径只用默认值
- 备注:审批实例 `(business_type, business_id)` 唯一,三种申请共用 `package_silence_approval`,因此购买时调整也建一个批次(`path = adjust`,迁移 000262 已含该取值与 `adjust_*` 字段),审批统一关联批次 ID。调整值经上下文传入 `activatePackage`(后台下单为钱包 / 线下即时支付,同一请求内发放套餐)。
- [x] 4.4 `canActivatePendingUsage` 在实名判断前调用 `ResolveUsageSilence`(仅总开关打开时拦截)
- 备注:`ActivateByRealname` 不经过 `canActivatePendingUsage`,同样在主套餐激活前加了闸门;挂在过期主套餐下的待实名加油包随主套餐不激活(D21)。
- [x] 4.5 `getNextPendingMainPackage` 跳过 `silence_status = expired`;`findAndActivateOrphanPackages` 候选查询排除 `expired`(总开关打开时)
- [x] 4.6 全局排查 `PackageUsageStatusPending` 的 42 处查询,逐一确认过期套餐的处理(激活、排队、展示、统计、停复机评估、换货、退款),在任务备注中列出结论
- 排查结论(2026-10-27):
- 需改并已改:激活闸门 `canActivatePendingUsage`、实名激活 `ActivateByRealname`、排队队首 `getNextPendingMainPackage`、主套餐到期后提交下一个激活任务 `polling.activateNextPackage`、孤儿扫描 `findAndActivateOrphanPackages`、加油包挂靠 `FindAttachableMainPackageForAddon`(过期主套餐不能再挂加油包)、退款申请创建(4.7)。
- 不改(过期仍按待生效处理是正确的):换货迁移 `migration.go:105`(随记录迁移)、退款 / 资产失效 `InvalidatePackagesForRefund`、`InvalidateAllPackagesByAsset`(失效照常)、加油包级联失效、实名轮询范围 `polling_realname_scope.go`(过期后仍需采集实名时间,延期后才能判定期内已实名并立即激活)、订单 / 自动购包 / 续费创建时的状态赋值、套餐到期提醒与续费查询 `query/packageexpiry`、`query/assetautorenewal`、运营报表 `domain/operationsreport`(统计口径不变)、`HasPendingPackageUsage`(优先轮询收敛条件)、`agent_open_api` 与各展示处的状态名(展示在 4.8 处理)。
- 已知影响:资产若只有一笔沉默期已过期的待生效主套餐,钱包自动续费资格仍按"存在待生效主套餐"拦截(与现状一致,不在本变更范围)。
- [x] 4.7 退款申请创建拦截 `silence_status = expired`(总开关打开时),返回 `silence_expired_not_refundable`
- [x] 4.8 资产套餐列表 / 详情(后台与 H5)增加沉默期字段与状态名;`waiting` 但已过截止的展示为"沉默期已过期";总开关关闭时不返回沉默期展示字段与延期入口标记
- [x] 4.9 套餐沉默期流水查询接口
## 5. 扫描任务与总开关(§3.7、§5.4)
- [x] 5.1 定时任务 `package:silence:scan`:每 10 分钟、单批 200、最多 20 批、Unique 9 分钟、MaxRetry 3;只在总开关打开时处理;注册到 worker 与调度
- [x] 5.2 总开关读取(系统配置缓存)与各入口的统一判断函数
- [x] 5.3 总开关写接口(仅超级管理员):打开前 4 项检查,不满足返回 `silence_switch_precheck_failed` 与未满足项数量;切换写审计 `package.silence_switch_changed`
- [x] 5.4 关闭时延期资格、下单、线下登记、特殊延期申请返回 `silence_feature_disabled`;已发生的支付回调、查单、审批回调不受开关影响
## 6. 延期政策(§3.6)
- [x] 6.1 政策应用层:列表、详情、新增、编辑、启停、查询某套餐当前匹配政策;保存时重叠校验;写审计
- [x] 6.2 后台 Handler 与路由,权限 `package_silence:policy`
## 7. 延期办理(§7、§8)
- [x] 7.1 延期资格接口(后台与 H5):按 §12.2 返回全部字段;前置条件与原因码
- [x] 7.2 在线下单(H5 自助):参照 `agentrecharge/online_creation.go`,创建延期订单、批次、明细与 `tb_payment`,商户池路由并 `FreezeRoute`,冻结政策快照与金额
- [x] 7.3 后台二维码:运营创建订单(上传申请依据),调用渠道适配器生成支付内容写入 `qr_content` 返回;已有待支付订单时先查单,未付关闭旧单、已付不新建
- [x] 7.4 支付回调:`internal/handler/callback/payment.go` 按 `order_type = silence_extension` 分发到延期用例;金额核对;`pending_payment → paid → completed` 条件更新幂等
- [x] 7.5 支付恢复:未支付订单查单与超时关闭(参照 `recover_online_payment.go`),处理"订单不存在"
- [x] 7.6 线下登记:参照 `offline_creation.go`,收款方式字典、交易单号唯一、实收金额等于计算金额、凭证;按政策快照决定提交审批或直接执行;权限 `package_silence:offline`
- [x] 7.7 特殊延期:单个申请与批量导入(Excel 明细、逐行校验);免费次数校验与并发控制;优惠价登记收款信息;按政策快照决定审批或直接执行;批量权限 `package_silence:extend_batch`
- [x] 7.8 延期执行用例(§8.6):同事务锁载体与套餐、重校验、更新字段、写明细 / 批次 / 流水 / 审计;提交后调用 `ResolveUsageSilence`;以明细状态条件更新保证幂等
- [x] 7.9 执行异常:明细 `exception`;在线订单按冻结商户原路退款(复用退款渠道适配)并写订单退款字段;后台异常明细重试 / 关闭接口,权限 `package_silence:exception`
- [x] 7.10 确认延期订单不触发佣金计算、不计入代理累计充值与业绩
## 8. 企微审批(§11.3)
- [x] 8.1 接入 `internal/application/approval` 创建与可靠提交:业务类型 `package_silence_approval`,申请类型控件与三种申请的表单映射(§11.3 表);发起人未绑定企微成员拒绝提交
- [x] 8.2 回调分发:按审批实例关联的调整申请 / 延期批次 / 延期订单处理通过、驳回、撤回;重复事件只处理一次
- [x] 8.3 调整审批通过后重算截止日并调用 `ResolveUsageSilence`;驳回保持默认值并立即判定
## 9. 换货与出库时间(§9、§17)
- [x] 9.1 换货迁移写沉默期流水(换货单号、前后资产、截止时间);确认 `expired` 记录随迁移且不重算
- [x] 9.2 出库时间写入函数:订单 `activatePackage` 与自动购包入口调用;条件 `outbound_at IS NULL AND generation = 订单世代`;绑定在设备上的卡跳过;影响行数为 1 时插入出库记录
- [x] 9.3 换货:旧资产世代 +1 的同一事务清空 `outbound_at` 并结束旧记录;新资产写换货完成时间与 `exchange` 记录
- [x] 9.4 卡 / 设备列表返回并可按出库时间筛选;详情与资产解析返回;导出增加"出库时间"列;出库记录查询接口
## 10. 展示、导出、文档与回填工具(§12、§6、§17.4)
- [x] 10.1 延期订单列表、详情、导出(字段与线上订单一致);H5 我的延期订单;即将过期 / 已过期套餐列表与导出;延期批次与明细查询
- [x] 10.2 财务交易流水(`internal/query/audit/finance.go`)纳入延期订单支付类型
- [x] 10.3 新增 Handler 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,重新生成 OpenAPI 文档
- [x] 10.4 沉默期回填工具(`cmd/`):`--dry-run` 输出影响清单(§6 第 7 条各项);执行模式分批、只处理空值、可重复执行、按实名时间判定 `ended`、写流水
- [x] 10.5 出库时间回填工具:按当前世代最早已支付 / 已退款套餐订单回填,排除绑定在设备上的卡,插入 `backfill` 记录,输出可回填数与无订单数
- [x] 10.6 前端接口变更说明(新增接口、字段、错误码、总开关对页面的影响)
- [x] 10.7 `gofmt`、`go build ./...`、`go vet`、`openspec validate add-package-silence-period`
## 11. 测试环境验证(§14、§17.6)
- [ ] 11.1 部署后确认总开关关闭时行为不变:新购买写入沉默期字段,过期后实名仍激活,延期接口返回 `silence_feature_disabled`
- [ ] 11.2 总开关打开前检查:逐项制造不满足条件,确认拒绝及返回内容;全部满足后打开成功并写审计
- [ ] 11.3 沉默期设置、购买时调整(直接生效 / 审批通过 / 驳回 / 免审批)、实名与过期判定(§14 第 1~12 条)
- [ ] 11.4 延期资格与计费(§14 第 13~15 条,含示例表四种情况)
- [ ] 11.5 四种延期办理(§14 第 16~21 条):H5 微信或支付宝真实小额支付、后台二维码扫码、线下登记审批、特殊与免费延期、批量部分失败、跨天支付异常退款
- [ ] 11.6 延期政策与免审批开关(§14 第 22~23 条)
- [ ] 11.7 换货与历史回填(§14 第 24~25 条):回填 dry-run 清单与执行、重复执行结果不变
- [ ] 11.8 出库时间(§17.6 全部场景)
- [ ] 11.9 应急关闭:打开后关闭,确认规则暂停、数据保留、进行中支付回调照常完成
- [ ] 11.10 清理本变更创建的测试数据,记录验证结果到本文件
## 实施备注(2026-10-27)
- 迁移编号 000259~000265(测试库 `junhong_cmp_test` 已执行 up → down 7 → up,默认政策、三项系统配置、扫描索引、部分唯一索引、审批白名单核对正确)。
- 审批关联:三种申请共用 `package_silence_approval`,审批实例 `business_id` 统一为批次 ID;购买时调整也建批次(`path = adjust`),线下付费的批次与订单一一对应。
- H5 自助与后台二维码都复用代理在线充值的渠道适配器 `CreatePaymentURL`,返回 `qr_content`(支付链接 / 二维码内容),未新增 JSAPI 支付方式。
- 在线订单执行异常(跨天、金额不一致、订单关闭后到账等)自动按冻结商户原路退款,恢复任务 `package:silence:order_recover` 每 5 分钟重试;线下与特殊延期的异常只能人工重试或关闭。
- 支付截止时间不跨天(不晚于当天 23:59:59),降低跨天支付进入异常的概率。
- 总开关在通用系统配置接口中只读,只能通过 `PUT /api/admin/package-silence/switch` 切换;进程内缓存 5 秒。
- 回填工具:`cmd/silence-backfill`(`-target silence|outbound`,默认只出清单,`-execute` 执行);2026-10-27 在测试库 dry-run:沉默期候选 1 条(套餐未配置默认沉默期、不回填),出库时间可回填卡 9 / 设备 6、无订单卡 7、绑定在设备上的卡 15。
- 前端说明:`docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md`。