diff --git a/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md index ed985f84..9a4a473b 100644 --- a/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md +++ b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md @@ -26,8 +26,9 @@ - **实名即生效的正式套餐必填**默认沉默期,新建时前端预填 30;不填或越界返回 **1260**。 - 购买即生效套餐、加油包不显示、不提交这两个字段。 - 套餐响应增加 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必须配置)。 -- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里覆盖为与套餐不同的生效条件,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。 -- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`;导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。 +- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里把实名即生效套餐覆盖为购买即生效,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。 +- 购买即生效的正式套餐也可以选填 `silence_period_days`(1~90,不强制;`silence_config_required=false`),加油包不能填。店铺分配把购买即生效套餐覆盖为实名即生效时,套餐已配置默认沉默期才允许,否则返回 **1262**。 +- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`(包含本身是购买即生效、但有店铺分配覆盖为实名即生效且未配置默认沉默期的套餐);导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。 ## 3. 后台购买时调整沉默期 diff --git a/internal/service/package/service.go b/internal/service/package/service.go index e9e91e53..c9de1cbb 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -299,8 +299,8 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq } if req.SilencePeriodDays != nil { pkg.SilencePeriodDays = req.SilencePeriodDays - } else if !RequiresSilencePeriod(pkg) { - // 套餐不再需要沉默期(如改为加油包)时清空默认值,避免普通套餐残留沉默期配置。 + } else if pkg.PackageType == constants.PackageTypeAddon { + // 改为加油包时清空默认值,避免加油包残留沉默期配置;购买即生效正式套餐可保留(分配覆盖为实名即生效时使用)。 pkg.SilencePeriodDays = nil } if req.SilenceExtensionEnabled != nil { diff --git a/internal/service/package/terms.go b/internal/service/package/terms.go index 2d0e89df..9c3460f2 100644 --- a/internal/service/package/terms.go +++ b/internal/service/package/terms.go @@ -50,17 +50,21 @@ func ExpiryBaseOverrideName(value *string) string { // ValidateAllocationExpiryBaseOverride 校验分配覆盖不得改变套餐生效条件(add-package-silence-period §3.3)。 // 套餐服务与店铺分配(单个、批量、系列授权)写入路径共用: -// 覆盖把实名即生效套餐改成购买即生效会绕过沉默期;把购买即生效套餐改成实名即生效时该套餐没有默认沉默期,两者都阻断。 -// 覆盖为空(跟随套餐默认)或与套餐一致时放行;加油包不进入沉默期,不受此约束。 +// 覆盖把实名即生效套餐改成购买即生效会绕过沉默期,阻断;把购买即生效套餐改成实名即生效时, +// 套餐已配置默认沉默期才放行(design D11)。覆盖为空(跟随套餐默认)或与套餐一致时放行;加油包不进入沉默期,不受此约束。 func ValidateAllocationExpiryBaseOverride(pkg *model.Package, override *string) error { - if pkg == nil || override == nil || pkg.PackageType == constants.PackageTypeAddon { + if pkg == nil || override == nil || pkg.PackageType == constants.PackageTypeAddon || *override == pkg.ExpiryBase { return nil } - if *override != pkg.ExpiryBase { - return errors.New(errors.CodePackageExpiryBaseChangeForbidden, - "套餐「"+pkg.PackageName+"」不允许通过分配覆盖改变生效条件(实名即生效与购买即生效不能互相覆盖)") + if *override == constants.PackageExpiryBaseFromActivation && pkg.SilencePeriodDays != nil { + return nil } - return nil + if *override == constants.PackageExpiryBaseFromActivation { + return errors.New(errors.CodePackageExpiryBaseChangeForbidden, + "套餐「"+pkg.PackageName+"」未配置默认沉默期,不允许通过分配覆盖改为实名即生效") + } + return errors.New(errors.CodePackageExpiryBaseChangeForbidden, + "套餐「"+pkg.PackageName+"」不允许通过分配覆盖从实名即生效改为购买即生效") } // ValidatePackageExpiryBaseChange 校验套餐自身生效条件变更(§3.3):实名即生效改为购买即生效阻断; @@ -83,16 +87,23 @@ func RequiresSilencePeriod(pkg *model.Package) bool { return pkg != nil && pkg.PackageType != constants.PackageTypeAddon && pkg.ExpiryBase == constants.PackageExpiryBaseFromActivation } -// ValidatePackageSilenceConfig 校验套餐最终的沉默期配置:实名即生效正式套餐必填 1~90 天;其他套餐不得填写。 +// ValidatePackageSilenceConfig 校验套餐最终的沉默期配置:实名即生效正式套餐必填 1~90 天; +// 购买即生效正式套餐可选填 1~90 天(只在店铺分配覆盖为实名即生效时使用,design D11);加油包不得填写。 func ValidatePackageSilenceConfig(pkg *model.Package) error { - if RequiresSilencePeriod(pkg) { - if pkg.SilencePeriodDays == nil || *pkg.SilencePeriodDays < constants.SilencePackageDaysMin || *pkg.SilencePeriodDays > constants.SilencePackageDaysMax { + if pkg == nil { + return nil + } + if pkg.SilencePeriodDays == nil { + if RequiresSilencePeriod(pkg) { return errors.New(errors.CodeSilencePeriodOutOfRange, "实名即生效套餐必须配置默认沉默期(1~90 天)") } return nil } - if pkg != nil && pkg.SilencePeriodDays != nil { - return errors.New(errors.CodeInvalidParam, "只有实名即生效的正式套餐可以配置沉默期") + if pkg.PackageType == constants.PackageTypeAddon { + return errors.New(errors.CodeInvalidParam, "加油包不能配置沉默期") + } + if *pkg.SilencePeriodDays < constants.SilencePackageDaysMin || *pkg.SilencePeriodDays > constants.SilencePackageDaysMax { + return errors.New(errors.CodeSilencePeriodOutOfRange, "默认沉默期须为 1~90 天") } return nil } diff --git a/internal/store/postgres/package_store.go b/internal/store/postgres/package_store.go index 3303593a..d52c1411 100644 --- a/internal/store/postgres/package_store.go +++ b/internal/store/postgres/package_store.go @@ -154,7 +154,10 @@ func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int6 } // SilenceUnconfiguredPackageCondition 返回"未配置默认沉默期的实名即生效正式套餐"的条件(总开关打开前检查与补齐清单共用)。 +// 实名即生效包含套餐本身 from_activation,以及存在未删除的店铺分配覆盖为 from_activation 的套餐(design D11)。 func SilenceUnconfiguredPackageCondition(alias string) string { - return alias + ".package_type <> '" + constants.PackageTypeAddon + "' AND " + alias + ".expiry_base = '" + - constants.PackageExpiryBaseFromActivation + "' AND " + alias + ".silence_period_days IS NULL" + return alias + ".package_type <> '" + constants.PackageTypeAddon + "' AND " + alias + ".silence_period_days IS NULL AND (" + + alias + ".expiry_base = '" + constants.PackageExpiryBaseFromActivation + "' OR EXISTS (" + + "SELECT 1 FROM tb_shop_package_allocation AS silence_alloc WHERE silence_alloc.package_id = " + alias + ".id" + + " AND silence_alloc.deleted_at IS NULL AND silence_alloc.expiry_base_override = '" + constants.PackageExpiryBaseFromActivation + "'))" } diff --git a/openspec/changes/add-package-silence-period/design.md b/openspec/changes/add-package-silence-period/design.md index 39108477..348a9648 100644 --- a/openspec/changes/add-package-silence-period/design.md +++ b/openspec/changes/add-package-silence-period/design.md @@ -82,6 +82,12 @@ 回填(沉默期、首次购买套餐时间)放在 `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 天的无法付费延期] → 回填清单单独列出"将过期""将过期且无法付费延期"数量,业务确认后再打开;特殊延期可兜底;可应急关闭。 diff --git a/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md b/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md index 36cb9776..dfc8886f 100644 --- a/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md +++ b/openspec/changes/add-package-silence-period/specs/package-silence-period/spec.md @@ -25,13 +25,20 @@ ### Requirement: 套餐默认沉默期与是否支持延期配置 -实名即生效套餐 MUST 配置默认沉默期 `silence_period_days`(1~90 天,新建时预填 30)与 `silence_extension_enabled`(默认支持);普通套餐 MUST NOT 写入沉默期。修改默认值 MUST 只影响之后的新购买,并写审计。系统 SHALL 提供"未配置沉默期的实名即生效套餐"清单(筛选与导出)。 +实名即生效套餐 MUST 配置默认沉默期 `silence_period_days`(1~90 天,新建时预填 30)与 `silence_extension_enabled`(默认支持);加油包 MUST NOT 写入沉默期;购买即生效正式套餐 MAY 选填默认沉默期(不强制,仅在店铺分配覆盖为 `from_activation` 时使用)。修改默认值 MUST 只影响之后的新购买,并写审计。系统 SHALL 提供"未配置沉默期的实名即生效套餐"清单(筛选与导出)。 + +配置口径中的"实名即生效套餐"指正式套餐本身为 `from_activation`,**或**存在未删除的店铺分配把它覆盖为 `from_activation`(存量数据)。后者 MUST 计入"未配置沉默期的实名即生效套餐"清单与总开关打开前检查"未配置默认沉默期的套餐"。 #### Scenario: 实名即生效套餐未填默认沉默期 - **WHEN** 新建或编辑 `from_activation` 套餐时未提供 `silence_period_days`,或值不在 1~90 - **THEN** 系统拒绝保存,返回 `silence_period_out_of_range` +#### Scenario: 分配覆盖为实名即生效的购买即生效套餐 + +- **WHEN** 套餐本身为 `from_purchase`,但某店铺分配覆盖为 `from_activation`,且套餐未配置默认沉默期 +- **THEN** 该套餐出现在未配置清单中并计入打开前检查;管理员可以为它配置默认沉默期,配置后该店铺的新购买进入沉默期,历史待实名记录可被回填 + #### Scenario: 修改默认值不影响已购买记录 - **WHEN** 将套餐默认沉默期从 30 改为 45 @@ -39,13 +46,18 @@ ### Requirement: 禁止绕过沉默期的生效条件变更 -系统 MUST 在套餐服务与店铺分配(单个、批量、系列授权)写入路径的共用校验中阻断:套餐 `from_activation` 改为 `from_purchase`、分配覆盖把 `from_activation` 套餐改为 `from_purchase`、分配覆盖把 `from_purchase` 套餐改为 `from_activation`。套餐 `from_purchase` 改为 `from_activation` 时同一请求 MUST 提供默认沉默期。 +系统 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` +#### Scenario: 购买即生效套餐在分配上覆盖为实名即生效 + +- **WHEN** 管理员在店铺分配上把 `from_purchase` 套餐覆盖为 `from_activation` +- **THEN** 套餐已配置默认沉默期时允许保存;未配置时拒绝,返回 `package_expiry_base_change_forbidden` + ### Requirement: 后台购买时调整沉默期 后台给资产购买会进入沉默期的套餐时,有 `package_silence:override` 权限的运营 SHALL 可以设置本次沉默期(≥ 1 天,不设上限),必须填写原因;优先级为本次设置值高于套餐默认值。 diff --git a/openspec/changes/add-package-silence-period/tasks.md b/openspec/changes/add-package-silence-period/tasks.md index 56c3b109..f9e6c960 100644 --- a/openspec/changes/add-package-silence-period/tasks.md +++ b/openspec/changes/add-package-silence-period/tasks.md @@ -28,6 +28,8 @@ - [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 "未配置沉默期的实名即生效套餐"列表接口(筛选、导出) +- [ ] 3.4 分配覆盖口径(design D11):共用条件"正式套餐本身 `from_activation` 或存在未删除的分配覆盖为 `from_activation`",用于未配置清单(列表、导出)与打开前检查;购买即生效正式套餐允许选填默认沉默期(不强制),加油包仍禁止;分配覆盖 `from_purchase → from_activation` 在套餐已配置默认沉默期时放行(单个、批量、系列授权共用校验) +- [ ] 3.5 测试环境核对:套餐 708 / 710 / 712 出现在未配置清单与打开前检查中;配置默认沉默期后回填套餐记录 20005;未配置时分配覆盖为实名即生效被拒绝,配置后放行 ## 4. 沉默期设置、判定与激活拦截(§4、§5) @@ -99,7 +101,10 @@ ## 11. 测试环境验证(§14、§17.6) -- [ ] 11.1 部署后确认总开关关闭时行为不变:新购买写入沉默期字段,过期后实名仍激活,延期接口返回 `silence_feature_disabled` +- [x] 11.1 部署后确认总开关关闭时行为不变:新购买写入沉默期字段,过期后实名仍激活,延期接口返回 `silence_feature_disabled` + - 验证结果(2026-10-27,部署 `69ec98aa`):总开关 `enabled=false`;代理 539 未实名卡 8019 后台钱包购买验证套餐 823(订单 892)→ 套餐记录 20175 `waiting`、30 天、截止 2026-11-26 23:59:59,流水 `set / order_paid`,首次购买套餐时间与记录写入;资格、试算、二维码下单、线下登记、特殊延期均返回 1274,未产生延期订单 / 批次 / 明细;把 20175 截止改为 2026-10-20 后将卡标为已实名,孤儿扫描 10 秒内激活(status=1),`silence_status` 保持 `waiting`、不写判定流水。 + - 发现并修复(`69ec98aa`):后台平台自营资产与赠送套餐线下订单 `buyer_type=personal`,`activateMainPackage` 误判为 C 端购买,未实名也直接生效、不进沉默期;增加 `order.Source == client` 条件后,代理卡 8030 线下购买赠送套餐 824(订单 893)→ 记录 20176 待实名、`waiting`、30 天。 + - 待 11.10 清理:套餐 822 / 823 / 824、分配 288、订单 891(卡 11895)/ 892(卡 8019,代理 539 钱包扣 1 分)/ 893(卡 8030)及其套餐记录、沉默期流水、首次购买套餐时间与记录;卡 8019 恢复 `real_name_status=0`、`first_realname_at=NULL`、`enable_polling=true`。 - [ ] 11.2 总开关打开前检查:逐项制造不满足条件,确认拒绝及返回内容;全部满足后打开成功并写审计 - [ ] 11.3 沉默期设置、购买时调整(直接生效 / 审批通过 / 驳回 / 免审批)、实名与过期判定(§14 第 1~12 条) - [ ] 11.4 延期资格与计费(§14 第 13~15 条,含示例表四种情况)