Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md
break c7f9e005af
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。
- 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。
- 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。
- 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。
- 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。
- 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
2026-09-14 16:51:44 +08:00

14 KiB
Raw Blame History

Context

  • 店铺已存在负责人字段 tb_shop.business_owner_account_idmigrations/000169_add_shop_business_owner.up.sql:单列普通索引、无外键、不回填历史)。店铺列表/详情/候选读取收口在 internal/query/shop/business_owner.go不使用 JOIN:负责人摘要由 loadBusinessOwners 二次批量查询后在 Go 中装配,List 在同一查询上先 CountFind
  • 数据范围由 pkg/middleware/data_scope.goApplyShopIDFilter 承担;SubordinateShopIDs 只对代理账号预计算,超管与平台用户恒为不受限 —— 因此「行级无权」在超管/平台入口下不可达。
  • 单店更新已显式拒绝代理设置店铺业务员(internal/application/shop/update.go)。
  • 仓库不存在统一导入任务框架既有四个导入场景IoT 卡、设备、订单套餐失效、资产套餐批量订购)各自独立成表、成 store、成 task type、成队列集中的只有 QueueForTaskType 与 worker 注册表两处。设备导入表的启动补偿按表扫描并以设备任务类型重新入队,因此不能复用其表。
  • 已有可复用先例:encoding/csv 解析、UTF-8 BOM 剥离(internal/task/device_batch_allocation.gointernal/task/asset_package_batch_order.go、异步导入任务状态机、jsonb 行明细、按批进度更新、启动补偿、逐行事务(internal/task/device_import.go)。
  • 仓库唯一 GBK 转换能力位于支付集成包 pkg/fuiou(导出函数 GBKToUTF8pkg/utils 是既有的中立工具包。
  • 仓库不存在店铺导出场景internal/exporter 的注册表与场景白名单、导出 DTO 场景枚举、店铺路由段均无 shop。
  • 用户组是新的业务分类,不能复用 RBAC 角色或代理店铺层级;推导必须实时,避免负责人改组后回写店铺造成不一致。

Goals / Non-Goals

Goals:

  • 用户组只描述平台用户业务分类,不进入鉴权、数据范围或登录链路。
  • 店铺所属组与业务线始终由当前负责人实时推导,店铺不落组字段。
  • 勾选批量全成或全不成CSV 导入逐行独立、成功行提交、失败行保留原值。
  • 推导、批量与导入复用既有审计、数据范围与统一错误响应口径。

Non-Goals:

  • 店铺导出场景;导入模板下载端点与服务端模板资源。
  • 用户组层级、上级组、组管理员。
  • 改动既有 RBAC、登录、数据范围与店铺具体负责人归属。
  • 重构既有 5 处内联的「有效平台业务员」判定。
  • 复用 openspec/specs/shop-bulk-import 维护者手工执行的离线 SQL 生成器。

Decisions

数据模型

  • 用户组表code164 字符,创建时必填,未删除组内唯一,创建后不可修改)、name1100 字符)、business_line(可选单值字符串,取值 standard 标品 / smart 智能产品 / other 其他,允为空)、sort(非负整数)、status0 禁用 / 1 启用,遵循 ENG-STATE-001remark(最多 500 字符)。不设上级、层级与组管理员。
  • 成员表:一账号一行,account_id 唯一。
  • 三处索引:用户组 UNIQUE(code) WHERE deleted_at IS NULL;成员 UNIQUE(account_id) WHERE deleted_at IS NULL(结构性保证「一个账号至多一个组」);成员 (business_user_group_id) WHERE deleted_at IS NULL不给 business_line 建索引:它是 3 值枚举且用户组表体量小,存在性子查询按 account_id 唯一索引定位成员后回表,额外索引无收益。
  • 账号改组走 UPDATE 组 ID,不做软删加新增,避免唯一索引与垃圾行。账号被软删时成员关系保留并继续参与推导(店铺仍展示已软删负责人,口径一致),账号删除不级联清成员。
  • 全部关联只保存 ID不新增 GORM 关联标签或数据库外键ENG-MODEL-001

读侧推导

  • 店铺不落组字段。列表/详情继续沿用「Go 批量装配」:在既有 loadBusinessOwners 之上追加一次按 account_id IN (...) 的成员批量查询与一次用户组批量查询,无 N+1。
  • 返回字段:business_user_group_id、组编码、组名称、组启用状态、组业务线。
  • 筛选必须用 EXISTS 子查询,不得改用 JOINList 在同一查询上先 CountFindJOIN 会使计数行膨胀并引入列歧义。备选方案 JOIN + DISTINCT 计数被否决(计数语义脆弱、易误改)。
  • 未分组口径:business_owner_account_id IS NULL OR NOT EXISTS(该负责人的未删除成员关系)停用组的负责人不计入未分组,停用组仍需可被筛出并携带已停用标记。
  • 不改动 Count/Find 的既有分页与排序契约;idx_shop_business_owner_account_id 继续支撑关联与批量更新。

用户组成员维护

  • 批量设置/清空成员的请求为账号 ID 数组;每个账号必须是启用平台用户,目标组必须是启用组。单事务内替换每个账号的原组关系,任一账号无效则全量回滚。停用组不得作为目标,且不得新增成员;停用后成员可改组或清空。
  • 采用「角色权限批量」先例:全量预读 + 预校验 + 单事务 + 同事务审计 + 业务回滚后独立短事务失败审计。备选「逐条尽力而为」被否决(与 PRD「设置会直接替换原所属组」的确定性语义不符

勾选批量交接

  • 请求携带店铺 ID 集合与目标业务员 ID。清空表达沿用既有「可空字段 + 是否出现标志」模式(与单店更新的请求契约一致),不引入操作类型字符串。
  • 单事务:ApplyShopIDFilter + id IN (...) FOR UPDATE 锁定 → 命中数不等于请求数即失败 → 校验目标业务员为启用平台用户 → 统一更新 → 逐店写审计 → 检查受影响行数。任一项失败整批不写入。
  • 失败文案统一为「无权限操作该资源或资源不存在」,不区分无权、不存在与已删除。

CSV 导入

  • 格式固定为 CSV,使用标准库 encoding/csv 流式解析;不引入 Excel 解析或生成能力,也不改动既有 pkg/utils/excel.go
  • 模板由前端提供。后端不提供模板下载端点、模板响应 DTO 或静态模板资源;固定列序、操作类型取值与编码要求只写入导入接口的 OpenAPI description。
  • 独立成表、独立 store、独立 task type 与队列,不复用设备导入任务表(其启动补偿按表绑定设备任务类型)。
  • 每行独立事务:成功行提交,失败行不写 tb_shop 并保留原值;任何一行失败不回滚其他已成功行。
  • 按批更新进度计数(进度写失败不回滚已提交行);任务收尾一次事务写汇总与行明细。

编码与解析

  • 先剥离 UTF-8 BOM沿用 bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) 既有做法)。
  • 剥离后若字节不是合法 UTF-8则按既有 GBK 转换能力尝试解码;解码仍失败按任务级失败处理并给出明确原因。
  • 解码实现落在中立工具包(pkg/utils),使用仓库已依赖的 golang.org/x/text/encoding/simplifiedchinese。备选「直接调用 pkg/fuiou.GBKToUTF8」被否决:会让任务层依赖支付集成包,层级不成立;备选「改造 pkg/fuiou 抽出共享工具」被否决:属于需求未触碰模块的重构。
  • 表头必须与固定列序完全一致;不一致即任务级失败并给出明确原因,不进入逐行阶段。
  • 行号从数据首行起计(表头不计入),写入行明细。
  • 不设行数硬上限,不新增体积常量:体积沿用既有上传与下载链路的校验,不复制设备/资产场景的 1000 行与 10MB 场景常量。
  • 任务级失败(文件不可下载、格式或表头不符、编码无法解码、无数据行)与行级失败(业务校验不通过)分开记录,任务级失败不产生行明细。

权限与审计

  • 用户组维护、成员维护、勾选批量与 CSV 导入入口一律限超级管理员与平台用户(与既有导入入口及「代理不得设置店铺业务员」一致)。代理与企业返回 403。
  • 由于超管/平台数据范围恒为不受限,「行级无权」在现入口下不可达;「无权限与不存在使用同一文案、不泄露存在性」作为勾选批量的强制原则保留,导入侧失败原因枚举按实际可达收敛。
  • 新增一处共享的「有效平台业务员」谓词供导入与批量目标校验复用;不重构既有 5 处内联判定(internal/query/shop/business_owner.gointernal/application/shop/create.gointernal/application/shop/update.gointernal/infrastructure/shop/recipient_resolver.gointernal/infrastructure/wallet/debit_event.go)。
  • 审计:勾选批量用批次根事件(平台作用域,承载批次统计)+ 逐店子事件(店铺作用域);导入新增任务级动作码(创建 / 完成)与独立任务资源,逐行实际变更写店铺资源前后值审计(负责人前后值、操作者、时间、行备注)。审计写入与业务事实同事务,业务回滚后的失败/拒绝审计用独立短事务ENG-TX-001

管理动作契约

用户组及成员

  • POST /business-user-groups:提交 codenamebusiness_line(可选)、sortenabledremark。重复编码返回「业务用户组编码已存在」。
  • GET /business-user-groupsGET /business-user-groups/:id:返回组字段含业务线,供维护与筛选下拉使用。
  • PUT /business-user-groups/:id:允许更新名称、业务线、排序、启停、备注;code 永不允许修改。不存在或已删除返回既有资源不存在。
  • DELETE /business-user-groups/:id:须带二次确认;存在成员时返回「用户组仍有成员,只能停用或先移走成员」,不物理删除。
  • PUT /business-user-groups/:id/members:请求 account_ids 为非空数组;所有账号必须是启用平台用户且目标组启用。事务内替换每个账号原组关系,任一账号无效则全量回滚。DELETE /business-user-groups/members 使用同一校验清空指定账号归属。成功操作写成员前后审计。

店铺负责人批量交接

  • PUT /shops/business-owner/batch:请求 shop_ids(非空、去重)及目标业务员 ID或显式空值表示清空。先锁定并校验全部目标店铺再统一更新 tb_shop.business_owner_account_id 并逐店写审计;任一目标无权、不存在、已删除或负责人无效时返回统一失败且整批无写入。

店铺负责人 CSV 导入

  • POST /shops/business-owner-imports:请求携带上传后的 file_key。校验 file_key 位于 shop-imports/ 前缀下且扩展名为 .csv(沿用既有导入服务的 file_key 前缀校验先例),创建待处理任务并入队。
  • GET /shops/business-owner-importsGET /shops/business-owner-imports/:id:任务列表与详情,详情返回成功数、失败数、逐行失败原因与任务级错误原因。
  • OpenAPI description 必须写明模板要求:文件格式 CSV固定列序「店铺编码、操作类型、业务员登录账号、备注」操作类型取值「换绑」「清空」换绑必须填业务员登录账号清空不得填备注可选并写入该行审计编码 UTF-8 且可带 BOM非 UTF-8 时按既有 GBK 转换能力尝试解码,仍失败则明确报错;店铺以店铺编码唯一定位,业务员以登录账号唯一定位。

读侧投影

  • 扩展既有店铺列表、详情与筛选:返回 business_owner_account_id、负责人名称、业务用户组 ID、组编码、组名称、组启用状态与组业务线均从当前负责人—成员关系实时推导。
  • 筛选支持按用户组、按用户组业务线与按未分组。组筛选只匹配当前负责人所属组;负责人为空或无成员关系时归入未分组;停用组可被筛出且带停用标记。历史店铺不回填,负责人改组或组停用后下一次读立即反映变化。不含导出

Risks / Trade-offs

  • 实时推导增加读侧查询次数 → 以 account_id 唯一索引做批量装配,不引入冗余字段换一致性风险;明确不改成 JOIN 以免破坏既有 Count 语义。
  • 「有效平台业务员」判定将有第 6 处实现 → 新增共享谓词并只在新链路使用;既有 5 处保留为 As-Is避免顺手重构。
  • 逐行事务遇大文件时任务时长与数据库往返增加 → 明确不设行数上限、按批更新进度;不引入批量合并事务(会破坏「一行失败不影响其他行」的语义)。
  • GBK 解码是启发式GBK 解码器对多数字节对不报错)→ 不静默改写内容:仅在 UTF-8 校验失败时尝试解码,解码报错即任务级失败并给出原因。
  • 编排可产生重复投递 → worker 依状态机幂等(待处理首跑,处理中被视为中断重跑,终态直接跳过),沿用既有导入场景语义。
  • 批量与导入并发 → 均以行锁 + 单事务保证原子性;读取接受当前已提交快照。

Migration Plan

  1. 新增成对迁移创建用户组表、成员表与三处索引,不回填历史组归属与历史分组快照;down 带守卫,存在数据时拒绝静默回滚。
  2. 先部署读侧空组兼容(新字段为空、新筛选不命中),再启用用户组维护、成员维护、勾选批量与 CSV 导入入口。
  3. 在 ENG-TEST-001 指定的唯一测试面验证:迁移 up/down/up、编码唯一、账号唯一、停用组保留成员、负责人改组实时推导、未分组与业务线筛选、批量原子失败、导入混合结果与任务级/行级失败分离、无行数上限。

Open Questions

无。GBK 解码落点、批量审计作用域、上传用途命名与入口角色范围已在本设计内定稿。