Files
junhong_cmp_fiber/.scratch/ur55-package-expiry-base/PRD.md
2026-07-21 15:26:07 +09:00

103 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRDUR#55 套餐分配生效条件覆盖与购买快照
Status: ready-for-agent
---
## Problem Statement
套餐目前只有全局 `expiry_base`,代理分配记录不能覆盖生效条件。套餐激活代码直接读取可修改的套餐当前值,`PackageUsage` 也没有生效条件、周期类型和购买时长快照,因此套餐购买后修改配置会改变历史订单的激活和预计到期语义。
现有套餐授权入口分散在系列授权、批量分配和购买链路中,如果只改某一个入口,会产生新旧订单行为不一致。
## Solution
建立“套餐默认值 → 代理分配可选覆盖 → 购买使用记录不可变快照”的完整链路。分配覆盖仅影响该分配下未来形成的购买记录;所有新 `PackageUsage` 在创建时写入有效生效条件及周期时长快照,激活、排队接续和预计最终到期只读取快照。
旧记录不批量回填,仅在快照缺失时兼容读取套餐当前值,并对该历史兼容路径保持可观测。
## User Stories
1. 作为平台运营人员,我希望给代理分配套餐时选择跟随默认、购买即生效或实名即生效。
2. 作为平台运营人员,我希望修改已分配套餐的覆盖值只影响后续购买。
3. 作为代理,我希望已购买套餐的计时规则不会因平台后来修改套餐或分配配置而改变。
4. 作为客户,我希望排队套餐和预计最终到期始终按购买时承诺的时长计算。
5. 作为维护人员,我希望所有创建 `PackageUsage` 的生产路径都写入同一组快照。
## Implementation Decisions
### 枚举与有效值
- 延用现有套餐常量:`from_purchase=购买即生效``from_activation=实名激活时生效`
- 禅道草稿中的 `from_realname` 不作为新值;前后端统一使用现有 `from_activation`
- `tb_shop_package_allocation.expiry_base_override` 为 nullable 字符串;`NULL` 表示跟随套餐当前默认值。
- 有效值计算为:分配覆盖非空时取覆盖,否则取套餐 `expiry_base`
- 分配覆盖不改变套餐自身默认配置,也不反向修改其他代理分配。
### 数据与快照
- `tb_package_usage` 新增 `expiry_base_snapshot``calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`
- 新使用记录必须在创建事务中写入四个快照,不允许先空值落库再异步补齐。
- 快照描述购买时承诺的计时规则,写入后不得因套餐、分配或系列授权修改而更新。
- 正式主套餐和加油包均保存快照确保历史一致UR#46 的最终到期只使用主套餐记录。
- 历史记录的快照保持空值/零值作为“旧数据未快照”标记,不批量伪造购买时配置。
- 旧记录兼容读取套餐当前值时记录结构化告警和指标,便于识别仍在依赖回退的数据;新记录不得进入回退。
- 迁移包含字段、中文注释和必要索引;向下迁移只删除本需求新增结构,不修改现有套餐使用状态。
### API 契约
- 创建或批量创建 `ShopPackageAllocation` 的现有业务入口增加显式 `expiry_base_override`,可为 `null|from_purchase|from_activation`
- 新增 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,请求必须包含 `expiry_base_override` 字段。
- PATCH 中 JSON `null` 表示恢复跟随默认;字段缺失属于参数错误,不能与显式 `null` 混淆。
- 分配响应返回 `default_expiry_base``default_expiry_base_name``expiry_base_override``expiry_base_override_name``effective_expiry_base``effective_expiry_base_name`
- 批量系列授权和批量套餐分配若一次创建多条分配,使用同一个显式覆盖选择并写入每条记录;不能只有单条入口支持。
- 非法枚举、分配不存在、软删除或越权统一走项目错误规范;权限不足与不存在不做区分。
- 修改相同值按幂等成功,不触碰已购买使用记录。
### 套餐生命周期边界
- 购买快照和激活规则进入套餐生命周期 Domain/Application不能继续由订单 Service、自动购包任务和激活 Service 各自读取套餐当前值。
- 建立统一 `PackageTermsSnapshot` 值对象或等价的单一解析函数,负责校验并返回生效条件、周期类型、月数和天数。
- C 端订单、后台代购、代理囤货、自动购包及其他所有 `PackageUsage` 创建路径必须调用同一快照能力。
- 使用记录激活、首次实名激活、前一主套餐到期后的排队接续、退款后的接续都只读取使用记录快照;旧记录才允许显式回退。
- 当前代码中“行业卡永远直接激活”等旧分支不能绕过快照和 UR#62/UR#73 的统一实名规则,应在触碰的完整套餐激活用例内收口。
- 配置修改是简单写事务;购买和激活涉及不可变业务规则,使用复杂写 Application/Domain 通道。
### 前端
- 套餐授权/分配表单增加:跟随套餐默认、购买即生效、实名即生效。
- 跟随默认必须发送 JSON `null`,不能通过省略字段表达。
- 列表和详情同时展示默认值、覆盖值和最终值的后端中文名称。
- 编辑时明确提示“仅影响后续新订单,不影响已购买套餐”。
- 配置更新成功后重新拉取分配详情,不由前端自行计算有效值。
### 发布顺序
- 先发布兼容读取新字段的应用,再执行迁移并切换所有创建路径写快照;在同一维护窗口完成,避免产生新的空快照记录。
- UR#46 和 UR#33 依赖本需求的购买时长快照,实施顺序为 UR#55 → UR#46 → UR#33
## Testing Decisions
- 领域测试覆盖无覆盖、两个覆盖值、非法值以及套餐默认修改前后的有效值。
- 集成测试覆盖创建分配、批量分配、系列授权、修改覆盖、显式恢复默认、字段缺失和越权。
- 覆盖 C 端购买、后台代购、代理购买、自动购包等所有 `PackageUsage` 创建路径,断言四个快照完整。
- 验证购买后修改套餐周期、时长、生效条件或分配覆盖,不改变已有使用记录的激活和预计到期结果。
- 验证新记录快照缺失时拒绝或告警,不静默使用回退;旧记录仍可兼容运行。
- 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App支付、Gateway 等外部副作用使用测试 Adapter。
- 验证事务失败不留下部分分配或部分使用记录,重复 PATCH 幂等。
- 前端验收三个选项、`null` 恢复、中文名称及“只影响未来购买”提示。
## Out of Scope
- 不修改已购买使用记录的快照。
- 不批量回填历史使用记录的购买时配置。
- 不新增第三个生效条件枚举。
- 不在本需求实现最终到期展示、临期列表或通知。
- 不迁移未触碰的套餐管理 CRUD。
## Further Notes
- 当前模型已保存流量相关快照,但没有计时快照;实现时应复用现有快照创建边界,而不是在查询阶段拼接可变套餐配置。
- `NULL=跟随默认` 与“购买时快照为空”是两个完全不同的概念:前者在购买时必须解析成非空有效值,后者只允许历史兼容。