Browse Source

feat(系统): module/system 84 个端点补方法级授权,附端点→权限串钉子测试

这批端点从 Java 平移起一个 @PreAuthorize 都没有:SecurityConfig 的授权规则只有
.anyRequest().authenticated()(仅认证),全 main 树没有授权用的 HandlerInterceptor
也没有 @Aspect,而 Python 版每条路由都挂 AuthPermission([...])。后果不是泛泛的
「权限不足」,是一条能走通的路径:sys_role / sys_menu / sys_role_menus 在
TenantInnerInterceptor 的 IGNORE_TABLES 里(不加租户谓词),所以任意已登录账号
(含普通租户账号)可以直接

  PATCH  /system/role/permission/setting   改写任意角色的授权
  DELETE /system/role/delete               删掉平台自己的角色

84 个端点挂 @perm.hasAny('<权限串>'),18 个端点有意豁免并显式列出:
登录入口 10 个(SecurityConfig WHITE_LIST)、/system/notice/available、
/system/param/info、/system/user/current/info(三者同样在白名单)、以及
/system/user/register、/forget/password(白名单)与三个自助接口
(current/password/change、current/info/update、current/avatar/upload)——
让用户改自己的密码还要 user:update 是错的。

钉子测试 SystemEndpointAuthzTest 用反射把整张「端点→权限串」表钉死,102 行。
它挡三件事:漏挂、串写错、以及**新端点**(表里没有就红 —— 新端点默认是开放的,
必须有人显式决定它要哪个串)。串写错是静默的:PermissionCheck 对 ROLE_SUPERUSER
直通,所以拿超管跑的用例一条都测不出写错的串,这处独立核对不能省。表里同时钉住
整个 SpEL 形式(@perm.hasAny('...')),hasAll 或 bean 名写错一样红。

两向变异(sha256 指纹 + 还原复核 5/5 逐字节相同):
- 删掉 role:permission 的守卫            ⇒ 红,失败信息点名 PATCH /system/role/permission/setting
- 把 user:query 打成 user:quer          ⇒ 红,点名 GET /system/user/list
- hasAny 改成 hasAll                     ⇒ 红,点名 GET /system/menu/tree
- 新增一个没有守卫的 /system/log/probe   ⇒ 红,keySet 断言点名该端点
- 只加注释 + 一个非映射私有方法           ⇒ 仍绿(证明它没把非端点方法误当端点)
全量 890 绿(此前 888,+2 即本测试)。

## 部署顺序(重要)

配套授权 SQL java/src/main/resources/db/sys-authz-grant-module-system.sql
必须在**部署本提交之前**执行。反过来的话,新后端上线到授权跑完之间,除超管外的
账号全被挡在系统管理之外,包括分配权限那个端点本身 —— 自己把自己锁在门外。
该文件 §0 是只读核对,先跑它,其中一个读数是「角色 1 是否被租户账号持有」,
那决定授权该授给谁。
alphaH 2 tuần trước cách đây
mục cha
commit
2a92d65dd9

+ 7 - 0
java/src/main/java/com/payment/platform/module/system/dept/controller/DeptController.java

@@ -6,6 +6,7 @@ import com.payment.platform.module.system.dept.dto.DeptUpdateDTO;
 import com.payment.platform.module.system.dept.service.DeptService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -18,6 +19,7 @@ public class DeptController {
 
     private final DeptService deptService;
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:query')")
     @GetMapping("/tree")
     public Result<List<Map<String, Object>>> tree(
             @RequestParam(required = false) String name,
@@ -25,28 +27,33 @@ public class DeptController {
         return Result.ok(deptService.getDeptTree(name, status));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:detail')")
     @GetMapping("/detail/{id}")
     public Result<Map<String, Object>> detail(@PathVariable Long id) {
         return Result.ok(deptService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:create')")
     @PostMapping("/create")
     public Result<Map<String, Object>> create(@Valid @RequestBody DeptCreateDTO dto) {
         return Result.ok(deptService.createFromDTO(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:update')")
     @PutMapping("/update/{id}")
     public Result<Map<String, Object>> update(@PathVariable Long id, @RequestBody DeptUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(deptService.updateFromDTO(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         deptService.delete(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dept:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")

+ 18 - 0
java/src/main/java/com/payment/platform/module/system/dict/controller/DictController.java

@@ -6,6 +6,7 @@ import com.payment.platform.module.system.dict.dto.*;
 import com.payment.platform.module.system.dict.service.DictService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.*;
@@ -19,11 +20,13 @@ public class DictController {
 
     // ==================== 字典类型 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:detail')")
     @GetMapping("/type/detail/{id}")
     public Result<DictTypeVO> getTypeDetail(@PathVariable Long id) {
         return Result.ok(dictService.getTypeDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:query')")
     @GetMapping("/type/list")
     public Result<PageResult<DictTypeVO>> getTypeList(
             @RequestParam(name = "dict_name", required = false) String dictName,
@@ -38,11 +41,13 @@ public class DictController {
         return Result.ok(dictService.getTypePage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:query')")
     @GetMapping("/type/optionselect")
     public Result<List<DictTypeVO>> getTypeOptionSelect() {
         return Result.ok(dictService.getTypeList(null));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:query')")
     @GetMapping("/type/page")
     public Result<PageResult<DictTypeVO>> getTypePage(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -51,23 +56,27 @@ public class DictController {
         return Result.ok(dictService.getTypePage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:create')")
     @PostMapping("/type/create")
     public Result<DictTypeVO> createType(@Valid @RequestBody DictTypeCreateDTO dto) {
         return Result.ok(dictService.createType(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:update')")
     @PutMapping("/type/update/{id}")
     public Result<DictTypeVO> updateType(@PathVariable Long id, @RequestBody DictTypeUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(dictService.updateType(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:delete')")
     @DeleteMapping("/type/delete")
     public Result<Void> deleteTypes(@RequestBody List<Long> ids) {
         dictService.deleteTypes(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:patch')")
     @PatchMapping("/type/available/setting")
     public Result<Void> batchSetTypeStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")
@@ -79,16 +88,19 @@ public class DictController {
 
     // ==================== 字典数据 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:query')")
     @GetMapping("/data/info/{dict_type}")
     public Result<List<DictDataVO>> getDataByType(@PathVariable(name = "dict_type") String dictType) {
         return Result.ok(dictService.getDataByType(dictType));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:detail')")
     @GetMapping("/data/detail/{id}")
     public Result<DictDataVO> getDataDetail(@PathVariable Long id) {
         return Result.ok(dictService.getDataDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:query')")
     @GetMapping("/data/list")
     public Result<PageResult<DictDataVO>> getDataPage(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -101,22 +113,26 @@ public class DictController {
         return Result.ok(dictService.getDataPage(pageNo, pageSize, dictType, dictLabel, status, dictTypeId, createdTime));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:create')")
     @PostMapping("/data/create")
     public Result<DictDataVO> createData(@RequestBody Map<String, Object> body) {
         return Result.ok(dictService.createData(body));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:update')")
     @PutMapping("/data/update/{id}")
     public Result<DictDataVO> updateData(@PathVariable Long id, @RequestBody Map<String, Object> body) {
         return Result.ok(dictService.updateData(id, body));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:delete')")
     @DeleteMapping("/data/delete")
     public Result<Void> deleteData(@RequestBody List<Long> ids) {
         dictService.deleteData(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:patch')")
     @PatchMapping("/data/available/setting")
     public Result<Void> batchSetDataStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")
@@ -128,11 +144,13 @@ public class DictController {
 
     // ==================== 导出(占位) ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_type:export')")
     @PostMapping("/type/export")
     public Result<Void> exportDictType(@RequestBody Map<String, Object> params) {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:dict_data:export')")
     @PostMapping("/data/export")
     public Result<Void> exportDictData(@RequestBody Map<String, Object> params) {
         return Result.ok();

+ 4 - 0
java/src/main/java/com/payment/platform/module/system/invite/controller/InvitationCodeController.java

@@ -5,6 +5,7 @@ import com.payment.platform.module.system.invite.service.InvitationCodeService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -19,6 +20,7 @@ public class InvitationCodeController {
     private final InvitationCodeService invitationCodeService;
 
     @Operation(summary = "生成邀请码")
+    @PreAuthorize("@perm.hasAny('module_system:invitation:create')")
     @PostMapping("/generate")
     public Result<List<String>> generate(@RequestBody Map<String, Object> body) {
         int count = body.containsKey("count") ? Integer.parseInt(body.get("count").toString()) : 1;
@@ -27,6 +29,7 @@ public class InvitationCodeController {
     }
 
     @Operation(summary = "邀请码列表")
+    @PreAuthorize("@perm.hasAny('module_system:invitation:list')")
     @GetMapping("/list")
     public Result<?> list(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -37,6 +40,7 @@ public class InvitationCodeController {
     }
 
     @Operation(summary = "删除邀请码")
+    @PreAuthorize("@perm.hasAny('module_system:invitation:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         invitationCodeService.delete(ids);

+ 5 - 0
java/src/main/java/com/payment/platform/module/system/log/controller/LogController.java

@@ -7,6 +7,7 @@ import com.payment.platform.module.system.log.dto.OperationLogVO;
 import com.payment.platform.module.system.log.service.OperationLogService;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
@@ -19,6 +20,7 @@ public class LogController {
 
     private final OperationLogService operationLogService;
 
+    @PreAuthorize("@perm.hasAny('module_system:log:query')")
     @GetMapping("/list")
     public Result<PageResult<OperationLogVO>> list(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -27,17 +29,20 @@ public class LogController {
         return Result.ok(operationLogService.getPage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:log:detail')")
     @GetMapping("/detail/{id}")
     public Result<OperationLogVO> detail(@PathVariable Long id) {
         return Result.ok(operationLogService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:log:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         operationLogService.delete(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:log:export')")
     @PostMapping("/export")
     public void exportLog(OperationLogQueryDTO query, HttpServletResponse response) throws IOException {
         byte[] bytes = operationLogService.export(query);

+ 7 - 0
java/src/main/java/com/payment/platform/module/system/menu/controller/MenuController.java

@@ -7,6 +7,7 @@ import com.payment.platform.module.system.menu.dto.MenuVO;
 import com.payment.platform.module.system.menu.service.MenuService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -19,6 +20,7 @@ public class MenuController {
 
     private final MenuService menuService;
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:query')")
     @GetMapping("/tree")
     public Result<List<MenuVO>> tree(
             @RequestParam(required = false) String name,
@@ -26,28 +28,33 @@ public class MenuController {
         return Result.ok(menuService.getMenuTree(name, status));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:detail')")
     @GetMapping("/detail/{id}")
     public Result<MenuVO> detail(@PathVariable Long id) {
         return Result.ok(menuService.getMenuDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:create')")
     @PostMapping("/create")
     public Result<MenuVO> create(@Valid @RequestBody MenuCreateDTO dto) {
         return Result.ok(menuService.createMenu(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:update')")
     @PutMapping("/update/{id}")
     public Result<MenuVO> update(@PathVariable Long id, @RequestBody MenuUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(menuService.updateMenu(id, dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         menuService.deleteMenus(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:menu:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")

+ 8 - 0
java/src/main/java/com/payment/platform/module/system/notice/controller/NoticeController.java

@@ -7,6 +7,7 @@ import com.payment.platform.module.system.notice.service.NoticeService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
@@ -22,6 +23,7 @@ public class NoticeController {
 
     // ==================== 查询 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:detail')")
     @GetMapping("/detail/{id}")
     public Result<NoticeVO> getDetail(@PathVariable Long id) {
         return Result.ok(noticeService.getNoticeDetail(id));
@@ -32,6 +34,7 @@ public class NoticeController {
         return Result.ok(noticeService.getNoticeAvailablePage());
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:query')")
     @GetMapping("/list")
     public Result<PageResult<NoticeVO>> getPage(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -42,23 +45,27 @@ public class NoticeController {
 
     // ==================== 增删改 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:create')")
     @PostMapping("/create")
     public Result<NoticeVO> create(@Valid @RequestBody NoticeCreateDTO dto) {
         return Result.ok(noticeService.createNotice(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:update')")
     @PutMapping("/update/{id}")
     public Result<NoticeVO> update(@PathVariable Long id, @RequestBody NoticeUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(noticeService.updateNotice(id, dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         noticeService.deleteNotices(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchSetStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")
@@ -70,6 +77,7 @@ public class NoticeController {
 
     // ==================== 导出 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:notice:export')")
     @PostMapping("/export")
     public void export(@Valid NoticeQueryDTO query, HttpServletResponse response) throws IOException {
         List<NoticeVO> list = noticeService.getNoticeList(query);

+ 11 - 0
java/src/main/java/com/payment/platform/module/system/params/controller/ParamsController.java

@@ -7,6 +7,7 @@ import com.payment.platform.module.system.params.service.ParamsService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
@@ -25,21 +26,25 @@ public class ParamsController {
 
     // ==================== 查询 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:param:detail')")
     @GetMapping("/detail/{id}")
     public Result<ParamsVO> detail(@PathVariable Long id) {
         return Result.ok(paramsService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:query')")
     @GetMapping("/key/{configKey}")
     public Result<ParamsVO> byKey(@PathVariable String configKey) {
         return Result.ok(paramsService.getByKey(configKey));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:query')")
     @GetMapping("/value/{configKey}")
     public Result<String> value(@PathVariable String configKey) {
         return Result.ok(paramsService.getValueByKey(configKey));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:query')")
     @GetMapping("/list")
     public Result<PageResult<ParamsVO>> list(
             @RequestParam(name = "config_name", required = false) String configName,
@@ -54,6 +59,7 @@ public class ParamsController {
         return Result.ok(paramsService.getPage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:query')")
     @GetMapping("/page")
     public Result<PageResult<ParamsVO>> page(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -69,17 +75,20 @@ public class ParamsController {
 
     // ==================== 写操作 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:param:create')")
     @PostMapping("/create")
     public Result<ParamsVO> create(@Valid @RequestBody ParamsCreateDTO dto) {
         return Result.ok(paramsService.create(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:update')")
     @PutMapping("/update/{id}")
     public Result<ParamsVO> update(@PathVariable Long id, @RequestBody ParamsUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(paramsService.update(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         paramsService.delete(ids);
@@ -88,11 +97,13 @@ public class ParamsController {
 
     // ==================== 导入导出 ====================
 
+    @PreAuthorize("@perm.hasAny('module_system:param:upload')")
     @PostMapping("/upload")
     public Result<Map<String, String>> upload(@RequestParam("file") MultipartFile file) {
         return Result.ok(paramsService.upload(file));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:param:export')")
     @PostMapping("/export")
     public void export(@RequestBody Map<String, Object> params, HttpServletResponse response) throws IOException {
         @SuppressWarnings("unchecked")

+ 8 - 0
java/src/main/java/com/payment/platform/module/system/position/controller/PositionController.java

@@ -7,6 +7,7 @@ import com.payment.platform.module.system.position.service.PositionService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
@@ -19,6 +20,7 @@ public class PositionController {
 
     private final PositionService positionService;
 
+    @PreAuthorize("@perm.hasAny('module_system:position:query')")
     @GetMapping("/list")
     public Result<PageResult<PositionVO>> list(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -27,28 +29,33 @@ public class PositionController {
         return Result.ok(positionService.getPage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:detail')")
     @GetMapping("/detail/{id}")
     public Result<PositionVO> detail(@PathVariable Long id) {
         return Result.ok(positionService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:create')")
     @PostMapping("/create")
     public Result<PositionVO> create(@Valid @RequestBody PositionCreateDTO dto) {
         return Result.ok(positionService.create(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:update')")
     @PutMapping("/update/{id}")
     public Result<PositionVO> update(@PathVariable Long id, @RequestBody PositionUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(positionService.update(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         positionService.delete(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchSetStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")
@@ -58,6 +65,7 @@ public class PositionController {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:position:export')")
     @PostMapping("/export")
     public void export(@Valid PositionQueryDTO query, HttpServletResponse response) throws IOException {
         List<PositionVO> list = positionService.getList(query);

+ 9 - 0
java/src/main/java/com/payment/platform/module/system/role/controller/RoleController.java

@@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.io.IOException;
@@ -23,6 +24,7 @@ public class RoleController {
 
     private final RoleService roleService;
 
+    @PreAuthorize("@perm.hasAny('module_system:role:query')")
     @GetMapping("/list")
     public Result<PageResult<RoleVO>> list(@RequestParam(name = "page_no", defaultValue = "1") int pageNo,
                                            @RequestParam(name = "page_size", defaultValue = "20") int pageSize,
@@ -30,28 +32,33 @@ public class RoleController {
         return Result.ok(roleService.getPage(pageNo, pageSize, name));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:detail')")
     @GetMapping("/detail/{id}")
     public Result<RoleVO> detail(@PathVariable Long id) {
         return Result.ok(roleService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:create')")
     @PostMapping("/create")
     public Result<RoleVO> create(@Valid @RequestBody RoleCreateDTO dto) {
         return Result.ok(roleService.create(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:update')")
     @PutMapping("/update/{id}")
     public Result<RoleVO> update(@PathVariable Long id, @RequestBody RoleUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(roleService.update(id, dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         roleService.delete(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")
@@ -60,6 +67,7 @@ public class RoleController {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:permission')")
     @PatchMapping("/permission/setting")
     @Operation(summary = "设置角色权限")
     public Result<Void> permissionSetting(@RequestBody Map<String, Object> body) {
@@ -74,6 +82,7 @@ public class RoleController {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:role:export')")
     @PostMapping("/export")
     @Operation(summary = "导出角色")
     public void exportRoles(@RequestBody Map<String, Object> body, HttpServletResponse response) throws IOException {

+ 7 - 0
java/src/main/java/com/payment/platform/module/system/tenant/controller/TenantController.java

@@ -6,6 +6,7 @@ import com.payment.platform.module.system.tenant.dto.*;
 import com.payment.platform.module.system.tenant.service.TenantService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -18,6 +19,7 @@ public class TenantController {
 
     private final TenantService tenantService;
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:query')")
     @GetMapping("/list")
     public Result<PageResult<TenantVO>> list(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
@@ -26,28 +28,33 @@ public class TenantController {
         return Result.ok(tenantService.getPage(pageNo, pageSize, query));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:query')")
     @GetMapping("/detail/{id}")
     public Result<TenantVO> detail(@PathVariable Long id) {
         return Result.ok(tenantService.getDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:create')")
     @PostMapping("/create")
     public Result<TenantVO> create(@Valid @RequestBody TenantCreateDTO dto) {
         return Result.ok(tenantService.create(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:update')")
     @PutMapping("/update/{id}")
     public Result<TenantVO> update(@PathVariable Long id, @RequestBody TenantUpdateDTO dto) {
         dto.setId(id);
         return Result.ok(tenantService.update(dto));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         tenantService.delete(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:tenant:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchSetStatus(@RequestBody Map<String, Object> body) {
         @SuppressWarnings("unchecked")

+ 11 - 0
java/src/main/java/com/payment/platform/module/system/user/controller/UserController.java

@@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -46,6 +47,7 @@ public class UserController {
         return u == null ? Result.unauthorized("未登录") : Result.ok(userService.getCurrentUserInfo(u));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:query')")
     @GetMapping("/list")
     public Result<PageResult<UserVO>> list(
             @RequestParam(name = "page_no", defaultValue = "1") int pageNo, @RequestParam(name = "page_size", defaultValue = "10") int pageSize,
@@ -59,27 +61,32 @@ public class UserController {
         return Result.ok(userService.getUserPage(pageNo, pageSize, username, name, mobile, email, status, deptId, createdId, updatedId));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:detail')")
     @GetMapping("/detail/{id}")
     public Result<UserInfoVO> detail(@PathVariable Long id) {
         return Result.ok(userService.getUserDetail(id));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:create')")
     @PostMapping("/create")
     public Result<UserVO> create(@Valid @RequestBody UserCreateDTO body) {
         return Result.ok(userService.createUser(body));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:update')")
     @PutMapping("/update/{id}")
     public Result<UserVO> update(@PathVariable Long id, @Valid @RequestBody UserUpdateDTO body) {
         return Result.ok(userService.updateUser(id, body));
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:delete')")
     @DeleteMapping("/delete")
     public Result<Void> delete(@RequestBody List<Long> ids) {
         userService.deleteUsers(ids);
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:patch')")
     @PatchMapping("/available/setting")
     public Result<Void> batchStatus(@Valid @RequestBody BatchStatusDTO body) {
         userService.batchSetStatus(body.getIds(), body.getStatus());
@@ -94,6 +101,7 @@ public class UserController {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:update')")
     @PutMapping("/reset/password")
     public Result<Void> resetPassword(@Valid @RequestBody ResetPasswordDTO body) {
         userService.resetPassword(body.getId(), body.getNewPwd());
@@ -126,6 +134,7 @@ public class UserController {
         return Result.ok();
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:export')")
     @PostMapping("/export")
     public void exportUser(@RequestBody Map<String, Object> body, HttpServletResponse response) throws IOException {
         byte[] bytes = userService.exportUsers(body);
@@ -134,6 +143,7 @@ public class UserController {
         response.getOutputStream().write(bytes);
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:download')")
     @GetMapping("/import/template")
     public void downloadTemplateUser(HttpServletResponse response) throws IOException {
         byte[] bytes = userService.downloadUserTemplate();
@@ -142,6 +152,7 @@ public class UserController {
         response.getOutputStream().write(bytes);
     }
 
+    @PreAuthorize("@perm.hasAny('module_system:user:import')")
     @PostMapping("/import/data")
     public Result<Void> importUser(@RequestParam("file") MultipartFile file) {
         userService.importUsers(file);

+ 288 - 0
java/src/test/java/com/payment/platform/module/system/SystemEndpointAuthzTest.java

@@ -0,0 +1,288 @@
+package com.payment.platform.module.system;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
+import org.springframework.core.type.filter.AnnotationTypeFilter;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * module/system 下**每一个** HTTP 端点都要挂方法级授权。
+ *
+ * 为什么需要这条钉子:这批端点从 Java 平移起就一个 @PreAuthorize 都没有
+ * (SecurityConfig 只有 .anyRequest().authenticated(),即「登录即可」),
+ * 而 Python 版每条路由都挂 AuthPermission([...])。后果不是抽象的:sys_role /
+ * sys_menu / sys_role_menus 在 TenantInnerInterceptor 的 IGNORE_TABLES 里,
+ * 不加租户谓词 ⇒ 任何已登录账号(含普通租户账号)都能 PATCH
+ * /system/role/permission/setting 改写任意角色的授权。
+ *
+ * 这个测试把「哪个端点该要哪个权限串」整张表钉死。它有三个作用:
+ *   1. 漏挂 —— 表里有、代码里没有 ⇒ 红。
+ *   2. 多挂/改串 —— 串不一致 ⇒ 红(权限串写错是静默的:写对了没人知道,
+ *      写错了所有人被 403 挡在门外,而超管直通 ⇒ 拿超管跑的用例一条都测不出)。
+ *   3. 新增端点 —— 代码里有、表里没有 ⇒ 红。这条最重要:新端点默认是**开放**的,
+ *      必须有人显式决定它要哪个串。
+ *
+ * 豁免不是「忘了」:登录入口、SecurityConfig 白名单、以及用户改自己密码/头像这类
+ * 自助接口不该被权限串挡住。豁免项在表里写成空串,且必须**显式列出** —— 表里
+ * 没有的端点一样会红。
+ */
+class SystemEndpointAuthzTest {
+
+    /** 端点 → 权限串。空串 = 有意豁免(登录 / 白名单 / 自助)。 */
+    private static Map<String, String> expected() {
+        Map<String, String> m = new LinkedHashMap<>();
+
+        // ---- 登录入口:SecurityConfig WHITE_LIST,本就不该要权限串 ----
+        m.put("POST /system/auth/login", "");
+        m.put("POST /system/auth/login/mini", "");
+        m.put("POST /system/auth/login/sms", "");
+        m.put("GET /system/auth/captcha/get", "");
+        m.put("POST /system/auth/logout", "");
+        m.put("POST /system/auth/token/refresh", "");
+        m.put("GET /system/auth/auto-login/users", "");
+        m.put("POST /system/auth/auto-login/token", "");
+        m.put("POST /system/auth/auto-login", "");
+        m.put("POST /system/auth/sms-code", "");
+
+        // ---- 部门 ----
+        m.put("GET /system/dept/tree", "module_system:dept:query");
+        m.put("GET /system/dept/detail/{id}", "module_system:dept:detail");
+        m.put("POST /system/dept/create", "module_system:dept:create");
+        m.put("PUT /system/dept/update/{id}", "module_system:dept:update");
+        m.put("DELETE /system/dept/delete", "module_system:dept:delete");
+        m.put("PATCH /system/dept/available/setting", "module_system:dept:patch");
+
+        // ---- 字典 ----
+        m.put("GET /system/dict/type/detail/{id}", "module_system:dict_type:detail");
+        m.put("GET /system/dict/type/list", "module_system:dict_type:query");
+        m.put("GET /system/dict/type/optionselect", "module_system:dict_type:query");
+        m.put("GET /system/dict/type/page", "module_system:dict_type:query");
+        m.put("POST /system/dict/type/create", "module_system:dict_type:create");
+        m.put("PUT /system/dict/type/update/{id}", "module_system:dict_type:update");
+        m.put("DELETE /system/dict/type/delete", "module_system:dict_type:delete");
+        m.put("PATCH /system/dict/type/available/setting", "module_system:dict_type:patch");
+        m.put("POST /system/dict/type/export", "module_system:dict_type:export");
+        m.put("GET /system/dict/data/info/{dict_type}", "module_system:dict_data:query");
+        m.put("GET /system/dict/data/detail/{id}", "module_system:dict_data:detail");
+        m.put("GET /system/dict/data/list", "module_system:dict_data:query");
+        m.put("POST /system/dict/data/create", "module_system:dict_data:create");
+        m.put("PUT /system/dict/data/update/{id}", "module_system:dict_data:update");
+        m.put("DELETE /system/dict/data/delete", "module_system:dict_data:delete");
+        m.put("PATCH /system/dict/data/available/setting", "module_system:dict_data:patch");
+        m.put("POST /system/dict/data/export", "module_system:dict_data:export");
+
+        // ---- 邀请码 ----
+        m.put("POST /system/invite/generate", "module_system:invitation:create");
+        m.put("GET /system/invite/list", "module_system:invitation:list");
+        m.put("DELETE /system/invite/delete", "module_system:invitation:delete");
+
+        // ---- 操作日志 ----
+        m.put("GET /system/log/list", "module_system:log:query");
+        m.put("GET /system/log/detail/{id}", "module_system:log:detail");
+        m.put("DELETE /system/log/delete", "module_system:log:delete");
+        m.put("POST /system/log/export", "module_system:log:export");
+
+        // ---- 菜单 ----
+        m.put("GET /system/menu/tree", "module_system:menu:query");
+        m.put("GET /system/menu/detail/{id}", "module_system:menu:detail");
+        m.put("POST /system/menu/create", "module_system:menu:create");
+        m.put("PUT /system/menu/update/{id}", "module_system:menu:update");
+        m.put("DELETE /system/menu/delete", "module_system:menu:delete");
+        m.put("PATCH /system/menu/available/setting", "module_system:menu:patch");
+
+        // ---- 公告 ----
+        m.put("GET /system/notice/detail/{id}", "module_system:notice:detail");
+        m.put("GET /system/notice/available", ""); // WHITE_LIST
+        m.put("GET /system/notice/list", "module_system:notice:query");
+        m.put("POST /system/notice/create", "module_system:notice:create");
+        m.put("PUT /system/notice/update/{id}", "module_system:notice:update");
+        m.put("DELETE /system/notice/delete", "module_system:notice:delete");
+        m.put("PATCH /system/notice/available/setting", "module_system:notice:patch");
+        m.put("POST /system/notice/export", "module_system:notice:export");
+
+        // ---- 参数 ----
+        m.put("GET /system/param/detail/{id}", "module_system:param:detail");
+        m.put("GET /system/param/key/{configKey}", "module_system:param:query");
+        m.put("GET /system/param/value/{configKey}", "module_system:param:query");
+        m.put("GET /system/param/list", "module_system:param:query");
+        m.put("GET /system/param/page", "module_system:param:query");
+        m.put("GET /system/param/info", ""); // WHITE_LIST
+        m.put("POST /system/param/create", "module_system:param:create");
+        m.put("PUT /system/param/update/{id}", "module_system:param:update");
+        m.put("DELETE /system/param/delete", "module_system:param:delete");
+        m.put("POST /system/param/upload", "module_system:param:upload");
+        m.put("POST /system/param/export", "module_system:param:export");
+
+        // ---- 岗位 ----
+        m.put("GET /system/position/list", "module_system:position:query");
+        m.put("GET /system/position/detail/{id}", "module_system:position:detail");
+        m.put("POST /system/position/create", "module_system:position:create");
+        m.put("PUT /system/position/update/{id}", "module_system:position:update");
+        m.put("DELETE /system/position/delete", "module_system:position:delete");
+        m.put("PATCH /system/position/available/setting", "module_system:position:patch");
+        m.put("POST /system/position/export", "module_system:position:export");
+
+        // ---- 角色 ----
+        m.put("GET /system/role/list", "module_system:role:query");
+        m.put("GET /system/role/detail/{id}", "module_system:role:detail");
+        m.put("POST /system/role/create", "module_system:role:create");
+        m.put("PUT /system/role/update/{id}", "module_system:role:update");
+        m.put("DELETE /system/role/delete", "module_system:role:delete");
+        m.put("PATCH /system/role/available/setting", "module_system:role:patch");
+        m.put("PATCH /system/role/permission/setting", "module_system:role:permission");
+        m.put("POST /system/role/export", "module_system:role:export");
+
+        // ---- 租户 ----
+        m.put("GET /system/tenant/list", "module_system:tenant:query");
+        m.put("GET /system/tenant/detail/{id}", "module_system:tenant:query");
+        m.put("POST /system/tenant/create", "module_system:tenant:create");
+        m.put("PUT /system/tenant/update/{id}", "module_system:tenant:update");
+        m.put("DELETE /system/tenant/delete", "module_system:tenant:delete");
+        m.put("PATCH /system/tenant/available/setting", "module_system:tenant:patch");
+
+        // ---- 用户 ----
+        m.put("GET /system/user/current/info", "");          // WHITE_LIST
+        m.put("GET /system/user/list", "module_system:user:query");
+        m.put("GET /system/user/detail/{id}", "module_system:user:detail");
+        m.put("POST /system/user/create", "module_system:user:create");
+        m.put("PUT /system/user/update/{id}", "module_system:user:update");
+        m.put("DELETE /system/user/delete", "module_system:user:delete");
+        m.put("PATCH /system/user/available/setting", "module_system:user:patch");
+        m.put("PUT /system/user/current/password/change", ""); // 自助
+        m.put("PUT /system/user/reset/password", "module_system:user:update");
+        m.put("PUT /system/user/current/info/update", "");     // 自助
+        m.put("POST /system/user/current/avatar/upload", "");  // 自助
+        m.put("POST /system/user/register", "");               // WHITE_LIST
+        m.put("POST /system/user/forget/password", "");        // WHITE_LIST
+        m.put("POST /system/user/export", "module_system:user:export");
+        m.put("GET /system/user/import/template", "module_system:user:download");
+        m.put("POST /system/user/import/data", "module_system:user:import");
+
+        return m;
+    }
+
+    @Test
+    void everySystemEndpointCarriesTheExpectedGuard() {
+        Map<String, String> expected = expected();
+        Map<String, String> actual = scanEndpoints();
+
+        assertEquals(new TreeMap<>(expected).keySet(), new TreeMap<>(actual).keySet(),
+                "端点集合对不上:代码里有表里没有的(新端点默认开放,必须显式决定它的权限串),"
+                        + "或表里有代码里没有的(端点被改名/删掉)。实测=" + new TreeMap<>(actual).keySet());
+
+        StringBuilder bad = new StringBuilder();
+        for (Map.Entry<String, String> e : expected.entrySet()) {
+            String got = actual.get(e.getKey());
+            if (!e.getValue().equals(got)) {
+                bad.append("\n  ").append(e.getKey())
+                   .append("  期望=").append(e.getValue().isEmpty() ? "(豁免)" : e.getValue())
+                   .append("  实际=").append(got == null ? "(豁免)" : got);
+            }
+        }
+        assertTrue(bad.length() == 0,
+                "以下端点的权限串与表不一致(注意:串写错是静默的 —— 超管直通,"
+                        + "所以用超管跑不出这个缺陷):" + bad);
+    }
+
+    /** 端点数必须等于表里的行数,防止扫描漏掉一个控制器却因为「两边都少」而假绿。 */
+    @Test
+    void theTableItselfCoversAllTwelveControllers() {
+        assertEquals(102, expected().size(), "表本身的行数变了,先确认是有意改动");
+
+        Map<String, Long> perController = new TreeMap<>();
+        for (String route : scanEndpoints().keySet()) {
+            String[] parts = route.split(" ")[1].split("/");
+            perController.merge(parts[2], 1L, Long::sum);
+        }
+        assertEquals(12, perController.size(), "扫到的控制器数不是 12:实测=" + perController);
+    }
+
+    // ==================== 反射扫描 ====================
+
+    private Map<String, String> scanEndpoints() {
+        Map<String, String> out = new TreeMap<>();
+        ClassPathScanningCandidateComponentProvider scanner =
+                new ClassPathScanningCandidateComponentProvider(false);
+        scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class));
+
+        for (BeanDefinition bd : scanner.findCandidateComponents("com.payment.platform.module.system")) {
+            Class<?> clazz;
+            try {
+                clazz = Class.forName(bd.getBeanClassName());
+            } catch (ClassNotFoundException ex) {
+                throw new IllegalStateException("扫到的类加载不了: " + bd.getBeanClassName(), ex);
+            }
+            String base = firstPath(clazz.getAnnotation(RequestMapping.class));
+            for (Method method : clazz.getDeclaredMethods()) {
+                for (Annotation a : method.getAnnotations()) {
+                    String verb = verbOf(a);
+                    if (verb == null) continue;
+                    String path = join(base, firstPath(a));
+                    PreAuthorize guard = method.getAnnotation(PreAuthorize.class);
+                    out.put(verb + " " + path, guard == null ? "" : permissionOf(guard));
+                }
+            }
+        }
+        return out;
+    }
+
+    private static String verbOf(Annotation a) {
+        if (a instanceof GetMapping) return "GET";
+        if (a instanceof PostMapping) return "POST";
+        if (a instanceof PutMapping) return "PUT";
+        if (a instanceof PatchMapping) return "PATCH";
+        if (a instanceof DeleteMapping) return "DELETE";
+        return null;
+    }
+
+    /** @perm.hasAny('<权限串>') —— 整个形式都要对:bean 名写错、hasAll/hasAny 写错都该红。 */
+    private static final java.util.regex.Pattern GUARD =
+            java.util.regex.Pattern.compile("^@perm\\.hasAny\\('([^']+)'\\)$");
+
+    /**
+     * 从 @PreAuthorize 的 SpEL 里取出权限串。
+     * 形式不合时**原样返回整条 SpEL**(不去抛异常):这样它会落进上面那张差异表里,
+     * 与期望串并排显示,且一条写坏不会遮住另外 101 个端点的读数。
+     * 原样串以 '@' 开头,不可能等于期望串,所以不会假绿。
+     */
+    private static String permissionOf(PreAuthorize guard) {
+        java.util.regex.Matcher m = GUARD.matcher(guard.value().trim());
+        return m.matches() ? m.group(1) : guard.value().trim();
+    }
+
+    /** 取出映射注解的路径:value() 与 path() 都算(@PostMapping(value = "/login") 用的是 value)。 */
+    private static String firstPath(Annotation a) {
+        if (a == null) return "";
+        String[] candidates;
+        if (a instanceof RequestMapping rm) candidates = rm.value().length > 0 ? rm.value() : rm.path();
+        else if (a instanceof GetMapping gm) candidates = gm.value().length > 0 ? gm.value() : gm.path();
+        else if (a instanceof PostMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
+        else if (a instanceof PutMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
+        else if (a instanceof PatchMapping pm) candidates = pm.value().length > 0 ? pm.value() : pm.path();
+        else if (a instanceof DeleteMapping dm) candidates = dm.value().length > 0 ? dm.value() : dm.path();
+        else return "";
+        return candidates.length == 0 ? "" : candidates[0];
+    }
+
+    private static String join(String base, String path) {
+        if (path.isEmpty()) return base;
+        String b = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
+        return path.startsWith("/") ? b + path : b + "/" + path;
+    }
+}