Files
junhong_cmp_fiber/.scratch/ur43-series-package-bulk-authorization/PRD.md
2026-07-21 15:26:07 +09:00

178 lines
16 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#43 代理系列套餐批量授权
Status: ready-for-agent
---
## Problem Statement
代理系列授权及套餐授权的数据结构和接口已经存在,首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图,后续追加套餐时不容易区分未授权与已授权,也容易混淆上级当前成本、目标代理授权成本和建议零售价。
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 还将新增、改价和移除混在同一请求中:已经授权的套餐再次提交不同价格会被直接改价。这会使一个看似“新增授权”的并发请求静默覆盖其他运营人员刚设置的成本价,业务意图和审计语义均不明确。
## Solution
复用现有套餐列表和系列授权详情,不新增候选 API。首次授权直接使用现有套餐列表选择多项并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列及套餐授权;后续管理同时读取现有套餐列表与 `GET /api/admin/shop-series-grants/{id}`,按 `package_id` 合并成同一批量选择视图,已授权项置灰、未授权项可多选。
保留现有批量写路径,但新增必填 `operation_type=authorize|update_cost|remove`。单次请求只能表达一种命令,并在一个事务内全成全败:`authorize` 只新增并支持同价幂等,不同价重复整批冲突;`update_cost` 只明确修改已授权套餐价格;`remove` 只明确移除授权。
## User Stories
1. 作为平台或上级代理,我希望一次选择多个系列套餐并授权给目标代理。
2. 作为授权人员,我希望看到公司成本价、目标代理当前授权成本价和建议零售价,不再混淆单一 `cost_price` 的含义。
3. 作为授权人员,我希望已经授权的套餐明确置灰,避免重复选择。
4. 作为授权人员,我希望新增授权、调价和移除是三个明确动作,避免误操作。
5. 作为并发操作人员,我希望重复同价授权安全幂等,不同价并发请求明确冲突而不是覆盖。
6. 作为上级代理,我只能把自己有权销售的套餐授权给直属下级,不能借候选或构造请求越权。
7. 作为审计人员,我希望知道一次批量操作的命令、系列、目标代理、套餐及前后价格。
## 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 必须唯一。
- 首次授权中的套餐必须满足与后续 `authorize` 相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权、价格历史和成功审计均不落库。
- 首次授权不需要 `operation_type`,因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”;该接口不承担后续调价或移除。
### 批量写契约
- 保留 `PUT /api/admin/shop-series-grants/{id}/packages`,请求固定为:
```json
{
"operation_type": "authorize",
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000}
]
}
```
- `operation_type` 是必填稳定枚举:
- `authorize`:新增套餐授权。
- `update_cost`:明确修改已有授权成本价。
- `remove`:明确移除已有套餐授权。
- `packages` 必填,最少 1 项;单次上限 100 项。同一请求中的 `package_id` 必须唯一,重复 ID 返回参数错误,不能以“最后一个覆盖前一个”处理。
- `authorize``update_cost` 每项必须提供大于等于 0 的 `cost_price``remove` 只使用 `package_id`,如携带价格则拒绝,避免无效参数制造歧义。
- 不再使用每项 `remove=true` 混合命令;旧模糊请求不得继续触发新增、改价或删除。
- `operation_type` 缺失返回参数错误。该契约要求前后端同批发布,不提供会延续模糊语义的长期兼容层。
### `authorize` 规则
- 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权,保持现有“可以先配置授权、销售时由可售策略拦截”的能力;本需求不把销售状态误当成授权状态。
- 代理操作者必须拥有该套餐的有效授权;目标店铺必须仍满足现有直属下级和系列授权管理权限。
- 目标代理尚未授权:创建 `tb_shop_package_allocation`,沿用现有初始零售价、状态和上架状态规则,并写价格历史“新增授权”。
- 目标代理已经存在有效授权且成本价相同:按幂等成功,不重复创建、不重复写价格历史。
- 目标代理已经存在有效授权但成本价不同:返回冲突,整批不做任何写入。不得借 `authorize` 静默改价。
- 已软删除的旧授权是否允许按新授权恢复,应复用项目现有唯一索引和新建授权语义:创建新的有效记录或按明确恢复操作处理,但不能把旧价格静默带回;审计必须标明恢复来源。
### `update_cost` 规则
- 每个套餐必须已经存在目标代理的当前有效授权;未授权项导致整批失败。
- 新价格与当前价格相同时按幂等成功,不写重复价格历史。
- 新价格不同时执行现有价格边界校验,并写 `ShopPackageAllocationPriceHistory`,变更原因明确为系列授权管理调价。
- 若目标代理已将该套餐继续授权给下级,沿用现有规则禁止修改成本价,返回“存在下级分配记录,请先回收后再修改成本价”;整批失败,不允许部分跳过。
- 此命令是按套餐指定绝对目标成本价,与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同,二者不能互相冒充。
### `remove` 规则
- 每个套餐必须已经存在目标代理的当前有效授权;同一授权已不存在时按幂等成功,不重复写删除审计。
- 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件,必须拒绝并要求先回收,不能留下下级拥有而上级无权的悬空链路。
- 移除使用软删除或项目现有撤销语义,不物理删除价格历史和审计事实。
- 本需求不自动取消存量客户已购买套餐,不修改 `PackageUsage` 或历史订单。
### 事务、并发与幂等
- 批量命令是轻量 Application 事务脚本。Application 负责权限、命令解析、批量加载、规则校验、条件写入、价格历史和审计;不创建无业务行为的聚合根。
- 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用,再执行写入。
- 数据库保留目标店铺与套餐的有效授权唯一约束;`authorize` 创建应使用条件写入/唯一冲突后的重新读取,正确区分同价幂等与不同价冲突。
- 并发 `authorize` 同一批套餐时,一方成功后另一方重新核对当前价格;相同价格成功返回,不同价格冲突,不得将唯一约束错误暴露给客户端。
- 不需要 Redis 分布式锁;数据库事务、唯一约束和条件写入足以保护这一轻量授权用例。
### 权限与越权防护
- 超级管理员和平台账号沿用现有授权管理范围。
- 代理账号只能管理由自己店铺分配、且目标为其现有规则允许的直属下级系列授权;不得操作其他平台或代理创建的授权。
- 写接口不能只检查 `allocation.allocator_shop_id`,还要复用目标店铺、操作者店铺层级、系列授权和逐套餐上级授权的完整校验。
- 资源不存在或无权使用统一安全错误语义,防止探测其他代理的授权和成本价。
- 前端置灰仅是交互提示,所有归属、系列、赠送、状态、价格和重复规则由后端重新校验。
### 审计与响应
- 每次成功批量命令写统一 Audit Event至少包含 `operation_type`、系列授权 ID、目标店铺、系列 ID、套餐 ID 列表、前后成本价、操作者和幂等项摘要。
- 拒绝和冲突按公共失败审计规则记录;价格、账号等敏感信息遵循全局审计脱敏规则。
- 成功响应返回最新授权详情或结构化结果,至少包括请求数、实际新增/更新/移除数、幂等数及相关套餐 ID不得以“跳过”掩盖不同价格冲突。
### 前端交互
- 首次授权和后续管理复用同一个批量选择表格组件;数据来自现有套餐列表,后续管理再与现有授权详情按 `package_id` 合并。
- 分列展示当前上级成本价、目标代理授权成本价、建议零售价;平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”,不能显示 0 元造成误解。
- `is_authorized=true` 显示“已授权”并在新增授权模式置灰;切换到调价或移除模式时只允许选择已授权项。
- 一个弹窗/提交只能处于授权、调价或移除一种模式,请求提交相应 `operation_type`
- 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要;提交期间禁止重复提交。
- 并发不同价冲突时保留用户输入,提示刷新候选数据后重新确认,不能自动覆盖。
### 发布与回滚
- 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。
- 前后端同批切换必填 `operation_type`;旧前端流量应在发布窗口清空或阻断,不能让缺失命令类型的请求继续按旧逻辑执行。
- 回滚应用时保留新版本产生的授权、价格历史和审计;不得删除业务事实。
## Testing Decisions
- 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示,以及不同视角下成本价字段的准确文案。
- 字段权限测试验证无成本价查看权限的账号不能读取敏感价格,代理不能查询其他授权记录详情或借套餐列表扩大授权范围。
- 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。
- `authorize` 测试覆盖多项成功、同价幂等、不同价冲突、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。
- `update_cost` 测试覆盖未授权、同价幂等、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。
- `remove` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用。
- 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。
- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证必填枚举、错误码、分页与越权安全语义。
- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、三种明确模式、批量摘要、空态、失败态和并发冲突刷新。
## Out of Scope
- 不重建系列授权、套餐授权、佣金或强充模型。
- 不新增 `package-options` 或其他重复的候选套餐读取接口。
- 不允许创建没有任何套餐的空系列授权。
- 不允许一个请求混合授权、调价和移除。
- 不在 `authorize` 中修改已授权套餐价格。
- 不把赠送套餐授权给代理。
- 不自动级联调整或回收下级代理授权。
- 不修改存量客户订单、套餐使用记录或零售价配置。
- 不用 Redis 锁替代数据库唯一约束和事务。
## Further Notes
- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`,并会在重复授权时直接改价;实现必须主动消除这段模糊语义。
- 仓库已有 `/shop-package-batch-pricing`,但它按整个店铺/系列进行固定或比例调整且允许逐项跳过,不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost`
- 用户已确认以必填 `operation_type` 将授权、调价和移除拆为明确命令,并接受前后端同批切换。
- 用户纠正并确认:首次授权保持现有 `POST` 同时授权系列和套餐;后续添加套餐由 `PUT /{id}/packages` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。