Files
junhong_cmp_fiber/openspec/changes/archive/2026-02-03-shop-role-inheritance/design.md
huang 5a90caa619
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m39s
feat(shop-role): 实现店铺角色继承功能和权限检查优化
- 新增店铺角色管理 API 和数据模型
- 实现角色继承和权限检查逻辑
- 添加流程测试框架和集成测试
- 更新权限服务和账号管理逻辑
- 添加数据库迁移脚本
- 归档 OpenSpec 变更文档

Ultraworked with Sisyphus
2026-02-03 10:06:13 +08:00

13 KiB
Raw Blame History

店铺级角色继承功能设计

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 阶段:不设置账号角色,自动继承店铺 → 达到简化目标
  • 未来扩展:特殊账号(如财务)可单独设置角色 → 覆盖店铺默认
  • 优先级明确:账号角色 > 店铺角色,易于理解和调试

实现逻辑

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)
    }
}

理由

  • 店铺角色修改后,继承该角色的账号权限立即生效
  • 有账号级角色的账号不受影响(因为优先级更高)
  • 下次权限检查时,自动使用新的继承逻辑重建缓存

决策 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 - 删除店铺角色

请求体设计

// 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

部署步骤

  1. 数据库迁移(无需停机):

    # 执行迁移
    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: 账号转移店铺后,角色如何处理?

当前设计:账号转移店铺后,自动继承新店铺角色(如果无账号级角色)

替代方案:账号转移店铺时,是否应该清除账号级角色?

建议:保持现状(账号角色不跟随店铺),理由:

  • 账号角色是独立设置的,转移店铺不应影响
  • 如果需要重新继承新店铺角色,可手动删除账号角色

决策时机:观察实际使用情况后决定(暂不修改)