- 新增店铺角色管理 API 和数据模型 - 实现角色继承和权限检查逻辑 - 添加流程测试框架和集成测试 - 更新权限服务和账号管理逻辑 - 添加数据库迁移脚本 - 归档 OpenSpec 变更文档 Ultraworked with Sisyphus
13 KiB
店铺级角色继承功能设计
Context
当前系统状态
RBAC 架构:
- 系统使用标准 RBAC(Role-Based Access Control)模型
- 角色分配粒度:账号级别(
tb_account_role表维护账号-角色关联) - 权限检查流程:
Account → AccountRole → Role → RolePermission → Permission - 权限缓存:Redis 缓存用户权限 30 分钟
用户类型:
- 超级管理员(UserType=1):跳过权限检查
- 平台用户(UserType=2):账号级角色,可分配多个平台角色
- 代理账号(UserType=3):账号级角色,可分配 1 个客户角色,归属于店铺(shop_id)
- 企业账号(UserType=4):账号级角色,可分配 1 个客户角色,归属于企业(enterprise_id)
- 个人客户(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
- 简化 MVP 阶段操作:平台可在店铺层面设置默认角色,店铺内所有账号自动继承
- 支持未来扩展:保留账号级角色覆盖能力,特殊账号可单独设置角色
- 完全向后兼容:现有账号级角色功能不受影响,不设置店铺角色的店铺行为保持一致
- 清晰的继承规则:账号级角色优先,无则继承店铺级角色
Non-Goals
- 不支持企业级角色继承:企业账号保持账号级角色(一企业一账号,暂无批量需求)
- 不支持平台级角色继承:平台账号数量少且权限差异大,不适合继承
- 不支持多角色继承:店铺只能设置单个角色(代理账号最大角色数为 1)
- 不支持角色叠加:账号角色和店铺角色二选一,不取并集
Decisions
决策 1:角色继承规则(默认继承 + 账号级覆盖)
选择:账号级角色优先,无则继承店铺级角色
替代方案考虑:
| 方案 | 优点 | 缺点 | 决策 |
|---|---|---|---|
| A. 强制继承 | 最简单,MVP 体验最好 | 未来扩展需要数据迁移 | ❌ 不选 |
| B. 默认继承 + 覆盖 | 简单且灵活,无缝升级 | 逻辑稍复杂(需判断优先级) | ✅ 选择 |
| C. 角色叠加(并集) | 最灵活 | 难理解,容易造成权限混乱 | ❌ 不选 |
理由:
- MVP 阶段:不设置账号角色,自动继承店铺 → 达到简化目标
- 未来扩展:特殊账号(如财务)可单独设置角色 → 覆盖店铺默认
- 优先级明确:账号角色 > 店铺角色,易于理解和调试
实现逻辑:
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 表
表结构:
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)
校验逻辑:
for _, role := range roles {
if role.RoleType != constants.RoleTypeCustomer {
return errors.New("店铺只能分配客户角色")
}
}
理由:
- 代理账号只能分配客户角色(RoleType=2)
- 平台角色(RoleType=1)只能分配给平台用户
- 防止配置错误导致权限混乱
决策 5:缓存失效策略(清理店铺下所有账号缓存)
选择:店铺角色修改时,清理该店铺下所有账号的权限缓存
实现:
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- 删除店铺角色
请求体设计:
// POST /api/admin/shops/:shop_id/roles
{
"role_ids": [5] // 传空数组 = 清空所有角色
}
响应体设计:
// 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 层依赖:
// 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 注册:
// 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
部署步骤
-
数据库迁移(无需停机):
# 执行迁移 migrate -path migrations -database "postgres://..." up- 创建
tb_shop_role表 - 不影响现有数据和功能
- 创建
-
代码部署(滚动更新):
- 部署新版本 API 服务
- 新增接口向后兼容,不影响现有功能
-
验证:
- 调用
POST /api/admin/shops/:id/roles设置店铺角色 - 验证该店铺下账号权限生效
- 验证有账号角色的账号不受影响
- 调用
回滚策略
如需回滚:
- 回滚代码到旧版本
- 保留
tb_shop_role表(不删除,避免数据丢失) - 清理所有权限缓存:
redis-cli KEYS "user:permissions:*" | xargs redis-cli DEL - 旧版本代码忽略
tb_shop_role表,继续使用tb_account_role
数据一致性:
- 回滚不影响
tb_account_role数据 tb_shop_role数据保留,重新部署新版本后继续生效
Open Questions
Q1: 是否需要支持多角色继承?
当前设计:店铺只能设置单个角色(代理账号最大角色数为 1)
未来考虑:如果代理账号需要支持多角色(如"代理店长" + "销售专员"),需要:
- 修改
constants.GetMaxRolesForUserType()返回值 - 修改
AssignRolesToShop()支持多角色分配 - 修改角色解析逻辑支持多角色继承
决策时机:需求明确后再调整(暂不实现)
Q2: 是否需要记录角色继承历史?
当前设计:不记录继承历史,只记录当前状态
未来考虑:如果需要审计"某账号在某时间段继承了哪个店铺角色",需要:
- 修改操作审计日志,记录角色继承关系变更
- 修改权限检查日志,记录使用的是账号角色还是店铺角色
决策时机:审计需求明确后再实现(暂不实现)
Q3: 账号转移店铺后,角色如何处理?
当前设计:账号转移店铺后,自动继承新店铺角色(如果无账号级角色)
替代方案:账号转移店铺时,是否应该清除账号级角色?
建议:保持现状(账号角色不跟随店铺),理由:
- 账号角色是独立设置的,转移店铺不应影响
- 如果需要重新继承新店铺角色,可手动删除账号角色
决策时机:观察实际使用情况后决定(暂不修改)