diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java new file mode 100644 index 00000000..b1f233f8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java @@ -0,0 +1,63 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 异常处置编辑请求 + *

+ * 只承载上报四件套。车牌、项目、承运商、配载单号等字段由上报时从运单回填, + * 不在此列,避免编辑后与运单脱钩。 + *

+ * 不含处置状态:状态只能经跟进与完成流转,编辑不得改变它。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置编辑请求") +public class ExceptionDisposalUpdateRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "异常类型") + private String exceptionType; + + @Schema(description = "异常原因") + private String exceptionReason; + + @Schema(description = "上报说明") + private String reportDescription; + + @Schema(description = "现场照片,逗号分隔") + private String scenePhotos; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 2fc52aa9..855f37ec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -30,11 +30,14 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; +import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.support.ExceptionDisposalPermissions; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController { return R.success("批量完成成功"); } + @PostMapping("/update") + @PreAuth(ExceptionDisposalPermissions.EDIT) + @ApiOperationSupport(order = 7) + @Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态") + public R update(@RequestBody ExceptionDisposalUpdateRequest request) { + exceptionDisposalService.update(request); + return R.success("编辑成功"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index b04ee264..d23ca19c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService 500) { + throw new ServiceException("上报说明不能超过500字"); + } + if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) { + throw new ServiceException("异常原因不能超过200字"); + } + ExceptionDisposal disposal = getExisting(request.getId()); + + String exceptionType = request.getExceptionType().trim(); + String exceptionReason = Func.isBlank(request.getExceptionReason()) + ? null + : request.getExceptionReason().trim(); + String reportDescription = request.getReportDescription().trim(); + String scenePhotos = Func.isBlank(request.getScenePhotos()) + ? null + : request.getScenePhotos().trim(); + + String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos); + if (Func.isBlank(changeLog)) { + throw new ServiceException("未检测到任何修改"); + } + + disposal.setExceptionType(exceptionType); + disposal.setExceptionReason(exceptionReason); + disposal.setReportDescription(reportDescription); + disposal.setScenePhotos(scenePhotos); + disposal.setLatestFollowContent(changeLog); + updateById(disposal); + + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(changeLog); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** + * 逐字段比对生成变更说明,用于写入跟进记录。 + * 未发生变更时返回 null,调用方据此拒绝这次编辑。 + *

+ * 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制, + * 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。 + */ + private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason, + String reportDescription, String scenePhotos) { + List changes = new ArrayList<>(); + appendChange(changes, "异常类型", before.getExceptionType(), exceptionType); + appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason); + appendChange(changes, "上报说明", before.getReportDescription(), reportDescription); + if (!Objects.equals(before.getScenePhotos(), scenePhotos)) { + changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张"); + } + if (changes.isEmpty()) { + return null; + } + return truncate("编辑:" + String.join(";", changes)); + } + + private void appendChange(List changes, String label, String before, String after) { + String oldValue = Func.isBlank(before) ? "空" : before; + String newValue = Func.isBlank(after) ? "空" : after; + if (!Objects.equals(before, after)) { + changes.add(label + "由 " + oldValue + " 改为 " + newValue); + } + } + + /** 截断到字段上限内,留出省略号位置 */ + private String truncate(String text) { + return text.length() <= FOLLOW_CONTENT_MAX_LENGTH + ? text + : text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…"; + } + + private int photoCount(String scenePhotos) { + return splitPhotos(scenePhotos).size(); + } + @Override @Transactional(rollbackFor = Exception.class) public void follow(ExceptionDisposalFollowRequest request) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java new file mode 100644 index 00000000..730f7453 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java @@ -0,0 +1,47 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 异常处置的权限表达式 + *

+ * 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。 + *

+ * 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故函数名必须与它的公开方法一致({@code hasMenu})。 + * 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作 + * 属性访问而非权限判断,请求时抛 500 而不是 403。 + * + * @author Chill + */ +public final class ExceptionDisposalPermissions { + + /** + * 编辑异常处置的上报内容 + */ + public static final String EDIT = "hasMenu('exception_disposal_edit')"; + + private ExceptionDisposalPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java new file mode 100644 index 00000000..f082dc22 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java @@ -0,0 +1,73 @@ +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}。写成裸字符串(如 {@code "exception_disposal_edit"}) + * 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。 + * + * @author Chill + */ +class ExceptionDisposalPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("编辑权限:拥有编辑菜单即通过") + void editWithMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true); + + assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "授予编辑菜单的用户应能编辑"); + } + + @Test + @DisplayName("编辑权限:没有编辑菜单则拒绝") + void editWithoutMenu() { + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)), + "未授予编辑菜单的用户不得编辑"); + } + + @Test + @DisplayName("编辑权限:只有查看/跟进菜单不能编辑") + void editRequiresDedicatedMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_view")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true); + + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "能跟进、能完成不等于能编辑"); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码") + void expressionReferencesRealMenuCode() { + assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit")); + assertFalse(ExceptionDisposalPermissions.EDIT.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)); + } + +} diff --git a/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql new file mode 100644 index 00000000..51ad9dfb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql @@ -0,0 +1,33 @@ +-- 异常处置新增编辑权限码 +-- 日期:2026-10-10 + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000002006,2090000000000002001,'exception_disposal_edit','编辑','exception_disposal_edit','','',5,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `name`=VALUES(`name`), + `path`=VALUES(`path`), + `sort`=VALUES(`sort`), + `is_deleted`=0; + +-- 授权给超级管理员角色。 +-- 不能按「持有 exception_disposal 菜单的角色」批量授权:司机角色同样持有该菜单, +-- 会被一并授予编辑权——而编辑是运管员才有的操作。 +-- 注意:运管角色当前并未持有 exception_disposal 菜单,故此处不涉及它; +-- 若日后把异常处置开放给运管,需另行授予本编辑码。 +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2090000000000002006 + (r.`id` % 1000), 2090000000000002006, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2090000000000002006 AND x.`role_id` = r.`id` + ); + +-- 幂等收口:本编辑码只应挂在超级管理员上,其余角色一律收回。 +-- 上面的 SELECT 是按授权规则下发,这条是按同一规则回收,二者合起来可重复执行。 +DELETE FROM `blade_role_menu` +WHERE `menu_id` = 2090000000000002006 + AND `role_id` NOT IN (SELECT `id` FROM `blade_role` WHERE `role_alias` = 'administrator');