# PRD：UR#43 代理系列套餐批量授权

Status: ready-for-agent

---

## Problem Statement

代理系列授权及套餐授权的数据结构和接口已经存在，首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图，后续追加套餐时不容易区分未授权与已授权，也容易混淆上级当前成本、目标代理授权成本和建议零售价。

现有 `PUT /api/admin/shop-series-grants/{id}/packages` 已通过每个套餐项的 `remove` 字段支持新增、改价和移除，其中 `remove=true` 的软删除语义已经交付并被前端使用。本需求必须在增强批量原子性、权限和价格边界时保持该字段兼容，不能将它删除或改成必填的新顶层命令字段。

## Solution

复用现有套餐列表和系列授权详情，不新增候选 API。首次授权直接使用现有套餐列表选择多项，并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列及套餐授权；后续管理同时读取现有套餐列表与 `GET /api/admin/shop-series-grants/{id}`，按 `package_id` 合并成同一批量选择视图，已授权项置灰、未授权项可多选。

保留现有批量写路径和 `packages:[{package_id,cost_price,remove}]` 契约：`remove=true` 表示移除，未设置或为 `false` 时根据当前授权状态新增或改价。单次最多 100 项并在一个事务内全成全败；既有请求和响应结构必须继续可用。

## User Stories

1. 作为平台或上级代理，我希望一次选择多个系列套餐并授权给目标代理。
2. 作为授权人员，我希望看到公司成本价、目标代理当前授权成本价和建议零售价，不再混淆单一 `cost_price` 的含义。
3. 作为授权人员，我希望已经授权的套餐明确置灰，避免重复选择。
4. 作为授权人员，我希望继续使用已交付的每项 `remove` 字段完成移除，避免发布后旧前端失效。
5. 作为并发操作人员，我希望批量请求全成全败，并在价格或授权状态冲突时得到明确反馈。
6. 作为上级代理，我只能把自己有权销售的套餐授权给直属下级，不能借候选或构造请求越权。
7. 作为维护人员，我希望 OpenAPI 和前端契约准确记录 `remove` 字段，避免后续再次误删。

## Implementation Decisions

### 现有模型与范围

- 复用 `tb_shop_series_allocation` 和 `tb_shop_package_allocation`，不新建授权关系表。
- 系列授权记录是目标店铺获得该系列销售能力的入口；套餐授权记录保存目标店铺对具体套餐的成本价、零售价、状态和上下架状态。
- 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则，也不新增候选 Query；只规范现有列表组合、首次批量授权和兼容既有字段的后续批量管理。
- 赠送套餐 `is_gift=true` 不属于代理授权候选，也不能通过构造请求加入。
- 金额统一使用分，类型为 `int64`，禁止浮点金额。

### 复用现有读取接口

- 不新增 `GET /api/admin/shop-series-grants/{id}/package-options`。七月评审稿中的该建议由当前用户决定覆盖，原因是现有接口已经分别提供候选来源和已授权套餐，新增接口会重复契约。
- `GET /api/admin/packages?series_id={series_id}&page=&page_size=&package_name=&status=&shelf_status=` 继续作为当前操作者可见的系列套餐列表：
  - 平台/超级管理员视角的 `cost_price` 是公司成本价。
  - 代理视角的 `cost_price` 是该上级代理自己的当前授权成本价，也就是其继续向下授权时的价格基线；不能将其标成公司的原始成本价。
  - `suggested_retail_price` 继续作为建议零售价。
  - 列表沿用现有数据权限和代理授权过滤，不能为了批量选择扩大可见范围。
- `GET /api/admin/shop-series-grants/{id}` 继续返回目标代理在该系列下已经授权的 `packages`；其中每项 `cost_price` 是目标代理当前授权成本价。
- 后续管理页面并行读取上述两个现有接口，以 `package_id` 合并：
  - 出现在授权详情中的套餐标记为 `is_authorized=true`，显示目标代理授权成本价。
  - 只出现在系列套餐列表中的套餐标记为 `is_authorized=false`，授权成本价显示“-”，不可用 0 代表未授权。
  - 已授权但因上级权限变化而不再出现在普通套餐列表的存量项，仍从授权详情只读展示真实状态，不能从界面静默消失。
- 首次授权尚无授权 ID 时只需调用现有套餐列表；此时所有可选择项对新目标代理均视为未授权，不需要伪造 grant ID。
- 套餐列表目前只支持 `package_name`，若现有前端确实需要按编码搜索，应在同一个 `GET /api/admin/packages` 增加受控 `keyword` 或 `package_code` 筛选，不为此新增授权候选接口。
- 平台成本、上级自身成本、目标代理授权成本和建议零售价按实际调用者及接口来源明确标注；不能继续用没有视角说明的“成本价”文案。
- 成本价属于敏感数据，继续受现有套餐与系列授权管理权限约束。无权限不得通过调用另一个列表接口探测其他代理成本。

### 首次系列与套餐授权

- 继续使用 `POST /api/admin/shop-series-grants`，在一个事务内创建 `ShopSeriesAllocation` 与请求中的多条 `ShopPackageAllocation`；不拆成两步，不允许留下无套餐的空系列授权。
- `packages` 从当前可选字段收紧为必填，至少 1 项、最多 100 项；每项为 `package_id + cost_price`，同一请求内套餐 ID 必须唯一。
- 首次授权中的套餐必须满足与后续新增授权相同的系列、赠送、状态、上级授权、价格和权限规则；任一套餐失败则系列授权、全部套餐授权和价格历史均不落库。
- 首次授权不需要 `operation_type`，因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”；该接口不承担后续调价或移除。

### 批量写兼容契约

- 保留 `PUT /api/admin/shop-series-grants/{id}/packages`，请求固定为：

```json
{
  "packages": [
    {"package_id": 1001, "cost_price": 6500},
    {"package_id": 1002, "cost_price": 7000},
    {"package_id": 1003, "remove": true}
  ]
}
```

- `packages` 必填，最少 1 项；单次上限 100 项。同一请求中的 `package_id` 必须唯一，重复 ID 返回参数错误，不能以“最后一个覆盖前一个”处理。
- 每项 `remove=true` 时执行软删除；字段缺失或为 `false` 时必须提供大于等于 0 的 `cost_price`，未授权则新增，已授权则按现有规则更新价格。
- `remove` 是已交付稳定字段，不得删除、改名或要求调用方改传必填顶层 `operation_type`。
- 成功响应继续返回最新 `ShopSeriesGrantResponse`，不得强制旧前端适配新的结构化计数响应。

### 新增授权规则

- 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权，保持现有“可以先配置授权、销售时由可售策略拦截”的能力；本需求不把销售状态误当成授权状态。
- 代理操作者必须拥有该套餐的有效授权；目标店铺必须仍满足现有直属下级和系列授权管理权限。
- 目标代理尚未授权：创建 `tb_shop_package_allocation`，沿用现有初始零售价、状态和上架状态规则，并写价格历史“新增授权”。
- 目标代理已经存在有效授权且成本价相同：按幂等成功，不重复创建、不重复写价格历史。
- 目标代理已经存在有效授权但成本价不同：返回冲突，整批不做任何写入。不得借 `authorize` 静默改价。
- 已软删除的旧授权是否允许按新授权恢复，应复用项目现有唯一索引和新建授权语义：创建新的有效记录或按明确恢复操作处理，但不能把旧价格静默带回；审计必须标明恢复来源。

### 更新成本价规则

- 每个套餐必须已经存在目标代理的当前有效授权；未授权项导致整批失败。
- 新价格与当前价格相同时按幂等成功，不写重复价格历史。
- 新价格不同时执行现有价格边界校验，并写 `ShopPackageAllocationPriceHistory`，变更原因明确为系列授权管理调价。
- 若目标代理已将该套餐继续授权给下级，沿用现有规则禁止修改成本价，返回“存在下级分配记录，请先回收后再修改成本价”；整批失败，不允许部分跳过。
- 此命令是按套餐指定绝对目标成本价，与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同，二者不能互相冒充。

### `remove=true` 规则

- 每个套餐必须已经存在目标代理的当前有效授权；同一授权已不存在时按幂等成功，不重复写删除审计。
- 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件，必须拒绝并要求先回收，不能留下下级拥有而上级无权的悬空链路。
- 移除使用软删除或项目现有撤销语义，不物理删除价格历史和审计事实。
- 本需求不自动取消存量客户已购买套餐，不修改 `PackageUsage` 或历史订单。

### 事务、并发与幂等

- 批量管理是轻量 Application 事务脚本。Application 负责权限、逐项语义解析、批量加载、规则校验、条件写入和价格历史；不创建无业务行为的聚合根。
- 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用，再执行写入。
- 数据库保留目标店铺与套餐的有效授权唯一约束；`authorize` 创建应使用条件写入/唯一冲突后的重新读取，正确区分同价幂等与不同价冲突。
- 并发 `authorize` 同一批套餐时，一方成功后另一方重新核对当前价格；相同价格成功返回，不同价格冲突，不得将唯一约束错误暴露给客户端。
- 不需要 Redis 分布式锁；数据库事务、唯一约束和条件写入足以保护这一轻量授权用例。

### 权限与越权防护

- 超级管理员和平台账号沿用现有授权管理范围。
- 代理账号只能管理由自己店铺分配、且目标为其现有规则允许的直属下级系列授权；不得操作其他平台或代理创建的授权。
- 写接口不能只检查 `allocation.allocator_shop_id`，还要复用目标店铺、操作者店铺层级、系列授权和逐套餐上级授权的完整校验。
- 资源不存在或无权使用统一安全错误语义，防止探测其他代理的授权和成本价。
- 前端置灰仅是交互提示，所有归属、系列、赠送、状态、价格和重复规则由后端重新校验。

### 审计与响应

- Audit Event 已按七月总 Change 决策移出本期，本需求不新增审计 Writer；价格历史继续作为调价事实保存。
- 成功响应保持已交付的最新授权详情结构，不引入破坏性响应变更。

### 前端交互

- 首次授权和后续管理复用同一个批量选择表格组件；数据来自现有套餐列表，后续管理再与现有授权详情按 `package_id` 合并。
- 分列展示当前上级成本价、目标代理授权成本价、建议零售价；平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”，不能显示 0 元造成误解。
- `is_authorized=true` 显示“已授权”并在新增授权模式置灰；切换到调价或移除模式时只允许选择已授权项。
- 前端可以提供授权、调价或移除操作模式，但提交时继续组装既有套餐项：移除项设置 `remove=true`，新增或调价项提交 `cost_price`。
- 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要；提交期间禁止重复提交。
- 并发不同价冲突时保留用户输入，提示刷新候选数据后重新确认，不能自动覆盖。

### 发布与回滚

- 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。
- 后端发布必须向后兼容现有前端请求，无需停机切换新命令字段；发布检查应确认 OpenAPI 仍包含 `packages[].remove`。
- 回滚应用时保留新版本产生的授权、价格历史和审计；不得删除业务事实。

## Testing Decisions

- 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示，以及不同视角下成本价字段的准确文案。
- 字段权限测试验证无成本价查看权限的账号不能读取敏感价格，代理不能查询其他授权记录详情或借套餐列表扩大授权范围。
- 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。
- 新增测试覆盖多项成功、重复请求、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。
- 调价测试覆盖同价提交、成功调价、价格历史、存在下级分配时整批失败，以及绝对价格不会被误解成固定/比例调整。
- `remove=true` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用，并验证缺失 `remove` 时不会误删。
- 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。
- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应，验证 `remove` 字段、错误码、分页与越权安全语义。
- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、`remove=true` 提交、空态、失败态和并发冲突刷新。

## Out of Scope

- 不重建系列授权、套餐授权、佣金或强充模型。
- 不新增 `package-options` 或其他重复的候选套餐读取接口。
- 不允许创建没有任何套餐的空系列授权。
- 不删除或替换既有 `packages[].remove` 字段。
- 不把赠送套餐授权给代理。
- 不自动级联调整或回收下级代理授权。
- 不修改存量客户订单、套餐使用记录或零售价配置。
- 不用 Redis 锁替代数据库唯一约束和事务。

## Further Notes

- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`；这是已交付契约，实现和文档必须继续保留。
- 仓库已有 `/shop-package-batch-pricing`，但它按整个店铺/系列进行固定或比例调整且允许逐项跳过，不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost`。
- 用户已于 2026-07-24 明确纠正：`remove` 字段不应移除，必须恢复并保持兼容。
- 用户纠正并确认：首次授权保持现有 `POST` 同时授权系列和套餐；后续添加套餐由 `PUT /{id}/packages` 负责；读取复用现有套餐列表和授权详情，不新增候选接口。
