PAYATHON 2026

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 不会自动完成这一步。

备注:内容仅供参考。