# PRD：UR#40 下架套餐历史用户续费

Status: ready-for-agent

---

## Problem Statement

套餐目前一旦在销售渠道下架，C 端可购列表和创建订单都会直接拒绝。该规则适合阻止新购，却也使已经在某个资产上真实购买过该套餐的当前客户无法继续续费，造成存量客户服务中断。

本需求需要严格区分“新购”和“原资产续费”：下架套餐不能重新进入套餐商城，也不能被代理或平台后台代购；只有当前客户本人，针对自己当前持有资产的当前世代、同一个套餐 ID，存在真实支付且未作废的历史使用记录时，才能从当前套餐或套餐历史记录进入续费。套餐被全局禁用时始终不可购买。

## Solution

建立唯一的套餐可售策略，统一判断套餐全局状态、当前销售渠道上下架状态、调用主体、资产当前归属与世代，以及同资产同套餐的有效历史使用记录。

正常在售套餐继续通过 `GET /api/c/v1/asset/packages` 展示和购买；下架套餐永远不回到该商城列表。`GET /api/c/v1/asset/info` 的当前套餐和 `GET /api/c/v1/asset/package-history` 的历史记录返回购买资格字段，符合条件的下架套餐以 `purchase_mode=renew_only` 提供“续费”入口。点击续费仍调用现有 `POST /api/c/v1/orders/create`，后端重新执行完整资格校验并按下单时当前销售渠道零售价计价。

## User Stories

1. 作为当前资产所有人，我希望继续续费这个资产以前真实购买过的下架套餐，避免存量服务中断。
2. 作为当前资产所有人，我希望待生效、生效中、已用完或已过期的有效记录都能提供下架续费资格。
3. 作为新客户，我不应在套餐商城看到或直接购买已经下架的套餐。
4. 作为代理或平台运营人员，我不能利用后台代购、批量订购或构造请求绕过下架限制。
5. 作为套餐运营人员，我希望禁用套餐无论是否存在历史都绝对不可购买。
6. 作为资产的新所有人，我不应继承上一世代客户的下架套餐续费资格。
7. 作为客户，我希望续费价格使用当前渠道现行零售价，而不是历史订单价格。

## Implementation Decisions

### 统一业务语义

- 套餐全局启用状态使用 `tb_package.status`：`0=禁用, 1=启用`。`status=0` 时无条件不可购买，不存在历史续费例外。
- 平台自营渠道的上下架状态读取 `tb_package.shelf_status`：`1=上架, 2=下架`。
- 代理销售渠道的上下架状态读取资产当前 `shop_id` 对应的 `tb_shop_package_allocation.shelf_status`：`1=上架, 2=下架`；同时分配记录必须存在、未删除且 `status=1`。平台套餐的 `shelf_status` 不替代代理渠道自己的上下架事实。
- 正常在售且其他购买条件成立时返回 `purchase_mode=normal`；当前渠道下架但满足本需求历史续费资格时返回 `purchase_mode=renew_only`；其余情况返回 `purchase_mode=disabled`。
- “当前资产所有人”由当前登录个人客户和有效资产绑定关系判断，不能信任前端传入的 `is_renewal`、客户 ID、资产类型或销售渠道。
- 续费资格严格绑定资产、当前世代和套餐 ID。卡 A 的历史不能给卡 B 使用，同系列其他套餐不等同于同一个套餐，资产转手后的新世代不继承上一世代资格。

### 有效历史使用记录

- 同一资产当前 `generation`、同一 `package_id` 存在至少一条未软删除的 `tb_package_usage`，且状态属于以下集合时，视为存在有效历史：
  - `0=待生效`：已经真实购买但尚在队列中，计入资格。
  - `1=生效中`：当前正在使用，计入资格。
  - `2=已用完`：流量已耗尽，计入资格。
  - `3=已过期`：使用周期结束，计入资格。
- `4=已失效` 不计入资格。软删除记录不计入资格。
- 使用记录关联订单必须是已经形成真实购买事实且未被撤销或退款的订单。`tb_package_usage.refund_id` 已有值、关联订单为取消/退款状态，或退款/撤销流程已使使用记录失效时均不得产生资格；实现不能只看一个仍未及时更新的状态字段而放过无效购买。
- 待支付订单本身不产生 `PackageUsage` 资格；赠送、后台发放或无真实客户购买事实的记录不得被当作本需求下架续费凭证。
- 查询应使用 `EXISTS` 或等价批量策略判断，不加载全部历史再在内存过滤。

### API 与响应契约

- `GET /api/c/v1/asset/packages?identifier=...` 保持普通套餐商城语义：
  - 只返回当前销售渠道正常上架、启用且满足既有系列、赠送套餐、主套餐/加油包及价格规则的套餐。
  - 下架套餐即使具备续费资格也不返回，防止续费例外重新变成新购入口。
  - 正常返回项增加或统一返回 `can_purchase=true`、`purchase_mode=normal`、`disabled_reason=""`，便于前端只消费服务端策略。
- `GET /api/c/v1/asset/info?identifier=...` 的当前套餐投影增加：
  - `can_purchase:bool`
  - `purchase_mode:string`，固定值为 `normal|renew_only|disabled`
  - `disabled_reason:string`
  当前套餐处于下架且资格成立时返回 `can_purchase=true, purchase_mode=renew_only`。
- `GET /api/c/v1/asset/package-history?identifier=...` 的每条历史套餐记录增加相同三个字段。状态为待生效、生效中、已用完或已过期的记录均按统一策略计算；因此已过期套餐也有实际可点击的续费入口。
- 同一个套餐存在多条历史使用记录时，各条记录返回相同的当前购买资格；续费命令只提交稳定 `package_id`，不把某个 `package_usage_id` 当作授权凭证。
- `disabled_reason` 使用稳定中文业务原因，至少区分“套餐已禁用”“套餐已下架，仅历史用户可续费”“当前资产无该套餐有效历史”“当前渠道未授权该套餐”“套餐价格配置异常”和既有主套餐/加油包限制。前端不得解析文案推导状态。
- 所有响应继续使用项目统一 `{code,msg,data,timestamp}` 包装，现有分页、排序和错误格式不变。

### 创建订单与强校验

- 继续使用 `POST /api/c/v1/orders/create`，请求仍为 `identifier + package_ids` 及现有支付相关字段；不新增续费接口，不新增可信 `renewal` 布尔值。
- 创建订单时必须重新解析登录客户、资产当前绑定、资产当前世代、销售渠道、套餐和历史资格，不能信任列表接口先前返回的资格，也不能因客户端缓存继续放行。
- 每一个请求套餐都进入同一可售策略。下架套餐只有个人客户本人在对应资产上满足历史资格时通过；后台普通订单、线下代购、代理代购、自动购买和批量订购均不得进入该例外。
- 正常在售套餐继续沿用现有购买规则；下架续费不能绕过同系列限制、赠送套餐限制、主套餐/加油包组合规则、价格不低于成本、实名顺序、支付、幂等和订单状态规则。
- 套餐在展示后被禁用、渠道分配被禁用/删除、资产转手、客户解绑、历史记录退款/失效或价格变为非法时，创建订单必须按最新事实拒绝。
- 下架续费价格不复用历史 `PackageUsage.paid_amount`、历史订单项价格或历史零售价快照：
  - 平台自营按下单时套餐当前有效零售价计算。
  - 代理渠道按下单时当前有效分配零售价计算，并继续校验不低于当前授权成本价。
- 当前订单创建防重机制继续生效；本需求不创建第二套续费订单类型或幂等表。

### 架构边界

- 这是购买策略变更。将“套餐是否可售”收口为一个可被 C 端 Query、C 端创建订单及其他购买入口复用的策略能力，禁止在 Handler、列表组装和订单 Service 各复制一套分支。
- C 端资产信息、套餐商城和套餐历史属于 Query/DTO 投影；可以批量读取套餐、渠道分配和历史资格，不经过聚合根。
- 创建订单属于现有订单写用例，在写入前调用同一策略并重新验证写时事实；不主动迁移未触碰的订单、套餐或资产模块。
- 需求 19 批量订购必须调用同一策略，但由于其操作者不是个人资产当前所有人，遇到渠道下架套餐固定拒绝；这项依赖不能被解释为批量订购也支持 `renew_only`。
- 资格判断应显式携带 actor、asset、generation、package、sales channel 和 operation context，不能依赖易被遗漏的隐式布尔开关。

### 权限、审计与数据

- 所有 C 端查询和创建订单先执行现有个人客户认证及 `OwnsAsset` 有效绑定校验；无权与资源不存在继续使用统一安全错误语义。
- 不新增数据库字段或资格快照表。资格从当前绑定、资产 `generation`、套餐、渠道分配、订单和 `PackageUsage` 实时判断，避免退款、转手或下架变化后保留过期授权。
- 现有套餐、渠道上下架和订单审计继续记录；成功创建下架续费订单时，统一 Audit Event 中增加 `purchase_mode=renew_only`、资产类型/ID/世代、套餐 ID、销售渠道及命中的历史资格摘要，不记录客户敏感明细。
- 被拒绝的构造请求按公共失败审计规则记录失败原因；本需求不新增独立续费日志表。
- 为 `tb_package_usage` 的资格查询核对现有索引；若生产库缺少覆盖 `asset + generation + package_id + status + deleted_at` 的有效索引，按真实查询计划补充普通/部分索引。禁止建立外键。

### 前端交互

- 套餐商城只渲染 `GET /asset/packages` 返回的正常在售套餐，不自行合并历史下架套餐。
- 当前套餐和套餐历史项根据 `can_purchase` 与 `purchase_mode` 展示：
  - `normal`：显示原购买/续费动作。
  - `renew_only`：显示“续费”，并明确该套餐已下架、仅限当前资产续费。
  - `disabled`：隐藏或禁用按钮；需要解释时展示 `disabled_reason`。
- 已过期套餐的续费入口位于套餐历史列表，因此不依赖“当前套餐”仍然存在。
- 点击“续费”复用现有套餐确认、下单和支付流程，只携带资产标识和套餐 ID；不向后端提交自判的上下架状态或续费资格。
- 页面加载、空态和失败态继续保持原交互。提交期间禁止重复操作；资格在展示后失效时原地展示后端最新中文错误并刷新资产/套餐数据。

### 发布与回滚

- 无历史数据回填，不为存量资产预计算续费资格。
- 发布前只读核验：套餐及代理分配上下架/启用组合、当前世代使用记录状态、退款记录与 `PackageUsage` 失效的一致性，以及资格查询执行计划。
- 若发现退款完成但使用记录仍处于 `0～3` 的异常数据，先按订单/退款事实修复或让策略显式排除，不能把异常记录作为续费资格。
- 应用回滚后不删除已产生的正常订单、使用记录或审计。若需要关闭能力，通过回滚应用恢复原“下架全部拒绝”行为，不修改历史数据。

## Testing Decisions

- 可售策略单元测试覆盖套餐全局禁用、平台/代理渠道上架、渠道下架、分配缺失/禁用/软删除、价格低于成本和赠送套餐。
- 历史资格测试逐一覆盖 `0=待生效、1=生效中、2=已用完、3=已过期` 均允许，`4=已失效`、软删除、退款、撤销、待支付和非真实购买记录均不允许。
- 归属测试覆盖当前客户/其他客户、同资产/其他资产、同套餐/同系列其他套餐、当前世代/上一世代，以及解绑、转手和换货后的资格隔离。
- Query 集成测试验证：商城永不返回下架套餐；当前套餐和历史记录正确返回 `can_purchase/purchase_mode/disabled_reason`；已过期记录存在可用续费入口；同套餐多条历史无 N+1。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Query/Application、GORM/PostgreSQL 和统一响应，验证字段枚举、中文错误、分页和数据权限。
- 创建订单测试验证不接受伪造续费标志，展示后禁用/下架/转手/退款/改价的竞态会按写时事实拒绝；正常上架新购不回归。
- 入口矩阵验证个人 C 端下架续费成功，而平台后台普通订单、代理代购、自动购买和需求 19 批量订购对同一下架套餐全部拒绝。
- 价格测试验证使用当前平台或代理渠道零售价，不复用历史价格；代理当前零售价低于成本时拒绝。
- PostgreSQL 查询测试核对历史资格 `EXISTS` 使用适当索引，并确保列表批量投影无逐行查询。
- 前端验收覆盖在售购买、当前套餐下架续费、待生效/已用完/已过期历史续费、禁用、无历史资格、资格过期后的错误刷新及重复提交。

## Out of Scope

- 不把下架套餐重新加入普通套餐商城或搜索结果。
- 不允许平台、代理、批量任务或其他后台主体代购下架套餐。
- 不按套餐系列继承资格，不跨资产、不跨世代共享资格。
- 不复用历史价格，不承诺原价续费。
- 不允许全局禁用、赠送、已失效、退款或撤销记录产生续费资格。
- 不新增续费订单类型、续费 API、资格表、资格缓存或独立日志表。
- 不限制客户拥有多少个待生效主套餐；如未来需要限制囤积，应另立业务规则。

## Further Notes

- 当前 `GET /api/c/v1/asset/packages` 已按平台套餐或代理分配的渠道状态过滤，`POST /api/c/v1/orders/create` 最终进入 `purchase_validation.Service`；实现应深化这一公共策略接缝，而不是在 C 端 Handler 临时放行。
- 当前套餐历史已经按资产当前 `generation` 查询，这是隔离资产转手前后购买事实的现有基础。
- 当前 `PackageUsage` 已保存 `order_id`、`refund_id`、状态和世代，可在不增加资格字段的情况下判断；但必须联合订单/退款事实防止异常状态误放行。
- 用户已明确确认：待生效记录属于有效历史；下架续费入口同时覆盖当前套餐和套餐历史；价格采用下单时当前销售渠道零售价。
