写在前面
Workforce Hub 有两个前端:员工端和admin管理端。后端只有一套——但"一套后端"不等于"一套 API 混着用"。
早期的偷懒方案是所有接口放一起,靠注解上的权限校验区分角色。写多了就会发现这种方案的隐患:一个本该只有管理员能调的接口,因为忘了加校验注解,员工也能调通——而且没人会发现,直到某天有人认真看了员工端的 JS 文件。
后来我把隔离做成了结构性的:员工和管理员从 URL 前缀开始就是两个世界,中间隔着过滤器,越界在架构层面被拒绝,而不是靠每个接口自觉。这篇记录这套分层。
一、第一道墙:URL 前缀即阵营
每个模块的 Controller 按端拆成两个:
agent/controller/
├── AdminAgentController.java /api/admin/agents
├── AdminAgentTaskController.java /api/admin/agent-tasks
├── EmployeeAgentController.java /api/employee/agents
└── EmployeeAgentTaskController.java /api/employee/agent-tasks
考勤、假期、IM、文件、组织,无一例外。加上少量例外路径:/api/public/**(无需登录的品牌信息)、/api/plugin-gateway/**(插件网关)。
URL 前缀不只是命名习惯,它是后面所有机制的挂载点:安全过滤器按前缀分流,权限映射按前缀配置,日志和审计按前缀归类。前缀不一致的接口在这些机制面前全是"无主之地"——所以规则第一条就是:业务接口只能活在两个前缀之下,出现第三种路径就是评审意见。
二、第二道墙:路径隔离过滤器
登录类型(UserType)和 URL 前缀的匹配,由一个过滤器全局强制:
// 路径隔离过滤器:/api/admin/** 仅允许管理员访问,/api/employee/** 仅允许员工访问
public class UserTypePathFilter extends OncePerRequestFilter {
private static final String EMPLOYEE_PATH_PREFIX = "/api/employee/";
private static final String ADMIN_PATH_PREFIX = "/api/admin/";
private static final String EMPLOYEE_LOGIN_PATH = "/api/employee/auth/login";
private static final String ADMIN_LOGIN_PATH = "/api/admin/auth/login";
protected void doFilterInternal(...) {
// 登录接口豁免——此时还没有身份
if (EMPLOYEE_LOGIN_PATH.equals(uri) || ADMIN_LOGIN_PATH.equals(uri)) {
filterChain.doFilter(request, response);
return;
}
...
if (uri.startsWith(EMPLOYEE_PATH_PREFIX) && userType != UserType.USER) {
reject(response, "员工接口不允许管理员访问");
return;
}
if (uri.startsWith(ADMIN_PATH_PREFIX) && userType != UserType.ADMIN) {
reject(response, "管理接口不允许员工访问");
return;
}
}
private void reject(HttpServletResponse response, String message) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
objectMapper.writeValue(response.getWriter(),
R.fail(BusinessErrorCode.FORBIDDEN.code(), message));
}
}
几个值得展开的点:
双向拒绝,不是单向设防。 员工进不了 /api/admin/**,管理员也进不了 /api/employee/**。后者常被忽略,但它是真实需求:管理员账号一旦也拥有员工身份的视图,测试环境里"拿管理员账号调员工接口"就会成为习惯,两类身份的数据边界会在开发者的麻木中逐渐模糊。
拒绝的响应也要是统一格式。 403 + R.fail(...),和全局异常处理器一个模子——过滤器抛出 AccessDeniedException 让别处转换当然也行,但直接按统一响应格式写回,前端的拦截器不用为这一类 403 写特殊分支。
登录接口显式豁免。 此刻请求还没有身份,UserType 无从谈起。豁免名单越短越好——目前就两个登录路径,每加一个豁免项都值得在评审里解释一遍。
三、第三道墙:管理端自己的细粒度权限
前缀隔离解决了"员工 vs 管理员",但管理员内部还有权限差异:管系统的管理员不该能看薪酬相关模块。这由第二层过滤器负责:
public class AdminPermissionFilter extends OncePerRequestFilter {
...
private record PathPermission(String pathPrefix, String permissionCode) {}
}
它维护一张路径前缀 → 权限码的映射表,管理员请求 /api/admin/** 时,按最长前缀匹配出所需权限码,校验当前管理员是否持有。两张过滤器的分工因此很清晰:
UserTypePathFilter:粗粒度,阵营隔离,逻辑固定,全系统一致;AdminPermissionFilter:细粒度,管理端内部授权,映射可配置,随时调整。
顺序也固定:先过阵营墙,再过权限墙。把两层职责塞进一个过滤器是常见的过度设计——阵营判断是永恒的,权限映射是会变的,变化的部分才需要可配置化。
四、Controller 为什么要复制粘贴
按端拆 Controller 最直观的代价是代码量翻倍:EmployeeAgentController 和 AdminAgentController 长得有点像。为什么不合成一个,用参数区分?
因为它们服务的是两种心智:
- 员工端视角:“我能看什么?”——接口天然带
CurrentEmployeeUserId(),查询范围被当前登录人锁死,返回的是裁剪过的视图(看不到别人的薪资字段、看不到已删除记录); - 管理端视角:“系统里有什么?”——接口带查询条件,跨人检索,返回完整视图,操作要过细粒度权限并落审计。
把两种视角揉进一个 Controller,结果必然是"一大堆 if (isAdmin)“,以及最危险的错误:员工接口忘了塞当前用户条件,查出了全公司数据。拆开之后,员工 Controller 的代码模式是"所有查询都锚定当前用户”——这个不变量在一个类内保持一致,code review 时一眼可查。
共享发生在下一层:两个 Controller 调用同一个 Service。业务规则只写一遍,双端只是不同的"取数视角 + 裁剪策略"。分层的位置是:Controller 分、Service 合、数据层合一。
五、身份模型也是分开的
auth/security/
├── AdminUserDetails.java AdminUserDetailsService.java
├── EmployeeUserDetails.java EmployeeUserDetailsService.java
├── WfhAuthenticationProvider.java
└── UserTypePathFilter.java AdminPermissionFilter.java
员工和管理员走各自的 UserDetailsService 加载、各自的 UserDetails 承载。两者可能在数据库里就是两张表(员工挂组织架构,管理员挂系统账号),登录类型(UserType.USER / UserType.ADMIN)写进认证主体,供过滤器读取。
把两类账号混进一张用户表加个 type 字段,是更"省"的做法,但省下的是建表,还回去的是一辈子的条件判断——每个查询都要记得过滤类型,漏一处就是权限穿透。身份体系从存储层就开始分离,隔离才有地基。
六、小结
三点体会:
- 隔离要做进结构,不能只靠自觉。 注解会忘加、校验会漏写,但 URL 前缀和全局过滤器不会——把"员工进不了管理接口"变成架构保证,它才真正成立。
- 两类过滤器管两件事。 阵营隔离(固定、粗粒度)和内部授权(可配置、细粒度)分开维护,变化的部分才需要灵活。
- Controller 可以复制,Service 不能。 双端是两个视角,不是两套业务——视角分开防越界,业务合一只写一遍。
上线这套隔离后做过一次渗透式的自查:拿员工 session 遍历管理端接口,全部 403,无一漏网。安全感不是测出来的,是前缀 + 过滤器 + 独立身份三层叠出来的——越界的请求甚至到不了 Controller 的门口。
默认评论
Halo系统提供的评论