♻️ 审核修订:补身份字段服务端校验并整理注释

修订通过时比对原提交的身份字段(司机身份证号/驾驶证号、车牌号),原提交为空时允许补全;抽出 preferBody 与修订解析方法,统一许可头与注释口径。
This commit is contained in:
2026-10-28 02:25:02 +08:00
parent bdce00c2ea
commit df3b21922d
6 changed files with 213 additions and 40 deletions
@@ -34,9 +34,7 @@ import java.io.Serializable;
/**
* 管理端认证审核请求
* <p>
* 这个接口原本只有 query 参数({@code id} / {@code pass} / {@code rejectReason}),
* 新增修订能力后改为「body 优先、query 兜底」:老前端不改代码仍然能用,
* 新前端把修订内容放进 body。两者同时给出时以 body 为准,避免审核员的修订被旧值覆盖。
* 同一字段 body 与 query 都可传,两处都给时以 body 为准。
*
* @author Chill
*/
@@ -49,6 +49,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.function.Function;
/**
* 认证审核(管理端)
* <p>
@@ -102,12 +104,11 @@ public class CertificationAuditController extends BladeController {
@Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id,
@Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass,
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
// 原地升级:老前端只发 query,新前端发 body。逐字段「body 优先、query 兜底」——
// 两者都给时以 body 为准,否则审核员在表单里改的内容会被 URL 上的旧值悄悄覆盖。
Long recordId = body != null && body.getId() != null ? body.getId() : id;
Boolean approved = body != null && body.getPass() != null ? body.getPass() : pass;
String reason = body != null && body.getRejectReason() != null ? body.getRejectReason() : rejectReason;
String revision = body == null ? null : body.getAuditedSnapshotJson();
// 同一字段 body 与 query 都可传,两处都给时以 body 为准。
Long recordId = preferBody(body, CertAuditRequest::getId, id);
Boolean approved = preferBody(body, CertAuditRequest::getPass, pass);
String reason = preferBody(body, CertAuditRequest::getRejectReason, rejectReason);
String revision = preferBody(body, CertAuditRequest::getAuditedSnapshotJson, null);
if (recordId == null) {
return R.fail("缺少认证记录ID");
}
@@ -125,4 +126,21 @@ public class CertificationAuditController extends BladeController {
return R.data(certAuditService.capability());
}
/**
* 取一个审核参数:body 里给了就用 body 的,否则用 query 的
*
* @param body 审核请求体,可为 null
* @param getter 从请求体取该字段
* @param queryValue 该字段的 query 参数值
* @param <T> 字段类型
* @return body 字段值;body 为 null 或该字段为 null 时返回 query 参数值
*/
private static <T> T preferBody(CertAuditRequest body, Function<CertAuditRequest, T> getter, T queryValue) {
if (body == null) {
return queryValue;
}
T bodyValue = getter.apply(body);
return bodyValue == null ? queryValue : bodyValue;
}
}
@@ -26,6 +26,7 @@
package org.springblade.transport.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -172,9 +173,13 @@ public class CertAuditServiceImpl implements ICertAuditService {
// 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕,
// 否则历史里会出现一条「已驳回却带着生效内容」的记录。
String revision = pass ? blankToNull(auditedSnapshotJson) : null;
// 校验放在写库之前——内容不可用或改动了身份字段就整体回滚,不能先落一条状态再报错。
if (revision != null) {
requireUsableRevision(record, revision);
requireIdentityUnchanged(record, revision);
}
// 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。
// 校验放在写库之前——格式不对就整体回滚,不能先落一条状态再报错。
String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : requireUsableRevision(record, revision);
String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : revision;
Long auditor = AuthUtil.getUserId();
LocalDateTime auditTime = LocalDateTime.now();
@@ -347,7 +352,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
}
/**
* 校验审核员填写的修订版内容,通过后原样返回(作为本次生效内容)
* 校验审核员填写的修订版内容,不满足时抛错
* <p>
* 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构,
* 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写:
@@ -358,33 +363,108 @@ public class CertAuditServiceImpl implements ICertAuditService {
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
* @return 同一个修订版内容
*/
private String requireUsableRevision(TransportCertRecord record, String revision) {
private void requireUsableRevision(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(revision, VehicleCertSubmitRequest.class);
} catch (Exception exception) {
throw new ServiceException("修订内容格式不正确,请检查后重试");
}
VehicleCertSubmitRequest snapshot = toSnapshot(parsed, VehicleCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg()))) {
throw new ServiceException("修订内容缺少行驶证号与核定载质量,无法写入车辆档案");
}
return revision;
}
PersonCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(revision, PersonCertSubmitRequest.class);
} catch (Exception exception) {
throw new ServiceException("修订内容格式不正确,请检查后重试");
return;
}
PersonCertSubmitRequest snapshot = toSnapshot(parsed, PersonCertSubmitRequest.class);
if (snapshot == null
|| (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) {
throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案");
}
return revision;
}
/**
* 身份字段不可修订:修订版改动了身份证号、驾驶证号(司机)或车牌号(车辆)时拒绝
* <p>
* 审核只认定证件内容,身份字段标识「是谁」,改了审核的就是另一个对象。
* 原提交里该字段为空时(旧数据、空快照)没有可比对的原值,不比对,允许补全。
*
* @param record 被审核的记录
* @param revision 已去空白的修订版内容
*/
private void requireIdentityUnchanged(TransportCertRecord record, String revision) {
JSONObject parsed = parseRevision(revision);
if (parsed == null) {
return;
}
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) {
VehicleCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), VehicleCertSubmitRequest.class);
if (original == null) {
return;
}
VehicleCertSubmitRequest revised = toSnapshot(parsed, VehicleCertSubmitRequest.class);
requireSameIdentity(original.getPlateNo(), revised.getPlateNo(), "修订内容不能修改车牌号");
return;
}
PersonCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), PersonCertSubmitRequest.class);
if (original == null) {
return;
}
String message = "修订内容不能修改身份证号与驾驶证号";
PersonCertSubmitRequest revised = toSnapshot(parsed, PersonCertSubmitRequest.class);
requireSameIdentity(original.getIdCardNo(), revised.getIdCardNo(), message);
requireSameIdentity(original.getDriverFileNo(), revised.getDriverFileNo(), message);
}
/**
* 原提交有值时,修订版必须与之一致;原提交为空时允许补全
*
* @param original 原提交里的身份字段值
* @param revised 修订版里的身份字段值
* @param message 不一致时抛出的文案
*/
private void requireSameIdentity(String original, String revised, String message) {
if (Func.isEmpty(original)) {
return;
}
if (!original.equals(revised)) {
throw new ServiceException(message);
}
}
/**
* 反序列化审核员填写的修订版 JSON
*
* @param revision 已去空白的修订版内容
* @return 解析结果;内容为字面量 null 时返回 null
*/
private JSONObject parseRevision(String revision) {
try {
return JSON.parseObject(revision);
} catch (Exception exception) {
throw new ServiceException("修订内容格式不正确,请检查后重试");
}
}
/**
* 解析原提交快照;为空或无法解析时返回 null(此时没有可比对的原值)
*
* @param snapshotJson 原提交内容
* @param clazz 目标结构
* @param <T> 提交结构类型
* @return 解析结果;为空或解析失败时返回 null
*/
private <T> T parseSnapshotQuietly(String snapshotJson, Class<T> clazz) {
if (Func.isEmpty(snapshotJson)) {
return null;
}
try {
return JSON.parseObject(snapshotJson, clazz);
} catch (Exception exception) {
return null;
}
}
private <T> T toSnapshot(JSONObject parsed, Class<T> clazz) {
return parsed == null ? null : parsed.toJavaObject(clazz);
}
/**
@@ -402,7 +482,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
* <p>
* 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**:
* 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行,
* 等于给一条空记录盖上已通过的章——状态与内容不一致正是这次改造要消灭的。
* 等于给一条空记录盖上已通过的章,状态与内容互相矛盾。
* 内容确实为空的历史记录,由审核员在审核时补全后重审即可。
* 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。
*
@@ -81,15 +81,6 @@ public final class CertificationRuleSupport {
return previousPending;
}
/**
* 判定认证对象是否已认证
* <p>
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
* 重新提交、待审、被驳回都不会让对象变回未认证。
*
* @param effectiveRecordId 生效快照指针
* @return 是否已认证
*/
/**
* 取一条记录的「当前生效内容」
* <p>
@@ -111,6 +102,15 @@ public final class CertificationRuleSupport {
: record.getSnapshotJson();
}
/**
* 判定认证对象是否已认证
* <p>
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
* 重新提交、待审、被驳回都不会让对象变回未认证。
*
* @param effectiveRecordId 生效快照指针
* @return 是否已认证
*/
public static boolean isCertified(Long effectiveRecordId) {
return Func.isNotEmpty(effectiveRecordId);
}
@@ -1,3 +1,28 @@
/**
* 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>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import org.junit.jupiter.api.BeforeEach;
@@ -22,8 +47,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* 认证审核接口的入参绑定测试。
* <p>
* 这个接口是「原地升级」的:老前端只发 query 参数,新前端发 JSON body 带修订内容。
* 两种调用都必须能用,且 body 一旦给了就以 body 为准——否则审核员的修订会被 query 里的旧值覆盖。
* 同一字段 body 与 query 都可传,两处都给时以 body 为准。
*/
class CertificationAuditEndpointTest {
@@ -699,6 +699,59 @@ class CertAuditServiceImplTest {
"生效一侧也修订版优先,否则审核页拿旧值做对比");
}
@Test
@DisplayName("身份不可修订:司机修订改了身份证号时拒绝,记录不被改动")
void approveRejectsDriverIdentityChange() {
when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING));
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\","
+ "\"driverFileNo\":\"450100888888\"}";
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, revision));
assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage());
verify(driverService, never()).updateById(any(Driver.class));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@Test
@DisplayName("身份不可修订:车辆修订改了车牌号时拒绝(车牌不参与写回,也不能被悄悄换掉)")
void approveRejectsVehicleIdentityChange() {
String snapshot = "{\"plateNo\":\"桂A32523\",\"drivingLicenseNo\":\"450100123456\","
+ "\"approvedLoadKg\":18000,\"ownerName\":\"广西现代物流集团有限公司\"}";
when(certRecordService.getById(RECORD_ID))
.thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING, snapshot));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
String revision = "{\"plateNo\":\"桂A99999\",\"drivingLicenseNo\":\"450100999999\","
+ "\"approvedLoadKg\":25000}";
ServiceException exception = assertThrows(ServiceException.class,
() -> service.audit(RECORD_ID, true, null, revision));
assertTrue(exception.getMessage().contains("车牌号"), exception.getMessage());
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
@Test
@DisplayName("身份不可修订:原提交该字段为空时允许补全,正常通过")
void approveAllowsCompletingEmptyIdentity() {
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
record.setSnapshotJson("{\"realName\":\"张三\"}");
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
String revision = "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\","
+ "\"driverFileNo\":\"450100888888\"}";
service.audit(RECORD_ID, true, null, revision);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
assertEquals("450100199001011234", captor.getValue().getIdCardNo(),
"原提交没有身份证号时修订版可以补上");
}
private static String vehicleRevision() {
return "{\"drivingLicenseNo\":\"450100999999\",\"approvedLoadKg\":25000,"
+ "\"ownerName\":\"修订后的所有人\"}";