✨ 新增认证记录不可变快照数据模型与迁移脚本

按 ADR-0001/0002 建立司机与车辆共用的认证记录表 blade_transport_cert_record
(biz_type 区分、载荷存 JSON、检索字段提升为真实列),并为司机/车辆表补生效快照指针。

不变式结构性保证:
- I1 同对象至多一条待审:生成列 pending_biz_id + 唯一索引,数据库层拦截;
  生成列同时排除 is_deleted,避免逻辑删除的待审记录永久占用唯一键。
- I3 快照不可变:snapshot_json 等 5 列标 FieldStrategy.NEVER,永不进入 UPDATE。
- status 承载审核状态(0审核中/1已通过/2已驳回/3已作废),新增时必须显式设置:
  框架 resolveEntity 会把 null 的 status 静默填成 1(即「已通过」),
  且继承来的 changeStatus 可直接写 status,两条通道均已封堵。

其他:司机表补身份证有效期三列并订正排序规则为 utf8mb4_general_ci
(原先为 0900_ai_ci,与车辆表 JOIN 已发生过 Illegal mix of collations);
生效快照指针加 @JsonProperty(READ_ONLY),防止以 /submit 请求体自行「认证」自己;
同步基线建表脚本与认证审核菜单/权限码。本票不含任何业务接口。
This commit is contained in:
2026-10-02 14:40:56 +08:00
parent 087f361a8b
commit 87f4c3a830
13 changed files with 852 additions and 4 deletions
@@ -46,6 +46,12 @@
<result column="emergency_contact_name" property="emergencyContactName"/>
<result column="emergency_contact_mobile" property="emergencyContactMobile"/>
<result column="remark" property="remark"/>
<result column="id_card_valid_start" property="idCardValidStart"/>
<result column="id_card_valid_end" property="idCardValidEnd"/>
<result column="id_card_long_term" property="idCardLongTerm"/>
<result column="certification_status" property="certificationStatus"/>
<result column="certification_reject_reason" property="certificationRejectReason"/>
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
</resultMap>
<sql id="BaseColumn">
@@ -70,6 +76,9 @@
posts,
id_card_front,
id_card_back,
id_card_valid_start,
id_card_valid_end,
id_card_long_term,
head_photo,
driving_type,
driving_license_no,
@@ -91,7 +100,10 @@
organization_name,
emergency_contact_name,
emergency_contact_mobile,
remark
remark,
certification_status,
certification_reject_reason,
effective_cert_record_id
</sql>
<select id="selectByIdCardNoIncludingDeleted" resultType="org.springblade.transport.pojo.entity.Driver">
@@ -0,0 +1,38 @@
/**
* 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.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.transport.pojo.entity.TransportCertRecord;
/**
* 认证记录 Mapper 接口
*
* @author Chill
*/
public interface TransportCertRecordMapper extends BaseMapper<TransportCertRecord> {
}
@@ -48,6 +48,7 @@
<result column="remark" property="remark"/>
<result column="certification_status" property="certificationStatus"/>
<result column="certification_reject_reason" property="certificationRejectReason"/>
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
</resultMap>
<sql id="BaseColumn">
@@ -96,6 +97,7 @@
remark
, certification_status
, certification_reject_reason
, effective_cert_record_id
</sql>
<sql id="ExpiryWithin30Condition">
@@ -0,0 +1,62 @@
/**
* 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.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.List;
/**
* 认证记录 服务类
* <p>
* 只承载认证记录本身的读写与<b>数据模型不变式</b>的守护;
* 提交(02/05)、审核(03)、可用性门槛(04)等业务链路在各自的票中实现。
*
* @author Chill
*/
public interface ITransportCertRecordService extends BaseService<TransportCertRecord> {
/**
* 某认证对象的认证记录列表,按提交时间倒序
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizId 认证对象主键
* @return 认证记录列表
*/
List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId);
/**
* 取某认证对象当前的待审快照(至多一条)
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizId 认证对象主键
* @return 待审快照,无则返回 null
*/
TransportCertRecord selectPendingRecord(String bizType, Long bizId);
}
@@ -0,0 +1,188 @@
/**
* 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.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 认证记录 服务实现类
*
* @author Chill
*/
@Service
public class TransportCertRecordServiceImpl extends BaseServiceImpl<TransportCertRecordMapper, TransportCertRecord> implements ITransportCertRecordService {
/**
* 合法的审核状态取值
*/
private static final Set<Integer> VALID_STATUS = Set.of(
TransportCertRecord.STATUS_PENDING,
TransportCertRecord.STATUS_APPROVED,
TransportCertRecord.STATUS_REJECTED,
TransportCertRecord.STATUS_SUPERSEDED
);
@Override
@Transactional(rollbackFor = Exception.class)
public boolean save(TransportCertRecord transportCertRecord) {
validateRecord(transportCertRecord);
return super.save(transportCertRecord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveBatch(Collection<TransportCertRecord> entityList, int batchSize) {
entityList.forEach(this::validateRecord);
return super.saveBatch(entityList, batchSize);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveOrUpdateBatch(Collection<TransportCertRecord> entityList, int batchSize) {
entityList.forEach(this::validateRecord);
return super.saveOrUpdateBatch(entityList, batchSize);
}
/**
* 禁用继承自 {@code BaseService} 的「修改状态」
* <p>
* ⚠️ 本表的 {@code status} 是<b>审核状态</b>,不是「启用 / 停用」。
* 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status,
* 调用 {@code changeStatus(ids, 1)} 即等于<b>批量通过审核</b>——
* 审核人、审核时间、驳回原因全部缺失,既破坏 I3 的留痕,也会凭空造出生效快照。
* 审核必须走 03 的审核接口(作用于单条记录、带留痕)。
*
* @param ids 主键集合
* @param status 状态
* @return 永不返回,直接抛出
*/
@Override
public boolean changeStatus(List<Long> ids, Integer status) {
throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口");
}
@Override
public List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId) {
return list(recordQuery(bizType, bizId)).stream()
.map(TransportCertRecordWrapper.build()::entityVO)
.collect(Collectors.toList());
}
@Override
public TransportCertRecord selectPendingRecord(String bizType, Long bizId) {
validateBizTarget(bizType, bizId);
return getOne(Wrappers.<TransportCertRecord>lambdaQuery()
.eq(TransportCertRecord::getBizType, bizType)
.eq(TransportCertRecord::getBizId, bizId)
.eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
.last("LIMIT 1"));
}
/**
* 认证对象检索条件:按提交时间倒序
* <p>
* 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之:
* 「认证记录」的业务时间轴是<b>提交</b>,ticket 02 明确要求
* 「查询某辆车的认证记录列表:按提交时间倒序」。
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
* @return 检索条件
*/
private LambdaQueryWrapper<TransportCertRecord> recordQuery(String bizType, Long bizId) {
validateBizTarget(bizType, bizId);
return Wrappers.<TransportCertRecord>lambdaQuery()
.eq(TransportCertRecord::getBizType, bizType)
.eq(TransportCertRecord::getBizId, bizId)
.orderByDesc(TransportCertRecord::getSubmitTime);
}
/**
* 认证对象标识校验
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
*/
private void validateBizTarget(String bizType, Long bizId) {
if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) {
throw new ServiceException("认证对象类型与主键不能为空");
}
}
/**
* 记录落库前的强制校验
* <p>
* ⚠️ 这里防的是框架的一个<b>静默</b>行为:{@code BaseServiceImpl#resolveEntity} 在插入时
* 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1),
* 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。
* 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。
* 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。
*
* @param transportCertRecord 待落库的认证记录
*/
private void validateRecord(TransportCertRecord transportCertRecord) {
if (Func.isEmpty(transportCertRecord)) {
throw new ServiceException("认证记录不能为空");
}
if (Func.isEmpty(transportCertRecord.getBizType())) {
throw new ServiceException("认证对象类型不能为空");
}
if (Func.isEmpty(transportCertRecord.getBizId())) {
throw new ServiceException("认证对象主键不能为空");
}
if (Func.isEmpty(transportCertRecord.getSnapshotJson())) {
throw new ServiceException("认证内容快照不能为空");
}
Integer status = transportCertRecord.getStatus();
if (Func.isEmpty(status)) {
throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)");
}
if (!VALID_STATUS.contains(status)) {
throw new ServiceException("认证记录状态取值不正确");
}
if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) {
throw new ServiceException("请输入认证驳回原因");
}
}
}
@@ -0,0 +1,57 @@
/**
* 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.wrapper;
import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.Objects;
/**
* 认证记录包装类
*
* @author Chill
*/
public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertRecord, TransportCertRecordVO> {
public static TransportCertRecordWrapper build() {
return new TransportCertRecordWrapper();
}
@Override
public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) {
TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class));
transportCertRecordVO.setCreateUserName(UserCache.getUserRealName(transportCertRecord.getCreateUser()));
transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecord.getUpdateUser()));
transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecord.getSubmitUser()));
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecord.getAuditUser()));
return transportCertRecordVO;
}
}