🐛 个人认证通过后写回司机档案,并补齐资格证字段

个人认证通过时只切了生效指针与认证状态,证件内容一个字都没落到
blade_transport_driver:认证状态显示「已通过」,司机档案里却仍是
管理端当初录入的旧值(甚至为空),管理端与司机端各看各的。
车辆侧早有 applySnapshotToVehicle 做这件事,司机侧一直没有对应实现。

- 新增 applySnapshotToDriver,审核通过时与指针合并在同一次 UPDATE 写回。
  只搬司机申报的证件内容,不碰身份标识(姓名/身份证号,由管理端建档确定)
  与内部管理归属(司机类型/岗位/驾驶车辆/所属组织)。
- 快照缺失、解析失败或字段对不上时只跳过写回,不阻断审核:审核是独立于
  写回的业务动作,不该因为一份读不懂的快照而失败,否则历史快照对应的
  对象再也审不过去。
- PersonCertSubmitRequest 补 qualificationType / qualificationBack /
  qualificationValidStart / qualificationLongTerm 四个字段。
- qualificationUrl 改名为 qualificationFront,与司机档案列
  qualification_front 及管理端用词统一;改名前的记录存的仍是旧键,
  写回时回退读一次旧键,避免已填的封面页在审核通过时被丢掉。
- 补测试:证件内容写回、不覆盖身份字段、驳回不写回、历史快照跳过写回
  但仍照常通过审核。

qualificationValidStart 只进快照、不写档案:司机档案表没有对应的开始日期列,
管理端的「从业资格证有效期起」同样是只收不存。此处保持一致,不新增列。
This commit is contained in:
2026-10-04 22:11:04 +08:00
parent 17ae367100
commit 4fa6d4c430
3 changed files with 217 additions and 3 deletions
@@ -77,12 +77,20 @@ public class PersonCertSubmitRequest implements Serializable {
@Schema(description = "驾驶证长期有效")
private Integer driverLongTerm;
@Schema(description = "从业资格证类型")
private String qualificationType;
@Schema(description = "从业资格证号")
private String qualificationNo;
@Schema(description = "从业资格证照片")
private String qualificationUrl;
@Schema(description = "从业资格证封面页")
private String qualificationFront;
@Schema(description = "从业资格证内容页")
private String qualificationBack;
@Schema(description = "从业资格证有效期起")
private LocalDate qualificationValidStart;
@Schema(description = "从业资格证有效期止")
private LocalDate qualificationValidEnd;
@Schema(description = "从业资格证长期有效")
private Integer qualificationLongTerm;
@Schema(description = "大头照")
private String headPhoto;
@@ -33,6 +33,7 @@ import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
@@ -214,10 +215,98 @@ public class CertAuditServiceImpl implements ICertAuditService {
driver.setCertificationRejectReason(reason);
if (pass) {
driver.setEffectiveCertRecordId(newEffectiveId);
// 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
applySnapshotToDriver(driver, record.getSnapshotJson());
}
driverService.updateById(driver);
}
/**
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
* <p>
* 只搬**司机自己申报的证件内容**,不碰两类字段:
* <ul>
* <li>{@code realName} / {@code idCardNo}:身份标识,
* 由管理端建档时确定,司机端提交不覆盖(重复认证时会按证件号校验为同一人);</li>
* <li>{@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}:
* 内部管理归属与派工关系,由管理端维护,不该被司机的提交内容覆盖。</li>
* </ul>
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
* 因此不会把管理端填过、司机端没填的字段清空。
* <p>
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
*
* @param driver 待更新的司机 patch
* @param snapshotJson 该条记录的全量提交内容
*/
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
if (Func.isEmpty(snapshotJson)) {
return;
}
PersonCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
} catch (Exception exception) {
return;
}
if (snapshot == null) {
return;
}
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
}
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
driver.setIdCardBack(snapshot.getIdCardBackUrl());
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
driver.setIdCardValidEnd(snapshot.getIdCardValidEnd());
driver.setIdCardLongTerm(snapshot.getIdCardLongTerm());
driver.setHeadPhoto(snapshot.getHeadPhoto());
driver.setDrivingType(snapshot.getDriverClass());
driver.setDrivingLicenseNo(snapshot.getDriverFileNo());
driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart());
driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd());
driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm());
driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl());
driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl());
driver.setQualificationType(snapshot.getQualificationType());
driver.setQualificationNo(snapshot.getQualificationNo());
driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson));
driver.setQualificationBack(snapshot.getQualificationBack());
driver.setQualificationEndDate(snapshot.getQualificationValidEnd());
driver.setQualificationLongTerm(snapshot.getQualificationLongTerm());
}
/**
* 取从业资格证封面页地址,兼容改名前的旧键
* <p>
* 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致,
* 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键,
* 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。
*
* @param snapshot 已解析的快照(可能因旧键而为 null)
* @param snapshotJson 原始快照 JSON,用于回退读取旧键
* @return 封面页地址;新旧键都没有时为 null
*/
private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) {
if (Func.isNotEmpty(snapshot.getQualificationFront())) {
return snapshot.getQualificationFront();
}
try {
return JSON.parseObject(snapshotJson).getString("qualificationUrl");
} catch (Exception exception) {
// 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列
return null;
}
}
/**
* 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象)
* <p>
@@ -65,12 +65,14 @@ class PersonCertRoundTripTest {
driver.setId(DRIVER_ID);
driver.setDriverName("张三");
when(driverAppService.currentDriverEntity()).thenAnswer(invocation -> driver);
// 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走
// 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走;
// 证件字段也要跟着落地,否则「审核通过写回档案」这条链路在测试里验不到
when(driverService.updateById(any(Driver.class))).thenAnswer(invocation -> {
Driver patch = invocation.getArgument(0);
if (patch.getEffectiveCertRecordId() != null) {
driver.setEffectiveCertRecordId(patch.getEffectiveCertRecordId());
}
copyCertFields(patch, driver);
return true;
});
@@ -208,6 +210,48 @@ class PersonCertRoundTripTest {
return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class);
}
/**
* 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖
* <p>
* 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」
* 这类只有真实更新策略才暴露的问题。
*/
private void copyCertFields(Driver patch, Driver target) {
if (patch.getIdCardFront() != null) {
target.setIdCardFront(patch.getIdCardFront());
}
if (patch.getIdCardBack() != null) {
target.setIdCardBack(patch.getIdCardBack());
}
if (patch.getHeadPhoto() != null) {
target.setHeadPhoto(patch.getHeadPhoto());
}
if (patch.getDrivingType() != null) {
target.setDrivingType(patch.getDrivingType());
}
if (patch.getDrivingLicenseNo() != null) {
target.setDrivingLicenseNo(patch.getDrivingLicenseNo());
}
if (patch.getDrivingLicenseFront() != null) {
target.setDrivingLicenseFront(patch.getDrivingLicenseFront());
}
if (patch.getDrivingLicenseBack() != null) {
target.setDrivingLicenseBack(patch.getDrivingLicenseBack());
}
if (patch.getQualificationType() != null) {
target.setQualificationType(patch.getQualificationType());
}
if (patch.getQualificationNo() != null) {
target.setQualificationNo(patch.getQualificationNo());
}
if (patch.getQualificationFront() != null) {
target.setQualificationFront(patch.getQualificationFront());
}
if (patch.getQualificationBack() != null) {
target.setQualificationBack(patch.getQualificationBack());
}
}
private PersonCertSubmitRequest personCert(String realName, String idCardNo) {
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
request.setRealName(realName);
@@ -217,4 +261,77 @@ class PersonCertRoundTripTest {
return request;
}
@Test
@DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致")
void approveWritesCertContentBackToDriver() {
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
request.setHeadPhoto("http://example.com/head.jpg");
request.setDriverClass("A2");
request.setDriverFileNo("450100769875");
request.setDriverLicenseFrontUrl("http://example.com/dl-front.jpg");
request.setDriverLicenseBackUrl("http://example.com/dl-back.jpg");
request.setQualificationType("道路旅客运输");
request.setQualificationNo("Q0001");
request.setQualificationFront("http://example.com/q-front.jpg");
request.setQualificationBack("http://example.com/q-back.jpg");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, true, null);
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
assertEquals("http://example.com/head.jpg", driver.getHeadPhoto());
assertEquals("A2", driver.getDrivingType());
assertEquals("450100769875", driver.getDrivingLicenseNo());
assertEquals("http://example.com/dl-front.jpg", driver.getDrivingLicenseFront());
assertEquals("http://example.com/dl-back.jpg", driver.getDrivingLicenseBack());
assertEquals("道路旅客运输", driver.getQualificationType());
assertEquals("Q0001", driver.getQualificationNo());
assertEquals("http://example.com/q-front.jpg", driver.getQualificationFront());
assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack());
}
@Test
@DisplayName("审核通过:不覆盖姓名与身份证号(身份标识由管理端建档确定)")
void approveDoesNotOverwriteIdentity() {
driver.setDriverName("张三(档案名)");
driver.setIdCardNo("110101199001011234");
Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
certAuditService.audit(recordId, true, null);
assertEquals("张三(档案名)", driver.getDriverName(),
"姓名是建档时确定的身份字段,司机端提交不得把它改掉");
assertEquals("110101199001011234", driver.getIdCardNo());
}
@Test
@DisplayName("驳回:证件内容一个字都不写回档案")
void rejectWritesNothingBack() {
driver.setHeadPhoto("http://example.com/original-head.jpg");
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
request.setHeadPhoto("http://example.com/new-head.jpg");
Long recordId = driverCertService.submitPersonCert(request);
certAuditService.audit(recordId, false, "证件照片模糊");
assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(),
"驳回只终结该次提交,档案内容不得被改动");
}
@Test
@DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过")
void legacySnapshotSkipsWriteBackWithoutFailingAudit() {
// 改造前的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
// 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
TransportCertRecord record = recordStore.get(recordId);
record.setSnapshotJson("{\"legacyField\":\"改造前的旧内容\"}");
certAuditService.audit(recordId, true, null);
assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(),
"快照读不懂不该阻断审核");
assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换");
}
}