PAYATHON 2026

客户与分层员工角色应共用 User,还是拆分为 Customer/Employee?

技术老齐

共用账户,分别建模客户资料和员工任职

按目前的需求,建议保留一个共享的 Account(也可以继续叫 User),统一保存登录凭据和账户状态。客户资料、员工资料和员工任职关系分别建模,JWT 校验、token 签发和密码认证基础设施继续共用。

客户不参与员工的 rank / department 判断,客户和员工仍可共用登录实体、密码认证和相关代码。

模型关系可以是:

Account
├── CustomerProfile(可选,关联购物车、订单等)
└── EmployeeProfile(可选,关联员工任职信息)

同一个人既是员工又会购物时,可以同时拥有两种业务身份。如果业务要求员工账号与购物账号隔离,也可以使用不同的 Account,认证代码仍然可以复用。

ROLE_CUSTOMER 可以与员工角色放在同一张角色表中,员工的级别和部门规则只用于员工角色。

区分账户、业务身份和授权规则

当前 User 同时负责登录账户、客户购物关系、员工职位,以及与 Spring Security 的适配。功能继续增加后,这些职责混在一起会更难维护。

建议按职责拆分:

模型负责的内容
Account登录标识、密码哈希、启用状态
CustomerProfile客户资料,以及购物车、订单的归属
EmployeeProfile员工资料、在职状态
StaffAssignment员工职位、部门、任职有效性
Role / Authority授权所需的角色或能力标识

StaffAssignment 可以先作为 EmployeeProfile 中的一组字段,不必立即单独建表。需要支持跨部门任职、多职位或任职历史时,再拆成独立的关联实体。

业务实体也不必实现 UserDetails。安全模块可以把账户转换成专门的 AccountPrincipal,或 Spring Security 的 UserDetails 实现。这样修改客户字段时,就不需要连带修改认证接口。

ROLE_CUSTOMER 是授权标识,不是认证成功的条件。在用户名和密码认证中,UserDetailsService 为 DaoAuthenticationProvider 提供账户及凭据信息;账户的角色用于后续授权。Spring Security 的 UserDetailsService 文档

客户角色可以共表,员工属性单独存放

共享的角色表可以只保存稳定的角色标识:

roles
-----
role_id
role_name

account_roles
-------------
account_id
role_id

表中可以同时存在:

ROLE_CUSTOMER
ROLE_EMPLOYEE
ROLE_SUPER_ADMIN

员工的职位和部门放在任职信息中:

staff_assignments
-----------------
employee_id
level
department

这是一种可选设计,现有的 ROLE_PRODUCT_MANAGER 等角色不必全部改名。如果部门角色本身就定义为“商品部门经理”,也可以把部门保留在员工专用角色模型中。要避免两处记录互相矛盾,例如角色是商品经理,任职部门却是订单部门。

对于小项目或迁移阶段,让 ROLE_CUSTOMER.rank 和 ROLE_CUSTOMER.department 为 null 也可以接受,但需要满足以下条件:

  • 员工授权逻辑先确认参与者具有有效员工身份。
  • 不把 null 隐式解释为最低级别或任意部门。
  • 通用角色处理代码不默认所有角色都有级别和部门。
  • 对员工角色所需的字段设置明确的数据校验。

是否拆表,不能只看有没有空字段。如果大量逻辑都在反复判断“这个角色是不是客户角色”,就需要重新考虑模型边界。

用角色表达能力,用部门和归属限制范围

这个需求适合结合 RBAC 和基于属性的授权检查:

  • 角色或权限决定能否执行某项操作,例如管理员工、处理订单。
  • 部门、职位和目标身份决定操作范围。
  • 客户通过资源归属关系访问自己的购物车和订单。

员工管理规则如下:

操作者目标员工管理权限
Super Admin任意员工允许,但仍受系统业务约束限制
部门经理本部门普通员工允许
部门经理其他部门员工、任意经理、Super Admin拒绝
普通员工或纯客户任意员工拒绝

客户资源使用独立的归属策略。Super Admin 如果需要管理客户账号,可以另外授予客户管理权限,无须将客户纳入员工级别比较。

Spring Security 的 RoleHierarchy 表达角色所包含的权限,例如经理包含某些员工权限。判断经理能删除哪些员工,还需要目标对象、部门和具体操作的信息,单靠角色继承无法得出结果。

不要用 getPrimaryRole() 代替完整授权

按最高 rank 选取一个角色,只有在每个员工恰好拥有一个有效职位,且该职位完整决定其管理范围时,才比较容易成立。

如果某人同时担任商品经理和订单部门员工,只保留最高职位,就会丢失订单部门的任职信息。多个角色同级且分属不同部门时,也无法仅凭最高级别唯一选出一个角色。

职位高低也不自动决定所有业务权限。商品经理可以管理本部门员工,但不一定拥有所有商品、订单或财务操作权限。

对于目前固定的三层结构,职位枚举通常比任意整数更清楚。以下示例假设每个员工只有一个有效任职,Super Admin 不绑定部门,其他职位必须绑定部门:

enum StaffLevel {
    SUPER_ADMIN, MANAGER, EMPLOYEE
}

enum Department {
    PRODUCT, CATEGORY, ORDER
}

record StaffAssignment(
        StaffLevel level,
        Department department
) {
    StaffAssignment {
        Objects.requireNonNull(level);

        if (level != StaffLevel.SUPER_ADMIN && department == null) {
            throw new IllegalArgumentException("Department is required");
        }
    }
}

final class StaffPolicy {

    static boolean canManage(
            StaffAssignment actor,
            StaffAssignment target
    ) {
        if (actor == null || target == null) {
            return false;
        }

        if (actor.level() == StaffLevel.SUPER_ADMIN) {
            return true;
        }

        return actor.level() == StaffLevel.MANAGER
                && target.level() == StaffLevel.EMPLOYEE
                && actor.department() == target.department();
    }
}

示例省略了包名和 import。该函数只判断员工职位及部门范围,调用方还要检查账户是否启用、任职是否有效,以及是否允许执行当前操作。

以后支持跨部门兼职时,需要根据完整任职集合定义策略,并明确部门经理能否管理拥有多个职位的目标员工,不能简单地改成“任意一个低级职位匹配就允许”。

创建、删除和角色变更需要分别检查

原来的 canAct(actor, target) 可以辅助判断已有员工的管理范围。创建员工时尚无目标用户,需要检查准备写入的任职信息:

boolean allowed = StaffPolicy.canManage(
        actorAssignment,
        requestedAssignment
);

部门经理应当只能创建本部门普通员工。服务端还应按允许字段列表构造实体,不能直接保存客户端提交的角色集合、账户状态或其他敏感字段。

更新员工时,要同时检查当前状态和变更后的状态。例如,商品经理可以管理本部门普通员工,但不能通过更新接口将其提升为经理。

建议将这些操作分别定义为 canCreate、canDelete、canChangeAssignment。它们可以复用范围判断,同时保留各自的业务限制。

在服务层接入权限检查

可以继续使用 PermissionEvaluator,也可以先用普通的 Spring Bean 实现策略:

@PreAuthorize(
    "@employeeAccess.canDelete(authentication, #p0)"
)
public void deleteEmployee(String employeeId) {
    // 执行员工删除或离职处理
}

employeeAccess.canDelete 应从可信的认证身份获取账户 ID,再从服务端数据中加载操作者的有效任职和目标员工信息。查询不到目标、身份无效或操作不受支持时,都应拒绝访问。

使用现代 Spring Security 方法安全配置时,需要启用 @EnableMethodSecurity。@PreAuthorize 可以调用 Spring Bean,hasPermission 则是接入 PermissionEvaluator 的入口。Spring Security 方法安全文档

如果保留原来的写法:

@PreAuthorize("hasPermission(#p0, 'Employee', 'DELETE')")

就需要将自定义 PermissionEvaluator 配置到方法安全的表达式处理器中,并正确处理根据目标 ID、目标类型和操作名称执行检查的重载。仅实现接口并不会自动补全业务策略。

客户资源直接按当前账户限定查询

购物车和订单不参与员工级别判断。访问订单时,可以同时用资源 ID 和当前账户 ID 限定查询。

假设实体属性关系为 Order.customer.account.accountId,Spring Data JPA 仓库方法可以写成:

Optional<Order> findByOrderIdAndCustomer_Account_AccountId(
        String orderId,
        String accountId
);

调用时,accountId 必须来自已认证的身份,不能直接用客户端提交的 customerId 或 userId 判断归属。

确认归属后,还要检查订单状态和具体操作。客户拥有订单,也不能任意修改支付状态或订单金额。

员工管理订单时,则应检查订单管理权限和订单业务规则。两类入口可以共用订单领域服务,但各自需要的授权检查都要执行。

拆分业务实体后,认证基础设施仍可共用

认证代码建议通过组合和委托复用:

identity/
  Account
  AccountUserDetailsService
  AccountPrincipal
  LoginService
  TokenService

security/
  SecurityConfig
  JWT 校验与 authority 转换

customer/
  CustomerProfile
  客户资料、购物车相关服务

employee/
  EmployeeProfile
  StaffAssignment
  StaffPolicy
  EmployeeAccess

order/
product/

包结构可以按项目规模调整。共享认证模块负责验证身份、建立 principal 和处理 token,员工管理及订单归属策略放在对应业务模块中。

共享账户时

一个 AccountUserDetailsService 就足够。即使客户和员工使用不同的登录页面或登录端点,也可以委托同一个登录服务和 token 签发服务。

员工登录入口可以额外要求有效员工身份,JWT 签名、验签和过期处理代码仍可共用。

两套账户存储时

如果客户和员工有独立的账户生命周期、登录命名空间或身份来源,可以分别提供加载适配器,再统一转换为安全层使用的 principal。

认证流程必须明确选择账户域。如果同一邮箱允许同时存在于两套账户中,不能通过“先查客户表,找不到再查员工表”决定身份。可以根据明确的登录入口或认证域,选择对应的 AuthenticationManager / AuthenticationProvider。

JWT 的主体标识也要避免碰撞。可以使用全局唯一主体 ID,或结合受信任的 issuer、账户域和主体 ID 定位身份。refresh token 同样需要绑定正确的账户及认证上下文。

JWT 校验可以使用现成支持

通过 Authorization: Bearer 传递 JWT 的 API,可以使用 Spring Security 的 OAuth2 Resource Server 支持,自己签发的 JWT 也适用。它负责请求中的 Bearer Token 处理和 JWT 认证,因此客户和员工不必各写一个类似的过滤器。登录和 token 签发仍由认证服务负责。Spring Security 对自定义 JWT 的支持

假设 token 中的 roles 格式如下:

{
  "sub": "全局唯一的账户ID",
  "roles": ["CUSTOMER"]
}

可以统一转换为 ROLE_CUSTOMER:

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter authorities =
            new JwtGrantedAuthoritiesConverter();

    authorities.setAuthoritiesClaimName("roles");
    authorities.setAuthorityPrefix("ROLE_");

    JwtAuthenticationConverter converter =
            new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

再将该 converter 接入资源服务器配置:

http.oauth2ResourceServer(oauth2 -> oauth2
        .jwt(jwt -> jwt
                .jwtAuthenticationConverter(
                        jwtAuthenticationConverter()
                )
        )
);

这段配置还需要相应依赖、JwtDecoder 和完整的请求访问规则。JWT 校验应覆盖签名、时间,以及适用的 issuer、audience 约束。默认 JWT 认证下,principal 是 Jwt,不会自动转换成你的 UserDetails。需要额外资料时,可以通过主体 ID 查询。Spring Security JWT 配置文档

什么时候需要两套 SecurityFilterChain

如果客户和员工使用相同的 Bearer Token 机制和信任配置,一条过滤链配合不同路径规则、方法权限检查就可以工作,即使它们使用不同的业务实体。

后台采用独立身份提供方、不同 token audience、不同登录方式或会话策略时,再考虑多条链。配置多条链时,要明确 securityMatcher 和顺序,并提供兜底保护。请求会使用按顺序找到的第一条匹配链。Spring Security 多过滤链配置文档

实现时还需明确的约束

建议保留现有账户表和认证流程,先独立员工权限策略,再逐步迁移客户、员工资料,避免同时重写实体、登录和 token 处理。

实现时还需要处理以下约束:

  • “只有一名 Super Admin”是跨账户约束。可以通过单例配置记录关联唯一管理员账户等方式实现,并在事务中处理交接。仅在创建前查询一次数量,无法防止并发创建。
  • Super Admin 的管理权限也要遵守唯一性规则。删除、停用或降级唯一管理员,需要专门的处理流程。
  • 员工离职时,通常应撤销其任职或后台访问资格。如果同一账户还有客户身份,应避免直接删除账户及订单关系。
  • 角色、部门或账户状态变更后,已签发 JWT 中的旧权限可能继续有效。需要根据撤权时效要求,选择短期 token、在线状态校验或其他失效机制。
  • 授权检查与写入之间可能发生并发变化。敏感的任职修改应在事务中校验,并按需要使用版本控制或锁。
  • 列表、批量操作、导出和搜索接口也必须执行相同的数据范围限制。

针对当前规则,至少应验证:经理不能跨部门删除员工、不能创建经理、不能通过更新字段提升职位,客户不能读取其他账户的订单。

备注:内容仅供参考。