feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s

- 迁移 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 验证证据链。
This commit is contained in:
2026-09-14 16:51:44 +08:00
parent 957a235585
commit c7f9e005af
56 changed files with 4166 additions and 344 deletions

View File

@@ -0,0 +1,158 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// shopOwnerImportDoc 说明店铺负责人导入 CSV 的模板要求。
// 模板由前端提供,后端只描述固定列序、取值与编码要求,不提供模板下载端点与模板资源。
const shopOwnerImportDoc = `仅超级管理员和平台账号可操作,代理与企业账号返回 403。
### 完整导入流程
1. **获取上传 URL**:调用 ` + "`POST /api/admin/storage/upload-url`" + `purpose 传 ` + "`shop_import`" + `
2. **上传 CSV**:使用返回的预签名 URL 上传文件到对象存储
3. **调用本接口**:使用返回的 ` + "`file_key`" + ` 创建导入任务(必须以 ` + "`shop-imports/`" + ` 开头且扩展名为 ` + "`.csv`" + `
### 模板列序(首行表头必须完全一致)
` + "`店铺编码`" + `` + "`操作类型`" + `` + "`业务员登录账号`" + `` + "`备注`" + `
- 店铺编码:以店铺编号唯一定位店铺;不存在或已删除时该行失败
- 操作类型:取值仅 ` + "`换绑`" + `` + "`清空`" + `;换绑必须填写业务员登录账号,清空不得填写
- 业务员登录账号:以登录账号唯一定位业务员;必须是启用平台用户
- 备注:可选,填写时写入该行审计
### 编码要求
文件编码为 UTF-8可带 BOM非 UTF-8 时按 GBK 尝试解码,仍失败时按任务级失败并给出明确原因。
### 执行语义
每行独立校验与执行:有效行成功更新,失败行保留原值且不影响其他已成功行。结果返回行号(自数据首行起计,表头不计入)、成功或失败状态与失败原因。表头或编码不符为任务级失败,不产生行明细。不设行数硬上限。`
// registerBusinessUserGroupRoutes 注册业务用户组、成员维护与店铺负责人批量交接路由。
// 入口只读认证上下文判断身份,代理与企业一律 403既有单店行为不变。
func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.BusinessUserGroupHandler, doc *openapi.Generator, basePath string) {
groups := router.Group("/business-user-groups")
groupPath := basePath + "/business-user-groups"
Register(groups, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建业务用户组",
Description: "仅超级管理员和平台账号可操作。稳定编码创建时必填、未删除组内唯一且创建后不可修改;用户组不设上级、层级与组管理员,不改变角色权限与数据范围。",
Tags: []string{"业务用户组"},
Input: new(dto.CreateBusinessUserGroupRequest),
Output: new(dto.BusinessUserGroupResponse),
Auth: true,
})
Register(groups, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询业务用户组列表",
Description: "仅超级管理员和平台账号可操作。分页默认第 1 页、每页 20 条,最大 100返回组字段与业务线供维护与筛选下拉使用。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupListRequest),
Output: new(dto.BusinessUserGroupPageResult),
Auth: true,
})
// 批量清空归属使用静态路径 /members必须先于动态 /:id 注册,否则会被 :id 吞掉。
Register(groups, doc, groupPath, "DELETE", "/members", handler.ClearMembers, RouteSpec{
Summary: "批量清空平台用户业务用户组归属",
Description: "仅超级管理员和平台账号可操作。与批量设置使用同一校验:所有账号必须是启用平台用户,任一账号无效则全量回滚。清空后账号回到未分组。",
Tags: []string{"业务用户组"},
Input: new(dto.ClearBusinessUserGroupMembersParams),
Output: new(dto.BusinessUserGroupMembersResult),
Auth: true,
})
Register(groups, doc, groupPath, "GET", "/:id", handler.Detail, RouteSpec{
Summary: "查询业务用户组详情",
Description: "仅超级管理员和平台账号可操作。返回组字段含业务线,供维护与筛选下拉使用;不存在或已删除返回资源不存在。",
Tags: []string{"业务用户组"},
Input: new(dto.IDReq),
Output: new(dto.BusinessUserGroupResponse),
Auth: true,
})
Register(groups, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新业务用户组",
Description: "仅超级管理员和平台账号可操作。允许更新名称、业务线、排序、启停与备注;稳定编码永不允许修改;不存在或已删除返回资源不存在。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupUpdateParams),
Output: new(dto.BusinessUserGroupResponse),
Auth: true,
})
Register(groups, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除业务用户组",
Description: "仅超级管理员和平台账号可操作,必须提交二次确认。仅无成员的用户组可删除;存在成员时返回“用户组仍有成员,只能停用或先移走成员”,不做物理删除。",
Tags: []string{"业务用户组"},
Input: new(dto.IDReq),
Body: new(dto.BusinessUserGroupDeleteRequest),
Output: nil,
Auth: true,
})
Register(groups, doc, groupPath, "PUT", "/:id/members", handler.SetMembers, RouteSpec{
Summary: "批量设置平台用户业务用户组归属",
Description: "仅超级管理员和平台账号可操作。请求为账号ID数组所有账号必须是启用平台用户且目标组启用事务内直接替换每个账号原归属任一账号无效则全量回滚。停用组不得作为目标已有成员关系保留并显示已停用。",
Tags: []string{"业务用户组"},
Input: new(dto.BusinessUserGroupMemberRequest),
Body: new(dto.SetBusinessUserGroupMembersRequest),
Output: new(dto.BusinessUserGroupMembersResult),
Auth: true,
})
}
// registerShopBusinessOwnerBatchRoute 注册勾选店铺批量设置或清空负责人路由。
func registerShopBusinessOwnerBatchRoute(router fiber.Router, handler *admin.BusinessUserGroupHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "PUT", "/business-owner/batch", handler.BatchUpdateShopBusinessOwner, RouteSpec{
Summary: "批量设置或清空店铺负责人",
Description: "仅超级管理员和平台账号可操作。提交前校验全部目标店铺均存在、未删除且可管理并校验目标业务员为启用平台用户任一项失败时整批不修改失败文案不区分无权、不存在与已删除。business_owner_account_id 缺失时请求非法,显式 null 表示清空负责人。成功时在同一事务内统一更新并为每家店铺记录负责人前后值、操作者、时间与入口审计,同时保留批次汇总结果。",
Tags: []string{"店铺管理"},
Input: new(dto.BatchUpdateShopBusinessOwnerRequest),
Output: new(dto.BatchUpdateShopBusinessOwnerResult),
Auth: true,
})
}
// registerShopBusinessOwnerImportRoutes 注册店铺负责人 CSV 导入任务路由。
func registerShopBusinessOwnerImportRoutes(router fiber.Router, handler *admin.ShopBusinessOwnerImportHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "POST", "/business-owner-imports", handler.Create, RouteSpec{
Summary: "创建店铺负责人 CSV 导入任务",
Description: shopOwnerImportDoc,
Tags: []string{"店铺负责人导入"},
Input: new(dto.CreateShopBusinessOwnerImportRequest),
Output: new(dto.ShopBusinessOwnerImportTaskResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/business-owner-imports", handler.List, RouteSpec{
Summary: "查询店铺负责人导入任务列表",
Description: constants.ShopOwnerImportAccessDescription,
Tags: []string{"店铺负责人导入"},
Input: new(dto.ListShopBusinessOwnerImportRequest),
Output: new(dto.ShopBusinessOwnerImportTaskPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/business-owner-imports/:id", handler.Detail, RouteSpec{
Summary: "查询店铺负责人导入任务详情",
Description: constants.ShopOwnerImportAccessDescription + "返回成功数、失败数与逐行失败原因,并单独返回任务级错误原因。",
Tags: []string{"店铺负责人导入"},
Input: new(dto.GetShopBusinessOwnerImportRequest),
Output: new(dto.ShopBusinessOwnerImportTaskDetailResponse),
Auth: true,
})
}