feat(shop-role): 实现店铺角色继承功能和权限检查优化
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m39s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m39s
- 新增店铺角色管理 API 和数据模型 - 实现角色继承和权限检查逻辑 - 添加流程测试框架和集成测试 - 更新权限服务和账号管理逻辑 - 添加数据库迁移脚本 - 归档 OpenSpec 变更文档 Ultraworked with Sisyphus
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-02
|
||||
@@ -0,0 +1,373 @@
|
||||
# 店铺级角色继承功能设计
|
||||
|
||||
## Context
|
||||
|
||||
### 当前系统状态
|
||||
|
||||
**RBAC 架构**:
|
||||
- 系统使用标准 RBAC(Role-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)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 店铺角色修改后,继承该角色的账号权限立即生效
|
||||
- 有账号级角色的账号不受影响(因为优先级更高)
|
||||
- 下次权限检查时,自动使用新的继承逻辑重建缓存
|
||||
|
||||
### 决策 6:API 设计(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: 账号转移店铺后,角色如何处理?
|
||||
|
||||
**当前设计**:账号转移店铺后,自动继承新店铺角色(如果无账号级角色)
|
||||
|
||||
**替代方案**:账号转移店铺时,是否应该清除账号级角色?
|
||||
|
||||
**建议**:保持现状(账号角色不跟随店铺),理由:
|
||||
- 账号角色是独立设置的,转移店铺不应影响
|
||||
- 如果需要重新继承新店铺角色,可手动删除账号角色
|
||||
|
||||
**决策时机**:观察实际使用情况后决定(暂不修改)
|
||||
@@ -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` 表和现有逻辑
|
||||
- ✅ 现有账号级角色不受影响,优先级高于店铺级角色
|
||||
- ✅ 不设置店铺角色的店铺,行为与现在完全一致
|
||||
@@ -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** 将权限列表写入 Redis,TTL 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** 不执行任何数据库查询
|
||||
@@ -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** 不执行任何操作
|
||||
@@ -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 接口正常工作
|
||||
- 验证:功能完整、稳定、性能达标
|
||||
@@ -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** 将权限列表写入 Redis,TTL 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** 不执行任何数据库查询
|
||||
|
||||
|
||||
323
openspec/specs/shop-role-management/spec.md
Normal file
323
openspec/specs/shop-role-management/spec.md
Normal 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** 不执行任何操作
|
||||
|
||||
Reference in New Issue
Block a user