feat(shop-role): 实现店铺角色继承功能和权限检查优化
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m39s

- 新增店铺角色管理 API 和数据模型
- 实现角色继承和权限检查逻辑
- 添加流程测试框架和集成测试
- 更新权限服务和账号管理逻辑
- 添加数据库迁移脚本
- 归档 OpenSpec 变更文档

Ultraworked with Sisyphus
This commit is contained in:
2026-02-03 10:06:13 +08:00
parent bc7e5d6f6d
commit 5a90caa619
61 changed files with 21284 additions and 131 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-02

View File

@@ -0,0 +1,373 @@
# 店铺级角色继承功能设计
## Context
### 当前系统状态
**RBAC 架构**
- 系统使用标准 RBACRole-Based Access Control模型
- 角色分配粒度:账号级别(`tb_account_role` 表维护账号-角色关联)
- 权限检查流程:`Account → AccountRole → Role → RolePermission → Permission`
- 权限缓存Redis 缓存用户权限 30 分钟
**用户类型**
1. 超级管理员UserType=1跳过权限检查
2. 平台用户UserType=2账号级角色可分配多个平台角色
3. 代理账号UserType=3账号级角色可分配 1 个客户角色归属于店铺shop_id
4. 企业账号UserType=4账号级角色可分配 1 个客户角色归属于企业enterprise_id
5. 个人客户UserType=5无角色系统
**店铺层级结构**
- 支持最多 7 级代理层级(通过 `tb_shop.parent_id` 维护)
- 一个店铺可有多个代理账号(员工)
- 店铺层级用于数据权限过滤GORM Callback 自动注入 `WHERE shop_id IN (...)` 条件)
### 问题
在 MVP 阶段,一个店铺内的所有账号权限通常一致(如 10 个员工都是"代理店长"角色)。平台需要为每个账号逐一分配角色,操作繁琐且容易出错。
### 约束
- 必须保持向后兼容,不能破坏现有账号级角色功能
- 仅适用于代理账号UserType=3其他用户类型保持现状
- 必须遵循项目架构分层Handler → Service → Store → Model
- 禁止使用外键约束和 GORM 关联关系
## Goals / Non-Goals
### Goals
1. **简化 MVP 阶段操作**:平台可在店铺层面设置默认角色,店铺内所有账号自动继承
2. **支持未来扩展**:保留账号级角色覆盖能力,特殊账号可单独设置角色
3. **完全向后兼容**:现有账号级角色功能不受影响,不设置店铺角色的店铺行为保持一致
4. **清晰的继承规则**:账号级角色优先,无则继承店铺级角色
### Non-Goals
1. **不支持企业级角色继承**:企业账号保持账号级角色(一企业一账号,暂无批量需求)
2. **不支持平台级角色继承**:平台账号数量少且权限差异大,不适合继承
3. **不支持多角色继承**:店铺只能设置单个角色(代理账号最大角色数为 1
4. **不支持角色叠加**:账号角色和店铺角色二选一,不取并集
## Decisions
### 决策 1角色继承规则默认继承 + 账号级覆盖)
**选择**:账号级角色优先,无则继承店铺级角色
**替代方案考虑**
| 方案 | 优点 | 缺点 | 决策 |
|------|------|------|------|
| A. 强制继承 | 最简单MVP 体验最好 | 未来扩展需要数据迁移 | ❌ 不选 |
| B. 默认继承 + 覆盖 | 简单且灵活,无缝升级 | 逻辑稍复杂(需判断优先级) | ✅ 选择 |
| C. 角色叠加(并集) | 最灵活 | 难理解,容易造成权限混乱 | ❌ 不选 |
**理由**
- MVP 阶段:不设置账号角色,自动继承店铺 → 达到简化目标
- 未来扩展:特殊账号(如财务)可单独设置角色 → 覆盖店铺默认
- 优先级明确:账号角色 > 店铺角色,易于理解和调试
**实现逻辑**
```go
func GetRoleIDsForAccount(accountID) []uint {
// 1. 查询账号级角色
accountRoles := GetRoleIDsByAccountID(accountID)
if len(accountRoles) > 0 {
return accountRoles // 有账号角色,不继承
}
// 2. 查询账号所属店铺
account := GetAccountByID(accountID)
if account.UserType != UserTypeAgent || account.ShopID == nil {
return [] // 非代理账号或无店铺,无继承
}
// 3. 查询店铺级角色(继承)
shopRoles := GetRoleIDsByShopID(account.ShopID)
return shopRoles
}
```
### 决策 2用户类型范围仅代理账号
**选择**仅对代理账号UserType=3启用店铺级角色继承
**理由**
- **代理账号**:有批量需求(一个店铺多个员工)
- **平台账号**:数量少(<10 个),权限差异大,不适合继承
- **企业账号**:一企业一账号,暂无批量需求
- **超级管理员**:跳过权限检查,无角色
- **个人客户**:无角色系统
**影响**:角色解析逻辑中需要判断 `UserType == 3` 才执行店铺角色查询。
### 决策 3数据库设计新增 tb_shop_role 表)
**选择**:新增 `tb_shop_role` 表,保留 `tb_account_role`
**表结构**
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引设计**
- `idx_shop_role_shop_id`:查询店铺角色(高频)
- `idx_shop_role_role_id`:查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at`:软删除过滤
**理由**
- 保留 `tb_account_role`:向后兼容,不影响现有功能
- 新增 `tb_shop_role`:明确语义,避免表字段冗余
- 禁止外键约束:遵循项目规范,关联在代码层维护
### 决策 4角色类型校验只能分配客户角色
**选择**店铺只能分配客户角色RoleType=2
**校验逻辑**
```go
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return errors.New("店铺只能分配客户角色")
}
}
```
**理由**
- 代理账号只能分配客户角色RoleType=2
- 平台角色RoleType=1只能分配给平台用户
- 防止配置错误导致权限混乱
### 决策 5缓存失效策略清理店铺下所有账号缓存
**选择**:店铺角色修改时,清理该店铺下所有账号的权限缓存
**实现**
```go
func (s *ShopRoleStore) clearShopRoleCache(ctx context.Context, shopID uint) {
// 查询该店铺下所有账号
var accountIDs []uint
s.db.Model(&Account{}).Where("shop_id = ?", shopID).Pluck("id", &accountIDs)
// 逐个清理权限缓存
for _, accountID := range accountIDs {
cacheKey := constants.RedisUserPermissionsKey(accountID)
s.redisClient.Del(ctx, cacheKey)
}
}
```
**理由**
- 店铺角色修改后,继承该角色的账号权限立即生效
- 有账号级角色的账号不受影响(因为优先级更高)
- 下次权限检查时,自动使用新的继承逻辑重建缓存
### 决策 6API 设计RESTful 风格)
**新增接口**
- `POST /api/admin/shops/:shop_id/roles` - 分配店铺角色
- `GET /api/admin/shops/:shop_id/roles` - 查询店铺角色
- `DELETE /api/admin/shops/:shop_id/roles/:role_id` - 删除店铺角色
**请求体设计**
```json
// POST /api/admin/shops/:shop_id/roles
{
"role_ids": [5] // 传空数组 = 清空所有角色
}
```
**响应体设计**
```json
// GET /api/admin/shops/:shop_id/roles
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
}
}
```
**权限检查**
- 使用现有的 `middleware.CanManageShop()` 验证权限
- 平台用户和管理该店铺的代理才能操作
### 决策 7依赖注入通过结构体字段
**Service 层依赖**
```go
// internal/service/shop/service.go
type Service struct {
shopStore *postgres.ShopStore
shopRoleStore *postgres.ShopRoleStore // 新增
roleStore *postgres.RoleStore
accountStore *postgres.AccountStore
}
// internal/service/permission/service.go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于调用角色解析
redisClient *redis.Client
}
```
**Bootstrap 注册**
```go
// internal/bootstrap/stores.go
stores := &Stores{
// ... 现有 stores
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis), // 新增
}
// internal/bootstrap/handlers.go
handlers := &Handlers{
// ... 现有 handlers
ShopRole: admin.NewShopRoleHandler(services.Shop), // 新增
}
```
## Risks / Trade-offs
### 风险 1角色解析增加数据库查询
**风险**:每次权限检查需要额外查询店铺角色(如果账号无角色)
**缓解措施**
- 权限结果在 Redis 缓存 30 分钟,大部分请求不会触发查询
- 店铺角色修改频率极低,缓存命中率高
- 查询有索引支持(`idx_shop_role_shop_id`),查询速度快(< 5ms
**性能影响评估**
- 最坏情况:首次权限检查增加 1 次查询(~ 5ms
- 后续请求:从缓存读取(~ 0.1ms
- 预计对 API P95 响应时间影响 < 5ms满足性能要求
### 风险 2缓存失效可能影响多个账号
**风险**:店铺角色修改时,需清理该店铺下所有账号缓存,可能导致短暂性能下降
**缓解措施**
- 缓存清理是异步操作,不阻塞主流程
- 店铺角色修改是低频操作(平均每天 < 10 次)
- 缓存重建是懒加载(下次请求时才重建),不会集中请求数据库
**最坏情况**:店铺有 100 个账号,角色修改后,下次 100 个账号同时请求 → 产生 100 次查询。但这种情况极少,且 PostgreSQL 可承受(连接池默认 100
### 风险 3继承规则理解偏差
**风险**:用户可能不理解"账号角色优先"规则,误以为店铺角色修改会影响所有账号
**缓解措施**
- UI 层明确标识继承状态("继承自店铺" vs "账号单独设置"
- 店铺角色设置页面显示"影响范围10 个账号1 个有单独设置不受影响"
- API 文档和操作指南明确说明继承规则
### Trade-off 1灵活性 vs 复杂度
**Trade-off**:选择"默认继承 + 覆盖"模式增加了逻辑复杂度
**权衡**
- 增加的复杂度:角色解析逻辑从 1 次查询变为最多 2 次查询(先查账号,再查店铺)
- 获得的灵活性MVP 简化 + 未来无缝扩展,无需数据迁移
- 结论:复杂度增加有限(~20 行代码),灵活性收益显著,权衡合理
### Trade-off 2用户类型限制 vs 通用性
**Trade-off**:仅支持代理账号,不支持企业和平台账号
**权衡**
- 限制原因:企业账号暂无批量需求(一企业一账号),平台账号不适合继承
- 未来扩展:如果企业需要多账号,可复制同样逻辑创建 `tb_enterprise_role`
- 结论:优先解决当前痛点(代理店铺批量分配),避免过度设计
## Migration Plan
### 部署步骤
1. **数据库迁移**(无需停机):
```bash
# 执行迁移
migrate -path migrations -database "postgres://..." up
```
- 创建 `tb_shop_role` 表
- 不影响现有数据和功能
2. **代码部署**(滚动更新):
- 部署新版本 API 服务
- 新增接口向后兼容,不影响现有功能
3. **验证**
- 调用 `POST /api/admin/shops/:id/roles` 设置店铺角色
- 验证该店铺下账号权限生效
- 验证有账号角色的账号不受影响
### 回滚策略
**如需回滚**
1. 回滚代码到旧版本
2. 保留 `tb_shop_role` 表(不删除,避免数据丢失)
3. 清理所有权限缓存:`redis-cli KEYS "user:permissions:*" | xargs redis-cli DEL`
4. 旧版本代码忽略 `tb_shop_role` 表,继续使用 `tb_account_role`
**数据一致性**
- 回滚不影响 `tb_account_role` 数据
- `tb_shop_role` 数据保留,重新部署新版本后继续生效
## Open Questions
### Q1: 是否需要支持多角色继承?
**当前设计**:店铺只能设置单个角色(代理账号最大角色数为 1
**未来考虑**:如果代理账号需要支持多角色(如"代理店长" + "销售专员"),需要:
- 修改 `constants.GetMaxRolesForUserType()` 返回值
- 修改 `AssignRolesToShop()` 支持多角色分配
- 修改角色解析逻辑支持多角色继承
**决策时机**:需求明确后再调整(暂不实现)
### Q2: 是否需要记录角色继承历史?
**当前设计**:不记录继承历史,只记录当前状态
**未来考虑**:如果需要审计"某账号在某时间段继承了哪个店铺角色",需要:
- 修改操作审计日志,记录角色继承关系变更
- 修改权限检查日志,记录使用的是账号角色还是店铺角色
**决策时机**:审计需求明确后再实现(暂不实现)
### Q3: 账号转移店铺后,角色如何处理?
**当前设计**:账号转移店铺后,自动继承新店铺角色(如果无账号级角色)
**替代方案**:账号转移店铺时,是否应该清除账号级角色?
**建议**:保持现状(账号角色不跟随店铺),理由:
- 账号角色是独立设置的,转移店铺不应影响
- 如果需要重新继承新店铺角色,可手动删除账号角色
**决策时机**:观察实际使用情况后决定(暂不修改)

View File

@@ -0,0 +1,66 @@
# 店铺级角色继承功能提案
## Why
当前系统的角色分配是账号级别的,平台需要为每个代理店铺的每个账号逐一分配角色。在 MVP 阶段,一个店铺内的所有账号权限通常是一致的(如 10 个员工都是"代理店长"角色),逐个分配造成了不必要的操作负担。本变更通过引入店铺级角色继承机制,允许平台在店铺层面设置默认角色,该店铺下所有账号自动继承,同时保留账号级覆盖能力以支持未来的权限差异化需求。
## What Changes
- **新增店铺级角色管理功能**:平台可为代理店铺设置默认角色,该店铺下所有账号自动继承
- **账号级角色覆盖机制**:特殊账号可单独设置角色,覆盖店铺默认角色
- **角色解析逻辑升级**:权限检查时优先查找账号级角色,如无则继承店铺级角色
- **新增数据库表**`tb_shop_role` 用于存储店铺-角色关联关系
- **新增 API 接口**`POST/GET/DELETE /api/admin/shops/:id/roles` 用于管理店铺角色
- **适用范围限定**仅适用于代理账号UserType=3企业账号和平台账号保持现状账号级角色
## Capabilities
### New Capabilities
- `shop-role-management`: 店铺级角色管理能力,包括为店铺分配角色、查询店铺角色、删除店铺角色等功能
### Modified Capabilities
- `rbac-permission-check`: 角色权限检查能力需要修改,增加店铺级角色继承逻辑,权限解析时需要支持"账号角色优先,无则继承店铺角色"的规则
## Impact
### 数据库层
- **新增表**`tb_shop_role`(店铺-角色关联表)
- **保留表**`tb_account_role`(向后兼容)
### 代码模块
- **新增**
- `internal/model/shop_role.go`ShopRole 模型)
- `internal/store/postgres/shop_role_store.go`ShopRoleStore 数据访问层)
- `internal/service/shop/shop_role.go`(店铺角色业务逻辑)
- `internal/handler/admin/shop_role.go`(店铺角色 HTTP 处理器)
- `internal/model/dto/shop_role_dto.go`(店铺角色 DTO
- **修改**
- `internal/service/account/role_resolver.go`(新增角色解析逻辑)
- `internal/service/permission/service.go`(修改权限检查,使用新的角色解析)
- `internal/routes/shop.go`(注册店铺角色路由)
- `internal/bootstrap/stores.go`(注册 ShopRoleStore
- `internal/bootstrap/handlers.go`(注册 ShopRoleHandler
### API 接口
- **新增**
- `POST /api/admin/shops/:shop_id/roles`(分配店铺角色)
- `GET /api/admin/shops/:shop_id/roles`(查询店铺角色)
- `DELETE /api/admin/shops/:shop_id/roles/:role_id`(删除店铺角色)
- **行为变更**
- `GET /api/admin/accounts/:id/roles`(查询账号角色时,返回结果可能包含继承的店铺角色,需要标识来源)
### 用户类型影响范围
- **代理账号UserType=3**:受影响,支持继承店铺角色
- **平台账号UserType=2**:不受影响,保持账号级角色
- **企业账号UserType=4**:不受影响,保持账号级角色
- **超级管理员UserType=1**:不受影响,跳过权限检查
- **个人客户UserType=5**:不受影响,无角色系统
### 性能考虑
- 角色解析增加一次额外查询(查询店铺角色),但通过 Redis 缓存权限结果30 分钟),性能影响可忽略
- 店铺角色修改时需清理该店铺下所有账号的权限缓存
### 向后兼容性
- ✅ 完全向后兼容:保留 `tb_account_role` 表和现有逻辑
- ✅ 现有账号级角色不受影响,优先级高于店铺级角色
- ✅ 不设置店铺角色的店铺,行为与现在完全一致

View File

@@ -0,0 +1,226 @@
# permission-check Delta Specification
## Purpose
为权限检查能力增加店铺级角色继承逻辑,支持代理账号在没有账号级角色时自动继承店铺级角色。
## MODIFIED Requirements
### Requirement: 权限查询链式执行
权限检查 SHALL 按照以下顺序执行查询(增加店铺角色继承逻辑):
1. 检查用户类型(超级管理员跳过)
2. **查询用户的角色 ID 列表(增加店铺角色继承)**
- 优先查询账号级角色(`tb_account_role`
- 如果账号级角色为空 **且用户是代理账号UserType=3且有 shop_id**
- 查询店铺级角色(`tb_shop_role`
- 返回店铺级角色作为继承角色
3. 查询角色的权限 ID 列表(去重)
4. 查询权限详情列表
5. 遍历匹配 `permCode``platform`
**角色解析函数签名**:
```go
GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
#### Scenario: 正常查询流程(现有行为保持不变)
- **WHEN** 调用 `CheckPermission` 检查普通用户权限
- **THEN** 按顺序执行以下查询:
1. 调用 `AccountService.GetRoleIDsForAccount(ctx, userID)` 获取角色 ID 列表(含继承逻辑)
2. `RolePermissionStore.GetPermIDsByRoleIDs(ctx, roleIDs)` 获取权限 ID 列表
3. `PermissionStore.GetByIDs(ctx, permIDs)` 获取权限详情
- **AND** 遍历权限列表进行匹配
- **AND** 找到匹配权限后立即返回 `true`(短路优化)
#### Scenario: 代理账号继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号UserType=3权限
- **AND** 该账号未分配账号级角色(`tb_account_role` 中无记录)
- **AND** 该账号的 `shop_id` 不为 NULL
- **AND** 该店铺已分配店铺级角色(`tb_shop_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回店铺级角色 ID 列表
- **AND** 后续权限检查使用店铺级角色的权限
#### Scenario: 代理账号有自己角色时不继承
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号已分配账号级角色(`tb_account_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(优先级:账号 > 店铺)
- **AND** 后续权限检查使用账号级角色的权限
#### Scenario: 代理账号无角色也无店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号未分配账号级角色
- **AND** 该账号的店铺未分配店铺级角色(`tb_shop_role` 中无记录)
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 后续权限检查返回 `false`(无权限)
#### Scenario: 非代理账号不继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查平台用户UserType=2权限
- **AND** 该账号未分配账号级角色
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 不查询店铺级角色(仅代理账号支持继承)
#### Scenario: 空结果短路(现有行为保持不变)
- **WHEN** `GetRoleIDsForAccount` 返回空列表(账号无角色且店铺无角色)
- **THEN** 立即返回 `(false, nil)`
- **AND** 不执行后续查询(角色权限查询、权限详情查询)
## ADDED Requirements
### Requirement: 角色解析服务
系统 SHALL 提供 `GetRoleIDsForAccount` 方法,统一处理账号角色查询和店铺角色继承逻辑。
**实现位置**: `internal/service/account/role_resolver.go`
**方法签名**:
```go
func (s *Service) GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
**返回值**:
- `[]uint`: 角色 ID 列表(可能是账号级角色或店铺级角色)
- `error`: 查询失败时的错误信息
#### Scenario: 角色解析 - 超级管理员
- **WHEN** 调用 `GetRoleIDsForAccount` 查询超级管理员UserType=1的角色
- **THEN** 返回空数组 `[]uint{}`(超级管理员无角色,跳过权限检查)
- **AND** 不执行任何数据库查询
#### Scenario: 角色解析 - 平台用户
- **WHEN** 调用 `GetRoleIDsForAccount` 查询平台用户UserType=2的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(平台用户无 shop_id
#### Scenario: 角色解析 - 代理账号有账号级角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号UserType=3的角色
- **AND** 该账号已分配账号级角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(账号角色优先)
#### Scenario: 角色解析 - 代理账号继承店铺角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号的角色
- **AND** 该账号未分配账号级角色(`tb_account_role` 查询结果为空)
- **AND** 该账号的 `shop_id` 不为 NULL
- **THEN** 查询 `tb_shop_role` 表获取店铺级角色
- **AND** 返回店铺级角色 ID 列表(继承)
#### Scenario: 角色解析 - 企业账号
- **WHEN** 调用 `GetRoleIDsForAccount` 查询企业账号UserType=4的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(企业账号无继承机制)
#### Scenario: 角色解析 - 数据库查询失败
- **WHEN** 调用 `GetRoleIDsForAccount` 过程中数据库查询失败
- **THEN** 返回错误 `errors.Wrap(errors.CodeInternalError, err, "查询角色失败")`
- **AND** 不返回部分结果
### Requirement: Permission Service 依赖注入升级
Permission Service SHALL 增加对 Account Service 的依赖,用于调用角色解析逻辑。
**修改的依赖**:
```go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore // 保留但不直接使用
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于角色解析
redisClient *redis.Client
}
```
#### Scenario: Service 初始化
- **WHEN** 创建 Permission Service 实例
- **THEN** 构造函数接收以下参数:
- `permissionStore *postgres.PermissionStore`
- `accountRoleStore *postgres.AccountRoleStore`(保留向后兼容)
- `rolePermStore *postgres.RolePermissionStore`
- `accountService *account.Service`(新增)
- `redisClient *redis.Client`
- **AND** 存储在结构体字段中供 `CheckPermission` 使用
#### Scenario: CheckPermission 使用新的角色解析
- **WHEN** `CheckPermission` 需要查询用户角色时
- **THEN** 调用 `s.accountService.GetRoleIDsForAccount(ctx, userID)`
- **AND** 不再直接调用 `s.accountRoleStore.GetRoleIDsByAccountID()`
- **AND** 获得的角色 ID 列表可能是账号级角色或店铺级角色
### Requirement: 缓存机制兼容
权限缓存机制 SHALL 与店铺角色继承逻辑兼容,确保角色变更后缓存及时失效。
**缓存键**: `user:permissions:{user_id}`
**缓存内容**: 用户的所有权限列表(不区分账号级角色还是店铺级角色)
**缓存时效**: 30 分钟
#### Scenario: 缓存命中时使用缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中存在缓存键 `user:permissions:{user_id}`
- **THEN** 直接从缓存读取权限列表
- **AND** 不调用 `GetRoleIDsForAccount`(避免查询)
- **AND** 使用缓存的权限进行匹配
#### Scenario: 缓存未命中时重建缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中不存在缓存键
- **THEN** 调用 `GetRoleIDsForAccount` 查询角色(含继承逻辑)
- **AND** 查询角色的所有权限
- **AND** 将权限列表写入 RedisTTL 30 分钟
#### Scenario: 店铺角色变更时清理缓存
- **WHEN** 店铺角色变更(分配/删除)
- **THEN** 查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,自动重建缓存(使用新的角色解析逻辑)
#### Scenario: 账号角色变更时清理缓存(现有行为)
- **WHEN** 账号级角色变更(分配/删除)
- **THEN** 删除该账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,重建缓存
### Requirement: 性能要求
角色继承逻辑 SHALL 满足以下性能要求:
- 角色解析查询时间 < 10ms含店铺角色查询
- 权限检查总时间 < 50ms含角色解析、权限查询、匹配
- 缓存命中时权限检查时间 < 1ms
#### Scenario: 角色解析性能
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号角色
- **AND** 账号无账号级角色,需查询店铺级角色
- **THEN** 总查询时间(账号角色查询 + 店铺角色查询)< 10ms
- **AND** 使用索引 `idx_shop_role_shop_id` 优化查询
#### Scenario: 缓存命中性能
- **WHEN** 调用 `CheckPermission` 且缓存命中
- **THEN** 总处理时间 < 1ms
- **AND** 不执行任何数据库查询

View File

@@ -0,0 +1,322 @@
# shop-role-management Specification
## Purpose
提供店铺级角色管理能力,允许平台为代理店铺设置默认角色,该店铺下所有账号自动继承,简化 MVP 阶段的批量角色分配操作。
## ADDED Requirements
### Requirement: 分配店铺角色
系统 SHALL 提供接口允许平台用户或店铺管理员为店铺分配角色。
**接口**: `POST /api/admin/shops/:shop_id/roles`
**请求体**:
```json
{
"role_ids": [5] // 角色 ID 列表,传空数组表示清空所有角色
}
```
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"shop_id": 10,
"role_id": 5,
"status": 1,
"created_at": "2026-02-02T10:00:00Z"
}
],
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功分配单个角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [5]}`
- **AND** 角色 ID 5 存在且为客户角色RoleType=2
- **AND** 店铺 ID 10 存在
- **THEN** 系统创建店铺-角色关联记录
- **AND** 返回 HTTP 200 和关联记录
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 清空店铺所有角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": []}`
- **THEN** 系统删除该店铺的所有角色关联
- **AND** 返回 HTTP 200 和空数组
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 替换现有角色
- **WHEN** 店铺已分配角色 ID 5
- **AND** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [7]}`
- **THEN** 系统删除原有角色 ID 5 的关联
- **AND** 创建新的角色 ID 7 的关联
- **AND** 返回 HTTP 200 和新关联记录
#### Scenario: 角色类型校验失败
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [3]}`
- **AND** 角色 ID 3 是平台角色RoleType=1
- **THEN** 返回 HTTP 400 错误码 `errors.CodeInvalidParam`
- **AND** 错误消息为"店铺只能分配客户角色"
- **AND** 不创建任何关联记录
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `POST /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `POST /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内(不是自己店铺或下级店铺)
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 查询店铺角色
系统 SHALL 提供接口查询店铺已分配的角色列表。
**接口**: `GET /api/admin/shops/:shop_id/roles`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
},
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 查询已分配角色
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 已分配角色 ID 5
- **THEN** 返回 HTTP 200 和角色详情列表
- **AND** 包含角色名称、描述等信息
#### Scenario: 查询未分配角色的店铺
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 未分配任何角色
- **THEN** 返回 HTTP 200
- **AND** `roles` 字段为空数组
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `GET /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `GET /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 删除店铺角色
系统 SHALL 提供接口删除店铺的特定角色关联。
**接口**: `DELETE /api/admin/shops/:shop_id/roles/:role_id`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功删除角色
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 存在
- **AND** 店铺已分配角色 ID 5
- **THEN** 系统删除该关联记录
- **AND** 返回 HTTP 200
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 删除不存在的角色关联
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 未分配角色 ID 5
- **THEN** 返回 HTTP 200幂等操作
- **AND** 不执行任何数据库操作
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/999/roles/5`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `DELETE /api/admin/shops/20/roles/5`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 数据库表结构
系统 SHALL 创建 `tb_shop_role` 表存储店铺-角色关联关系。
**表结构**:
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引**:
- `idx_shop_role_shop_id` - 查询店铺角色(高频)
- `idx_shop_role_role_id` - 查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at` - 软删除过滤
#### Scenario: 唯一性约束
- **WHEN** 尝试为同一店铺分配同一角色两次
- **THEN** 数据库返回唯一性约束冲突错误
- **AND** 系统捕获错误并返回友好错误消息
#### Scenario: 软删除机制
- **WHEN** 删除店铺角色关联
- **THEN** 系统设置 `deleted_at` 字段为当前时间
- **AND** 后续查询自动过滤 `deleted_at IS NOT NULL` 的记录
### Requirement: 缓存失效策略
系统 SHALL 在店铺角色变更时清理相关账号的权限缓存。
#### Scenario: 分配角色时清理缓存
- **WHEN** 为店铺 ID 10 分配角色
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,账号会重新查询并继承新角色
#### Scenario: 删除角色时清理缓存
- **WHEN** 删除店铺 ID 10 的角色关联
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键
- **AND** 下次权限检查时,账号将无角色(如果无账号级角色)
#### Scenario: 账号有自己角色时不受影响
- **WHEN** 店铺角色变更
- **AND** 某账号有自己的账号级角色
- **THEN** 该账号的权限缓存被清理
- **AND** 下次权限检查时,继续使用账号级角色(不继承店铺角色)
### Requirement: 权限控制
店铺角色管理接口 SHALL 实施权限控制,只有有权限的用户才能操作。
**权限规则**:
- 超级管理员UserType=1可操作所有店铺
- 平台用户UserType=2可操作所有店铺
- 代理用户UserType=3只能操作自己店铺及下级店铺
- 企业用户UserType=4无权限操作店铺角色
#### Scenario: 超级管理员操作任意店铺
- **WHEN** 超级管理员调用店铺角色管理接口
- **THEN** 跳过权限检查
- **AND** 允许操作任意店铺
#### Scenario: 平台用户操作任意店铺
- **WHEN** 平台用户调用店铺角色管理接口
- **THEN** 允许操作任意店铺
#### Scenario: 代理用户操作下级店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 15 是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 15, shopStore)`
- **AND** 返回 nil有权限
- **AND** 允许操作
#### Scenario: 代理用户操作无关店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 20 不是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 20, shopStore)`
- **AND** 返回 error无权限
- **AND** 拒绝操作
#### Scenario: 企业用户尝试操作店铺角色
- **WHEN** 企业用户调用店铺角色管理接口
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 业务规则校验
店铺角色分配 SHALL 执行业务规则校验,确保数据一致性。
#### Scenario: 角色存在性校验
- **WHEN** 分配店铺角色时指定角色 ID 列表
- **THEN** 系统查询所有角色是否存在
- **AND** 如果部分角色不存在,返回错误"部分角色不存在"
- **AND** 不创建任何关联记录(原子操作)
#### Scenario: 角色状态校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `status` 字段为 0禁用
- **THEN** 返回错误"角色已禁用"
- **AND** 不创建关联记录
#### Scenario: 角色类型校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `role_type` 字段为 1平台角色
- **THEN** 返回错误"店铺只能分配客户角色"
- **AND** 不创建关联记录
#### Scenario: 店铺存在性校验
- **WHEN** 分配店铺角色时指定店铺 ID
- **AND** 该店铺不存在或已软删除
- **THEN** 返回错误"店铺不存在"
- **AND** 不执行任何操作

View File

@@ -0,0 +1,266 @@
# 店铺级角色继承功能实现任务清单
## 1. 数据库层实现
- [x] 1.1 创建数据库迁移文件 `migrations/YYYYMMDDHHMMSS_add_shop_role_table.up.sql`
- 创建 `tb_shop_role`
- 添加唯一约束 `(shop_id, role_id) WHERE deleted_at IS NULL`
- 创建索引 `idx_shop_role_shop_id``idx_shop_role_role_id``idx_shop_role_deleted_at`
- 验证:执行 `migrate -path migrations -database "..." up` 成功
- [x] 1.2 创建数据库迁移回滚文件 `migrations/YYYYMMDDHHMMSS_add_shop_role_table.down.sql`
- 删除 `tb_shop_role`
- 验证:执行 `migrate -path migrations -database "..." down` 成功
## 2. Model 层实现
- [x] 2.1 创建 `internal/model/shop_role.go`
- 定义 `ShopRole` 结构体,包含所有字段和 GORM 标签
- 实现 `TableName()` 方法返回 `"tb_shop_role"`
- 验证:运行 `go build ./internal/model/`,无编译错误
- [x] 2.2 创建 `internal/model/dto/shop_role_dto.go`
- 定义 `AssignShopRolesRequest` 结构体(包含 `role_ids` 字段和 description 标签)
- 定义 `ShopRoleResponse` 结构体(包含店铺和角色详情)
- 定义 `ShopRolesResponse` 结构体(包含 `shop_id``roles` 列表)
- 验证:运行 `go build ./internal/model/dto/`,无编译错误
## 3. Store 层实现
- [x] 3.1 创建 `internal/store/postgres/shop_role_store.go`
- 实现 `ShopRoleStore` 结构体,包含 `db``redisClient` 字段
- 实现 `NewShopRoleStore()` 构造函数
- 实现 `Create()` 方法(创建单个店铺角色关联)
- 实现 `BatchCreate()` 方法(批量创建)
- 实现 `Delete()` 方法(删除指定店铺角色关联)
- 实现 `DeleteByShopID()` 方法(删除店铺的所有角色关联)
- 实现 `GetByShopID()` 方法(查询店铺的所有角色关联)
- 实现 `GetRoleIDsByShopID()` 方法(查询店铺的所有角色 ID
- 实现 `clearShopRoleCache()` 私有方法(清理店铺下所有账号的权限缓存)
- 验证:运行 `go build ./internal/store/postgres/`,无编译错误
- [x] 3.2 编写 `ShopRoleStore` 单元测试
- 测试文件:`internal/store/postgres/shop_role_store_test.go`
- 测试 `Create()` 成功场景
- 测试 `BatchCreate()` 成功场景
- 测试 `Delete()` 成功场景
- 测试 `DeleteByShopID()` 成功场景
- 测试 `GetByShopID()` 成功场景
- 测试 `GetRoleIDsByShopID()` 成功场景
- 测试唯一性约束冲突
- 验证:运行 `source .env.local && go test -v ./internal/store/postgres/ -run TestShopRoleStore`,所有测试通过
## 4. Service 层实现
- [x] 4.1 创建 `internal/service/account/role_resolver.go`
- 实现 `GetRoleIDsForAccount(ctx, accountID) ([]uint, error)` 方法
- 实现角色解析逻辑:
- 超级管理员返回空数组
- 查询账号级角色,如有则返回
- 代理账号且无账号级角色,查询店铺级角色并返回
- 其他用户类型返回空数组
- 验证:运行 `go build ./internal/service/account/`,无编译错误
- [x] 4.2 编写 `GetRoleIDsForAccount` 单元测试
- 测试文件:`internal/service/account/role_resolver_test.go`
- 测试场景:超级管理员返回空数组
- 测试场景:平台用户返回账号级角色
- 测试场景:代理账号有账号级角色,返回账号级角色(不继承)
- 测试场景:代理账号无账号级角色,继承店铺级角色
- 测试场景:代理账号无账号级角色且店铺无角色,返回空数组
- 测试场景:企业账号返回账号级角色
- 验证:运行 `source .env.local && go test -v ./internal/service/account/ -run TestGetRoleIDsForAccount`,测试覆盖率 ≥ 90%
- [x] 4.3 修改 `internal/service/permission/service.go`
- 修改 `Service` 结构体,添加 `accountService *account.Service` 字段
- 修改 `New()` 构造函数,接收 `accountService` 参数
- 修改 `CheckPermission()` 方法,调用 `accountService.GetRoleIDsForAccount()` 替代直接查询 `accountRoleStore`
- 验证:运行 `go build ./internal/service/permission/`,无编译错误
- [x] 4.4 更新 `Permission Service` 单元测试
- 修改 `internal/service/permission/service_test.go`
- 更新 mock accountService 或使用真实 accountService
- 验证所有现有测试仍然通过
- 新增测试:代理账号继承店铺角色的权限检查场景
- 验证:运行 `source .env.local && go test -v ./internal/service/permission/ -run TestCheckPermission`,所有测试通过
- [x] 4.5 修改 `internal/service/account/service.go`
- 修改 `Service` 结构体,添加 `shopRoleStore *postgres.ShopRoleStore` 字段(用于角色解析)
- 修改 `New()` 构造函数,接收 `shopRoleStore` 参数
- 验证:运行 `go build ./internal/service/account/`,无编译错误
- [x] 4.6 创建 `internal/service/shop/shop_role.go`
- 实现 `AssignRolesToShop(ctx, shopID, roleIDs) ([]*model.ShopRole, error)` 方法
- 实现业务逻辑:
- 权限检查(调用 `middleware.CanManageShop`
- 验证店铺存在
- 验证角色存在、类型正确RoleType=2、状态启用
- 空数组表示清空所有角色
- 删除现有角色关联,批量创建新关联(原子操作)
- 实现 `GetShopRoles(ctx, shopID) ([]*dto.ShopRoleResponse, error)` 方法
- 实现业务逻辑:
- 权限检查
- 查询店铺角色关联
- 查询角色详情并组装响应
- 验证:运行 `go build ./internal/service/shop/`,无编译错误
- [x] 4.7 编写 `Shop Service` 店铺角色管理单元测试
- 测试文件:`internal/service/shop/shop_role_test.go`
- 测试 `AssignRolesToShop()` 成功分配单个角色
- 测试 `AssignRolesToShop()` 清空所有角色
- 测试 `AssignRolesToShop()` 替换现有角色
- 测试 `AssignRolesToShop()` 角色类型校验失败
- 测试 `AssignRolesToShop()` 角色不存在
- 测试 `AssignRolesToShop()` 店铺不存在
- 测试 `AssignRolesToShop()` 权限不足
- 测试 `GetShopRoles()` 查询已分配角色
- 测试 `GetShopRoles()` 查询未分配角色的店铺
- 测试 `GetShopRoles()` 权限不足
- 验证:运行 `source .env.local && go test -v ./internal/service/shop/ -run TestShopRole`,测试覆盖率 ≥ 90%
## 5. Handler 层实现
- [x] 5.1 创建 `internal/handler/admin/shop_role.go`
- 实现 `ShopRoleHandler` 结构体,包含 `service *shop.Service` 字段
- 实现 `NewShopRoleHandler()` 构造函数
- 实现 `AssignShopRoles(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id`
- 解析请求体 `AssignShopRolesRequest`
- 调用 `service.AssignRolesToShop()`
- 返回统一响应格式
- 实现 `GetShopRoles(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id`
- 调用 `service.GetShopRoles()`
- 返回统一响应格式
- 实现 `DeleteShopRole(c *fiber.Ctx) error` 方法
- 解析路径参数 `shop_id``role_id`
- 调用 `service` 删除逻辑
- 返回统一响应格式
- 验证:运行 `go build ./internal/handler/admin/`,无编译错误
- [ ] 5.2 编写 Handler 集成测试
- 测试文件:`tests/integration/shop_role_test.go`
- 测试 `POST /api/admin/shops/:shop_id/roles` 成功分配角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 清空角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 替换角色
- 测试 `POST /api/admin/shops/:shop_id/roles` 角色类型校验失败
- 测试 `POST /api/admin/shops/:shop_id/roles` 权限不足
- 测试 `GET /api/admin/shops/:shop_id/roles` 查询角色
- 测试 `GET /api/admin/shops/:shop_id/roles` 店铺不存在
- 测试 `DELETE /api/admin/shops/:shop_id/roles/:role_id` 删除角色
- 验证:运行 `source .env.local && go test -v ./tests/integration/ -run TestShopRole`,所有测试通过
## 6. 路由注册和依赖注入
- [x] 6.1 修改 `internal/routes/shop.go`
- 注册 `POST /api/admin/shops/:shop_id/roles` 路由到 `handlers.ShopRole.AssignShopRoles`
- 注册 `GET /api/admin/shops/:shop_id/roles` 路由到 `handlers.ShopRole.GetShopRoles`
- 注册 `DELETE /api/admin/shops/:shop_id/roles/:role_id` 路由到 `handlers.ShopRole.DeleteShopRole`
- 验证:运行 `go build ./internal/routes/`,无编译错误
- [x] 6.2 修改 `internal/bootstrap/stores.go`
-`Stores` 结构体添加 `ShopRole *postgres.ShopRoleStore` 字段
-`initStores()` 中初始化 `ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis)`
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.3 修改 `internal/bootstrap/services.go`
- 修改 Account Service 初始化,传入 `stores.ShopRole`
- 修改 Permission Service 初始化,传入 `Account Service` 实例
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.4 修改 `internal/bootstrap/handlers.go`
-`Handlers` 结构体添加 `ShopRole *admin.ShopRoleHandler` 字段
-`initHandlers()` 中初始化 `ShopRole: admin.NewShopRoleHandler(services.Shop)`
- 验证:运行 `go build ./internal/bootstrap/`,无编译错误
- [x] 6.5 更新 API 文档生成器
- 修改 `cmd/api/docs.go`,在 handlers 初始化中添加 `ShopRole: admin.NewShopRoleHandler(nil)`
- 修改 `cmd/gendocs/main.go`,在 handlers 初始化中添加 `ShopRole: admin.NewShopRoleHandler(nil)`
- 验证:运行 `go run cmd/gendocs/main.go`,生成文档成功,包含新的店铺角色管理接口
## 7. 常量定义
- [x] 7.1 检查是否需要新增错误码
- 检查 `pkg/errors/codes.go` 是否已有所需错误码
- 如需新增,添加错误码常量和错误消息
- 验证:运行 `go build ./pkg/errors/`,无编译错误
- [x] 7.2 检查是否需要新增 Redis Key 生成函数
- 检查 `pkg/constants/redis.go` 是否需要新增店铺角色相关的 Redis Key
- 当前使用 `RedisUserPermissionsKey(userID)` 已满足需求,无需新增
- 验证:确认缓存清理逻辑使用正确的 Key
## 8. 端到端测试
- [ ] 8.1 测试完整的店铺角色继承流程
- 创建测试店铺和代理账号(无账号级角色)
- 为店铺分配角色
- 验证账号权限检查返回 true继承店铺角色
- 为账号分配账号级角色
- 验证账号权限检查使用账号级角色(不继承店铺角色)
- 删除账号级角色
- 验证账号权限检查恢复继承店铺角色
- 验证:手动测试或编写端到端测试脚本
- [ ] 8.2 测试缓存失效机制
- 为店铺分配角色,账号继承
- 触发一次权限检查(缓存写入)
- 修改店铺角色
- 再次触发权限检查,验证使用新角色(缓存已失效)
- 验证:手动测试或编写测试脚本
- [ ] 8.3 测试权限控制
- 使用平台用户操作任意店铺角色(应成功)
- 使用代理用户操作自己店铺角色(应成功)
- 使用代理用户操作下级店铺角色(应成功)
- 使用代理用户操作无关店铺角色(应失败 403
- 使用企业用户操作店铺角色(应失败 403
- 验证:手动测试或编写测试脚本
## 9. 代码质量和文档
- [x] 9.1 运行 LSP 诊断检查所有修改的文件
- 运行 `lsp_diagnostics` 检查所有新增和修改的 Go 文件
- 确保无错误、无警告
- 验证:所有文件通过 LSP 检查
- [x] 9.2 运行代码规范检查
- 运行 `gofmt -w .` 格式化所有 Go 文件
- 运行 `go vet ./...` 检查潜在问题
- 验证:无错误输出
- [x] 9.3 运行所有单元测试
- 运行 `source .env.local && go test -v ./...`
- 确保所有测试通过,包括现有测试和新增测试
- 验证:测试通过率 100%,核心逻辑测试覆盖率 ≥ 90%
- [x] 9.4 运行所有集成测试
- 运行 `source .env.local && go test -v ./tests/integration/`
- 确保所有 API 测试通过
- 验证:测试通过率 100%
- [x] 9.5 更新项目文档
-`docs/` 目录创建功能总结文档(如果需要)
- 更新 README.md如果有重大功能说明
- 验证:文档清晰、准确、完整
## 10. 部署准备
- [ ] 10.1 验证数据库迁移
- 在测试环境执行迁移:`migrate -path migrations -database "..." up`
- 验证表创建成功,索引创建成功
- 验证回滚:`migrate -path migrations -database "..." down`
- 验证表删除成功
- [ ] 10.2 性能测试
- 测试角色解析性能(< 10ms
- 测试权限检查性能(< 50ms
- 测试缓存命中性能(< 1ms
- 验证:性能满足设计要求
- [ ] 10.3 最终验收测试
- 在模拟生产环境执行完整测试流程
- 验证向后兼容性(现有账号级角色功能不受影响)
- 验证不设置店铺角色的店铺行为保持一致
- 验证所有 API 接口正常工作
- 验证:功能完整、稳定、性能达标

View File

@@ -1,7 +1,8 @@
# permission-check Specification
## Purpose
TBD - created by archiving change implement-permission-check. Update Purpose after archive.
提供完整的权限检查能力,支持基于角色的权限验证和店铺级角色继承机制,实现细粒度的访问控制。
## Requirements
### Requirement: 权限检查核心服务
@@ -92,53 +93,117 @@ CheckPermission(ctx context.Context, userID uint, permCode string, platform stri
### Requirement: 权限查询链式执行
权限检查 SHALL 按照以下顺序执行查询:
权限检查 SHALL 按照以下顺序执行查询(增加店铺角色继承逻辑)
1. 检查用户类型(超级管理员跳过)
2. 查询用户的角色 ID 列表
2. **查询用户的角色 ID 列表(增加店铺角色继承)**
- 优先查询账号级角色(`tb_account_role`
- 如果账号级角色为空 **且用户是代理账号UserType=3且有 shop_id**
- 查询店铺级角色(`tb_shop_role`
- 返回店铺级角色作为继承角色
3. 查询角色的权限 ID 列表(去重)
4. 查询权限详情列表
5. 遍历匹配 `permCode``platform`
#### Scenario: 正常查询流程
**角色解析函数签名**:
```go
GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
#### Scenario: 正常查询流程(现有行为保持不变)
- **WHEN** 调用 `CheckPermission` 检查普通用户权限
- **THEN** 按顺序执行以下查询:
1. `AccountRoleStore.GetRoleIDsByAccountID(ctx, userID)` 获取角色 ID 列表
1. 调用 `AccountService.GetRoleIDsForAccount(ctx, userID)` 获取角色 ID 列表(含继承逻辑)
2. `RolePermissionStore.GetPermIDsByRoleIDs(ctx, roleIDs)` 获取权限 ID 列表
3. `PermissionStore.GetByIDs(ctx, permIDs)` 获取权限详情
- **AND** 遍历权限列表进行匹配
- **AND** 找到匹配权限后立即返回 `true`(短路优化)
#### Scenario: 空结果短路
#### Scenario: 代理账号继承店铺角色
- **WHEN** 任意查询步骤返回空列表(如用户无角色)
- **WHEN** 调用 `CheckPermission` 检查代理账号UserType=3权限
- **AND** 该账号未分配账号级角色(`tb_account_role` 中无记录)
- **AND** 该账号的 `shop_id` 不为 NULL
- **AND** 该店铺已分配店铺级角色(`tb_shop_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回店铺级角色 ID 列表
- **AND** 后续权限检查使用店铺级角色的权限
#### Scenario: 代理账号有自己角色时不继承
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号已分配账号级角色(`tb_account_role` 中有记录)
- **THEN** `GetRoleIDsForAccount` 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(优先级:账号 > 店铺)
- **AND** 后续权限检查使用账号级角色的权限
#### Scenario: 代理账号无角色也无店铺角色
- **WHEN** 调用 `CheckPermission` 检查代理账号权限
- **AND** 该账号未分配账号级角色
- **AND** 该账号的店铺未分配店铺级角色(`tb_shop_role` 中无记录)
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 后续权限检查返回 `false`(无权限)
#### Scenario: 非代理账号不继承店铺角色
- **WHEN** 调用 `CheckPermission` 检查平台用户UserType=2权限
- **AND** 该账号未分配账号级角色
- **THEN** `GetRoleIDsForAccount` 返回空数组
- **AND** 不查询店铺级角色(仅代理账号支持继承)
#### Scenario: 空结果短路(现有行为保持不变)
- **WHEN** `GetRoleIDsForAccount` 返回空列表(账号无角色且店铺无角色)
- **THEN** 立即返回 `(false, nil)`
- **AND** 不执行后续查询
- **AND** 不执行后续查询(角色权限查询、权限详情查询)
### Requirement: Service 依赖注入
Permission Service SHALL 在初始化时注入所需的 Store 依赖。
Permission Service SHALL 在初始化时注入所需的 Store 和 Service 依赖。
**依赖**:
- `PermissionStore` - 查询权限详情
- `AccountRoleStore` - 查询用户角色关联
- `AccountRoleStore` - 查询用户角色关联(保留向后兼容)
- `RolePermissionStore` - 查询角色权限关联
- `AccountService` - 角色解析服务(含店铺角色继承逻辑)
- `RedisClient` - 权限缓存
**修改的依赖**:
```go
type Service struct {
permissionStore *postgres.PermissionStore
accountRoleStore *postgres.AccountRoleStore // 保留但不直接使用
rolePermStore *postgres.RolePermissionStore
accountService *account.Service // 新增:用于角色解析
redisClient *redis.Client
}
```
#### Scenario: Service 初始化
- **WHEN** 创建 Permission Service 实例
- **THEN** 构造函数接收以下参数:
- `permissionStore *postgres.PermissionStore`
- `accountRoleStore *postgres.AccountRoleStore`
- `accountRoleStore *postgres.AccountRoleStore`(保留向后兼容)
- `rolePermStore *postgres.RolePermissionStore`
- `accountService *account.Service`(新增)
- `redisClient *redis.Client`
- **AND** 存储在结构体字段中供 `CheckPermission` 使用
#### Scenario: CheckPermission 使用新的角色解析
- **WHEN** `CheckPermission` 需要查询用户角色时
- **THEN** 调用 `s.accountService.GetRoleIDsForAccount(ctx, userID)`
- **AND** 不再直接调用 `s.accountRoleStore.GetRoleIDsByAccountID()`
- **AND** 获得的角色 ID 列表可能是账号级角色或店铺级角色
#### Scenario: Bootstrap 集成
- **WHEN** 在 `internal/bootstrap/services.go` 初始化 Permission Service
- **THEN** 传入所有必需的 Store 依赖
- **THEN** 传入所有必需的 Store 和 Service 依赖
- **AND** Store 依赖已在 `initStores()` 中初始化
- **AND** Account Service 已在 Permission Service 之前初始化
### Requirement: 错误处理和日志
@@ -163,3 +228,120 @@ Permission Service SHALL 在初始化时注入所需的 Store 依赖。
- 检查结果
- **AND** 用于安全审计和问题排查
### Requirement: 角色解析服务
系统 SHALL 提供 `GetRoleIDsForAccount` 方法,统一处理账号角色查询和店铺角色继承逻辑。
**实现位置**: `internal/service/account/role_resolver.go`
**方法签名**:
```go
func (s *Service) GetRoleIDsForAccount(ctx context.Context, accountID uint) ([]uint, error)
```
**返回值**:
- `[]uint`: 角色 ID 列表(可能是账号级角色或店铺级角色)
- `error`: 查询失败时的错误信息
#### Scenario: 角色解析 - 超级管理员
- **WHEN** 调用 `GetRoleIDsForAccount` 查询超级管理员UserType=1的角色
- **THEN** 返回空数组 `[]uint{}`(超级管理员无角色,跳过权限检查)
- **AND** 不执行任何数据库查询
#### Scenario: 角色解析 - 平台用户
- **WHEN** 调用 `GetRoleIDsForAccount` 查询平台用户UserType=2的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(平台用户无 shop_id
#### Scenario: 角色解析 - 代理账号有账号级角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号UserType=3的角色
- **AND** 该账号已分配账号级角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(账号角色优先)
#### Scenario: 角色解析 - 代理账号继承店铺角色
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号的角色
- **AND** 该账号未分配账号级角色(`tb_account_role` 查询结果为空)
- **AND** 该账号的 `shop_id` 不为 NULL
- **THEN** 查询 `tb_shop_role` 表获取店铺级角色
- **AND** 返回店铺级角色 ID 列表(继承)
#### Scenario: 角色解析 - 企业账号
- **WHEN** 调用 `GetRoleIDsForAccount` 查询企业账号UserType=4的角色
- **THEN** 查询 `tb_account_role` 表获取账号级角色
- **AND** 返回账号级角色 ID 列表
- **AND** 不查询店铺级角色(企业账号无继承机制)
#### Scenario: 角色解析 - 数据库查询失败
- **WHEN** 调用 `GetRoleIDsForAccount` 过程中数据库查询失败
- **THEN** 返回错误 `errors.Wrap(errors.CodeInternalError, err, "查询角色失败")`
- **AND** 不返回部分结果
### Requirement: 缓存机制兼容
权限缓存机制 SHALL 与店铺角色继承逻辑兼容,确保角色变更后缓存及时失效。
**缓存键**: `user:permissions:{user_id}`
**缓存内容**: 用户的所有权限列表(不区分账号级角色还是店铺级角色)
**缓存时效**: 30 分钟
#### Scenario: 缓存命中时使用缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中存在缓存键 `user:permissions:{user_id}`
- **THEN** 直接从缓存读取权限列表
- **AND** 不调用 `GetRoleIDsForAccount`(避免查询)
- **AND** 使用缓存的权限进行匹配
#### Scenario: 缓存未命中时重建缓存
- **WHEN** 调用 `CheckPermission` 检查用户权限
- **AND** Redis 中不存在缓存键
- **THEN** 调用 `GetRoleIDsForAccount` 查询角色(含继承逻辑)
- **AND** 查询角色的所有权限
- **AND** 将权限列表写入 RedisTTL 30 分钟
#### Scenario: 店铺角色变更时清理缓存
- **WHEN** 店铺角色变更(分配/删除)
- **THEN** 查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,自动重建缓存(使用新的角色解析逻辑)
#### Scenario: 账号角色变更时清理缓存(现有行为)
- **WHEN** 账号级角色变更(分配/删除)
- **THEN** 删除该账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,重建缓存
### Requirement: 性能要求
角色继承逻辑 SHALL 满足以下性能要求:
- 角色解析查询时间 < 10ms含店铺角色查询
- 权限检查总时间 < 50ms含角色解析、权限查询、匹配
- 缓存命中时权限检查时间 < 1ms
#### Scenario: 角色解析性能
- **WHEN** 调用 `GetRoleIDsForAccount` 查询代理账号角色
- **AND** 账号无账号级角色,需查询店铺级角色
- **THEN** 总查询时间(账号角色查询 + 店铺角色查询)< 10ms
- **AND** 使用索引 `idx_shop_role_shop_id` 优化查询
#### Scenario: 缓存命中性能
- **WHEN** 调用 `CheckPermission` 且缓存命中
- **THEN** 总处理时间 < 1ms
- **AND** 不执行任何数据库查询

View File

@@ -0,0 +1,323 @@
# shop-role-management Specification
## Purpose
提供店铺级角色管理能力,允许平台为代理店铺设置默认角色,该店铺下所有账号自动继承,简化 MVP 阶段的批量角色分配操作。
## Requirements
### Requirement: 分配店铺角色
系统 SHALL 提供接口允许平台用户或店铺管理员为店铺分配角色。
**接口**: `POST /api/admin/shops/:shop_id/roles`
**请求体**:
```json
{
"role_ids": [5] // 角色 ID 列表,传空数组表示清空所有角色
}
```
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": [
{
"id": 1,
"shop_id": 10,
"role_id": 5,
"status": 1,
"created_at": "2026-02-02T10:00:00Z"
}
],
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功分配单个角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [5]}`
- **AND** 角色 ID 5 存在且为客户角色RoleType=2
- **AND** 店铺 ID 10 存在
- **THEN** 系统创建店铺-角色关联记录
- **AND** 返回 HTTP 200 和关联记录
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 清空店铺所有角色
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": []}`
- **THEN** 系统删除该店铺的所有角色关联
- **AND** 返回 HTTP 200 和空数组
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 替换现有角色
- **WHEN** 店铺已分配角色 ID 5
- **AND** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [7]}`
- **THEN** 系统删除原有角色 ID 5 的关联
- **AND** 创建新的角色 ID 7 的关联
- **AND** 返回 HTTP 200 和新关联记录
#### Scenario: 角色类型校验失败
- **WHEN** 平台用户调用 `POST /api/admin/shops/10/roles` 请求体为 `{"role_ids": [3]}`
- **AND** 角色 ID 3 是平台角色RoleType=1
- **THEN** 返回 HTTP 400 错误码 `errors.CodeInvalidParam`
- **AND** 错误消息为"店铺只能分配客户角色"
- **AND** 不创建任何关联记录
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `POST /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `POST /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内(不是自己店铺或下级店铺)
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 查询店铺角色
系统 SHALL 提供接口查询店铺已分配的角色列表。
**接口**: `GET /api/admin/shops/:shop_id/roles`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": {
"shop_id": 10,
"roles": [
{
"shop_id": 10,
"role_id": 5,
"role_name": "代理店长",
"role_desc": "代理店铺管理员",
"status": 1
}
]
},
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 查询已分配角色
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 已分配角色 ID 5
- **THEN** 返回 HTTP 200 和角色详情列表
- **AND** 包含角色名称、描述等信息
#### Scenario: 查询未分配角色的店铺
- **WHEN** 平台用户调用 `GET /api/admin/shops/10/roles`
- **AND** 店铺 ID 10 未分配任何角色
- **THEN** 返回 HTTP 200
- **AND** `roles` 字段为空数组
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `GET /api/admin/shops/999/roles`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `GET /api/admin/shops/20/roles`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 删除店铺角色
系统 SHALL 提供接口删除店铺的特定角色关联。
**接口**: `DELETE /api/admin/shops/:shop_id/roles/:role_id`
**响应体**:
```json
{
"code": 0,
"msg": "success",
"data": null,
"timestamp": "2026-02-02T10:00:00Z"
}
```
#### Scenario: 成功删除角色
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 存在
- **AND** 店铺已分配角色 ID 5
- **THEN** 系统删除该关联记录
- **AND** 返回 HTTP 200
- **AND** 清理该店铺下所有账号的权限缓存
#### Scenario: 删除不存在的角色关联
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/10/roles/5`
- **AND** 店铺 ID 10 未分配角色 ID 5
- **THEN** 返回 HTTP 200幂等操作
- **AND** 不执行任何数据库操作
#### Scenario: 店铺不存在
- **WHEN** 平台用户调用 `DELETE /api/admin/shops/999/roles/5`
- **AND** 店铺 ID 999 不存在
- **THEN** 返回 HTTP 404 错误码 `errors.CodeNotFound`
- **AND** 错误消息为"店铺不存在"
#### Scenario: 权限不足
- **WHEN** 代理用户调用 `DELETE /api/admin/shops/20/roles/5`
- **AND** 店铺 ID 20 不在该代理的管理范围内
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 数据库表结构
系统 SHALL 创建 `tb_shop_role` 表存储店铺-角色关联关系。
**表结构**:
```sql
CREATE TABLE tb_shop_role (
id SERIAL PRIMARY KEY,
shop_id INT NOT NULL,
role_id INT NOT NULL,
status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用
creator INT NOT NULL,
updater INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE (shop_id, role_id) WHERE deleted_at IS NULL
);
```
**索引**:
- `idx_shop_role_shop_id` - 查询店铺角色(高频)
- `idx_shop_role_role_id` - 查询角色被哪些店铺使用(低频)
- `idx_shop_role_deleted_at` - 软删除过滤
#### Scenario: 唯一性约束
- **WHEN** 尝试为同一店铺分配同一角色两次
- **THEN** 数据库返回唯一性约束冲突错误
- **AND** 系统捕获错误并返回友好错误消息
#### Scenario: 软删除机制
- **WHEN** 删除店铺角色关联
- **THEN** 系统设置 `deleted_at` 字段为当前时间
- **AND** 后续查询自动过滤 `deleted_at IS NOT NULL` 的记录
### Requirement: 缓存失效策略
系统 SHALL 在店铺角色变更时清理相关账号的权限缓存。
#### Scenario: 分配角色时清理缓存
- **WHEN** 为店铺 ID 10 分配角色
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键 `user:permissions:{account_id}`
- **AND** 下次权限检查时,账号会重新查询并继承新角色
#### Scenario: 删除角色时清理缓存
- **WHEN** 删除店铺 ID 10 的角色关联
- **THEN** 系统查询该店铺下所有账号 ID 列表
- **AND** 遍历删除每个账号的权限缓存键
- **AND** 下次权限检查时,账号将无角色(如果无账号级角色)
#### Scenario: 账号有自己角色时不受影响
- **WHEN** 店铺角色变更
- **AND** 某账号有自己的账号级角色
- **THEN** 该账号的权限缓存被清理
- **AND** 下次权限检查时,继续使用账号级角色(不继承店铺角色)
### Requirement: 权限控制
店铺角色管理接口 SHALL 实施权限控制,只有有权限的用户才能操作。
**权限规则**:
- 超级管理员UserType=1可操作所有店铺
- 平台用户UserType=2可操作所有店铺
- 代理用户UserType=3只能操作自己店铺及下级店铺
- 企业用户UserType=4无权限操作店铺角色
#### Scenario: 超级管理员操作任意店铺
- **WHEN** 超级管理员调用店铺角色管理接口
- **THEN** 跳过权限检查
- **AND** 允许操作任意店铺
#### Scenario: 平台用户操作任意店铺
- **WHEN** 平台用户调用店铺角色管理接口
- **THEN** 允许操作任意店铺
#### Scenario: 代理用户操作下级店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 15 是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 15, shopStore)`
- **AND** 返回 nil有权限
- **AND** 允许操作
#### Scenario: 代理用户操作无关店铺
- **WHEN** 代理用户shop_id=10调用店铺角色管理接口
- **AND** 目标店铺 ID 20 不是店铺 10 的下级店铺
- **THEN** 调用 `middleware.CanManageShop(ctx, 20, shopStore)`
- **AND** 返回 error无权限
- **AND** 拒绝操作
#### Scenario: 企业用户尝试操作店铺角色
- **WHEN** 企业用户调用店铺角色管理接口
- **THEN** 返回 HTTP 403 错误码 `errors.CodeForbidden`
- **AND** 错误消息为"无权限操作该资源或资源不存在"
### Requirement: 业务规则校验
店铺角色分配 SHALL 执行业务规则校验,确保数据一致性。
#### Scenario: 角色存在性校验
- **WHEN** 分配店铺角色时指定角色 ID 列表
- **THEN** 系统查询所有角色是否存在
- **AND** 如果部分角色不存在,返回错误"部分角色不存在"
- **AND** 不创建任何关联记录(原子操作)
#### Scenario: 角色状态校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `status` 字段为 0禁用
- **THEN** 返回错误"角色已禁用"
- **AND** 不创建关联记录
#### Scenario: 角色类型校验
- **WHEN** 分配店铺角色时指定角色 ID
- **AND** 该角色的 `role_type` 字段为 1平台角色
- **THEN** 返回错误"店铺只能分配客户角色"
- **AND** 不创建关联记录
#### Scenario: 店铺存在性校验
- **WHEN** 分配店铺角色时指定店铺 ID
- **AND** 该店铺不存在或已软删除
- **THEN** 返回错误"店铺不存在"
- **AND** 不执行任何操作