diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java index efcdae5d..d4528ea1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java @@ -37,6 +37,7 @@ import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.vo.CertAuditDetailVO; import org.springblade.transport.pojo.vo.TransportCertRecordVO; import org.springblade.transport.service.ICertAuditService; +import org.springblade.transport.support.CertAuditPermissions; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -56,31 +57,12 @@ import org.springframework.web.bind.annotation.RestController; @Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核") public class CertificationAuditController extends BladeController { - /** - * 待审列表与详情的权限:司机或车辆任一审核菜单即可。 - *

- * 用菜单码而不是角色名——本项目的权限走「角色-菜单」授权, - * 写死角色名会漏掉实际承担审核的岗位(例如「运管」), - * 也会在角色改名后静默失效。 - *

- * 用 SpEL 而非 {@code @PreAuth(menu = "...")},是因为同一组接口同时服务 - * 司机与车辆两侧(由 {@code bizType} 区分),单值 menu 表达不了「二者之一」。 - */ - private static final String AUDIT_MENU_EXPRESSION = - "hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')"; - - /** - * 审核动作的权限:司机或车辆任一「审核」按钮权限即可 - */ - private static final String AUDIT_ACTION_MENU_EXPRESSION = - "hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')"; - private final ICertAuditService certAuditService; @GetMapping("/pending-page") @ApiOperationSupport(order = 1) @Operation(summary = "待审列表", description = "按认证对象类型分页查询审核中的记录,按提交时间倒序") - @PreAuth(AUDIT_MENU_EXPRESSION) + @PreAuth(CertAuditPermissions.AUDIT_VIEW) public R> pendingPage( @Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true) @RequestParam String bizType, @@ -94,7 +76,7 @@ public class CertificationAuditController extends BladeController { @GetMapping("/detail") @ApiOperationSupport(order = 2) @Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比") - @PreAuth(AUDIT_MENU_EXPRESSION) + @PreAuth(CertAuditPermissions.AUDIT_VIEW) public R detail( @Parameter(description = "认证记录主键", required = true) @RequestParam Long id) { return R.data(certAuditService.detail(id)); @@ -103,7 +85,7 @@ public class CertificationAuditController extends BladeController { @PostMapping("/audit") @ApiOperationSupport(order = 3) @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变") - @PreAuth(AUDIT_ACTION_MENU_EXPRESSION) + @PreAuth(CertAuditPermissions.AUDIT_ACTION) public R audit( @Parameter(description = "认证记录主键", required = true) @RequestParam Long id, @Parameter(description = "是否通过", required = true) @RequestParam Boolean pass, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java new file mode 100644 index 00000000..4817baaf --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertAuditPermissions.java @@ -0,0 +1,62 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 认证审核的权限表达式 + *

+ * 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑: + * 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}), + * 以及会漏掉实际承担审核的岗位(例如「运管」)。 + *

+ * 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口 + * 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。 + *

+ * 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。 + * {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式, + * 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。 + * + * @author Chill + */ +public final class CertAuditPermissions { + + /** + * 待审列表与审核详情:司机或车辆任一审核菜单即可 + */ + public static final String AUDIT_VIEW = + "hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')"; + + /** + * 审核动作:司机或车辆任一「审核」按钮权限即可 + */ + public static final String AUDIT_ACTION = + "hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')"; + + private CertAuditPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java new file mode 100644 index 00000000..4422d9cd --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPermissionsTest.java @@ -0,0 +1,99 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 认证审核权限表达式的可求值性测试。 + *

+ * 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值, + * root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露, + * 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。 + *

+ * 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值, + * 与框架 {@code AuthAspect} 的做法一致。 + */ +class CertAuditPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("查看权限:拥有司机审核菜单即可通过") + void auditViewWithDriverMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun), + "只授予司机审核菜单的用户也应能查看待审列表"); + } + + @Test + @DisplayName("查看权限:拥有车辆审核菜单即可通过") + void auditViewWithVehicleMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true); + + assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun)); + } + + @Test + @DisplayName("查看权限:两个菜单都没有则拒绝") + void auditViewWithoutAnyMenu() { + assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)), + "无任何审核菜单的用户不得查看待审列表"); + } + + @Test + @DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝") + void auditActionRequiresButtonPermission() { + AuthFun authFun = mock(AuthFun.class); + // 只给了列表菜单,没给「审核」按钮 + when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true); + + assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun), + "能看待审列表不等于能执行审核"); + } + + @Test + @DisplayName("审核动作:任一审核按钮权限即可通过") + void auditActionWithEitherButton() { + AuthFun driverAuditor = mock(AuthFun.class); + when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor)); + + AuthFun vehicleAuditor = mock(AuthFun.class); + when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true); + assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor)); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)") + void expressionsReferenceRealMenuCodes() { + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver")); + assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit")); + assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit")); + // 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位 + assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole")); + assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +}