From a52759678a9e3299cb3d8eb3fa9fc301ae2cbfad Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 01:24:04 +0800 Subject: [PATCH 1/2] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=96=B0=E5=A2=9E=E4=BF=AE=E8=AE=A2=E7=89=88=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核员修订提交内容后需要留痕:snapshot_json 记「司机提交了什么」且永不修改, 修订版另存 audited_snapshot_json,「通过时认定了什么内容」才有据可查。 - 新增列 audited_snapshot_json longtext NULL,位置在 snapshot_json 之后,不进任何索引 - 幂等:查 information_schema 判列,列已存在即跳过;表不存在时故意报 1146 防假绿 - 同步基线建表脚本,避免「迁移加列、基线没同步」 --- .../blade_transport_cert_record_20261002.sql | 1 + ...rt_cert_record_audit_revision_20261028.sql | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index 8e9b8870..4d14057a 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` ( `biz_key` varchar(100) DEFAULT NULL COMMENT '展示键:司机姓名/车牌号', `status` int(11) NOT NULL COMMENT '审核状态:0审核中 1已通过 2已驳回 3已作废', `snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '该次提交的全量认证内容JSON,一经创建不可修改', + `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核通过时确定的修订版内容;NULL=未修订', `reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值', `submit_time` datetime DEFAULT NULL COMMENT '提交时间', `submit_user` bigint(20) DEFAULT NULL COMMENT '提交人', diff --git a/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql b/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql new file mode 100644 index 00000000..786742ef --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql @@ -0,0 +1,68 @@ +-- ---------------------------- +-- 认证记录:新增修订版快照列 audited_snapshot_json(2026-10-28) +-- ---------------------------- +-- 背景:审核员可以在审核页面对提交内容做修订(全字段可改,身份字段除外)。 +-- 修订结果必须留痕:snapshot_json 是「司机当时提交了什么」,一经创建逐字节不可改; +-- 修订版另存一列,「审核通过时到底认定了什么内容」才查得清。 +-- +-- 语义: +-- NULL —— 未修订,生效内容即 snapshot_json; +-- 非 NULL —— 审核通过时认定的修订版全量 JSON,生效内容以它为准。 +-- 只在「通过」时同事务写入;驳回不写(驳回不产生生效内容,留修订版只会让人误读)。 +-- 修订人/修订时间复用 audit_user / audit_time,不新增列。 +-- +-- 只做结构变更,不回填数据:存量记录一律保持 NULL(历史审核确实没有修订动作)。 +-- +-- 不加索引:本列只在审核详情与生效内容读取时按主键取用,从不作为查询条件; +-- longtext 建索引会超出索引键长度并拖慢写入。 +-- +-- 幂等:先查 information_schema 判列,列已存在(或类型/可空性已符合)即跳过; +-- 只用 PREPARE 动态语句、不用存储过程,因此不需要 DELIMITER——按分号逐句切分的 +-- 客户端(含数据库驱动)也能直接执行。重复执行零变更。 + +SET @cert_record_schema = DATABASE(); + +-- ---------------------------- +-- 0. 表存在性守卫 +-- ---------------------------- +-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」; +-- 否则下面因查不到列而静默跳过,脚本会假绿。 +SET @cert_record_guard_sql = ( + SELECT IF(COUNT(*) > 0, + 'SELECT 1', + 'SELECT 1 FROM `blade_transport_cert_record` LIMIT 0') + FROM information_schema.tables + WHERE table_schema = @cert_record_schema + AND table_name = 'blade_transport_cert_record' +); +PREPARE cert_record_guard_stmt FROM @cert_record_guard_sql; +EXECUTE cert_record_guard_stmt; +DEALLOCATE PREPARE cert_record_guard_stmt; + +-- ---------------------------- +-- 1. 加列 audited_snapshot_json +-- ---------------------------- +-- 位置紧接 snapshot_json:两列是同一份内容的前后两态,放一起便于对照。 +-- 条件分三种:列不存在则 ADD;列存在但类型不是 longtext 或可空性不是 YES 则 MODIFY +-- (防止历史上的手工变更把列建成 varchar 而截断 JSON);其余情况跳过。 +SET @cert_record_audited_snapshot_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_cert_record` ADD COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订'' AFTER `snapshot_json`', + IF(SUM(data_type <> 'longtext' OR is_nullable <> 'YES') > 0, + 'ALTER TABLE `blade_transport_cert_record` MODIFY COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订''', + 'SELECT 1')) + FROM information_schema.columns + WHERE table_schema = @cert_record_schema + AND table_name = 'blade_transport_cert_record' + AND column_name = 'audited_snapshot_json' +); +PREPARE cert_record_audited_snapshot_stmt FROM @cert_record_audited_snapshot_sql; +EXECUTE cert_record_audited_snapshot_stmt; +DEALLOCATE PREPARE cert_record_audited_snapshot_stmt; + +-- ---------------------------- +-- 2. 回滚 +-- ---------------------------- +-- 只新增了一列、未进任何索引,回滚即删该列; +-- snapshot_json 的 updateStrategy=NEVER 与生成列 pending_biz_id、唯一键 uk_cert_record_pending 均不受影响: +-- ALTER TABLE `blade_transport_cert_record` DROP COLUMN `audited_snapshot_json`; From a06b2ead160df6276f990018b4b671373020af5c Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 01:36:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=E7=89=88=E5=86=85=E5=AE=B9=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CertAuditRequest,audit 接口原地升级为 body 优先、query 兜底,缺 id/pass 返回 400 - 通过时同事务写入 audited_snapshot_json(驳回忽略修订);修订内容先校验可反序列化与关键字段再落库 - 写回档案与司机端「当前生效内容」改为修订版优先,为空回退 snapshot_json - 读不懂的历史快照不再静默跳过,改为抛错回滚,杜绝空内容被盖章通过 - 审核详情下发 auditedSnapshotJson;司机端 VO 增加 revised 标记,快照字段不随 VO 下发 --- .../transport/pojo/dto/CertAuditRequest.java | 64 +++++ .../pojo/entity/TransportCertRecord.java | 14 ++ .../transport/pojo/vo/CertAuditDetailVO.java | 7 + .../pojo/vo/TransportCertRecordVO.java | 8 + .../CertificationAuditController.java | 24 +- .../transport/service/ICertAuditService.java | 9 +- .../service/impl/CertAuditServiceImpl.java | 94 +++++-- .../service/impl/DriverCertServiceImpl.java | 6 +- .../support/CertificationRuleSupport.java | 21 ++ .../wrapper/TransportCertRecordWrapper.java | 3 + .../CertificationAuditEndpointTest.java | 119 +++++++++ .../impl/CertAuditServiceImplTest.java | 234 ++++++++++++++++-- .../impl/DriverCertServiceImplTest.java | 50 ++++ .../service/impl/PersonCertRoundTripTest.java | 44 ++-- ...ransportCertRecordWrapperRevisionTest.java | 86 +++++++ 15 files changed, 711 insertions(+), 72 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java new file mode 100644 index 00000000..e34302f6 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java @@ -0,0 +1,64 @@ +/** + * 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.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 管理端认证审核请求 + *

+ * 这个接口原本只有 query 参数({@code id} / {@code pass} / {@code rejectReason}), + * 新增修订能力后改为「body 优先、query 兜底」:老前端不改代码仍然能用, + * 新前端把修订内容放进 body。两者同时给出时以 body 为准,避免审核员的修订被旧值覆盖。 + * + * @author Chill + */ +@Data +@Schema(description = "管理端认证审核请求") +public class CertAuditRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "认证记录主键") + private Long id; + @Schema(description = "true 通过,false 驳回") + private Boolean pass; + @Schema(description = "驳回原因;驳回时必填,≤200 字") + private String rejectReason; + /** + * 审核员修订后的全量认证内容(JSON);空或空白表示不修订 + *

+ * 通过时才生效:它既是写回档案的依据,也会原样留痕到认证记录上。 + */ + @Schema(description = "审核员修订后的全量认证内容JSON;空表示不修订") + private String auditedSnapshotJson; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java index 4f1346f4..bfed3f33 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -28,6 +28,7 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; @@ -125,6 +126,19 @@ public class TransportCertRecord extends TenantEntity { @TableField(updateStrategy = FieldStrategy.NEVER) @Schema(description = "该次提交的全量认证内容JSON") private String snapshotJson; + /** + * 审核通过时确定的修订版内容(JSON) + *

+ * 审核员可以在通过时直接改内容:司机提交的是 {@code snapshotJson}(永不可改), + * 最终写回档案、司机端读到的「当前生效内容」以本字段优先,为 null 才回退到 + * {@code snapshotJson}。为 null 即「审核员没有修订」。 + *

+ * {@code @JsonIgnore}:司机端的 {@code TransportCertRecordVO} 继承本实体, + * 修订版是审核过程的留痕,不该下发给司机。 + */ + @JsonIgnore + @Schema(description = "审核通过时确定的修订版内容JSON;null 表示未修订") + private String auditedSnapshotJson; /** * 驳回原因,仅当审核状态为已驳回时有值 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java index 212eb072..672a7826 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -81,6 +81,13 @@ public class CertAuditDetailVO implements Serializable { @Schema(description = "待审快照:本次提交的全量内容") private String pendingSnapshotJson; + /** + * 审核员在本次审核中填写的修订版内容;未修订时为 null + *

+ * 只在「通过」时才会落库,驳回不产生生效内容。审核页据此回显审核员改了什么。 + */ + @Schema(description = "本次审核提交的修订版内容;未修订时为 null") + private String auditedSnapshotJson; @Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null") private String effectiveSnapshotJson; @Schema(description = "生效快照记录ID;null 表示该对象从未认证通过") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java index c4bf8a40..0983df03 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java @@ -96,5 +96,13 @@ public class TransportCertRecordVO extends TransportCertRecord { @TableField(exist = false) @Schema(description = "司机手机号") private String driverMobile; + /** + * 该条记录是否经过审核员修订 + *

+ * 由服务层按 {@code auditedSnapshotJson} 是否有内容算出,前端据此打「已修订」标记。 + */ + @TableField(exist = false) + @Schema(description = "是否经过审核员修订") + private Boolean revised; } 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 6e6a024f..aed2c2da 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 @@ -36,6 +36,7 @@ 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.CertAuditRequest; import org.springblade.transport.pojo.vo.CertAuditCapabilityVO; import org.springblade.transport.pojo.vo.CertAuditDetailVO; import org.springblade.transport.pojo.vo.TransportCertRecordVO; @@ -43,6 +44,7 @@ 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -92,13 +94,27 @@ public class CertificationAuditController extends BladeController { @PostMapping("/audit") @ApiOperationSupport(order = 3) - @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变") + @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变。通过时可携带修订后的全量认证内容,它将成为新的生效内容并原样留痕") @PreAuth(CertAuditPermissions.AUDIT_ACTION) public R audit( - @Parameter(description = "认证记录主键", required = true) @RequestParam Long id, - @Parameter(description = "是否通过", required = true) @RequestParam Boolean pass, + @Parameter(description = "审核请求体(推荐):id、pass、rejectReason、auditedSnapshotJson") + @RequestBody(required = false) CertAuditRequest body, + @Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id, + @Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass, @Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) { - return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), 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(); + if (recordId == null) { + return R.fail("缺少认证记录ID"); + } + if (approved == null) { + return R.fail("缺少审核结果"); + } + return R.status(certAuditService.audit(recordId, Boolean.TRUE.equals(approved), reason, revision)); } // 本接口不挂 @PreAuth;无权限时返回 false。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java index bdfd3327..fdba48f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java @@ -74,12 +74,13 @@ public interface ICertAuditService { /** * 审核一条认证记录 * - * @param recordId 认证记录主键 - * @param pass true 通过,false 驳回 - * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @param recordId 认证记录主键 + * @param pass true 通过,false 驳回 + * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @param auditedSnapshotJson 审核员修订后的全量认证内容;空或空白表示不修订,驳回时忽略 * @return 是否成功 */ - boolean audit(Long recordId, boolean pass, String rejectReason); + boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson); /** * 当前登录用户的审核能力:能否查看、能否审核 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index a35ee3a3..b6a3a8fe 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -138,20 +138,24 @@ public class CertAuditServiceImpl implements ICertAuditService { detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); detail.setRejectReason(record.getRejectReason()); detail.setPendingSnapshotJson(record.getSnapshotJson()); + // 审核员上次填过的修订版:详情页要能回显,否则重开页面就丢了 + detail.setAuditedSnapshotJson(record.getAuditedSnapshotJson()); // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 detail.setEffectiveRecordId(effectiveId); detail.setCertified(CertificationRuleSupport.isCertified(effectiveId)); if (Func.isNotEmpty(effectiveId)) { TransportCertRecord effective = certRecordService.getById(effectiveId); - detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson()); + // 生效一侧同样修订版优先:审核页是拿「新提交」和「当前生效内容」做 diff 的, + // 这里取旧值会让审核员看到一份从未生效过的差异 + detail.setEffectiveSnapshotJson(CertificationRuleSupport.effectiveSnapshotJson(effective)); } return detail; } @Override @Transactional(rollbackFor = Exception.class) - public boolean audit(Long recordId, boolean pass, String rejectReason) { + public boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson) { TransportCertRecord record = requireRecord(recordId); // 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」, @@ -165,6 +169,13 @@ public class CertAuditServiceImpl implements ICertAuditService { + TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)"); } + // 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕, + // 否则历史里会出现一条「已驳回却带着生效内容」的记录。 + String revision = pass ? blankToNull(auditedSnapshotJson) : null; + // 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。 + // 校验放在写库之前——格式不对就整体回滚,不能先落一条状态再报错。 + String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : requireUsableRevision(record, revision); + Long auditor = AuthUtil.getUserId(); LocalDateTime auditTime = LocalDateTime.now(); @@ -177,11 +188,13 @@ public class CertAuditServiceImpl implements ICertAuditService { : CertificationRuleSupport.reasonForReject(rejectReason)); patch.setAuditUser(auditor); patch.setAuditTime(auditTime); + // 未修订时留 null:框架的 not_null 更新策略会跳过该列,库里保持 NULL 而不是空串 + patch.setAuditedSnapshotJson(revision); certRecordService.updateById(patch); // 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE, // 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。 - updateCertTarget(record, pass, rejectReason); + updateCertTarget(record, pass, rejectReason, effectiveSnapshotJson); return true; } @@ -212,11 +225,12 @@ public class CertAuditServiceImpl implements ICertAuditService { * * 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。 * - * @param record 被审核的记录 - * @param pass 是否通过 - * @param rejectReason 驳回原因 + * @param record 被审核的记录 + * @param pass 是否通过 + * @param rejectReason 驳回原因 + * @param effectiveSnapshotJson 本次生效内容(修订版优先,否则司机原提交) */ - private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { + private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason, String effectiveSnapshotJson) { boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 @@ -232,7 +246,7 @@ public class CertAuditServiceImpl implements ICertAuditService { // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 // 与指针合并成同一次 UPDATE。 - applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + applySnapshotToVehicle(vehicle, effectiveSnapshotJson); } else { vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } @@ -255,7 +269,7 @@ public class CertAuditServiceImpl implements ICertAuditService { // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 // 与指针合并成同一次 UPDATE。 - applySnapshotToDriver(driver, record.getSnapshotJson()); + applySnapshotToDriver(driver, effectiveSnapshotJson); } else { driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } @@ -332,6 +346,47 @@ public class CertAuditServiceImpl implements ICertAuditService { } } + /** + * 校验审核员填写的修订版内容,通过后原样返回(作为本次生效内容) + *

+ * 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构, + * 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写: + * 司机看身份证号与驾驶证号,车辆看行驶证号与核定载质量。 + *

+ * 不满足时抛错、整体回滚——审核员填错 JSON 是操作失误,不能悄悄降级成「不修订」, + * 那样他会以为自己的修订已经生效。 + * + * @param record 被审核的记录 + * @param revision 已去空白的修订版内容 + * @return 同一个修订版内容 + */ + private String requireUsableRevision(TransportCertRecord record, String revision) { + if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) { + VehicleCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(revision, VehicleCertSubmitRequest.class); + } catch (Exception exception) { + throw new ServiceException("修订内容格式不正确,请检查后重试"); + } + 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("修订内容格式不正确,请检查后重试"); + } + if (snapshot == null + || (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) { + throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案"); + } + return revision; + } + /** * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) *

@@ -345,33 +400,34 @@ public class CertAuditServiceImpl implements ICertAuditService { * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), * 因此不会把管理端填过、司机端没填的字段清空。 *

- * 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: - * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; - * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 - * 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号, - * 必须阻断。 + * 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**: + * 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行, + * 等于给一条空记录盖上已通过的章——状态与内容不一致正是这次改造要消灭的。 + * 内容确实为空的历史记录,由审核员在审核时补全后重审即可。 + * 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。 * * @param driver 待更新的司机 patch * @param snapshotJson 该条记录的全量提交内容 */ private void applySnapshotToDriver(Driver driver, String snapshotJson) { if (Func.isEmpty(snapshotJson)) { - return; + throw new ServiceException("该认证记录没有提交内容,无法写入司机档案;请让司机重新提交一次认证"); } PersonCertSubmitRequest snapshot; try { snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class); } catch (Exception exception) { - return; + throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证"); } if (snapshot == null) { - return; + throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证"); } // 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况), // 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。 - // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。 + // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就拒绝。 if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { - return; + throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照)," + + "未写入司机档案;请重新提交一次认证"); } // 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库, // 建档查重与按证件号识别同一人都会失效 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index 703119df..82e05905 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -209,9 +209,11 @@ public class DriverCertServiceImpl implements IDriverCertService { Driver driver = requireCurrentDriver(); // 当前司机实体已加载过,直接读它身上的指针,不再回查一次 TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId()); + // 生效内容以修订版为准:审核员改过的值才是档案里真正生效的那份, + // 仍读 snapshot_json 会让司机端拿旧值覆盖审核员的改动 return effective == null ? null - : JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class); + : JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), PersonCertSubmitRequest.class); } @Override @@ -272,7 +274,7 @@ public class DriverCertServiceImpl implements IDriverCertService { TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId()); return effective == null ? null - : JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class); + : JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), VehicleCertSubmitRequest.class); } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java index 7fc8ce28..b6831191 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -90,6 +90,27 @@ public final class CertificationRuleSupport { * @param effectiveRecordId 生效快照指针 * @return 是否已认证 */ + /** + * 取一条记录的「当前生效内容」 + *

+ * 审核员可以在通过时修订内容:修订版存在则以修订版为准,否则回退到司机原提交 + * ({@code snapshot_json} 永不修改,它是司机当时提交了什么的历史证据)。 + *

+ * 写回档案、司机端读生效值、审核详情对比三处必须用同一套取法, + * 否则会出现「档案是新值、司机端还是旧值」这种自相矛盾。 + * + * @param record 认证记录,可为 null + * @return 生效内容 JSON;记录为空、或两者都为空时返回 null + */ + public static String effectiveSnapshotJson(TransportCertRecord record) { + if (record == null) { + return null; + } + return Func.isNotBlank(record.getAuditedSnapshotJson()) + ? record.getAuditedSnapshotJson() + : record.getSnapshotJson(); + } + public static boolean isCertified(Long effectiveRecordId) { return Func.isNotEmpty(effectiveRecordId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java index f6a8d7e2..fdd8994d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java @@ -27,6 +27,7 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.TransportCertRecord; @@ -70,6 +71,8 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper + * 这个接口是「原地升级」的:老前端只发 query 参数,新前端发 JSON body 带修订内容。 + * 两种调用都必须能用,且 body 一旦给了就以 body 为准——否则审核员的修订会被 query 里的旧值覆盖。 + */ +class CertificationAuditEndpointTest { + + private static final Long RECORD_ID = 5001L; + private static final String REVISION = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"}"; + + private ICertAuditService certAuditService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + certAuditService = mock(ICertAuditService.class); + when(certAuditService.audit(any(), anyBoolean(), any(), any())).thenReturn(true); + mockMvc = MockMvcBuilders.standaloneSetup(new CertificationAuditController(certAuditService)).build(); + } + + @Test + @DisplayName("老调用:只带 query 的 id/pass/rejectReason,行为不变") + void legacyQueryOnlyStillWorks() throws Exception { + mockMvc.perform(post("/certification-audit/audit") + .param("id", String.valueOf(RECORD_ID)) + .param("pass", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, null); + } + + @Test + @DisplayName("新调用:body 携带修订内容,透传到服务层") + void bodyCarriesRevision() throws Exception { + String body = """ + {"id": 5001, "pass": true, "auditedSnapshotJson": "{\\"realName\\":\\"李四\\",\\"idCardNo\\":\\"450100199001019999\\"}"} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, REVISION); + } + + @Test + @DisplayName("两种都带:以 body 为准(query 里的旧值不得覆盖修订)") + void bodyWinsOverQuery() throws Exception { + String body = """ + {"id": 5001, "pass": true} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .param("id", "6001") + .param("pass", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, null); + } + + @Test + @DisplayName("body 只给一半时,缺的字段回退到 query") + void partialBodyFallsBackToQuery() throws Exception { + String body = """ + {"id": 5001, "pass": false} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .param("rejectReason", "行驶证模糊")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, false, "行驶证模糊", null); + } + + @Test + @DisplayName("缺少 id 或 pass:按业务失败返回(code=400),不能把 null 传进服务层") + void missingIdOrPassIsRejected() throws Exception { + mockMvc.perform(post("/certification-audit/audit").param("pass", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(post("/certification-audit/audit").param("id", String.valueOf(RECORD_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)); + + verify(certAuditService, never()).audit(anyLong(), anyBoolean(), any(), any()); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index fa461766..a38af5d7 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -96,7 +96,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); verify(certRecordService).updateById(recordCaptor.capture()); @@ -118,7 +118,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -141,7 +141,7 @@ class CertAuditServiceImplTest { when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage()); verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); } @@ -154,7 +154,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -169,7 +169,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -183,7 +183,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); verify(driverService).updateById(captor.capture()); @@ -199,7 +199,7 @@ class CertAuditServiceImplTest { existing.setDrivingVehicle("桂A12345,桂B67890"); when(driverService.getById(DRIVER_ID)).thenReturn(existing); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); verify(driverService).updateById(captor.capture()); @@ -216,7 +216,7 @@ class CertAuditServiceImplTest { when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("未生效"), exception.getMessage()); } @@ -228,7 +228,7 @@ class CertAuditServiceImplTest { when(driverService.updateById(any(Driver.class))).thenReturn(false); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("司机"), exception.getMessage()); } @@ -242,7 +242,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); - service.audit(RECORD_ID, false, "证件照片模糊"); + service.audit(RECORD_ID, false, "证件照片模糊", null); ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); verify(certRecordService).updateById(recordCaptor.capture()); @@ -264,7 +264,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); - service.audit(RECORD_ID, false, "运输证过期"); + service.audit(RECORD_ID, false, "运输证过期", null); // 指针字段没有被写成 null,也没有被写成新记录 ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); @@ -277,8 +277,8 @@ class CertAuditServiceImplTest { void rejectRequiresReason() { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null)); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ")); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ", null)); verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); } @@ -288,7 +288,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, false, "长".repeat(201))); + () -> service.audit(RECORD_ID, false, "长".repeat(201), null)); } // ------------------------------------------------------------ 终态保护 @@ -303,9 +303,9 @@ class CertAuditServiceImplTest { TransportCertRecord record = vehicleRecord(terminal); when(certRecordService.getById(RECORD_ID)).thenReturn(record); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null), + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null), "状态 " + terminal + " 属终态,不应可再审"); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"), + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回", null), "状态 " + terminal + " 属终态,不应可再审"); } verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); @@ -315,13 +315,13 @@ class CertAuditServiceImplTest { @DisplayName("记录不存在:拒绝") void recordNotFound() { when(certRecordService.getById(RECORD_ID)).thenReturn(null); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null)); } @Test @DisplayName("记录主键为空:拒绝") void recordIdRequired() { - assertThrows(ServiceException.class, () -> service.audit(null, true, null)); + assertThrows(ServiceException.class, () -> service.audit(null, true, null, null)); } // ------------------------------------------------------------ 审核详情(diff 用) @@ -470,7 +470,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(transportVehicleService).update(captor.capture()); @@ -494,7 +494,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(driverService).update(captor.capture()); @@ -518,7 +518,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); - service.audit(RECORD_ID, false, " 行驶证模糊 "); + service.audit(RECORD_ID, false, " 行驶证模糊 ", null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -527,6 +527,183 @@ class CertAuditServiceImplTest { verify(transportVehicleService, never()).update(any()); } + // ------------------------------------------------------------ 修订版 + + @Test + @DisplayName("修订版:通过时写入新列,且 snapshot_json 一个字都不改") + void approveStoresAuditedSnapshot() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + String revision = vehicleRevision(); + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertEquals(revision, captor.getValue().getAuditedSnapshotJson(), + "修订版必须原样留痕,供事后追责"); + assertNull(captor.getValue().getSnapshotJson(), + "snapshot_json 是司机当时提交的内容,审核 patch 里不得出现"); + } + + @Test + @DisplayName("修订版:写回车辆档案的是修订内容,不是司机原提交") + void approveAppliesRevisionToVehicleProfile() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + String revision = vehicleRevision(); + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("修订后的所有人", captor.getValue().getOwnerName(), "档案要落修订后的内容"); + assertEquals(25000, captor.getValue().getApprovedLoadKg()); + assertEquals("450100999999", captor.getValue().getDrivingLicenseNo()); + } + + @Test + @DisplayName("修订版:司机档案同样以修订内容写回") + void approveAppliesRevisionToDriverProfile() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"," + + "\"driverFileNo\":\"450100888899\"}"; + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertEquals("李四", captor.getValue().getDriverName()); + assertEquals("450100199001019999", captor.getValue().getIdCardNo()); + assertEquals("450100888899", captor.getValue().getDrivingLicenseNo()); + } + + @Test + @DisplayName("修订版:未提供时不写新列,生效内容仍是司机原提交") + void approveWithoutRevisionLeavesColumnNull() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null, null); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertNull(recordCaptor.getValue().getAuditedSnapshotJson(), + "未修订时该列保持 NULL,不能塞一份复制品进来"); + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName()); + } + + @Test + @DisplayName("修订版:纯空白等于未修订(前端清空输入框就是这种情况)") + void blankRevisionIsTreatedAsAbsent() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null, " "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertNull(captor.getValue().getAuditedSnapshotJson()); + } + + @Test + @DisplayName("修订版:JSON 无法解析时拒绝,且记录与档案都不写(事务回滚)") + void approveRejectsMalformedRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{不是JSON")); + assertTrue(exception.getMessage().contains("修订内容格式不正确"), exception.getMessage()); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("修订版:司机缺身份证号与驾驶证号时拒绝(写回等于什么也没写)") + void approveRejectsIncompleteDriverRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{\"realName\":\"李四\"}")); + assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage()); + verify(driverService, never()).updateById(any(Driver.class)); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("修订版:车辆缺行驶证号与核定载质量时拒绝") + void approveRejectsIncompleteVehicleRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{\"ownerName\":\"只改了个名字\"}")); + assertTrue(exception.getMessage().contains("核定载质量"), exception.getMessage()); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("驳回:修订内容完全忽略,新列保持 NULL") + void rejectIgnoresRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, false, "行驶证模糊", vehicleRevision()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertNull(captor.getValue().getAuditedSnapshotJson(), + "驳回不产生生效内容,留修订版只会让人误读"); + assertEquals(TransportCertRecord.STATUS_REJECTED, captor.getValue().getStatus()); + } + + @Test + @DisplayName("空快照不再静默通过:司机记录没有内容、也没有修订时拒绝") + void approveRejectsEmptyDriverSnapshot() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson(null); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, null)); + assertTrue(exception.getMessage().contains("司机档案"), exception.getMessage()); + verify(driverService, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("审核详情:修订版内容随详情下发,生效一侧同样修订版优先") + void detailExposesAuditedSnapshot() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setAuditedSnapshotJson("{\"vin\":\"REVISED\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + TransportCertRecord effective = new TransportCertRecord(); + effective.setId(777L); + effective.setSnapshotJson("{\"vin\":\"OLD\"}"); + effective.setAuditedSnapshotJson("{\"vin\":\"OLD-REVISED\"}"); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + when(certRecordService.getById(777L)).thenReturn(effective); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"REVISED\"}", detail.getAuditedSnapshotJson()); + assertEquals("{\"vin\":\"OLD-REVISED\"}", detail.getEffectiveSnapshotJson(), + "生效一侧也修订版优先,否则审核页拿旧值做对比"); + } + + private static String vehicleRevision() { + return "{\"drivingLicenseNo\":\"450100999999\",\"approvedLoadKg\":25000," + + "\"ownerName\":\"修订后的所有人\"}"; + } + // ------------------------------------------------------------ 辅助 private TransportCertRecord vehicleRecord(Integer status) { @@ -556,6 +733,19 @@ class CertAuditServiceImplTest { + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; } + /** + * 一份字段齐全的司机认证快照 + *

+ * 司机侧的空快照不再「静默跳过」而是拒审,故通过类用例不能再拿 "{}" 走流程; + * 身份证号与驾驶证号是「这份快照是否为当前格式」的判据,缺一不可。 + */ + static String completeDriverSnapshot() { + return "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\"," + + "\"driverFileNo\":\"450100888888\",\"driverClass\":\"A2\"," + + "\"headPhoto\":\"http://oss/head.jpg\",\"idCardFrontUrl\":\"http://oss/idcard-front.jpg\"," + + "\"idCardBackUrl\":\"http://oss/idcard-back.jpg\"}"; + } + private TransportCertRecord driverRecord(Integer status) { TransportCertRecord record = new TransportCertRecord(); record.setId(RECORD_ID); @@ -563,7 +753,7 @@ class CertAuditServiceImplTest { record.setBizId(DRIVER_ID); record.setBizKey("张三"); record.setStatus(status); - record.setSnapshotJson("{}"); + record.setSnapshotJson(completeDriverSnapshot()); return record; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 7e99a3e5..10b8e190 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -358,6 +358,56 @@ class DriverCertServiceImplTest { assertNull(service.getEffectivePersonCert()); } + @Test + @DisplayName("读生效值:有修订版时返回修订版内容(司机端不能拿旧值覆盖审核员的改动)") + void getEffectivePersonCertPrefersRevision() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(certRecordService.getById(effectiveId)).thenReturn(pointedRecord(effectiveId, + "司机原提交", "审核员修订")); + + PersonCertSubmitRequest result = service.getEffectivePersonCert(); + + assertEquals("审核员修订", result.getRealName()); + } + + @Test + @DisplayName("读生效值:车辆同理,修订版优先") + void getEffectiveVehicleCertPrefersRevision() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + when(certRecordService.getById(777L)).thenReturn(pointedVehicleRecord(777L, + "司机原提交", "审核员修订")); + + VehicleCertSubmitRequest result = service.getEffectiveVehicleCert(VEHICLE_ID); + + assertEquals("审核员修订", result.getOwnerName()); + } + + private TransportCertRecord pointedRecord(Long id, String submittedName, String revisedName) { + PersonCertSubmitRequest submitted = new PersonCertSubmitRequest(); + submitted.setRealName(submittedName); + PersonCertSubmitRequest revised = new PersonCertSubmitRequest(); + revised.setRealName(revisedName); + TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED); + record.setSnapshotJson(JSON.toJSONString(submitted)); + record.setAuditedSnapshotJson(JSON.toJSONString(revised)); + return record; + } + + private TransportCertRecord pointedVehicleRecord(Long id, String submittedName, String revisedName) { + VehicleCertSubmitRequest submitted = new VehicleCertSubmitRequest(); + submitted.setOwnerName(submittedName); + VehicleCertSubmitRequest revised = new VehicleCertSubmitRequest(); + revised.setOwnerName(revisedName); + TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED); + record.setSnapshotJson(JSON.toJSONString(submitted)); + record.setAuditedSnapshotJson(JSON.toJSONString(revised)); + return record; + } + @Test @DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)") void resubmitKeepsEffectiveValue() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index 0fee6807..d6e3c229 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -108,7 +108,7 @@ class PersonCertRoundTripTest { void submitThenApprove() { // 1) 先走一遍完整流程,让该司机有一个生效值 Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(firstRecordId, true, null); + certAuditService.audit(firstRecordId, true, null, null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "审核通过后应能查到生效值"); @@ -120,7 +120,7 @@ class PersonCertRoundTripTest { "指针应仍指向原来那条已通过记录"); // 3) 审核通过:生效值才变成新值 - certAuditService.audit(secondRecordId, true, null); + certAuditService.audit(secondRecordId, true, null, null); assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(), "审核通过后生效值应切到新记录"); assertEquals(secondRecordId, driver.getEffectiveCertRecordId()); @@ -130,10 +130,10 @@ class PersonCertRoundTripTest { @DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)") void rejectKeepsEffectiveValue() { Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(firstRecordId, true, null); + certAuditService.audit(firstRecordId, true, null, null); Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); - certAuditService.audit(secondRecordId, false, "证件照片模糊"); + certAuditService.audit(secondRecordId, false, "证件照片模糊", null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "驳回只终结该次提交,既得认证必须保持有效"); @@ -149,7 +149,7 @@ class PersonCertRoundTripTest { assertNull(driverCertService.getEffectivePersonCert(), "待审期间不应有生效值"); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName()); } @@ -171,18 +171,18 @@ class PersonCertRoundTripTest { // 被作废的那条不能再被审核(否则会把已作废的记录变成生效值) org.junit.jupiter.api.Assertions.assertThrows( org.springblade.core.log.exception.ServiceException.class, - () -> certAuditService.audit(first, true, null)); + () -> certAuditService.audit(first, true, null, null)); } @Test @DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)") void approvedRecordCannotBeReaudited() { Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); org.junit.jupiter.api.Assertions.assertThrows( org.springblade.core.log.exception.ServiceException.class, - () -> certAuditService.audit(recordId, false, "事后反悔")); + () -> certAuditService.audit(recordId, false, "事后反悔", null)); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "重复审核被拒后,生效值不受影响"); } @@ -314,7 +314,7 @@ class PersonCertRoundTripTest { request.setQualificationBack("http://example.com/q-back.jpg"); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 档案里必须是这次提交的内容:只切指针、不写内容的话, // 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的 @@ -342,7 +342,7 @@ class PersonCertRoundTripTest { request.setAddressRegion(" "); request.setAddress(""); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉 assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(), @@ -358,7 +358,7 @@ class PersonCertRoundTripTest { driver.setIdCardNo("110101199001011234"); Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 审核通过即认可这次申报:档案里必须也是申报的姓名, // 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字 @@ -377,7 +377,7 @@ class PersonCertRoundTripTest { Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999")); ServiceException exception = assertThrows(ServiceException.class, - () -> certAuditService.audit(recordId, true, null)); + () -> certAuditService.audit(recordId, true, null, null)); assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage()); // 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审); // 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来 @@ -392,25 +392,27 @@ class PersonCertRoundTripTest { PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); request.setHeadPhoto("http://example.com/new-head.jpg"); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, false, "证件照片模糊"); + certAuditService.audit(recordId, false, "证件照片模糊", null); assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(), "驳回只终结该次提交,档案内容不得被改动"); } @Test - @DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过") - void legacySnapshotSkipsWriteBackWithoutFailingAudit() { - // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null; - // 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败 + @DisplayName("历史快照:读不懂就拒绝通过并回滚(不再静默放行)") + void legacySnapshotBlocksAuditInsteadOfPassing() { + // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null。 + // 「通过」的语义是「把这批内容认定为生效值」,内容读不懂就无从认定, + // 必须拒绝——静默放行等于给一条空记录盖上已通过的章。 Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); TransportCertRecord record = recordStore.get(recordId); record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}"); - certAuditService.audit(recordId, true, null); + ServiceException exception = assertThrows(ServiceException.class, + () -> certAuditService.audit(recordId, true, null, null)); - assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(), - "快照读不懂不该阻断审核"); - assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换"); + assertTrue(exception.getMessage().contains("不匹配"), exception.getMessage()); + // 内存版记录表没有事务,这里只验最关键的一条:生效指针不能被切过去 + assertNull(driver.getEffectiveCertRecordId(), "审核失败不得切换生效指针"); } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java new file mode 100644 index 00000000..df025a94 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java @@ -0,0 +1,86 @@ +/** + * 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.wrapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; + +/** + * 认证记录视图对象的修订版标记与字段可见性 + *

+ * 两件事必须同时成立:司机端要能从 {@code revised} 看出「这条记录经过审核员修订」, + * 而又不能顺着 VO 把整份修订内容(含身份证号、证件照地址)再下发一遍。 + * + * @author Chill + */ +class TransportCertRecordWrapperRevisionTest { + + @Test + @DisplayName("修订版非空才算修订,空串与空白不算") + void marksRevisedOnlyWhenRevisionPresent() { + try (MockedStatic ignoredDisplay = mockStatic(UserDisplayCache.class); + MockedStatic ignoredUser = mockStatic(UserCache.class)) { + assertTrue(wrapper().fillDisplayFields(record("{\"realName\":\"张三\"}")).getRevised(), "有修订版应标记为已修订"); + assertFalse(wrapper().fillDisplayFields(record(null)).getRevised(), "无修订版不应标记"); + assertFalse(wrapper().fillDisplayFields(record("")).getRevised(), "空串不应标记"); + assertFalse(wrapper().fillDisplayFields(record(" ")).getRevised(), "空白不应标记"); + } + } + + @Test + @DisplayName("视图对象序列化不包含修订内容,只保留 revised 标记") + void voJsonOmitsRevisionContent() throws Exception { + TransportCertRecordVO vo = record("{\"realName\":\"张三\"}"); + vo.setRevised(Boolean.TRUE); + + String json = new ObjectMapper().writeValueAsString(vo); + + assertFalse(json.contains("auditedSnapshotJson"), "修订内容不得随 VO 下发:" + json); + assertTrue(json.contains("\"revised\":true"), "必须下发 revised 标记:" + json); + } + + private static TransportCertRecordWrapper wrapper() { + return TransportCertRecordWrapper.build(); + } + + private static TransportCertRecordVO record(String auditedSnapshotJson) { + TransportCertRecordVO vo = new TransportCertRecordVO(); + vo.setSnapshotJson("{\"realName\":\"李四\"}"); + vo.setAuditedSnapshotJson(auditedSnapshotJson); + return vo; + } + +}