✨ 新增司机档案登录自动建档与账号绑定接口

This commit is contained in:
2026-10-23 15:48:57 +08:00
parent 3475ec1f87
commit 49be9c6d52
8 changed files with 402 additions and 5 deletions
@@ -0,0 +1,58 @@
/**
* 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.feign;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* 司机档案 Feign 接口
*
* @author Chill
*/
@FeignClient(value = "blade-transport")
public interface IDriverClient {
String API_PREFIX = "/feign/client/driver";
String ENSURE_PROFILE = API_PREFIX + "/ensure-profile";
/**
* 查找或创建司机档案,并把档案绑定到指定小程序账号
* <p>
* 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用)
* 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。
*
* @param param 租户、小程序账号与手机号
* @return 档案ID与本次处置标志
*/
@PostMapping(ENSURE_PROFILE)
R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param);
}
@@ -0,0 +1,55 @@
/**
* 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.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 司机档案「查找或创建并绑定账号」参数
*
* @author Chill
*/
@Data
@Schema(description = "司机档案查找或创建参数")
public class DriverEnsureProfileDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "租户ID,为空时取当前登录租户;仍为空则拒绝写库")
private String tenantId;
@Schema(description = "关联小程序用户ID(blade_mp_user.id)")
private Long mpUserId;
@Schema(description = "手机号,仅在按账号未命中时用于查找已有档案")
private String phone;
}
@@ -40,6 +40,7 @@ import org.springblade.transport.jackson.FlexibleLocalDateDeserializer;
import java.io.Serial;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 司机管理实体类
@@ -223,6 +224,17 @@ public class Driver extends TenantEntity {
@Schema(description = "关联小程序用户ID")
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
/**
* 档案来源:login_auto(登录自动建)/ login_bound(绑到已有档案)/
* admin(后台建)/ cert_backfill(存量订正);NULL 表示来源未知
*/
@Schema(description = "档案来源")
private String profileSource;
/**
* 档案与账号建立关联的时点
*/
@Schema(description = "账号绑定时间")
private LocalDateTime accountBoundTime;
/**
* 与联系人关系
*/
@@ -0,0 +1,58 @@
/**
* 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.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 司机档案「查找或创建并绑定账号」结果
*
* @author Chill
*/
@Data
@Schema(description = "司机档案查找或创建结果")
public class DriverEnsureProfileVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "命中的司机档案ID,冲突时为null")
private Long driverId;
@Schema(description = "本次新建了档案")
private Boolean created;
@Schema(description = "本次把已有档案绑定到了账号")
private Boolean boundExisting;
@Schema(description = "冲突原因;成功时为null")
private String message;
}
@@ -0,0 +1,61 @@
/**
* 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.feign;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.AllArgsConstructor;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.service.IDriverService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 司机档案 Feign 实现类
*
* @author Chill
*/
@NonDS
@Hidden
@RestController
@AllArgsConstructor
public class DriverClient implements IDriverClient {
private final IDriverService driverService;
@Override
@PostMapping(ENSURE_PROFILE)
public R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param) {
if (param == null) {
return R.fail("司机档案参数不能为空");
}
return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone()));
}
}
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
@@ -59,6 +60,16 @@ public interface IDriverService extends BaseService<Driver> {
*/
boolean submit(Driver driver);
/**
* 查找或创建司机档案并绑定小程序账号(登录路径专用)
*
* @param tenantId 租户ID,必须非空
* @param mpUserId 小程序用户ID
* @param phone 手机号
* @return 档案ID与本次处置标志;冲突时 message 携带可读原因
*/
DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
/**
* 修改状态
*
@@ -349,8 +349,9 @@ public class CertAuditServiceImpl implements ICertAuditService {
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
}
// 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
// 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
// 身份证号要落到档案上,先确认它没有落在别人名下:该列在基线建表脚本与本地库上
// 有唯一索引 uk_transport_driver_id_card,但迁移历史里没有任何一次添加它的记录,
// 生产库是否已建未核实;应用层查重不能省,否则重复证件号会静默落库
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
driver.setDriverName(snapshot.getRealName());
driver.setIdCardNo(snapshot.getIdCardNo());
@@ -382,9 +383,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
/**
* 身份证号不得落在另一名司机名下
* <p>
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
* 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
* 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
* 该列在基线建表脚本与本地库上有唯一索引 {@code uk_transport_driver_id_card},
* 但迁移历史里没有任何一次添加它的记录,生产库是否已建未核实;
* 应用层查重仍不能省:重复证件号一旦落库,后续建档查重、
* 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
* <p>
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
@@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.mapper.DriverMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
@@ -53,6 +55,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@@ -61,6 +64,7 @@ import java.util.Objects;
*
* @author Chill
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> implements IDriverService {
@@ -74,6 +78,16 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
private static final int REMARK_MAX_LENGTH = 200;
private static final int DEFAULT_ENABLED_STATUS = 1;
private static final int DEFAULT_FALSE = 0;
/**
* 档案来源:登录时自动新建
*/
private static final String PROFILE_SOURCE_LOGIN_AUTO = "login_auto";
/**
* 档案来源:绑定到已有档案
*/
private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound";
private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员";
private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对";
private final IMpUserClient mpUserClient;
private final ISysClient sysClient;
@@ -101,6 +115,132 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone) {
String resolvedTenantId = trimToNull(tenantId);
if (resolvedTenantId == null) {
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
}
// 租户是档案归属与手机号查重的范围,宁可让本次建档失败(登录层只记 WARN),
// 也不能写出 tenant_id 为空的档案——那会让下一条同号档案无法被查到
if (resolvedTenantId == null) {
throw new ServiceException("租户信息为空,无法创建司机档案");
}
if (Func.isEmpty(mpUserId)) {
throw new ServiceException("小程序用户ID不能为空");
}
String mobile = trimToNull(phone);
if (mobile == null) {
throw new ServiceException("手机号不能为空");
}
// 查找顺序固定为 user_id → phone:user_id 是稳定标识;
// phone 会被运营商二次放号,拿它优先会把别人档案绑给新持卡人
Driver bound = findDriverByUserId(mpUserId);
if (bound != null) {
return ensureResult(bound.getId(), false, false, null);
}
List<Driver> sameMobile = list(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getTenantId, resolvedTenantId)
.eq(Driver::getMobile, mobile));
if (sameMobile.isEmpty()) {
return createPlaceholderProfile(resolvedTenantId, mpUserId, mobile);
}
if (sameMobile.size() > 1) {
return ensureResult(null, false, false, CONFLICT_MOBILE_MULTIPLE);
}
Driver existing = sameMobile.get(0);
if (Func.isNotEmpty(existing.getUserId())) {
if (Objects.equals(existing.getUserId(), mpUserId)) {
return ensureResult(existing.getId(), false, false, null);
}
return ensureResult(null, false, false, CONFLICT_MOBILE_OCCUPIED);
}
return bindExistingProfile(existing.getId(), mpUserId);
}
/**
* 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件
*/
private Driver findDriverByUserId(Long mpUserId) {
return getOne(Wrappers.<Driver>lambdaQuery()
.eq(Driver::getUserId, mpUserId)
.last("LIMIT 1"));
}
/**
* 新建占位档案(票 01 定值)
* <p>
* 不走 {@link #submit}:那条路径要求 15 项必填,自动建档必然被拦。这里只满足表约束——
* id_card_no 留 NULL(唯一索引对 NULL 不去重,且 NULL 是「未采集」的判据),
* 其余 NOT NULL 列写空串或可读占位。
*/
private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) {
Driver driver = new Driver();
driver.setTenantId(tenantId);
driver.setUserId(mpUserId);
driver.setDriverName("司机" + mobileTail(mobile));
driver.setMobile(mobile);
driver.setDrivingType("");
driver.setDriverType("");
driver.setOrganizationName("");
driver.setEmergencyContactName("");
driver.setEmergencyContactMobile("");
driver.setStatus(DEFAULT_ENABLED_STATUS);
driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO);
driver.setAccountBoundTime(LocalDateTime.now());
try {
save(driver);
} catch (Exception exception) {
// 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案
Driver concurrent = findDriverByUserId(mpUserId);
if (concurrent != null) {
return ensureResult(concurrent.getId(), false, false, null);
}
log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception);
throw new ServiceException("创建司机档案失败");
}
return ensureResult(driver.getId(), true, false, null);
}
/**
* 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容
*/
private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) {
Driver patch = new Driver();
patch.setId(driverId);
patch.setUserId(mpUserId);
patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND);
patch.setAccountBoundTime(LocalDateTime.now());
// 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle
carryDrivingVehicle(driverId, patch);
try {
updateById(patch);
} catch (Exception exception) {
// 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上
Driver concurrent = findDriverByUserId(mpUserId);
if (concurrent != null) {
return ensureResult(concurrent.getId(), false, false, null);
}
log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception);
throw new ServiceException("绑定司机档案失败");
}
return ensureResult(driverId, false, true, null);
}
private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) {
DriverEnsureProfileVO result = new DriverEnsureProfileVO();
result.setDriverId(driverId);
result.setCreated(created);
result.setBoundExisting(boundExisting);
result.setMessage(message);
return result;
}
private String mobileTail(String mobile) {
return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {