Spring MVC REST API:多个角色应共用一个 endpoint 还是分别设置?
结论
这个场景可以共用 PATCH /users/{id},无需仅因调用者角色不同就增加 /admin/users/{id}。
如果普通用户和管理员执行的都是“更新用户资料”,区别只在于可修改的字段,建议共用 endpoint,并在服务端分别检查:
- 调用者能否更新目标用户。
- 调用者能否修改请求中的受限字段,例如
role。
普通用户提交角色变更时,建议返回 403 Forbidden。静默忽略会让客户端在收到成功响应后,难以判断哪些修改实际生效。
如果管理员操作有独立的审批、审计或业务规则,拆分 endpoint 和 DTO 会更清楚。两种设计都能实现安全的权限控制,URL 如何划分本身并不提供权限保障。
为什么共用路由仍然需要两层授权
假设用户 1 已登录,下面两个请求涉及不同的权限问题:
PATCH /users/2
Content-Type: application/json
{"displayName": "Alice"}
请求中虽然没有 role,但用户 1 未必有权修改用户 2。
PATCH /users/1
Content-Type: application/json
{"role": "ADMIN"}
普通用户修改自己的资料时,也不能给自己提升权限。
因此,授权不能只检查用户是否已认证。按题目中的常见业务假设,可以采用以下规则:
| 调用者 | 目标用户 | 普通资料字段 | role |
|---|---|---|---|
| 普通用户 | 自己 | 允许 | 禁止 |
| 普通用户 | 其他用户 | 禁止 | 禁止 |
| 管理员 | 业务允许管理的用户 | 允许 | 允许 |
多租户系统还应检查租户范围,不能让管理员直接更新任意租户的用户。
共用 endpoint 的实现示例
先验证身份,再检查调用者对目标用户和请求字段的修改权限,接着校验数据,最后执行更新。所有检查通过后才能修改实体。
示例采用以下约定:
- 使用 Java 17 和支持
@EnableMethodSecurity的 Spring Security。 - 认证由 Spring Security 完成,principal 是项目自定义的
AppPrincipal,其中userId()返回可信的用户 ID。 - 管理员权限在
Authentication中表示为ROLE_ADMIN。 - 为简化示例,DTO 中字段缺失或值为
null都表示“不修改”;这个接口不支持用null清空字段。 - 省略 import、实体和 Repository 定义。
首先启用方法级授权。仅引入 Spring Security 不会自动启用这项能力,具体配置参见 Spring Security 方法安全文档。
@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {
}
DTO 只包含允许通过此接口修改的字段:
public enum Role {
ADMIN, USER
}
public record UpdateUserDTO(
String displayName,
Role role
) {
}
Controller 接收请求,将更新交给服务层:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PatchMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void update(
@PathVariable("id") long id,
@RequestBody UpdateUserDTO dto) {
userService.update(id, dto);
}
}
服务层同时检查用户范围和字段权限:
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
@PreAuthorize("isAuthenticated()")
public void update(long id, UpdateUserDTO dto) {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
AppPrincipal principal =
(AppPrincipal) authentication.getPrincipal();
boolean admin = authentication.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
if (!admin && principal.userId() != id) {
throw new AccessDeniedException("不能修改其他用户");
}
if (!admin && dto.role() != null) {
throw new AccessDeniedException("只有管理员可以修改角色");
}
if (dto.displayName() != null
&& dto.displayName().isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"displayName 不能为空白"
);
}
User user = userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"用户不存在"
));
if (dto.displayName() != null) {
user.setDisplayName(dto.displayName());
}
if (dto.role() != null) {
user.setRole(dto.role());
}
userRepository.save(user);
}
}
权限判断使用当前认证上下文中的身份和 authorities。请求体中的 role 是要设置的目标值,不能用它判断调用者是不是管理员。
在这个实现中,如果普通用户提交:
{
"displayName": "Alice",
"role": "ADMIN"
}
整个请求会被拒绝,displayName 也不会更新。PATCH 要求整组修改原子地应用,不能只执行其中一部分,见 RFC 5789 第 2 节。
什么时候适合拆分路由和 DTO
如果“修改资料”和“管理权限”已经是两项独立的业务操作,可以拆成:
PATCH /users/{id} 修改普通资料
PUT /users/{id}/role 设置角色,仅管理员可调用
这里把角色建模为单独的子资源,用 PUT 设置它的完整值。相应的请求类型可以是:
public record UpdateUserProfileDTO(String displayName) {
}
public record SetUserRoleDTO(Role role) {
}
这样便于单独处理角色变更的审计、审批和权限规则,也能避免普通资料接口包含越来越多的管理员字段。
项目已有独立管理 API 时,也可以采用题目中的 /admin/users/{id}。路径包含 /admin,仍须在服务端配置授权;使用不同的 DTO 也不能替代授权检查。
拆分路由后,共同的数据校验和持久化逻辑仍可复用,无需复制两套更新代码。
实现时容易遗漏的细节
明确 PATCH 的字段语义
示例约定 null 表示“不修改”,因此:
{"role": null}
不会修改角色。如果业务需要区分“未提交字段”和“显式提交 null”,普通 Java 可空属性通常无法表达这个区别,需要额外记录字段是否出现,或采用明确的补丁格式。
如果约定普通用户只要提交 role 字段就必须被拒绝,权限检查就必须判断字段是否出现,不能只检查 dto.role() != null。
DTO 应限制可写字段
不要把请求体直接绑定到持久化实体,也不要未经检查就用通用属性复制工具覆盖实体。逐字段赋值能清楚地列出允许修改的属性,避免实体新增敏感属性后,意外暴露写入口。
采用不含 role 的 DTO 时,还应确认反序列化配置如何处理未知字段。如果项目会忽略未知字段,提交 role 仍可能得到成功响应。接口需要严格反馈时,应明确拒绝这类输入。
验证权限失败时的数据状态
至少应验证以下行为:
- 普通用户修改自己的资料成功,修改他人资料被拒绝。
- 普通用户请求修改
role时被拒绝,其他字段也没有变化。 - 管理员能够执行业务允许的角色变更。
- 非法角色值、空值和未知字段按接口约定处理。
如果角色变更后,现有会话或令牌需要立即反映新权限,还须设计刷新或失效机制。修改数据库中的 role 不会自动完成这一步。
备注:内容仅供参考。
