✨ 异常处置新增编辑功能

运管员可订正已上报的异常内容:异常类型、异常原因、上报说明、现场照片。
车牌、项目、承运商、配载单号由上报时从运单回填,不开放编辑,避免与运单脱钩。

编辑不改动处置状态。状态只能经跟进与完成流转,因为运单详情、运单列表、
首页计数与待办四处都以 disposal_status IN (pending, processing) 判定未完成,
允许编辑改状态会让已结案的异常重新长出来,而那四处判定无从知晓。

每次编辑按字段逐项比对生成变更说明,写入一条跟进记录留痕。
变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超过跟进内容的
varchar(500) 上限,故按 500 截断。

Permission 表达式用 hasMenu('exception_disposal_edit'):
@PreAuth 的值由 SpEL 求值,root object 是 AuthFun,
写成裸字符串会被当作属性访问而在请求时抛错。
This commit is contained in:
2026-10-05 00:54:08 +08:00
parent e3d860668b
commit 8d35abd23e
7 changed files with 330 additions and 0 deletions
@@ -0,0 +1,63 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* 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;
/**
* 异常处置编辑请求
* <p>
* 只承载上报四件套。车牌、项目、承运商、配载单号等字段由上报时从运单回填,
* 不在此列,避免编辑后与运单脱钩。
* <p>
* 不含处置状态:状态只能经跟进与完成流转,编辑不得改变它。
*
* @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;
}
@@ -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("编辑成功");
}
}
@@ -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<ExceptionDisposal
*/
ExceptionDisposalVO submitReport(ExceptionDisposal request);
/**
* 编辑异常处置的上报内容。不改动处置状态,并按字段变更追加一条跟进记录
*/
void update(ExceptionDisposalUpdateRequest request);
void follow(ExceptionDisposalFollowRequest request);
void complete(Long id);
@@ -35,6 +35,7 @@ import org.springblade.system.cache.UserCache;
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
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.entity.ExceptionDisposalFollowRecord;
import org.springblade.transport.pojo.entity.Waybill;
@@ -47,6 +48,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -68,6 +70,9 @@ public class ExceptionDisposalServiceImpl
private static final String STATUS_PROCESSING = "processing";
private static final String STATUS_COMPLETED = "completed";
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
private final IWaybillService waybillService;
@@ -165,6 +170,97 @@ public class ExceptionDisposalServiceImpl
disposal.setLoadingOrMasterNo(loadingOrMaster);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(ExceptionDisposalUpdateRequest request) {
if (request == null || request.getId() == null) {
throw new ServiceException("请选择需要编辑的异常");
}
if (Func.isBlank(request.getExceptionType())) {
throw new ServiceException("请选择异常类型");
}
if (Func.isBlank(request.getReportDescription())) {
throw new ServiceException("请填写上报说明");
}
if (request.getReportDescription().length() > 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,调用方据此拒绝这次编辑。
* <p>
* 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制,
* 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。
*/
private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason,
String reportDescription, String scenePhotos) {
List<String> 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<String> 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) {
@@ -0,0 +1,47 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
/**
* 异常处置的权限表达式
* <p>
* 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。
* <p>
* 表达式由框架交给 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() {
}
}
@@ -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;
/**
* 异常处置权限表达式的可求值性测试。
* <p>
* 表达式写在 {@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));
}
}
@@ -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');