Files
junhong_cmp_fiber/.scratch/ur96-shop-business-owner/PRD.md
2026-07-21 15:26:07 +09:00

130 lines
13 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#96 店铺业务员归属、继承与筛选
Status: ready-for-agent
---
## Problem Statement
店铺目前只有上下级代理关系,没有明确的平台业务员归属。运营无法按负责员工筛选店铺,套餐临期和钱包低余额等通知也无法稳定找到对应平台员工。
代理账号可以发展下级代理,因此业务员归属不能只在平台创建店铺时人工填写。下级代理应默认继承直属上级店铺当时的业务员,但代理不得自行指定或更换平台员工;同时,平台后续调整上级店铺业务员不能自动覆盖已有下级店铺,否则会破坏平台对单个下级店铺的独立调整。
## Solution
`tb_shop` 增加可空 `business_owner_account_id`,表示该店铺当前负责业务员。代理创建下级店铺时由服务端复制所选直属上级店铺当时的业务员 ID代理请求不得提供该字段复制完成后父子店铺各自保存后续不做级联同步。
只有超级管理员和平台账号可以显式设置、清空或更换业务员,且只能选择当前启用、未删除的普通平台账号。店铺列表、详情和筛选 Query 批量投影业务员账号名及手机号摘要;该字段只用于平台内部业务归属和通知接收,不进入代理层级、数据权限、佣金或分销计算。
## User Stories
1. 作为平台运营人员,我希望创建或编辑店铺时可以选择一个启用的平台业务员,也可以清空归属。
2. 作为平台运营人员,我希望按业务员筛选我权限范围内的店铺,并在列表和详情看到业务员摘要。
3. 作为代理账号,我希望发展下级代理时,新店铺自动沿用我的店铺当前业务员,无需也不能自行选择。
4. 作为平台运营人员,我希望修改上级店铺业务员后,已有下级店铺不被级联覆盖,便于逐店铺独立管理。
5. 作为代理账号,我希望能查看有权限店铺的业务员归属,但不能通过构造请求篡改它。
6. 作为通知系统,我希望按店铺稳定找到当前业务员;账号停用或删除时保留归属历史,但不向不可用账号发送通知。
7. 作为审计人员,我希望区分平台人工设置、代理建店继承、清空和更换,并能看到变更前后账号。
## Implementation Decisions
### 领域含义与数据模型
- `tb_shop` 新增 `business_owner_account_id BIGINT NULL`,不建立数据库外键或 GORM 关联标签Model 只保存 ID关联账号由 Query 显式批量加载。
- `business_owner_account_id` 表示平台内部当前业务负责人和通知接收关系,不是“发展人”层级事实。它不参与 `parent_id` 店铺层级、数据权限、授权范围、代理分销、佣金、提现或客户归属计算。
- 可被人工选择的业务员必须满足 `tb_account.user_type=2``status=1``deleted_at IS NULL`。超级管理员可以管理该字段,但 `user_type=1` 的超级管理员账号本身不是业务员候选。
- 一个店铺本期最多一个业务员,可为空;一个平台业务员可以负责多个店铺。
- 业务员账号禁用或软删除后不清空店铺字段,不级联更新店铺;列表/详情仍尽可能按原 ID 展示历史账号摘要并标记当前不可用,通知接收人解析时跳过。账号重新启用后,该未变更关联重新成为可用通知关系。
-`business_owner_account_id` 建普通索引以支持筛选,不建立唯一约束。
### 创建时继承
- 创建店铺继续使用 `POST /api/admin/shops`,新增存在性感知的可空字段 `business_owner_account_id`
- 代理账号创建其现有权限允许的新下级店铺时,业务员只能由服务端从最终校验通过的 `parent_id` 店铺复制:
- 上级有业务员 ID原值复制到新店铺包括上级账号此刻已停用/软删除但关联仍保留的情况。
- 上级业务员为空:新店铺也为空。
- 请求 JSON 只要主动出现 `business_owner_account_id`,无论值为原值、其他 ID 或 `null`,都返回禁止操作,不静默忽略。
- 代理建店必须先复用现有店铺层级与管理权限校验,确保 `parent_id` 确实处于调用者允许发展的范围;不能通过选择无权上级间接复制归属或创建店铺。本需求不扩大代理原有发展层级权限。
- 超级管理员或平台账号创建店铺时:
- 显式传正整数 ID校验为当前可用平台业务员后设置。
- 显式传 `null`:创建为空业务员。
- 字段缺失且存在上级店铺:默认复制上级店铺当前业务员。
- 字段缺失且为顶级店铺:默认为空。
- 创建时复制是一次性快照关系。上级店铺以后被设置、清空或更换业务员,既有下级、孙级店铺均不自动更新;只有之后新创建的直属下级读取上级当时的当前值。
- 店铺、初始主账号、角色、主/分佣钱包、业务员归属和创建审计属于同一个完整创建用例,必须在同一数据库事务内成功或失败,避免当前多表创建留下半成品。
### 编辑权限和三态字段
- `PUT /api/admin/shops/{id}` 增加存在性感知的可空 `business_owner_account_id`,语义为:字段缺失保持不变,显式 `null` 清空,正整数校验后替换。
- DTO/解析必须真实区分“字段缺失”和“显式 null”不能用普通 `*uint` 把二者都解释成 nil可使用项目内明确的 Optional/Nullable 类型,但不得把这个差异留给 Handler 猜测。
- 超级管理员和平台账号可以设置、清空、更换;代理账号不得修改。代理更新其他店铺资料且字段缺失时保留原业务员,字段一旦出现则返回统一禁止访问错误。
- 企业账号不具备业务员候选或写权限。所有写操作除账号类型检查外,仍要执行目标店铺的既有资源权限校验;无权或不存在统一返回“无权限操作该资源或资源不存在”。
- 设置 ID 时在事务内重新校验业务员仍为启用平台账号,不能仅相信前端候选列表。禁用、删除、代理或企业账号 ID 均返回参数/业务错误且不更新店铺。
- 平台手工修改一个店铺只影响该店铺,不向上、向下或同级传播,也不修改历史通知和审计快照。
### Query 与 API
- `GET /api/admin/shops` 新增可选精确筛选 `business_owner_account_id`,与店铺名、编号、联系电话、层级、状态等现有条件按 AND 组合;分页、排序和调用者数据范围保持原契约。
- 店铺响应增加:
- `business_owner_account_id:uint|null`
- `business_owner_username:string`
- `business_owner_phone_summary:string`
- `business_owner_available:bool`
- 手机号摘要固定保留前三位和后四位,例如 `138****8000`;空值返回空字符串。普通店铺 Query 不返回业务员完整手机号。
- 列表按当前页业务员 ID 一次批量加载,包括必要的软删除只读投影,禁止每行查询账号。筛选按店铺保存的 ID 执行,因此账号停用/删除后仍可用该 ID 查到历史负责店铺。
- 若前端现有店铺详情没有独立接口,应补齐 `GET /api/admin/shops/{id}` 并返回同一 `ShopResponse`;动态路由必须排在 `/cascade``/fund-summary``/business-owner-candidates` 等静态路由之后,避免吞掉现有路径。
- 新增最小候选 Query`GET /api/admin/shops/business-owner-candidates?keyword=&page=&page_size=`,仅超级管理员和平台账号可调用。固定筛选当前启用、未删除的 `user_type=2` 账号,`keyword` 对用户名或手机号做受控查询,返回 `id/username/phone_summary`,默认 20、最大 100。
- 代理端不需要候选接口;其创建/编辑表单只展示继承或现有业务员摘要,不显示可搜索选择框。
- 新字段向后兼容:旧平台客户端不传字段时,创建子店铺按继承规则、编辑保持不变;旧代理客户端不传字段时正常继承/保留。
### Application、通知与审计
- 本需求使用 Application 事务脚本,不创建空洞 Shop 聚合。Application 负责操作者类型、资源权限、上级解析、继承/三态命令、候选账号校验、事务保存和 Audit EventQuery 负责列表、详情、候选及 DTO 投影。
- 店铺创建事件记录 `assignment_source=inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID。编辑事件只有字段实际变化时记录业务员变更前后数据均使用 ID、账号名摘要和可用状态。
- 业务员归属变更属于平台内部敏感业务操作,接入统一 Audit Writer关键成功审计与店铺事务同事务失败/拒绝按公共审计规则记录。
- 公共通知接收人解析按店铺当前 `business_owner_account_id` 查账号并再次校验可用性;不得因为父店铺后来变更业务员而动态向上追溯,也不得沿代理层级向所有上级业务员发送。
- 账号禁用/删除不触发批量清空或改派。运营通过店铺列表按该业务员筛选后逐个或后续独立批量能力处理,本需求不建设自动转派。
- 新增/修改 DTO、Model、Handler 和迁移时遵循相应项目专项规范;新增 Handler 后同步两个 OpenAPI 文档生成器。
### 前端交互
- 超级管理员和平台账号的店铺创建/编辑表单增加可搜索业务员下拉:支持不选择、清空、加载、无候选和失败重试;候选显示账号名与手机号摘要。
- 创建下级店铺时字段初始显示继承到的上级业务员;平台可覆盖或清空。前端是否展示默认值不作为业务规则,最终继承由后端保证。
- 代理账号只读展示“业务员”,不显示选择或清空控件;创建下级时提示“默认继承上级店铺业务员”。
- 店铺列表增加业务员列和筛选。停用/删除账号显示原摘要及“已停用/不可用”,不误显示为空;清空归属显示“-”。
- 编辑提交期间禁用重复提交;权限拒绝、候选失效和并发变更原地展示后端中文错误,成功后重新请求店铺详情。
### 发布与历史数据
- 迁移新增可空字段和普通索引,不回填存量店铺,不根据祖先或创建人猜测历史业务员。所有存量店铺初始为空,由平台后续维护。
- 部署后新建店铺立即按新规则写入;不运行父子全量级联脚本。回滚应用时新增字段可以保留,已产生的业务员关联和审计不得清空。
- 发布前只读核验 `tb_shop` 层级异常和平台账号状态;发布后抽查平台显式设置、代理继承、父级修改不级联及通知接收人解析。
## Testing Decisions
- Application 测试覆盖超级管理员、平台、代理、企业四类操作者的创建和编辑矩阵;验证只有前两类可显式设置/清空/更换。
- 创建测试覆盖:代理上级有/无业务员、上级业务员可用/停用/软删除、代理主动传相同 ID/其他 ID/null、平台显式 ID/null/字段缺失和顶级店铺。
- 继承回归验证父店铺后续设置、清空和更换均不改变既有子孙店铺;新建直属下级只复制创建时父店铺当前值。
- 候选与写入测试验证只有 `user_type=2 + status=1 + 未删除` 可选择;超级管理员、代理、企业、停用和软删除账号均不能被人工新绑定。
- 权限测试验证代理不能利用无权 `parent_id` 创建店铺,不能通过字段缺失/null 混淆清空归属,企业和越权平台请求使用统一安全错误语义。
- PostgreSQL 集成测试验证创建多表事务原子性、业务员索引筛选、AND 条件、分页总数、软删除账号历史投影和批量查询无 N+1。
- HTTP 集成测试穿过真实 Fiber 认证、Handler、Application/Query、GORM 和统一响应,特别验证 JSON 字段缺失、null、0、正 ID 四种解析语义。
- API 测试覆盖店铺列表、详情和候选;验证手机号固定脱敏,代理不能调用候选,静态候选/资金概况/级联路由不被详情动态路由吞掉。
- Audit 测试验证继承、显式设置、清空、更换和拒绝结果,事务失败不留下店铺/账号/钱包半成品或成功审计。
- 前端验收覆盖平台选择/清空、代理只读、停用业务员展示、业务员筛选、空态、失败态和父级修改不级联。
## Out of Scope
- 不恢复需求 16 的分销码、发展关系、佣金、提现或 H5 代理申请;“业务员”不等于分销发展人。
- 不因业务员归属扩大账号的数据权限或店铺管理范围。
- 不自动级联上级业务员的后续变更,不批量回填存量子店铺。
- 不允许代理账号自行设置、清空或更换业务员。
- 不建设多业务员、团队、部门、区域或自动轮转分配。
- 不在业务员账号停用/删除时自动改派,也不发送外部渠道通知。
## Further Notes
- 当前代码的 `Shop`、创建/更新 DTO 和列表 Query 均没有业务员字段;店铺创建还连续创建店铺、主账号、角色和两个钱包但没有显式完整事务,接入继承时必须把该完整创建用例收口。
- 当前已有平台账号列表能力,但返回字段和权限范围大于下拉框所需;专用候选 Query 用于最小披露和明确写权限。
- 用户确认的核心口径是“创建时继承、之后独立、不自动级联”;实现不得把查询时动态向父级取值当作继承。