org.springblade
diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java
index dfa3b898..8bbd2755 100644
--- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java
+++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.SneakyThrows;
import org.springblade.auth.constant.BladeAuthConstant;
+import org.springblade.auth.support.MpLoginRequestSupport;
import org.springblade.core.oauth2.props.OAuth2Properties;
import org.springblade.core.oauth2.provider.OAuth2Request;
import org.springblade.core.oauth2.service.OAuth2User;
@@ -87,13 +88,16 @@ public class Oauth2SmsEndpoint {
if (StringUtil.isBlank(decryptedPhone)) {
return R.fail(USER_PHONE_NOT_FOUND);
}
- // 校验手机是否已注册,防止恶意发送验证码
// buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user
OAuth2Request request = OAuth2Request.create().buildArgs();
request.setTenantId(tenantId);
- OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
- if (oAuth2User == null) {
- return R.fail(USER_PHONE_NOT_FOUND);
+ // 「手机号是否已注册」只对 Web 校验;小程序端未注册手机号也发码,账号在登录时创建。
+ // 发码路径只做查询,不建号。
+ if (!MpLoginRequestSupport.isMp(request)) {
+ OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
+ if (oAuth2User == null) {
+ return R.fail(USER_PHONE_NOT_FOUND);
+ }
}
// 使用指定短信资源(默认 ali_reg)
String smsResourceCode = Func.toStr(code, BladeAuthConstant.LOGIN_SMS_CODE);
diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java
index 540095c0..cd01f6ef 100644
--- a/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java
+++ b/blade-auth/src/main/java/org/springblade/auth/granter/SmsTokenGranter.java
@@ -27,6 +27,8 @@ package org.springblade.auth.granter;
import jakarta.servlet.http.HttpServletRequest;
import org.springblade.auth.constant.BladeAuthConstant;
+import org.springblade.auth.support.MpLoginRequestSupport;
+import org.springblade.auth.support.MpLoginSupport;
import org.springblade.core.oauth2.constant.OAuth2TokenConstant;
import org.springblade.core.oauth2.exception.UserInvalidException;
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
@@ -47,6 +49,9 @@ import org.springframework.stereotype.Component;
/**
* SmsTokenGranter
+ *
+ * 按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,Web 短信登录不建号。
+ * {@code BladeUserDetailService.loadByPhone} 同时被「发送短信验证码」复用,其中不得建号。
*
* @author BladeX
*/
@@ -56,12 +61,14 @@ public class SmsTokenGranter extends AbstractTokenGranter {
private final OAuth2UserService userService;
private final ISmsClient smsClient;
private final OAuth2Properties properties;
+ private final MpLoginSupport mpLoginSupport;
- public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties) {
+ public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties, MpLoginSupport mpLoginSupport) {
super(clientService, userService, passwordHandler);
this.userService = userService;
this.smsClient = smsClient;
this.properties = properties;
+ this.mpLoginSupport = mpLoginSupport;
}
@Override
@@ -86,8 +93,10 @@ public class SmsTokenGranter extends AbstractTokenGranter {
if (!result.isSuccess()) {
throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT);
}
- // 获取用户信息
- OAuth2User user = userService.loadByPhone(decryptedPhone, request);
+ // 获取用户信息:小程序登录走自动建号编排,Web 短信登录只做查询
+ OAuth2User user = MpLoginRequestSupport.isMp(request)
+ ? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null)
+ : userService.loadByPhone(decryptedPhone, request);
// 校验用户信息
if (!userService.validateUser(user)) {
throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT);
diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java
index 33de5291..9d2bb75c 100644
--- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java
+++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java
@@ -2,7 +2,7 @@ package org.springblade.auth.granter;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
-import org.springblade.auth.utils.TokenUtil;
+import org.springblade.auth.support.MpLoginSupport;
import org.springblade.core.oauth2.exception.UserInvalidException;
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
import org.springblade.core.oauth2.handler.PasswordHandler;
@@ -11,12 +11,9 @@ import org.springblade.core.oauth2.service.OAuth2Client;
import org.springblade.core.oauth2.service.OAuth2ClientService;
import org.springblade.core.oauth2.service.OAuth2User;
import org.springblade.core.oauth2.service.OAuth2UserService;
-import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.core.tool.utils.WebUtil;
-import org.springblade.system.feign.IMpUserClient;
-import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.thirdparty.wechat.constant.WechatMiniConstant;
import org.springblade.thirdparty.wechat.exception.WechatMiniException;
import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO;
@@ -28,8 +25,8 @@ import org.springframework.stereotype.Component;
* 微信小程序手机号一键登录。
*
* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。
- * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝)
- * → 记录 openid → 按身份发令牌(不依赖系统角色)。
+ * 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案
+ * → 按身份发令牌(不依赖系统角色)。
*/
@Slf4j
@Component
@@ -37,17 +34,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
private final OAuth2UserService userService;
private final IWechatMiniService wechatMiniService;
- private final IMpUserClient mpUserClient;
+ private final MpLoginSupport mpLoginSupport;
public WechatMiniTokenGranter(OAuth2ClientService clientService,
OAuth2UserService userService,
PasswordHandler passwordHandler,
IWechatMiniService wechatMiniService,
- IMpUserClient mpUserClient) {
+ MpLoginSupport mpLoginSupport) {
super(clientService, userService, passwordHandler);
this.userService = userService;
this.wechatMiniService = wechatMiniService;
- this.mpUserClient = mpUserClient;
+ this.mpLoginSupport = mpLoginSupport;
}
@Override
@@ -82,28 +79,12 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
throw new UserInvalidException("未获取到微信手机号");
}
- R mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone);
- if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null
- || mpUserResult.getData().getUser() == null) {
- throw new UserInvalidException("用户不存在,无法登录");
- }
-
- OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData());
+ // 账号不存在时自动建号;建档与绑 openid 失败不阻断登录
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid());
if (!userService.validateUser(user)) {
throw new UserInvalidException("用户身份无效,无法登录");
}
- R bindResult = mpUserClient.bindOpenId(
- request.getTenantId(),
- Func.toLong(user.getUserId()),
- session.getOpenid()
- );
- if (bindResult == null || !bindResult.isSuccess()) {
- String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败";
- log.warn("绑定微信 openid 失败 userId={} openid={} msg={}", user.getUserId(), session.getOpenid(), msg);
- throw new UserInvalidException(StringUtil.isBlank(msg) ? "绑定 openid 失败" : msg);
- }
-
user.setClient(oauthClient);
return user;
}
diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java
index 22904efe..10f5d6b7 100644
--- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java
+++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java
@@ -23,6 +23,7 @@
package org.springblade.auth.service;
import lombok.RequiredArgsConstructor;
+import org.springblade.auth.support.MpLoginRequestSupport;
import org.springblade.auth.utils.TokenUtil;
import org.springblade.core.oauth2.exception.OAuth2ErrorCode;
import org.springblade.core.oauth2.provider.OAuth2Request;
@@ -36,7 +37,6 @@ import org.springblade.system.feign.IMpUserClient;
import org.springblade.system.feign.IUserClient;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.entity.UserInfo;
-import org.springblade.system.pojo.enums.MpUserIdentity;
import org.springblade.system.pojo.enums.UserType;
import java.util.Optional;
@@ -54,7 +54,7 @@ public class BladeUserDetailService implements OAuth2UserService {
@Override
public OAuth2User loadByUserId(String userId, OAuth2Request request) {
// 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user
- if (isMpLoginRequest(request)) {
+ if (MpLoginRequestSupport.isMp(request)) {
OAuth2User mpUser = loadMpUserById(userId);
if (mpUser == null) {
OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER);
@@ -83,7 +83,7 @@ public class BladeUserDetailService implements OAuth2UserService {
String tenantId = request.getTenantId();
// 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user
- if (isMpLoginRequest(request)) {
+ if (MpLoginRequestSupport.isMp(request)) {
R mpResult = mpUserClient.userInfoByAccount(tenantId, username);
if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) {
return null;
@@ -104,7 +104,7 @@ public class BladeUserDetailService implements OAuth2UserService {
String tenantId = request.getTenantId();
// 小程序短信登录:仅查 blade_mp_user
- if (isMpLoginRequest(request)) {
+ if (MpLoginRequestSupport.isMp(request)) {
return loadMpUserByPhone(tenantId, phone);
}
@@ -125,17 +125,6 @@ public class BladeUserDetailService implements OAuth2UserService {
.isPresent();
}
- /**
- * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流)
- */
- private boolean isMpLoginRequest(OAuth2Request request) {
- return request != null && isMpUserType(request.getUserType());
- }
-
- private boolean isMpUserType(String userType) {
- return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType));
- }
-
private String resolveUserType(OAuth2Request request) {
return Optional.ofNullable(request.getUserType())
.filter(s -> !StringUtil.isBlank(s))
diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java
new file mode 100644
index 00000000..012ffbbb
--- /dev/null
+++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginRequestSupport.java
@@ -0,0 +1,54 @@
+/**
+ * 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.auth.support;
+
+import org.springblade.core.oauth2.provider.OAuth2Request;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.system.pojo.enums.MpUserIdentity;
+
+/**
+ * 登录请求来源判定
+ *
+ * @author Chill
+ */
+public final class MpLoginRequestSupport {
+
+ private MpLoginRequestSupport() {
+ }
+
+ /**
+ * 是否小程序请求:请求头 User-Type 为 mp
+ *
+ * Web 与小程序共用同一个客户端,来源只认请求头 User-Type。
+ *
+ * @param request 登录请求,可为 null
+ * @return 请求头 User-Type 为 mp 时返回 true
+ */
+ public static boolean isMp(OAuth2Request request) {
+ return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType()));
+ }
+
+}
diff --git a/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java
new file mode 100644
index 00000000..ab28cd7f
--- /dev/null
+++ b/blade-auth/src/main/java/org/springblade/auth/support/MpLoginSupport.java
@@ -0,0 +1,335 @@
+/**
+ * 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.auth.support;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springblade.auth.utils.TokenUtil;
+import org.springblade.core.oauth2.exception.UserInvalidException;
+import org.springblade.core.oauth2.service.OAuth2User;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.support.Kv;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.system.feign.IMpUserClient;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
+import org.springblade.system.pojo.entity.MpUser;
+import org.springblade.system.pojo.entity.MpUserInfo;
+import org.springblade.system.pojo.enums.MpUserIdentity;
+import org.springblade.transport.feign.IDriverClient;
+import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
+import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。
+ *
+ * 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。
+ * 本类只供登录链路调用。
+ *
+ * @author Chill
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class MpLoginSupport {
+
+ /**
+ * detail 中承载登录提示的键,前端从令牌的 detail 里读取
+ */
+ public static final String DETAIL_LOGIN_NOTICE = "loginNotice";
+
+ /**
+ * 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号
+ */
+ private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员";
+
+ private final IMpUserClient mpUserClient;
+ private final IDriverClient driverClient;
+
+ /**
+ * 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。
+ *
+ * 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo
+ * (identities / permissions / detail)只在 system 侧组装。
+ *
+ * @param tenantId 请求租户,为空时由 system 侧兜到默认租户
+ * @param phone 本次登录的手机号
+ * @param openid 微信小程序 openid,短信登录传 null
+ * @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成)
+ */
+ public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) {
+ if (StringUtil.isBlank(phone)) {
+ throw new UserInvalidException("未获取到手机号,无法登录");
+ }
+ MpUser account;
+ String loginNotice = null;
+ // openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开)
+ boolean openidOccupied = false;
+ // ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号
+ MpUser openidAccount = findByOpenid(tenantId, openid);
+ if (openidAccount == null) {
+ // ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常)
+ account = ensureUser(tenantId, phone, openid);
+ } else {
+ account = openidAccount;
+ // 账号 phone 经 feign 下发时是脱敏值(MpUser.phone 标了 @Sensitive),不能拿来比较。
+ // 改按「该手机号是否已有账号」判断:命中自己是同一个人、命中别人是撞键、没命中就换号。
+ MpUser phoneAccount = findByPhone(tenantId, phone);
+ if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) {
+ // ①-撞键:落到手机号账号,不迁移 openid
+ account = phoneAccount;
+ loginNotice = NOTICE_PHONE_OCCUPIED;
+ openidOccupied = true;
+ log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}",
+ tenantId, openidAccount.getId(), phoneAccount.getId());
+ } else if (phoneAccount == null) {
+ account = movePhone(openidAccount, phone);
+ if (account == null) {
+ // 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点
+ MpUser concurrent = findByPhone(tenantId, phone);
+ if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) {
+ account = concurrent;
+ loginNotice = NOTICE_PHONE_OCCUPIED;
+ openidOccupied = true;
+ log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}",
+ tenantId, openidAccount.getId(), concurrent.getId());
+ } else {
+ // 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试
+ account = openidAccount;
+ }
+ }
+ }
+ }
+ // ③ 身份含 driver 才建档;纯 admin 不建
+ String profileNotice = ensureProfile(account, phone, tenantId);
+ if (StringUtil.isBlank(loginNotice)) {
+ loginNotice = profileNotice;
+ }
+ // ④ 微信登录补绑 openid;失败只 WARN
+ bindOpenid(account, openid, openidOccupied);
+ MpUserInfo userInfo = loadUserInfo(account);
+ attachNotice(userInfo, loginNotice);
+ return TokenUtil.convertMpUser(userInfo);
+ }
+
+ private MpUser findByOpenid(String tenantId, String openid) {
+ if (StringUtil.isBlank(openid)) {
+ return null;
+ }
+ try {
+ R result = mpUserClient.userByOpenid(tenantId, openid);
+ return isSuccess(result) ? result.getData() : null;
+ } catch (Exception exception) {
+ // 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败
+ log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception);
+ return null;
+ }
+ }
+
+ private MpUser findByPhone(String tenantId, String phone) {
+ try {
+ R result = mpUserClient.userByPhone(tenantId, phone);
+ return isSuccess(result) ? result.getData() : null;
+ } catch (Exception exception) {
+ log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception);
+ return null;
+ }
+ }
+
+ /**
+ * ② 查找或创建账号:登录路径上唯一允许让登录失败的动作
+ */
+ private MpUser ensureUser(String tenantId, String phone, String openid) {
+ MpUserEnsureDTO param = new MpUserEnsureDTO();
+ param.setTenantId(tenantId);
+ param.setPhone(phone);
+ param.setOpenid(openid);
+ try {
+ R result = mpUserClient.ensureUser(param);
+ if (isSuccess(result) && result.getData() != null && Func.isNotEmpty(result.getData().getId())) {
+ return result.getData();
+ }
+ // R.data(null) 也是 success,此时 msg 是「暂无数据」之类的占位文案,不能当业务错误下发
+ String message = result != null && !result.isSuccess() ? result.getMsg() : null;
+ log.error("小程序账号查找或创建失败:租户={} 手机号={} 返回={}", tenantId, phone, message);
+ throw new UserInvalidException(StringUtil.isBlank(message) ? "账号创建失败,请稍后重试" : message);
+ } catch (UserInvalidException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("小程序账号查找或创建失败:租户={} 手机号={}", tenantId, phone, exception);
+ throw new UserInvalidException("账号创建失败,请稍后重试");
+ }
+ }
+
+ /**
+ * 把 openid 账号的手机号换成新号
+ *
+ * 只 patch phone:account 是登录名,不随手机号变;status 必须带原值,
+ * system 侧 updateUser 的 prepare 会把空 status 重置成启用。
+ *
+ * @return 更新成功后的账号;失败返回 null,不抛异常
+ */
+ private MpUser movePhone(MpUser account, String phone) {
+ MpUser patch = new MpUser();
+ patch.setId(account.getId());
+ patch.setTenantId(account.getTenantId());
+ patch.setPhone(phone);
+ patch.setAccount(account.getAccount());
+ patch.setIdentities(account.getIdentities());
+ patch.setStatus(account.getStatus());
+ try {
+ R result = mpUserClient.updateUser(patch);
+ if (isSuccess(result) && Boolean.TRUE.equals(result.getData())) {
+ account.setPhone(phone);
+ return account;
+ }
+ log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={} 返回={}",
+ account.getTenantId(), account.getId(), phone, result == null ? null : result.getMsg());
+ } catch (Exception exception) {
+ log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={}",
+ account.getTenantId(), account.getId(), phone, exception);
+ }
+ return null;
+ }
+
+ /**
+ * ③ 为司机身份账号补齐档案;失败或冲突只记 WARN,不阻断登录
+ *
+ * @return 冲突时的可读原因,供 detail 提示使用;成功或无需建档返回 null
+ */
+ private String ensureProfile(MpUser account, String requestedPhone, String requestedTenantId) {
+ if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) {
+ return null;
+ }
+ // 租户取账号上的值(档案必须与账号同租户,唯一键对 NULL 不去重);
+ // 手机号只能取登录入参:账号 phone 经 feign 下发时是脱敏值,写进档案会让按手机号搜不到司机
+ String tenantId = Func.toStr(account.getTenantId(), requestedTenantId);
+ String phone = trimToNull(requestedPhone);
+ if (phone == null) {
+ log.warn("登录自动建档跳过:登录手机号为空,租户={} 账号={}", tenantId, account.getId());
+ return null;
+ }
+ DriverEnsureProfileDTO param = new DriverEnsureProfileDTO();
+ param.setTenantId(tenantId);
+ param.setMpUserId(account.getId());
+ param.setPhone(phone);
+ try {
+ R result = driverClient.ensureProfile(param);
+ if (!isSuccess(result) || result.getData() == null) {
+ log.warn("登录自动建档未完成(不阻断登录):租户={} 账号={} 手机号={} 返回={}",
+ tenantId, account.getId(), phone, result == null ? null : result.getMsg());
+ return null;
+ }
+ DriverEnsureProfileVO profile = result.getData();
+ if (StringUtil.isNotBlank(profile.getMessage())) {
+ log.warn("登录自动建档冲突(不阻断登录):租户={} 账号={} 手机号={} 原因={}",
+ tenantId, account.getId(), phone, profile.getMessage());
+ return profile.getMessage();
+ }
+ return null;
+ } catch (Exception exception) {
+ log.warn("登录自动建档失败(不阻断登录):租户={} 账号={} 手机号={}",
+ tenantId, account.getId(), phone, exception);
+ return null;
+ }
+ }
+
+ /**
+ * ④ 微信登录补绑 openid;失败只 WARN,不阻断登录
+ */
+ private void bindOpenid(MpUser account, String openid, boolean openidOccupied) {
+ if (account == null || StringUtil.isBlank(openid)) {
+ return;
+ }
+ // 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定
+ if (openidOccupied) {
+ return;
+ }
+ if (StringUtil.isNotBlank(account.getOpenid())) {
+ if (!openid.equals(account.getOpenid())) {
+ log.warn("小程序账号已有其他 openid,本次不覆盖:租户={} 账号={} 已有={} 本次={}",
+ account.getTenantId(), account.getId(), account.getOpenid(), openid);
+ }
+ return;
+ }
+ try {
+ R result = mpUserClient.bindOpenId(account.getTenantId(), account.getId(), openid);
+ if (!isSuccess(result) || !Boolean.TRUE.equals(result.getData())) {
+ log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}",
+ account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg());
+ }
+ } catch (Exception exception) {
+ log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}",
+ account.getTenantId(), account.getId(), openid, exception);
+ }
+ }
+
+ /**
+ * MpUserInfo 由 system 侧 buildUserInfo 统一组装,auth 不复制一份 detail 口径
+ */
+ private MpUserInfo loadUserInfo(MpUser account) {
+ try {
+ R result = mpUserClient.userInfoById(account.getId());
+ if (isSuccess(result) && result.getData() != null && result.getData().getUser() != null) {
+ return result.getData();
+ }
+ String message = result != null && !result.isSuccess() ? result.getMsg() : null;
+ log.error("小程序账号登录信息查询失败:租户={} 账号={} 返回={}", account.getTenantId(), account.getId(), message);
+ throw new UserInvalidException(StringUtil.isBlank(message) ? "账号信息查询失败,请稍后重试" : message);
+ } catch (UserInvalidException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("小程序账号登录信息查询失败:租户={} 账号={}", account.getTenantId(), account.getId(), exception);
+ throw new UserInvalidException("账号信息查询失败,请稍后重试");
+ }
+ }
+
+ private void attachNotice(MpUserInfo userInfo, String loginNotice) {
+ if (userInfo == null || StringUtil.isBlank(loginNotice)) {
+ return;
+ }
+ // detail 是否下发到前端取决于令牌序列化,冲突点同时另记 WARN
+ Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail();
+ detail.set(DETAIL_LOGIN_NOTICE, loginNotice);
+ userInfo.setDetail(detail);
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimValue = value.trim();
+ return trimValue.isEmpty() ? null : trimValue;
+ }
+
+ private boolean isSuccess(R> result) {
+ return result != null && result.isSuccess();
+ }
+
+}
diff --git a/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java
new file mode 100644
index 00000000..0a363310
--- /dev/null
+++ b/blade-auth/src/test/java/org/springblade/auth/endpoint/Oauth2SmsEndpointTest.java
@@ -0,0 +1,158 @@
+package org.springblade.auth.endpoint;
+
+import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
+import org.bouncycastle.util.encoders.Hex;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springblade.core.oauth2.props.OAuth2Properties;
+import org.springblade.core.oauth2.service.OAuth2User;
+import org.springblade.core.oauth2.service.OAuth2UserService;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.utils.SM2Util;
+import org.springblade.resource.feign.ISmsClient;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHONE_NOT_FOUND;
+
+/**
+ * 发码端点的端分流测试。
+ *
+ * 保住发码端点的分流口径:小程序端(User-Type: mp)对未注册手机号也发码,
+ * Web 端仍拒绝。
+ * 同时保住「发码不建号」:mp 分支根本不查账号。
+ */
+class Oauth2SmsEndpointTest {
+
+ private static final String TENANT_ID = "000000";
+ private static final String PHONE = "13800000001";
+ private static final String SMS_CODE = "ali_reg";
+
+ private static String publicKey;
+ private static String privateKey;
+
+ private ISmsClient smsClient;
+ private OAuth2UserService userService;
+ private Oauth2SmsEndpoint endpoint;
+
+ @BeforeAll
+ static void initSm2Keys() {
+ AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair();
+ publicKey = SM2Util.getPublicKeyString(keyPair);
+ privateKey = SM2Util.getPrivateKeyString(keyPair);
+ }
+
+ @BeforeEach
+ void setUp() {
+ OAuth2Properties properties = new OAuth2Properties();
+ properties.setPublicKey(publicKey);
+ properties.setPrivateKey(privateKey);
+ smsClient = mock(ISmsClient.class);
+ userService = mock(OAuth2UserService.class);
+ endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties);
+ when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1"));
+ }
+
+ @AfterEach
+ void clearRequestContext() {
+ RequestContextHolder.resetRequestAttributes();
+ }
+
+ @Test
+ @DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)")
+ void mpUnregisteredPhoneSendsCode() {
+ bindRequest("mp");
+ when(userService.loadByPhone(anyString(), any())).thenReturn(null);
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ verify(userService, never()).loadByPhone(anyString(), any());
+ }
+
+ @Test
+ @DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码")
+ void webUnregisteredPhoneIsRejected() {
+ bindRequest(null);
+ when(userService.loadByPhone(anyString(), any())).thenReturn(null);
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertFalse(result.isSuccess());
+ assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
+ verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码")
+ void webRegisteredPhoneSendsCode() {
+ bindRequest(null);
+ when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class));
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(userService).loadByPhone(anyString(), any());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ }
+
+ @Test
+ @DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码")
+ void mpRegisteredPhoneSendsCode() {
+ bindRequest("mp");
+
+ R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
+
+ assertTrue(result.isSuccess());
+ verify(userService, never()).loadByPhone(anyString(), any());
+ verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
+ }
+
+ @Test
+ @DisplayName("密文无法解密:无论哪端都不发码")
+ void invalidCipherIsRejected() {
+ bindRequest("mp");
+
+ R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE);
+
+ assertFalse(result.isSuccess());
+ assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
+ verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
+ }
+
+ /**
+ * 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提
+ */
+ private void bindRequest(String userType) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ String credential = Base64.getEncoder()
+ .encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8));
+ request.addHeader("Authorization", "Basic " + credential);
+ if (userType != null) {
+ request.addHeader("User-Type", userType);
+ }
+ RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+ }
+
+ private String encrypt(String plainPhone) {
+ return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey));
+ }
+
+}
diff --git a/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java
new file mode 100644
index 00000000..6d09b316
--- /dev/null
+++ b/blade-auth/src/test/java/org/springblade/auth/support/MpLoginSupportTest.java
@@ -0,0 +1,263 @@
+package org.springblade.auth.support;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springblade.core.oauth2.exception.UserInvalidException;
+import org.springblade.core.oauth2.service.OAuth2User;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.support.Kv;
+import org.springblade.system.feign.IMpUserClient;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
+import org.springblade.system.pojo.entity.MpUser;
+import org.springblade.system.pojo.entity.MpUserInfo;
+import org.springblade.transport.feign.IDriverClient;
+import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
+import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 登录编排测试。
+ *
+ * 保住三条不变式:建号失败才阻断登录;建档失败放行;
+ * openid 换号撞键时落到手机号账号且不迁移 openid。
+ */
+class MpLoginSupportTest {
+
+ private static final String TENANT_ID = "000000";
+ private static final String PHONE = "13800000001";
+ private static final String NEW_PHONE = "13800000002";
+ private static final String OPENID = "wx-openid-1";
+
+ private IMpUserClient mpUserClient;
+ private IDriverClient driverClient;
+ private MpLoginSupport mpLoginSupport;
+
+ @BeforeEach
+ void setUp() {
+ mpUserClient = mock(IMpUserClient.class);
+ driverClient = mock(IDriverClient.class);
+ mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient);
+ }
+
+ @Test
+ @DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示")
+ void newPhoneCreatesAccountAndProfile() {
+ // feign 下发时 phone 是脱敏值(MpUser.phone 标了 @Sensitive),建档必须仍用登录入参的真实号
+ MpUser created = mpUser(11L, "138****0001", OPENID, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
+ when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertNotNull(user);
+ assertEquals("11", user.getUserId());
+ assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE));
+ ArgumentCaptor ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class);
+ verify(mpUserClient).ensureUser(ensureCaptor.capture());
+ assertEquals(TENANT_ID, ensureCaptor.getValue().getTenantId());
+ assertEquals(PHONE, ensureCaptor.getValue().getPhone());
+ assertEquals(OPENID, ensureCaptor.getValue().getOpenid());
+ ArgumentCaptor profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class);
+ verify(driverClient).ensureProfile(profileCaptor.capture());
+ assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId());
+ assertEquals(11L, profileCaptor.getValue().getMpUserId());
+ assertEquals(PHONE, profileCaptor.getValue().getPhone());
+ }
+
+ @Test
+ @DisplayName("建档抛异常:登录仍然成功")
+ void profileFailureDoesNotBlockLogin() {
+ MpUser created = mpUser(12L, PHONE, OPENID, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
+ when(mpUserClient.userInfoById(12L)).thenReturn(R.data(userInfo(created, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class)))
+ .thenThrow(new RuntimeException("transport 不可用"));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertNotNull(user);
+ assertEquals("12", user.getUserId());
+ verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class));
+ }
+
+ @Test
+ @DisplayName("openid 绑定失败:登录仍然成功,不抛异常")
+ void bindOpenidFailureDoesNotBlockLogin() {
+ MpUser existing = mpUser(13L, PHONE, null, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
+ when(mpUserClient.userInfoById(13L)).thenReturn(R.data(userInfo(existing, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+ when(mpUserClient.bindOpenId(TENANT_ID, 13L, OPENID)).thenReturn(R.fail("该微信已绑定其他小程序用户"));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertNotNull(user);
+ assertEquals("13", user.getUserId());
+ verify(mpUserClient).bindOpenId(TENANT_ID, 13L, OPENID);
+ }
+
+ @Test
+ @DisplayName("打开号码来源:微信登录时给空 openid 账号补绑 openid")
+ void bindsOpenidForPhoneAccount() {
+ MpUser existing = mpUser(14L, PHONE, null, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
+ when(mpUserClient.userInfoById(14L)).thenReturn(R.data(userInfo(existing, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+ when(mpUserClient.bindOpenId(TENANT_ID, 14L, OPENID)).thenReturn(R.data(Boolean.TRUE));
+
+ mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ verify(mpUserClient).bindOpenId(TENANT_ID, 14L, OPENID);
+ }
+
+ @Test
+ @DisplayName("openid 命中旧账号但新手机号已被别的账号占用:落到手机号账号、不迁移 openid、有提示")
+ void phoneOccupiedKeepsPhoneAccount() {
+ MpUser openidAccount = mpUser(21L, "13900000000", OPENID, "driver");
+ MpUser phoneAccount = mpUser(22L, NEW_PHONE, null, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
+ when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(phoneAccount));
+ when(mpUserClient.userInfoById(22L)).thenReturn(R.data(userInfo(phoneAccount, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
+
+ assertNotNull(user);
+ assertEquals("22", user.getUserId(), "应落在手机号账号上");
+ verify(mpUserClient, never()).updateUser(any(MpUser.class));
+ verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
+ assertNotNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "应带上可读提示");
+ assertTrue(String.valueOf(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)).contains("该微信已绑定其他账号"));
+ }
+
+ @Test
+ @DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号")
+ void openidHitWithFreePhoneUpdatesPhone() {
+ MpUser openidAccount = mpUser(31L, "139****0000", OPENID, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
+ when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null));
+ when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE));
+ when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(openidAccount, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
+
+ assertEquals("31", user.getUserId());
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MpUser.class);
+ verify(mpUserClient).updateUser(updateCaptor.capture());
+ assertEquals(31L, updateCaptor.getValue().getId());
+ assertEquals(NEW_PHONE, updateCaptor.getValue().getPhone());
+ verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class));
+ }
+
+ @Test
+ @DisplayName("openid 命中且该手机号就是本账号:不重复更新手机号")
+ void openidHitSameAccountDoesNotUpdatePhone() {
+ MpUser openidAccount = mpUser(32L, "138****0001", OPENID, "driver");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
+ when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(openidAccount));
+ when(mpUserClient.userInfoById(32L)).thenReturn(R.data(userInfo(openidAccount, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertEquals("32", user.getUserId());
+ verify(mpUserClient, never()).updateUser(any(MpUser.class));
+ verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class));
+ }
+
+ @Test
+ @DisplayName("纯 admin 身份:不建司机档案")
+ void adminOnlyDoesNotCreateProfile() {
+ MpUser admin = mpUser(41L, PHONE, OPENID, "admin");
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(admin));
+ when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(admin, "admin")));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
+
+ assertEquals("41", user.getUserId());
+ verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
+ }
+
+ @Test
+ @DisplayName("建号失败是唯一阻断登录的条件")
+ void accountCreationFailureBlocksLogin() {
+ when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败"));
+
+ UserInvalidException exception = assertThrows(UserInvalidException.class,
+ () -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID));
+ assertEquals("创建小程序用户失败", exception.getMessage());
+ verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
+ }
+
+ @Test
+ @DisplayName("短信登录(openid 为空)不做 openid 相关调用")
+ void smsLoginSkipsOpenid() {
+ MpUser created = mpUser(51L, PHONE, null, "driver");
+ when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
+ when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(created, "driver")));
+ when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
+
+ OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null);
+
+ assertEquals("51", user.getUserId());
+ verify(mpUserClient, never()).userByOpenid(anyString(), anyString());
+ verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
+ }
+
+ private MpUser mpUser(Long id, String phone, String openid, String identities) {
+ MpUser user = new MpUser();
+ user.setId(id);
+ user.setTenantId(TENANT_ID);
+ user.setPhone(phone);
+ user.setAccount(phone);
+ user.setOpenid(openid);
+ user.setIdentities(identities);
+ user.setStatus(1);
+ return user;
+ }
+
+ private MpUserInfo userInfo(MpUser user, String... identities) {
+ MpUserInfo userInfo = new MpUserInfo();
+ userInfo.setUser(user);
+ userInfo.setIdentities(List.of(identities));
+ userInfo.setDetail(Kv.create()
+ .set("type", "mp")
+ .set("identities", String.join(",", identities))
+ .set("openid", user.getOpenid() == null ? "" : user.getOpenid()));
+ return userInfo;
+ }
+
+ private DriverEnsureProfileVO profileResult() {
+ DriverEnsureProfileVO result = new DriverEnsureProfileVO();
+ result.setDriverId(9001L);
+ result.setCreated(true);
+ result.setBoundExisting(false);
+ return result;
+ }
+
+}
diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java
index d92f413b..2e7e75f3 100644
--- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java
+++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java
@@ -61,6 +61,9 @@ public class OaPersonSyncPageVO implements Serializable {
@Schema(description = "本页跳过条数")
private Integer skippedCount;
+ @Schema(description = "全量同步结束时删除的、不在本次 OA 数据中的 is_oa=2 人员数")
+ private Integer removedCount;
+
@Schema(description = "是否已到最后一页")
private Boolean finished;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java
new file mode 100644
index 00000000..cdaf96ce
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/feign/IDriverClient.java
@@ -0,0 +1,58 @@
+/**
+ * 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.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";
+
+ /**
+ * 查找或创建司机档案,并把档案绑定到指定小程序账号
+ *
+ * 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用)
+ * 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。
+ *
+ * @param param 租户、小程序账号与手机号
+ * @return 档案ID与本次处置标志
+ */
+ @PostMapping(ENSURE_PROFILE)
+ R ensureProfile(@RequestBody DriverEnsureProfileDTO param);
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java
new file mode 100644
index 00000000..cab277e8
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/DriverEnsureProfileDTO.java
@@ -0,0 +1,55 @@
+/**
+ * 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;
+
+/**
+ * 司机档案「查找或创建并绑定账号」参数
+ *
+ * @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;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java
new file mode 100644
index 00000000..2e16aac2
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRejectRequest.java
@@ -0,0 +1,55 @@
+/**
+ * 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;
+
+/**
+ * 异常处置拒绝请求
+ *
+ * 只有拒绝原因一个业务字段:拒绝复用了结终态,不留独立列,
+ * 原因随跟进记录落进 follow_content。
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "异常处置拒绝请求")
+public class ExceptionDisposalRejectRequest implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "异常处置ID")
+ private Long id;
+
+ @Schema(description = "拒绝原因,必填,最多200字")
+ private String rejectReason;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java
new file mode 100644
index 00000000..b8a78404
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalRouteAdjustRequest.java
@@ -0,0 +1,78 @@
+/**
+ * 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;
+import java.util.List;
+
+/**
+ * 异常处置「同意并调整路线」请求
+ *
+ * 站点按数组顺序即途经顺序回传,kind 是站序的声明:首个 start、末个 end、其余 transit。
+ * 每个站点上的 tags 指向配载单里第 N 张子运单(形如「装1」「卸2」),后端据此落成站点行;
+ * 没有配载单的运单没有子运单归属,tags 不参与落库。
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "异常处置调整路线请求")
+public class ExceptionDisposalRouteAdjustRequest implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "异常处置ID")
+ private Long id;
+
+ @Schema(description = "调整后的站点,按途经顺序排列")
+ private List routeNodes;
+
+ /**
+ * 一个站点:地址 + 该站装卸的子运单标记
+ */
+ @Data
+ @Schema(description = "路线站点")
+ public static class RouteNode implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "站点类型:start 首站、transit 中间站、end 末站")
+ private String kind;
+
+ @Schema(description = "站点地址")
+ private String address;
+
+ @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号")
+ private List tags;
+
+ }
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
index fc7c3ce8..9c2007c1 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java
@@ -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;
/**
* 与联系人关系
*/
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java
index b0dca1a4..aa0dbfb5 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java
@@ -65,6 +65,9 @@ public class AdminWaybillCardVO implements Serializable {
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
private Integer status;
+ @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
+ private String businessStatus;
+
@Schema(description = "承运方")
private String carrierName;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java
index ff601f14..e874df68 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java
@@ -48,6 +48,9 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
private Integer status;
+ @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
+ private String businessStatus;
+
@Schema(description = "起点名称")
private String fromName;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java
new file mode 100644
index 00000000..8859abad
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnsureProfileVO.java
@@ -0,0 +1,58 @@
+/**
+ * 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.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;
+
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java
index 691cb573..d5d10b60 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java
@@ -52,4 +52,7 @@ public class DriverNodePunchVO implements Serializable {
@Schema(description = "本次打卡是否使运单自动完成")
private Boolean waybillCompleted;
+ @Schema(description = "本次打卡后的运单业务状态:running / pending_return_confirm / completed")
+ private String waybillBusinessStatus;
+
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java
index 23432c6a..a4be3625 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java
@@ -76,6 +76,9 @@ public class DriverWaybillCardVO implements Serializable {
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
private Integer status;
+ @Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
+ private String businessStatus;
+
@Schema(description = "创建时间")
private String createTime;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java
index 77839038..d120aa54 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java
@@ -30,6 +30,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serial;
+import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -93,4 +94,40 @@ public class ExceptionDisposalVO extends ExceptionDisposal {
@Schema(description = "货物信息:{name, weight}")
private Map cargo;
+ @TableField(exist = false)
+ @Schema(description = "关联运单的配载单号,没有配载单时为空")
+ private String loadingNo;
+
+ @TableField(exist = false)
+ @Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列")
+ private List childWaybillNos;
+
+ @TableField(exist = false)
+ @Schema(description = "可调整的路线站点,少于两个站时为空")
+ private List routeNodes;
+
+ /**
+ * 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名
+ */
+ @Data
+ @Schema(description = "路线站点")
+ public static class RouteNode implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "站点类型:start 首站、transit 中间站、end 末站")
+ private String kind;
+
+ @Schema(description = "站点地址")
+ private String address;
+
+ @Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号")
+ private List tags;
+
+ @Schema(description = "该站装卸的子运单项目名,按标记顺序去重")
+ private List projects;
+
+ }
+
}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java
index 0fd0b218..750dd6d0 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java
@@ -65,7 +65,7 @@ public class TripQueryVO implements Serializable {
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
private List statuses;
- @Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
+ @Schema(description = "关键字:运单号 / 配载单号 / 项目 / 司机 / 车牌 / 起终点 / 地址(任一子单命中即整趟车命中)")
private String keyword;
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
index ad462f4d..1e140461 100644
--- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
+++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java
@@ -24,6 +24,7 @@ package org.springblade.system.feign;
import org.springblade.core.launch.constant.AppConstant;
import org.springblade.core.tool.api.R;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
@@ -47,6 +48,7 @@ public interface IMpUserClient {
String USER_BY_ID = API_PREFIX + "/user-by-id";
String USER_BY_PHONE = API_PREFIX + "/user-by-phone";
String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account";
+ String USER_BY_OPENID = API_PREFIX + "/user-by-openid";
String USER_INFO_BY_ID = API_PREFIX + "/info-by-id";
String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone";
String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account";
@@ -55,6 +57,7 @@ public interface IMpUserClient {
String BIND_OPENID = API_PREFIX + "/bind-openid";
String SET_PASSWORD = API_PREFIX + "/set-password";
String RESOLVE_DATA_SCOPE = API_PREFIX + "/resolve-data-scope";
+ String ENSURE_USER = API_PREFIX + "/ensure-user";
/**
* 按主键查询
@@ -80,6 +83,18 @@ public interface IMpUserClient {
@GetMapping(USER_BY_ACCOUNT)
R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account);
+ /**
+ * 按租户 + openid 查询
+ */
+ @GetMapping(USER_BY_OPENID)
+ R userByOpenid(@RequestParam("tenantId") String tenantId, @RequestParam("openid") String openid);
+
+ /**
+ * 查找或创建小程序用户(登录路径专用,已存在时不抛异常、不改身份)
+ */
+ @PostMapping(ENSURE_USER)
+ R ensureUser(@RequestBody MpUserEnsureDTO param);
+
/**
* 按主键查询登录信息
*/
diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java
new file mode 100644
index 00000000..ea38a27c
--- /dev/null
+++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/dto/MpUserEnsureDTO.java
@@ -0,0 +1,55 @@
+/**
+ * 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.system.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 MpUserEnsureDTO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "租户ID,为空时依次取 Tenant-Id 请求头与默认租户")
+ private String tenantId;
+
+ @Schema(description = "手机号")
+ private String phone;
+
+ @Schema(description = "微信小程序openid,非空时随首次建号一并写入")
+ private String openid;
+
+}
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java
index 9f0ec270..e8b4df1e 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java
@@ -43,6 +43,7 @@ import org.springblade.core.excel.util.ExcelUtil;
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.secure.constant.AuthConstant;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
@@ -117,6 +118,9 @@ public class RegionController extends BladeController {
/**
* 懒加载列表
*/
+ // 引用数据下拉:小程序司机认证表单的住址选择也调用本接口。小程序用户不绑定系统角色,
+ // 落到类上的 menu=region 校验会抛 401,前端随即清登录态把用户踢回登录页。
+ @PreAuth(AuthConstant.PERMIT_ALL)
@GetMapping("/lazy-tree")
@Parameters({
@Parameter(name = "code", description = "区划编号", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
index 4a4becd4..57dbd07c 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java
@@ -26,6 +26,7 @@ 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.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
@@ -73,6 +74,18 @@ public class MpUserClient implements IMpUserClient {
return R.data(mpUserService.getByAccount(tenantId, account));
}
+ @Override
+ @GetMapping(USER_BY_OPENID)
+ public R userByOpenid(String tenantId, String openid) {
+ return R.data(mpUserService.getByOpenid(tenantId, openid));
+ }
+
+ @Override
+ @PostMapping(ENSURE_USER)
+ public R ensureUser(@RequestBody MpUserEnsureDTO param) {
+ return R.data(mpUserService.ensureUser(param));
+ }
+
@Override
@GetMapping(USER_INFO_BY_ID)
public R userInfoById(Long userId) {
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml
index 76c059bf..24b2d01f 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserDeptMapper.xml
@@ -13,7 +13,7 @@
bud.user_id
from
blade_user_dept bud
- left join blade_dept bd on bd.dept_code = bud.dept_code
+ left join blade_dept bd on bd.id = bud.dept_id and bd.is_deleted = 0
where
bd.id is not null
and bud.is_deleted = 0
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java
index e6102c4d..718041cb 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java
@@ -24,6 +24,7 @@ package org.springblade.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
@@ -48,6 +49,18 @@ public interface IMpUserService extends BaseService {
*/
MpUser getByAccount(String tenantId, String account);
+ /**
+ * 按租户 + 微信小程序 openid 查询
+ */
+ MpUser getByOpenid(String tenantId, String openid);
+
+ /**
+ * 查找或创建小程序用户(登录路径专用)
+ *
+ * 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。
+ */
+ MpUser ensureUser(MpUserEnsureDTO param);
+
/**
* 按主键组装登录信息
*/
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
index fdd83446..c93c8097 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java
@@ -24,7 +24,9 @@ package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springblade.common.utils.PasswordRuleUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
@@ -34,10 +36,12 @@ import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.DigestUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
+import org.springblade.core.tool.utils.WebUtil;
import org.springblade.system.cache.MpUserCache;
import org.springblade.system.cache.SysCache;
import org.springblade.system.mapper.MpUserDataScopeMapper;
import org.springblade.system.mapper.MpUserMapper;
+import org.springblade.system.pojo.dto.MpUserEnsureDTO;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserDataScope;
import org.springblade.system.pojo.entity.MpUserInfo;
@@ -58,6 +62,7 @@ import java.util.Objects;
*
* @author Chill
*/
+@Slf4j
@Service
@RequiredArgsConstructor
public class MpUserServiceImpl extends BaseServiceImpl implements IMpUserService {
@@ -73,6 +78,16 @@ public class MpUserServiceImpl extends BaseServiceImpl imp
/** 数据层级范围:仅本级 */
private static final int DATA_LEVEL_CURRENT_ONLY = 2;
+ /**
+ * 兜底租户:与 blade_mp_user.tenant_id 的建表默认值一致
+ */
+ private static final String DEFAULT_TENANT_ID = "000000";
+
+ /**
+ * 登录请求携带租户的请求头名
+ */
+ private static final String TENANT_HEADER = "Tenant-Id";
+
private final MpUserDataScopeMapper mpUserDataScopeMapper;
@Override
@@ -99,6 +114,92 @@ public class MpUserServiceImpl extends BaseServiceImpl imp
.last("LIMIT 1"));
}
+ @Override
+ public MpUser getByOpenid(String tenantId, String openid) {
+ if (Func.isBlank(tenantId) || Func.isBlank(openid)) {
+ return null;
+ }
+ return getOne(Wrappers.lambdaQuery()
+ .eq(MpUser::getTenantId, tenantId)
+ .eq(MpUser::getOpenid, openid)
+ .orderByDesc(MpUser::getCreateTime)
+ .last("LIMIT 1"));
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public MpUser ensureUser(MpUserEnsureDTO param) {
+ if (param == null) {
+ throw new ServiceException("小程序用户参数不能为空");
+ }
+ String tenantId = resolveEnsureTenantId(param.getTenantId());
+ String phone = trimToNull(param.getPhone());
+ if (Func.isBlank(phone)) {
+ throw new ServiceException("手机号不能为空");
+ }
+ // 先查后插:命中即返回,不新建也不改身份。租户为空时唯一键对 NULL 不去重,
+ // 故 resolveEnsureTenantId 已在写库前把租户兜到非空。
+ MpUser existed = getByPhone(tenantId, phone);
+ if (existed != null && Func.isNotEmpty(existed.getId())) {
+ return existed;
+ }
+ MpUser user = new MpUser();
+ user.setTenantId(tenantId);
+ user.setPhone(phone);
+ user.setAccount(phone);
+ user.setIdentities(MpUserIdentity.DRIVER.getCode());
+ user.setOpenid(trimToNull(param.getOpenid()));
+ user.setStatus(1);
+ prepare(user);
+ validate(user);
+ // 登录路径不设密码,password 保持为空
+ user.setPassword(null);
+ try {
+ save(user);
+ } catch (Exception exception) {
+ // 并发建号撞 (tenant_id, phone) 唯一键:重读一次并返回已有账号
+ MpUser concurrent = getByPhone(tenantId, phone);
+ if (concurrent != null && Func.isNotEmpty(concurrent.getId())) {
+ return concurrent;
+ }
+ log.warn("小程序用户建号失败,租户={} 手机号={}", tenantId, phone, exception);
+ throw new ServiceException("创建小程序用户失败");
+ }
+ if (Func.isEmpty(user.getId())) {
+ throw new ServiceException("创建小程序用户失败");
+ }
+ MpUserCache.clear();
+ return user;
+ }
+
+ /**
+ * 登录路径的租户解析:Tenant-Id 头 → 入参 tenantId → 默认租户
+ *
+ * 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (tenant_id, phone)
+ * 对 NULL 不去重,租户为 NULL 会为同一手机号造出重复账号。
+ */
+ private String resolveEnsureTenantId(String tenantId) {
+ String resolved = requestHeaderTenantId();
+ if (resolved == null) {
+ resolved = trimToNull(tenantId);
+ }
+ if (resolved == null) {
+ resolved = DEFAULT_TENANT_ID;
+ }
+ return resolved;
+ }
+
+ /**
+ * 当前请求头里的租户;无请求上下文时返回 null
+ */
+ private String requestHeaderTenantId() {
+ HttpServletRequest request = WebUtil.getRequest();
+ if (request == null) {
+ return null;
+ }
+ return trimToNull(request.getHeader(TENANT_HEADER));
+ }
+
@Override
public MpUserInfo userInfo(Long userId) {
if (Func.isEmpty(userId)) {
@@ -324,6 +425,13 @@ public class MpUserServiceImpl extends BaseServiceImpl imp
if (!tenantId.equals(user.getTenantId())) {
throw new ServiceException("租户不匹配,无法绑定 openid");
}
+ // 目标账号已有另一个非空 openid 时不覆盖:一个微信换绑由管理员处理,
+ // 登录路径直接放弃本次绑定
+ if (Func.isNotBlank(user.getOpenid()) && !user.getOpenid().equals(openid)) {
+ log.warn("小程序用户已绑定其他 openid,跳过绑定:租户={} 账号={} 已有openid={} 本次openid={}",
+ tenantId, userId, user.getOpenid(), openid);
+ return false;
+ }
MpUser byOpenId = getOne(Wrappers.lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getOpenid, openid)
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java
index 44e5e746..21d62c4a 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java
@@ -277,6 +277,11 @@ public class OASyncServiceImpl implements IOASyncService {
}, 10000, ComposeLogUtil.getLastLog()::info).accept(oaPersons::addAll);
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
+ if (startTime == null) {
+ oaUserListSyncHelper.removeOaUsersAbsentFrom(
+ oaUserListSyncHelper.extractOaLoginIds(oaPersons),
+ oaUserListSyncHelper.extractWorkcodes(oaPersons));
+ }
CacheUtil.clear(USER_CACHE);
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
@@ -295,9 +300,10 @@ public class OASyncServiceImpl implements IOASyncService {
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200);
- // 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON
+ // 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员
if (!incremental && pageNo == 1) {
oaSyncRawDumpHelper.beginFullDump();
+ oaUserListSyncHelper.beginFullSyncReconcile();
}
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
@@ -314,9 +320,17 @@ public class OASyncServiceImpl implements IOASyncService {
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
if (!incremental) {
oaSyncRawDumpHelper.collectPersons(oaPersons);
+ oaUserListSyncHelper.collectFullSyncPersons(oaPersons);
}
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
+ boolean finished = oaPersons.isEmpty()
+ || oaPersons.size() < pageSize
+ || (long) pageNo * pageSize >= totalSize;
+ int removedCount = personSyncCount.getRemovedCount();
+ if (!incremental && finished) {
+ removedCount += oaUserListSyncHelper.finishFullSyncReconcile();
+ }
CacheUtil.clear(USER_CACHE);
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
@@ -327,17 +341,15 @@ public class OASyncServiceImpl implements IOASyncService {
pageVO.setFetchedCount(oaPersons.size());
pageVO.setSyncedCount(personSyncCount.getSyncedCount());
pageVO.setSkippedCount(personSyncCount.getSkippedCount());
+ pageVO.setRemovedCount(removedCount);
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
- boolean finished = oaPersons.isEmpty()
- || oaPersons.size() < pageSize
- || (long) pageNo * pageSize >= totalSize;
pageVO.setFinished(finished);
if (!incremental && finished) {
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump());
}
- ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
+ ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{},删除{}",
incremental ? "incremental" : "full", pageNo, totalSize,
- personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
+ personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount(), removedCount);
return pageVO;
}
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java
index 46f8af2b..f7382827 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
+import org.springblade.common.constant.CommonConstant;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.DigestUtil;
@@ -55,10 +56,12 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD;
@@ -76,6 +79,22 @@ public class OaUserListSyncHelper {
private static final int MAX_DEPT_ID_LENGTH = 2000;
+ private static final int OA_LOGIN_ID_BATCH_SIZE = 500;
+
+ /**
+ * OA 人员状态:5 离职。离职人员不入库,已有 is_oa=2 账号直接删除。
+ */
+ private static final String OA_PERSON_STATUS_RESIGNED = "5";
+
+ /**
+ * 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。
+ */
+ private final Set fullSyncLoginIds = ConcurrentHashMap.newKeySet();
+
+ private final Set fullSyncWorkcodes = ConcurrentHashMap.newKeySet();
+
+ private volatile boolean fullSyncCollecting;
+
private final IDeptService deptService;
private final IUserService userService;
private final UserConvert userConvert;
@@ -390,48 +409,325 @@ public class OaUserListSyncHelper {
}
/**
- * 同步人员并绑定到三级部门
+ * 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。
+ */
+ public synchronized void beginFullSyncReconcile() {
+ this.fullSyncLoginIds.clear();
+ this.fullSyncWorkcodes.clear();
+ this.fullSyncCollecting = true;
+ }
+
+ /**
+ * 累积本页 OA 人员登录名,供全量同步结束后比对删除。
+ *
+ * @param oaPersons 本页 OA 人员
+ */
+ public synchronized void collectFullSyncPersons(List oaPersons) {
+ if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) {
+ return;
+ }
+ this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons));
+ this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons));
+ }
+
+ /**
+ * 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。
+ *
+ * @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员
+ */
+ public synchronized int finishFullSyncReconcile() {
+ if (!this.fullSyncCollecting) {
+ return 0;
+ }
+ this.fullSyncCollecting = false;
+ Set oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds);
+ Set workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes);
+ this.fullSyncLoginIds.clear();
+ this.fullSyncWorkcodes.clear();
+ return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes);
+ }
+
+ /**
+ * 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。
+ *
+ * @param oaPersons OA人员
+ * @return 去重后的登录名
+ */
+ public Set extractOaLoginIds(List oaPersons) {
+ Set oaLoginIds = new LinkedHashSet<>();
+ if (CollectionUtil.isEmpty(oaPersons)) {
+ return oaLoginIds;
+ }
+ for (OAPersonResponse oaPerson : oaPersons) {
+ String oaLoginId = this.resolveOaLoginId(oaPerson);
+ if (oaLoginId != null && !this.isOaResigned(oaPerson)) {
+ oaLoginIds.add(oaLoginId);
+ }
+ }
+ return oaLoginIds;
+ }
+
+ /**
+ * 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。
+ *
+ * @param oaPersons OA人员
+ * @return 去重后的工号
+ */
+ public Set extractWorkcodes(List oaPersons) {
+ Set workcodes = new LinkedHashSet<>();
+ if (CollectionUtil.isEmpty(oaPersons)) {
+ return workcodes;
+ }
+ for (OAPersonResponse oaPerson : oaPersons) {
+ String workcode = this.resolveWorkcode(oaPerson);
+ if (workcode != null && !this.isOaResigned(oaPerson)) {
+ workcodes.add(workcode);
+ }
+ }
+ return workcodes;
+ }
+
+ /**
+ * 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。
+ * 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。
+ *
+ * @param oaLoginIds 本次全量同步出现过的 OA 登录名
+ * @param workcodes 本次全量同步出现过的工号
+ * @return 删除人数
+ */
+ public int removeOaUsersAbsentFrom(Set oaLoginIds, Set workcodes) {
+ boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds);
+ boolean noWorkcodes = CollectionUtil.isEmpty(workcodes);
+ if (noLoginIds && noWorkcodes) {
+ ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员");
+ return 0;
+ }
+ Set loginIdSet = noLoginIds ? Set.of() : oaLoginIds;
+ Set workcodeSet = noWorkcodes ? Set.of() : workcodes;
+ String tenantId = this.resolveTenantId();
+ List oaUsers = this.userService.list(Wrappers.lambdaQuery()
+ .select(User::getId, User::getOaLoginId, User::getCode)
+ .eq(User::getTenantId, tenantId)
+ .eq(User::getIsOa, CommonConstant.YES));
+ Long currentUserId = AuthUtil.getUserId();
+ List removeIds = new ArrayList<>();
+ for (User oaUser : oaUsers) {
+ if (oaUser.getId() == null) {
+ continue;
+ }
+ if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
+ ComposeLogUtil.getLastLog().warn("全量同步跳过删除当前登录账号 userId={}", oaUser.getId());
+ continue;
+ }
+ String oaLoginId = StringUtils.trimToNull(oaUser.getOaLoginId());
+ String workcode = StringUtils.trimToNull(oaUser.getCode());
+ boolean keptByLoginId = oaLoginId != null && loginIdSet.contains(oaLoginId);
+ boolean keptByWorkcode = oaLoginId == null && workcode != null && workcodeSet.contains(workcode);
+ if (!keptByLoginId && !keptByWorkcode) {
+ removeIds.add(oaUser.getId());
+ }
+ }
+ if (removeIds.isEmpty()) {
+ ComposeLogUtil.getLastLog().info("全量同步无需删除 is_oa=2 人员");
+ return 0;
+ }
+ return this.deleteOaUsers(removeIds, "全量同步删除不在OA数据中的 is_oa=2 人员");
+ }
+
+ /**
+ * 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。
+ *
+ * @param tenantId 租户
+ * @param oaLoginIds 离职人员登录名
+ * @return 删除人数
+ */
+ private int removeOaUsersByLoginIds(String tenantId, Collection oaLoginIds) {
+ if (CollectionUtil.isEmpty(oaLoginIds)) {
+ return 0;
+ }
+ List oaLoginIdList = oaLoginIds.stream()
+ .filter(StringUtils::isNotBlank)
+ .distinct()
+ .toList();
+ if (oaLoginIdList.isEmpty()) {
+ return 0;
+ }
+ Long currentUserId = AuthUtil.getUserId();
+ List removeIds = new ArrayList<>();
+ for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
+ this.userService.list(Wrappers.lambdaQuery()
+ .select(User::getId, User::getOaLoginId)
+ .eq(User::getTenantId, tenantId)
+ .eq(User::getIsOa, CommonConstant.YES)
+ .in(User::getOaLoginId, oaLoginIdBatch))
+ .forEach(oaUser -> {
+ if (oaUser.getId() == null) {
+ return;
+ }
+ if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
+ ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId());
+ return;
+ }
+ removeIds.add(oaUser.getId());
+ });
+ }
+ return this.deleteOaUsers(removeIds, "同步删除OA离职人员");
+ }
+
+ /**
+ * 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。
+ *
+ * @param tenantId 租户
+ * @param workcodes 离职人员工号
+ * @return 删除人数
+ */
+ private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection workcodes) {
+ if (CollectionUtil.isEmpty(workcodes)) {
+ return 0;
+ }
+ List workcodeList = workcodes.stream()
+ .filter(StringUtils::isNotBlank)
+ .distinct()
+ .toList();
+ if (workcodeList.isEmpty()) {
+ return 0;
+ }
+ Long currentUserId = AuthUtil.getUserId();
+ List removeIds = new ArrayList<>();
+ for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
+ this.userService.list(Wrappers.lambdaQuery()
+ .select(User::getId, User::getOaLoginId, User::getCode)
+ .eq(User::getTenantId, tenantId)
+ .eq(User::getIsOa, CommonConstant.YES)
+ .in(User::getCode, workcodeBatch)
+ .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
+ .forEach(oaUser -> {
+ if (oaUser.getId() == null) {
+ return;
+ }
+ if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
+ ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId());
+ return;
+ }
+ removeIds.add(oaUser.getId());
+ });
+ }
+ return this.deleteOaUsers(removeIds, "同步删除无登录名的OA离职人员");
+ }
+
+ /**
+ * 逻辑删除用户及其部门关系。
+ *
+ * @param removeIds 用户 id
+ * @param logMessage 日志说明
+ * @return 删除人数
+ */
+ private int deleteOaUsers(List removeIds, String logMessage) {
+ if (CollectionUtil.isEmpty(removeIds)) {
+ return 0;
+ }
+ for (int fromIndex = 0; fromIndex < removeIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List removeIdBatch = removeIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, removeIds.size()));
+ this.userService.deleteLogic(removeIdBatch);
+ this.userDeptService.remove(Wrappers.lambdaQuery().in(UserDept::getUserId, removeIdBatch));
+ }
+ ComposeLogUtil.getLastLog().info("{}:{}", logMessage, removeIds.size());
+ return removeIds.size();
+ }
+
+ /**
+ * OA 状态 5 表示离职。
+ */
+ private boolean isOaResigned(OAPersonResponse oaPerson) {
+ return oaPerson != null && OA_PERSON_STATUS_RESIGNED.equals(StringUtils.trimToEmpty(oaPerson.getStatus()));
+ }
+
+ /**
+ * 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。
*
* @param oaPersons OA人员
* @param orgIndex 组织索引
- * @return 本批同步成功与跳过数量
+ * @return 本批同步成功、跳过与删除数量
*/
public PersonSyncCount handlePerson(List oaPersons, OaOrgIndex orgIndex) {
if (CollectionUtil.isEmpty(oaPersons)) {
- return new PersonSyncCount(0, 0);
+ return new PersonSyncCount(0, 0, 0);
}
Map uniquePersonMap = new LinkedHashMap<>();
+ Set resignedLoginIds = new LinkedHashSet<>();
+ Set resignedWorkcodes = new LinkedHashSet<>();
int skippedCount = 0;
for (OAPersonResponse oaPerson : oaPersons) {
- String oaLoginId = resolveOaLoginId(oaPerson);
- if (StringUtils.isBlank(oaLoginId)) {
+ String syncKey = this.resolveSyncKey(oaPerson);
+ if (syncKey == null) {
skippedCount++;
continue;
}
- uniquePersonMap.put(oaLoginId, oaPerson);
- }
- if (uniquePersonMap.isEmpty()) {
- ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步");
- return new PersonSyncCount(0, skippedCount);
+ String oaLoginId = this.resolveOaLoginId(oaPerson);
+ String workcode = this.resolveWorkcode(oaPerson);
+ if (this.isOaResigned(oaPerson)) {
+ uniquePersonMap.remove(syncKey);
+ if (oaLoginId != null) {
+ resignedLoginIds.add(oaLoginId);
+ } else if (workcode != null) {
+ resignedWorkcodes.add(workcode);
+ }
+ continue;
+ }
+ if (oaLoginId != null) {
+ resignedLoginIds.remove(oaLoginId);
+ } else if (workcode != null) {
+ resignedWorkcodes.remove(workcode);
+ }
+ uniquePersonMap.put(syncKey, oaPerson);
}
String tenantId = resolveTenantId();
+ int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds)
+ + this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes);
+ if (uniquePersonMap.isEmpty()) {
+ if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) {
+ ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步");
+ }
+ return new PersonSyncCount(0, skippedCount, removedCount);
+ }
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
- List users = uniquePersonMap.entrySet().stream()
- .map(entry -> {
- User user = userConvert.person2user(entry.getValue(), defaultPassword);
- user.setTenantId(tenantId);
- user.setOaLoginId(entry.getKey());
- user.setAccount(entry.getKey());
- return user;
- })
- .toList();
- Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
+ Set oaLoginIds = new LinkedHashSet<>();
+ Set workcodes = new LinkedHashSet<>();
+ for (OAPersonResponse oaPerson : uniquePersonMap.values()) {
+ String oaLoginId = this.resolveOaLoginId(oaPerson);
+ if (oaLoginId != null) {
+ oaLoginIds.add(oaLoginId);
+ continue;
+ }
+ String workcode = this.resolveWorkcode(oaPerson);
+ if (workcode != null) {
+ workcodes.add(workcode);
+ }
+ }
+ Map existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds);
+ Map existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes);
+ List users = new ArrayList<>();
Map userMap = new HashMap<>();
Set existsUserIds = new HashSet<>();
- users.forEach(user -> {
- User existingUser = existingByOaLoginId.get(user.getOaLoginId());
+ for (Map.Entry entry : uniquePersonMap.entrySet()) {
+ OAPersonResponse oaPerson = entry.getValue();
+ String oaLoginId = this.resolveOaLoginId(oaPerson);
+ String workcode = this.resolveWorkcode(oaPerson);
+ User user = this.userConvert.person2user(oaPerson, defaultPassword);
+ user.setTenantId(tenantId);
+ User existingUser = oaLoginId != null ? existingByOaLoginId.get(oaLoginId) : existingByWorkcode.get(workcode);
+ if (oaLoginId != null) {
+ user.setOaLoginId(oaLoginId);
+ user.setAccount(oaLoginId);
+ } else {
+ user.setOaLoginId(null);
+ user.setAccount(existingUser == null ? workcode : null);
+ }
if (existingUser != null) {
user.setId(existingUser.getId());
+ // 全局更新策略为 not_null,置空后不会回写密码和登录名
user.setPassword(null);
existsUserIds.add(existingUser.getId());
} else {
@@ -442,9 +738,10 @@ public class OaUserListSyncHelper {
user.setDataLevelRange(1);
user.setIncludeNewCustomer(0);
}
- userService.encryptPhone(user);
- userMap.put(user.getOaLoginId(), user.getId());
- });
+ this.userService.encryptPhone(user);
+ users.add(user);
+ userMap.put(entry.getKey(), user.getId());
+ }
List addUsers = users.stream()
.filter(user -> !existsUserIds.contains(user.getId()))
@@ -460,33 +757,58 @@ public class OaUserListSyncHelper {
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
userService.updateBatchById(updateUsers);
}
- ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount);
+ ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount);
int syncedCount = addUsers.size() + updateUsers.size();
if (userMap.isEmpty()) {
- return new PersonSyncCount(0, skippedCount);
+ return new PersonSyncCount(0, skippedCount, removedCount);
}
- Map userDeptMap = userDeptService.list(Wrappers.lambdaQuery()
- .in(UserDept::getUserId, userMap.values())
- ).stream()
+ List existingUserDeptList = userDeptService.list(Wrappers.lambdaQuery()
+ .in(UserDept::getUserId, userMap.values()));
+ Map userDeptMap = existingUserDeptList.stream()
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second));
Set existsUserDeptIds = new HashSet<>(userDeptMap.values());
- List userDeptList = oaPersons.stream()
+ Map currentUserDeptMap = new LinkedHashMap<>();
+ oaPersons.stream()
.filter(oaPerson -> {
- String oaLoginId = resolveOaLoginId(oaPerson);
- return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId);
+ String syncKey = this.resolveSyncKey(oaPerson);
+ return syncKey != null && userMap.containsKey(syncKey) && !this.isOaResigned(oaPerson);
})
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
.filter(Objects::nonNull)
+ .forEach(userDept -> {
+ String userDeptKey = getUserDeptKey(userDept);
+ if (userDeptMap.containsKey(userDeptKey)) {
+ userDept.setId(userDeptMap.get(userDeptKey));
+ } else {
+ UserDept duplicated = currentUserDeptMap.get(userDeptKey);
+ userDept.setId(duplicated == null
+ ? DefaultIdentifierGenerator.getInstance().nextId(null)
+ : duplicated.getId());
+ }
+ currentUserDeptMap.put(userDeptKey, userDept);
+ });
+ List userDeptList = new ArrayList<>(currentUserDeptMap.values());
+ Set currentUserDeptIds = userDeptList.stream()
+ .map(UserDept::getId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ Set reboundUserIds = userDeptList.stream()
+ .map(UserDept::getUserId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ List staleUserDeptIds = existingUserDeptList.stream()
+ .filter(userDept -> userDept.getId() != null && reboundUserIds.contains(userDept.getUserId()))
+ .map(UserDept::getId)
+ .filter(userDeptId -> !currentUserDeptIds.contains(userDeptId))
.toList();
- userDeptList.forEach(userDept -> {
- String userDeptKey = getUserDeptKey(userDept);
- if (userDeptMap.containsKey(userDeptKey)) {
- userDept.setId(userDeptMap.get(userDeptKey));
- } else {
- userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
+ if (CollectionUtil.isNotEmpty(staleUserDeptIds)) {
+ ComposeLogUtil.getLastLog().info("移除不再属于当前OA部门的人员关系:{}", staleUserDeptIds.size());
+ for (int fromIndex = 0; fromIndex < staleUserDeptIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List staleIdBatch = staleUserDeptIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, staleUserDeptIds.size()));
+ userDeptService.removeByIds(staleIdBatch);
}
- });
+ }
List addList = userDeptList.stream()
.filter(userDept -> !existsUserDeptIds.contains(userDept.getId()))
.toList();
@@ -540,11 +862,11 @@ public class OaUserListSyncHelper {
if (CollectionUtil.isNotEmpty(updateUserParams)) {
userService.updateBatchById(updateUserParams);
}
- return new PersonSyncCount(syncedCount, skippedCount);
+ return new PersonSyncCount(syncedCount, skippedCount, removedCount);
}
/**
- * 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
+ * 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
*/
private Map loadUsersByOaLoginId(String tenantId, Collection oaLoginIds) {
Map existingByOaLoginId = new HashMap<>();
@@ -555,11 +877,11 @@ public class OaUserListSyncHelper {
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
- int batchSize = 500;
- for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
- List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
+ for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
userService.list(Wrappers.lambdaQuery()
.eq(User::getTenantId, tenantId)
+ .eq(User::getIsOa, CommonConstant.YES)
.in(User::getOaLoginId, oaLoginIdBatch))
.forEach(user -> {
String oaLoginId = StringUtils.trimToNull(user.getOaLoginId());
@@ -584,6 +906,39 @@ public class OaUserListSyncHelper {
return existingByOaLoginId;
}
+ /**
+ * 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。
+ */
+ private Map loadUsersByBlankLoginWorkcode(String tenantId, Collection workcodes) {
+ Map existingByWorkcode = new HashMap<>();
+ if (CollectionUtil.isEmpty(workcodes)) {
+ return existingByWorkcode;
+ }
+ List workcodeList = workcodes.stream()
+ .filter(StringUtils::isNotBlank)
+ .distinct()
+ .toList();
+ for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
+ List workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
+ this.userService.list(Wrappers.lambdaQuery()
+ .eq(User::getTenantId, tenantId)
+ .eq(User::getIsOa, CommonConstant.YES)
+ .in(User::getCode, workcodeBatch)
+ .and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
+ .forEach(user -> {
+ String workcode = StringUtils.trimToNull(user.getCode());
+ if (workcode == null) {
+ return;
+ }
+ User previous = existingByWorkcode.get(workcode);
+ if (previous == null || (user.getId() != null && previous.getId() != null && user.getId() < previous.getId())) {
+ existingByWorkcode.put(workcode, user);
+ }
+ });
+ }
+ return existingByWorkcode;
+ }
+
private String resolveOaLoginId(OAPersonResponse oaPerson) {
if (oaPerson == null) {
return null;
@@ -591,6 +946,28 @@ public class OaUserListSyncHelper {
return StringUtils.trimToNull(oaPerson.getLoginid());
}
+ private String resolveWorkcode(OAPersonResponse oaPerson) {
+ if (oaPerson == null) {
+ return null;
+ }
+ return StringUtils.trimToNull(oaPerson.getWorkcode());
+ }
+
+ /**
+ * 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。
+ */
+ private String resolveSyncKey(OAPersonResponse oaPerson) {
+ String oaLoginId = this.resolveOaLoginId(oaPerson);
+ if (oaLoginId != null) {
+ return "L:" + oaLoginId;
+ }
+ String workcode = this.resolveWorkcode(oaPerson);
+ if (workcode != null) {
+ return "W:" + workcode;
+ }
+ return null;
+ }
+
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
if (StringUtils.isBlank(deptCode)) {
return true;
@@ -635,6 +1012,11 @@ public class OaUserListSyncHelper {
return null;
}
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
+ String syncKey = this.resolveSyncKey(oaPerson);
+ userDept.setUserId(syncKey == null ? null : userMap.get(syncKey));
+ if (StringUtils.isBlank(this.resolveOaLoginId(oaPerson))) {
+ userDept.setOaLoginId(null);
+ }
if (userDept.getUserId() == null) {
return null;
}
@@ -684,10 +1066,12 @@ public class OaUserListSyncHelper {
public static class PersonSyncCount {
private final int syncedCount;
private final int skippedCount;
+ private final int removedCount;
- public PersonSyncCount(int syncedCount, int skippedCount) {
+ public PersonSyncCount(int syncedCount, int skippedCount, int removedCount) {
this.syncedCount = syncedCount;
this.skippedCount = skippedCount;
+ this.removedCount = removedCount;
}
public int getSyncedCount() {
@@ -697,6 +1081,10 @@ public class OaUserListSyncHelper {
public int getSkippedCount() {
return skippedCount;
}
+
+ public int getRemovedCount() {
+ return removedCount;
+ }
}
/**
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java
index bb9b4693..313db1da 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java
@@ -92,7 +92,7 @@ public class DriverWaybillController extends BladeController {
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
- @Parameter(description = "关键字:运单号/起终点") @RequestParam(required = false) String keyword) {
+ @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.page(current, size, statusCode, keyword));
}
@@ -129,12 +129,12 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/task/page")
@ApiOperationSupport(order = 24)
- @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
+ @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/项目/司机/车牌/起终点/地址")
public R> tripPage(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
- @Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
+ @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
Integer statusCode = parseStatus(status);
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
}
@@ -201,13 +201,6 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.submitNode(dto));
}
- @PostMapping("/complete")
- @ApiOperationSupport(order = 10)
- @Operation(summary = "司机完成运单", description = "校验司机归属后改状态为已完成,并检查生成应收应付明细(与管理端一致)")
- public R complete(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
- return R.status(driverWaybillService.complete(id));
- }
-
/** 前端可能传空字符串表示「全部」 */
private Integer parseStatus(String status) {
if (Func.isEmpty(status)) {
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
index 855f37ec..2b42643a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java
@@ -33,6 +33,8 @@ 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.ExceptionDisposalFollowRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -50,7 +52,7 @@ import org.springframework.web.bind.annotation.RestController;
*
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
- * 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。
+ * 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。
*/
@RestController
@AllArgsConstructor
@@ -114,4 +116,28 @@ public class ExceptionDisposalController extends BladeController {
return R.success("编辑成功");
}
+ @PostMapping("/reject")
+ @ApiOperationSupport(order = 8)
+ @Operation(summary = "拒绝异常", description = "调度端拒绝上报:先写一条拒绝原因的跟进记录,再了结;仅需登录态")
+ public R reject(@RequestBody ExceptionDisposalRejectRequest request) {
+ exceptionDisposalService.reject(request);
+ return R.success("已拒绝");
+ }
+
+ @PostMapping("/revoke")
+ @ApiOperationSupport(order = 9)
+ @Operation(summary = "撤销异常上报", description = "上报人撤销自己未了结的上报,不写跟进记录;仅需登录态")
+ public R revoke(@RequestBody ExceptionDisposalFollowRequest request) {
+ exceptionDisposalService.revoke(request.getId());
+ return R.success("已撤销");
+ }
+
+ @PostMapping("/route-adjust")
+ @ApiOperationSupport(order = 10)
+ @Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态")
+ public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) {
+ exceptionDisposalService.adjustRoute(request);
+ return R.success("已调整路线");
+ }
+
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java
index 67c2d15c..81265fc4 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java
@@ -88,7 +88,7 @@ public class ManageWaybillController extends BladeController {
public R> list(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
- @Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
+ @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword,
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
@Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception,
@Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType,
@@ -112,7 +112,7 @@ public class ManageWaybillController extends BladeController {
public R> pending(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
- @Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
+ @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword,
@Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) {
return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign));
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java
index b703f1ce..6315f953 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExporter.java
@@ -145,7 +145,7 @@ public final class SettlementImportTemplateExporter {
List lines = new ArrayList<>();
lines.add("导入结算单填写说明");
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
- lines.add("2. 运输方式请从下拉项中选择。");
+ lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
lines.add("4. 里程(km) 可为空;填写时须为正数。");
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java
new file mode 100644
index 00000000..c5ab2d70
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/feign/DriverClient.java
@@ -0,0 +1,61 @@
+/**
+ * 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.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 ensureProfile(@RequestBody DriverEnsureProfileDTO param) {
+ if (param == null) {
+ return R.fail("司机档案参数不能为空");
+ }
+ return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone()));
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java
index 45200175..8864ed8f 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java
@@ -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 {
*/
boolean submit(Driver driver);
+ /**
+ * 查找或创建司机档案并绑定小程序账号(登录路径专用)
+ *
+ * @param tenantId 租户ID,必须非空
+ * @param mpUserId 小程序用户ID
+ * @param phone 手机号
+ * @return 档案ID与本次处置标志;冲突时 message 携带可读原因
+ */
+ DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
+
/**
* 修改状态
*
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java
index 9ad5f543..0cc4d89b 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java
@@ -157,11 +157,6 @@ public interface IDriverWaybillService {
*/
DriverNodePunchVO submitNode(NodeSubmitDTO dto);
- /**
- * 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。
- */
- boolean complete(Long id);
-
/**
* 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。
*
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
index d23ca19c..de58cb2a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java
@@ -25,6 +25,8 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -56,4 +58,21 @@ public interface IExceptionDisposalService extends BaseService {
boolean complete(Long id);
/**
- * 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
+ * 末位打卡自动完成:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
* (改状态 + 生成应收应付明细 + 尝试完成配载单)。
*/
boolean completeWithoutDeptCheck(Long id);
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 247239d0..14fa3cbf 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
@@ -349,8 +349,8 @@ public class CertAuditServiceImpl implements ICertAuditService {
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
}
- // 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
- // 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
+ // 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
+ // 建档查重与按证件号识别同一人都会失效
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
driver.setDriverName(snapshot.getRealName());
driver.setIdCardNo(snapshot.getIdCardNo());
@@ -382,10 +382,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
/**
* 身份证号不得落在另一名司机名下
*
- * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
- * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
- * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
- * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
+ * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
+ * {@code blade_transport_driver.id_card_no} 上有唯一索引
+ * {@code uk_transport_driver_id_card};证件号已属于另一条档案时,
+ * 此处阻断审核并整体回滚,交由管理端先处理档案归属。
*
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
index 825c3bc0..41964813 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java
@@ -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 implements IDriverService {
@@ -74,6 +78,21 @@ public class DriverServiceImpl extends BaseServiceImpl 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 PROFILE_DEFAULT_POST = "司机";
+ private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员";
+ private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对";
private final IMpUserClient mpUserClient;
private final ISysClient sysClient;
@@ -101,6 +120,137 @@ public class DriverServiceImpl extends BaseServiceImpl 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());
+ }
+ // 租户是档案归属与手机号查重的范围,tenant_id 为空的档案不能被后续同号查询命中,
+ // 故此处拒绝写库(登录层只记 WARN)
+ if (resolvedTenantId == null) {
+ throw new ServiceException("租户信息为空,无法创建司机档案");
+ }
+ if (Func.isEmpty(mpUserId)) {
+ throw new ServiceException("小程序用户ID不能为空");
+ }
+ String mobile = trimToNull(phone);
+ if (mobile == null) {
+ throw new ServiceException("手机号不能为空");
+ }
+ // 脱敏手机号(如 155****2748)不是可用的业务数据:写进档案后按手机号查不到司机
+ if (mobile.contains("*")) {
+ throw new ServiceException("手机号无效,不能写入司机档案");
+ }
+ // 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识,
+ // phone 会被运营商二次放号
+ Driver bound = findDriverByUserId(mpUserId);
+ if (bound != null) {
+ return ensureResult(bound.getId(), false, false, null);
+ }
+ List sameMobile = list(Wrappers.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.lambdaQuery()
+ .eq(Driver::getUserId, mpUserId)
+ .last("LIMIT 1"));
+ }
+
+ /**
+ * 新建占位档案
+ *
+ * 不走 {@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.setPosts(PROFILE_DEFAULT_POST);
+ 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) {
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
index 079dfcf8..428c4398 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
@@ -107,7 +107,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private static final String STATUS_WAITING_DISPATCH = "waiting_dispatch";
private static final String STATUS_DISPATCHING = "dispatching";
private static final String STATUS_RUNNING = "running";
- private static final String STATUS_PENDING_RETURN = WaybillProcessSupport.STATUS_PENDING_RETURN;
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_CANCELLED = "cancelled";
@@ -119,14 +118,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private static final List PENDING_STATUSES = Arrays.asList(
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
);
- /** Tab「全部」统计口径:待接 + 运输中 + 待确认回单 + 已完成(不含已取消) */
+ /** Tab「全部」统计口径:待接 + 运输中 + 待回单确认 + 已完成(不含已取消) */
private static final List TAB_ALL_STATUSES = Arrays.asList(
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING,
- STATUS_PENDING_RETURN, STATUS_COMPLETED
+ WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, STATUS_COMPLETED
);
- /** 允许完成运单的业务状态 */
- private static final List COMPLETABLE_STATUSES = Arrays.asList(
- STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
+ /**
+ * 进行中口径:司机手上还没走完的单,含待回单确认。
+ * 待回单确认指司机已打完末位卡、等业务员确认回单,此时业务状态还不是 completed。
+ */
+ private static final List ACTIVE_STATUSES = Arrays.asList(
+ STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING,
+ WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
);
private static final int DEFAULT_PREVIEW_SIZE = 2;
@@ -522,35 +525,35 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
nodePunchMapper.insert(punch);
advanceCurrentProcessNode(waybill, nodeCfg);
- // 末位打卡节点是到货/卸货/签收时:
- // - 回单无需确认 → 直接完成;
- // - 回单需确认 → 进入「待确认回单」,等运管员批量/单条完成后才变已完成。
- // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
+ // 末位打卡节点为到货/卸货/签收、且运单允许完成时:回单无需确认则本次打卡一并完成运单,
+ // 回单需业务员确认则停在待回单确认。
+ // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
String nodeName = WaybillProcessSupport.nodeName(nodeCfg);
+ String waybillBusinessStatus = waybill.getBusinessStatus();
boolean completed = false;
if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey)
&& WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName)
&& canAutoCompleteWaybill(waybill)) {
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
completed = waybillService.completeWithoutDeptCheck(waybill.getId());
- } else {
- markPendingReturn(waybill);
+ if (completed) {
+ waybillBusinessStatus = STATUS_COMPLETED;
+ }
+ } else if (WaybillProcessSupport.STATUS_RUNNING.equalsIgnoreCase(waybill.getBusinessStatus())) {
+ // 当前业务状态为 running 才置为待回单确认;canAutoCompleteWaybill 的状态口径还含待执行/待调度。
+ // 待回单确认在 isPunchEnded 里与已完成、已取消同属结束态。
+ waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
+ waybillService.updateById(waybill);
+ waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
}
}
DriverNodePunchVO vo = toNodePunchVO(punch);
vo.setWaybillCompleted(completed);
+ vo.setWaybillBusinessStatus(waybillBusinessStatus);
return vo;
}
- /**
- * 司机末位打卡完成且回单需内部确认时,将运单置为待确认回单。
- */
- private void markPendingReturn(Waybill waybill) {
- waybill.setBusinessStatus(STATUS_PENDING_RETURN);
- waybillService.updateById(waybill);
- }
-
@Override
@Transactional(rollbackFor = Exception.class)
public boolean accept(Long id) {
@@ -626,24 +629,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return waybillService.updateById(waybill);
}
- @Override
- @Transactional(rollbackFor = Exception.class)
- public boolean complete(Long id) {
- if (id == null) {
- throw new ServiceException("运单ID不能为空");
- }
- requireCurrentDriver();
- Waybill waybill = loadDriverWaybill(id);
- waybill = normalizeAcceptStatus(waybill);
- if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) {
- throw new ServiceException("当前运单状态不允许完成");
- }
- // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁
- assertNoUnresolvedException(waybill.getId());
- // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单
- return waybillService.completeWithoutDeptCheck(waybill.getId());
- }
-
private void assertAcceptable(Waybill waybill) {
if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) {
throw new ServiceException("当前运单状态不允许接单");
@@ -858,8 +843,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
switch (status) {
case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES);
- case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES);
- case 2 -> wrapper.in(Waybill::getBusinessStatus, STATUS_COMPLETED, STATUS_PENDING_RETURN);
+ case 1 -> wrapper.in(Waybill::getBusinessStatus, ACTIVE_STATUSES);
+ case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED);
case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED);
default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES);
}
@@ -1026,6 +1011,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
+ card.setBusinessStatus(waybill.getBusinessStatus());
String createTime = formatDateTime(waybill.getCreateTime());
card.setCreateTime(createTime);
card.setPublishTime(createTime);
@@ -1127,6 +1113,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
card.setStatus(trip.getStatus());
+ // 原始业务状态只在整趟车子单状态唯一时下发:配载车混状态时一趟车没有唯一原始状态,给 null 由前端按数字态展示
+ List childBusinessStatuses = children.stream()
+ .map(TripVO.TripWaybillVO::getBusinessStatus)
+ .filter(Func::isNotBlank)
+ .distinct()
+ .collect(Collectors.toList());
+ card.setBusinessStatus(childBusinessStatuses.size() == 1 ? childBusinessStatuses.get(0) : null);
card.setRequireAccept(trip.getRequireAccept());
card.setAcceptStatus(trip.getAcceptStatus());
card.setTransportOrgType(trip.getTransportOrgType());
@@ -1534,27 +1527,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
/**
- * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。
+ * 自动完成的前置:业务状态在进行中口径内,且没有未了结异常。
*
* 不满足返回 false,由调用方静默跳过,打卡不受影响。
*/
private boolean canAutoCompleteWaybill(Waybill waybill) {
- return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())
+ return ACTIVE_STATUSES.contains(waybill.getBusinessStatus())
&& unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty();
}
- /**
- * 断言该运单没有未了结的异常。
- *
- * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
- * 「完成运单」绕过打卡门禁。
- */
- private void assertNoUnresolvedException(Long waybillId) {
- if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
- throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
- }
- }
-
/**
* 组装过程配置 punch=是 的打卡节点列表。
*
@@ -2018,9 +1999,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
return switch (businessStatus) {
case STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING -> 0;
- case STATUS_RUNNING -> 1;
- // 待确认回单:司机侧节点已打完,对司机按已完成展示
- case STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
+ case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
+ case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java
index 3d82894b..102938e7 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java
@@ -29,15 +29,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.secure.utils.AuthUtil;
+import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.BeanUtil;
+import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserDisplayCache;
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
import org.springblade.transport.mapper.ExceptionDisposalMapper;
+import org.springblade.transport.mapper.LoadingManageMapper;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
+import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
+import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
@@ -45,6 +51,7 @@ import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.MpUserDataScopeSupport;
+import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -54,10 +61,13 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -73,23 +83,53 @@ public class ExceptionDisposalServiceImpl
private static final String STATUS_PENDING = "pending";
private static final String STATUS_PROCESSING = "processing";
private static final String STATUS_COMPLETED = "completed";
+ private static final String STATUS_REVOKED = "revoked";
+ private static final String STATUS_CANCELLED = "cancelled";
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
+ /** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
+ private static final int REJECT_REASON_MAX_LENGTH = 200;
+
+ /** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
+ private static final int ROUTE_JSON_MAX_LENGTH = 8000;
+ private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
+ private static final int ADDRESS_MAX_LENGTH = 255;
+
+ private static final String NODE_KIND_START = "start";
+ private static final String NODE_KIND_TRANSIT = "transit";
+ private static final String NODE_KIND_END = "end";
+
+ /** 途经站地址在配载单上的连接符,与 PC 同步地址列时一致 */
+ private static final String TRANSIT_ADDRESS_JOINER = ",";
+
+ /** 跟进记录与变更记录共用的同一句结论 */
+ private static final String ROUTE_CHANGE_UNCHANGED = "同意调整路线,未修改路线";
+ private static final String ROUTE_CHANGE_PREFIX = "同意调整路线:";
+
+ /** 结论里站点之间的连接符,如「A → B → C」 */
+ private static final String ROUTE_STATION_SEPARATOR = " → ";
+
+ /** task_info_json 里存路线变更记录数组的键,与 PC 同一份结构 */
+ private static final String TASK_INFO_ROUTE_CHANGE_RECORDS = "routeChangeRecords";
+
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
private final IWaybillService waybillService;
private final IDriverWaybillService driverWaybillService;
private final MpUserDataScopeSupport mpUserDataScopeSupport;
+ private final LoadingManageMapper loadingManageMapper;
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
IWaybillService waybillService,
IDriverWaybillService driverWaybillService,
- MpUserDataScopeSupport mpUserDataScopeSupport) {
+ MpUserDataScopeSupport mpUserDataScopeSupport,
+ LoadingManageMapper loadingManageMapper) {
this.followRecordMapper = followRecordMapper;
this.waybillService = waybillService;
this.driverWaybillService = driverWaybillService;
this.mpUserDataScopeSupport = mpUserDataScopeSupport;
+ this.loadingManageMapper = loadingManageMapper;
}
@Override
@@ -102,7 +142,9 @@ public class ExceptionDisposalServiceImpl
@Override
public ExceptionDisposalVO detail(Long id) {
- ExceptionDisposalVO vo = toVO(getExisting(id));
+ ExceptionDisposal entity = getExisting(id);
+ ExceptionDisposalVO vo = convertVO(entity);
+ fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
vo.setFollowRecords(followRecords(id));
return vo;
}
@@ -342,7 +384,7 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("跟进说明不能超过500字");
}
ExceptionDisposal disposal = getExisting(request.getId());
- if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
+ if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("已完成的异常不允许继续跟进");
}
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
@@ -368,7 +410,7 @@ public class ExceptionDisposalServiceImpl
@Transactional(rollbackFor = Exception.class)
public void complete(Long id) {
ExceptionDisposal disposal = getExisting(id);
- if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
+ if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("异常已完成,请勿重复操作");
}
disposal.setDisposalStatus(STATUS_COMPLETED);
@@ -390,7 +432,7 @@ public class ExceptionDisposalServiceImpl
throw new ServiceException("存在无效的异常记录");
}
for (ExceptionDisposal disposal : disposals) {
- if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
+ if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
throw new ServiceException("已完成的异常不允许重复完成");
}
disposal.setDisposalStatus(STATUS_COMPLETED);
@@ -398,6 +440,484 @@ public class ExceptionDisposalServiceImpl
updateBatchById(disposals);
}
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void reject(ExceptionDisposalRejectRequest request) {
+ if (request == null || request.getId() == null) {
+ throw new ServiceException("请选择需要拒绝的异常");
+ }
+ ExceptionDisposal disposal = getExisting(request.getId());
+ assertNotSettled(disposal);
+ String rejectReason = Func.toStr(request.getRejectReason(), "").trim();
+ if (Func.isBlank(rejectReason)) {
+ throw new ServiceException("请填写拒绝原因");
+ }
+ if (rejectReason.length() > REJECT_REASON_MAX_LENGTH) {
+ throw new ServiceException("拒绝原因不能超过200字");
+ }
+
+ String rejectContent = "拒绝:" + rejectReason;
+ // 拒绝弹窗只有原因、没有附件,attachments_json 不写
+ insertFollowRecord(disposal.getId(), rejectContent);
+
+ // 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的
+ disposal.setDisposalStatus(STATUS_COMPLETED);
+ disposal.setLatestFollowContent(rejectContent);
+ updateById(disposal);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void revoke(Long id) {
+ ExceptionDisposal disposal = getExisting(id);
+ Long currentUserId = AuthUtil.getUserId();
+ // 撤销是上报人收回自己的上报,不是一次处置
+ if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) {
+ throw new ServiceException("只能撤销本人上报的异常");
+ }
+ assertNotSettled(disposal);
+ // 不写跟进记录,也不动 latest_follow_content
+ disposal.setDisposalStatus(STATUS_REVOKED);
+ updateById(disposal);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void adjustRoute(ExceptionDisposalRouteAdjustRequest request) {
+ if (request == null || request.getId() == null) {
+ throw new ServiceException("请选择需要调整路线的异常");
+ }
+ ExceptionDisposal disposal = getExisting(request.getId());
+ assertNotSettled(disposal);
+ Waybill waybill = disposal.getWaybillId() == null ? null : waybillService.getById(disposal.getWaybillId());
+ if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
+ throw new ServiceException("关联运单不存在");
+ }
+ // 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体
+ LoadingManage loading = findLoading(waybill.getLoadingNo());
+ if (loading == null && Func.isNotBlank(waybill.getLoadingNo())) {
+ throw new ServiceException("运单已关联配载单,请在配载单中修改");
+ }
+ // 校验与拼装全部在写入之前完成
+ List stations = validateRouteNodes(request.getRouteNodes(), loading);
+ List sourceAddresses = loading != null
+ ? loadingStationAddresses(loading)
+ : waybillStationAddresses(waybill);
+ List targetAddresses = stations.stream().map(Station::address).toList();
+ // 首站地址不可修改;原数据首站地址为空时跳过比对
+ if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0))
+ && !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) {
+ throw new ServiceException("起始地址不允许修改");
+ }
+ String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus();
+ if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) {
+ throw new ServiceException("当前状态不允许变更运输路线");
+ }
+ RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses);
+
+ // 事务内顺序固定:跟进记录 → 载体路线 → 异常状态
+ insertFollowRecord(disposal.getId(), plan.followContent());
+ if (loading != null) {
+ applyLoadingRoute(loading, plan);
+ } else {
+ applyWaybillRoute(waybill, plan);
+ }
+ disposal.setDisposalStatus(STATUS_COMPLETED);
+ disposal.setLatestFollowContent(plan.followContent());
+ updateById(disposal);
+ }
+
+ /** 已了结(已完成 / 已撤销)的异常不允许再次处置 */
+ private void assertNotSettled(ExceptionDisposal disposal) {
+ if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
+ throw new ServiceException("该异常已处理");
+ }
+ }
+
+ /** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */
+ private void insertFollowRecord(Long disposalId, String content) {
+ ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
+ record.setDisposalId(disposalId);
+ record.setFollowContent(content);
+ record.setFollowUser(AuthUtil.getUserId());
+ record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
+ record.setFollowTime(LocalDateTime.now());
+ followRecordMapper.insert(record);
+ }
+
+ /** 配载单按配载单号取,逻辑删除的视为没有 */
+ private LoadingManage findLoading(String loadingNo) {
+ if (Func.isBlank(loadingNo)) {
+ return null;
+ }
+ return loadingManageMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(LoadingManage::getLoadingNo, loadingNo.trim())
+ .eq(LoadingManage::getIsDeleted, 0)
+ .last("LIMIT 1"));
+ }
+
+ /**
+ * 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。
+ *
+ * kind 由位置决定,必须与站序一致。
+ */
+ private List validateRouteNodes(List routeNodes,
+ LoadingManage loading) {
+ if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) {
+ throw new ServiceException("路线节点至少需要两个");
+ }
+ List stations = new ArrayList<>(routeNodes.size());
+ for (int index = 0; index < routeNodes.size(); index++) {
+ ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index);
+ if (node == null) {
+ throw new ServiceException("请完善路线节点");
+ }
+ String address = Func.toStr(node.getAddress(), "").trim();
+ if (Func.isBlank(address)) {
+ throw new ServiceException("站点地址不能为空");
+ }
+ String expectedKind = index == 0
+ ? NODE_KIND_START
+ : (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT);
+ if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) {
+ throw new ServiceException("路线节点类型不正确");
+ }
+ stations.add(new Station(address, normalizedTags(node.getTags())));
+ }
+ assertTagsMatchChildWaybills(stations, loading);
+ return stations;
+ }
+
+ private List normalizedTags(List tags) {
+ List normalized = new ArrayList<>();
+ if (tags == null) {
+ return normalized;
+ }
+ for (String tag : tags) {
+ String text = Func.toStr(tag, "").trim();
+ if (Func.isNotBlank(text)) {
+ normalized.add(text);
+ }
+ }
+ return normalized;
+ }
+
+ /**
+ * 站点标记里的数字是配载单运单集合里的位次(1 起),越界时拒绝。
+ *
+ * 没有配载单时没有子运单序号,站点标记不参与落库。
+ */
+ private void assertTagsMatchChildWaybills(List stations, LoadingManage loading) {
+ if (loading == null) {
+ return;
+ }
+ int childCount = childWaybillCount(loading.getWaybillIdsJson());
+ for (Station station : stations) {
+ for (String tag : station.tags()) {
+ int order = tagOrder(tag);
+ if (order < 1 || order > childCount) {
+ throw new ServiceException("路线节点未关联子运单");
+ }
+ }
+ }
+ }
+
+ /** 配载单运单集合的元素个数,即站点标记可用的位次上限 */
+ private int childWaybillCount(String waybillIdsJson) {
+ if (Func.isBlank(waybillIdsJson)) {
+ return 0;
+ }
+ try {
+ List> waybillIds = JsonUtil.parse(waybillIdsJson, List.class);
+ return waybillIds == null ? 0 : waybillIds.size();
+ } catch (Exception ignored) {
+ // 运单集合不是 JSON 数组时按没有子运单处理
+ return 0;
+ }
+ }
+
+ /** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */
+ private List loadingStationAddresses(LoadingManage loading) {
+ List addresses = new ArrayList<>();
+ for (Map row : loadingStationRows(loading)) {
+ addAddress(addresses, text(row.get("address")));
+ }
+ if (!addresses.isEmpty()) {
+ return addresses;
+ }
+ return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress());
+ }
+
+ /**
+ * 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。
+ * 段里缺地址时用站名替代。
+ */
+ private List waybillStationAddresses(Waybill waybill) {
+ List> segments = routeSegments(waybill.getRouteJson());
+ if (segments.isEmpty()) {
+ return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress());
+ }
+ List addresses = new ArrayList<>(segments.size() + 1);
+ Map firstSegment = segments.get(0);
+ addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")),
+ text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), "")));
+ for (Map segment : segments) {
+ addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), ""));
+ }
+ return addresses;
+ }
+
+ /** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */
+ private List addressColumns(String departureAddress, String transitAddress, String arrivalAddress) {
+ List addresses = new ArrayList<>(3);
+ addAddress(addresses, departureAddress);
+ if (Func.isNotBlank(transitAddress)) {
+ for (String transit : transitAddress.split("[,,]")) {
+ addAddress(addresses, transit.trim());
+ }
+ }
+ addAddress(addresses, arrivalAddress);
+ return addresses;
+ }
+
+ private void addAddress(List addresses, String address) {
+ if (Func.isNotBlank(address)) {
+ addresses.add(address.trim());
+ }
+ }
+
+ /**
+ * 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。
+ * 长度口径与 PC 改路线一致。
+ */
+ private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List stations,
+ List sourceAddresses, List targetAddresses) {
+ String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses));
+ String departureAddress = targetAddresses.get(0);
+ String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1);
+ String routeJson;
+ String transitAddress = null;
+ String sourceTaskInfoJson;
+ if (loading != null) {
+ routeJson = stationRowsJson(stations);
+ transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream()
+ .collect(Collectors.joining(TRANSIT_ADDRESS_JOINER));
+ sourceTaskInfoJson = loading.getTaskInfoJson();
+ } else {
+ // 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,不整份换成站点行
+ List> segments = routeSegments(waybill.getRouteJson());
+ boolean addressChanged = !sourceAddresses.equals(targetAddresses);
+ // 段形状可识别时站数必须等于段数 + 1;认不出段形状时不改 route_json
+ if (addressChanged && !segments.isEmpty() && segments.size() + 1 != targetAddresses.size()) {
+ throw new ServiceException("无配载单的运单不支持增减途经站");
+ }
+ routeJson = addressChanged ? rewriteWaybillRouteSegments(segments, targetAddresses) : null;
+ sourceTaskInfoJson = waybill.getTaskInfoJson();
+ }
+ String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent);
+
+ TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字");
+ TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字");
+ TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字");
+ TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字");
+ return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress,
+ arrivalAddress, taskInfoJson);
+ }
+
+ /**
+ * 同一份结论既进跟进记录,也进 task_info_json 的变更记录:
+ * 写调整后的站点顺序(地址 → 地址 → …),不写「哪一站从什么改成什么」;
+ * 一个地址都没改时给固定的一句。
+ */
+ private String routeChangeContent(List sourceAddresses, List targetAddresses) {
+ if (sourceAddresses.equals(targetAddresses)) {
+ return ROUTE_CHANGE_UNCHANGED;
+ }
+ List parts = new ArrayList<>(targetAddresses.size());
+ for (String address : targetAddresses) {
+ parts.add(displayAddress(address));
+ }
+ return ROUTE_CHANGE_PREFIX + String.join(ROUTE_STATION_SEPARATOR, parts);
+ }
+
+ private String displayAddress(String address) {
+ return Func.isBlank(address) ? "-" : address;
+ }
+
+ /** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */
+ private String stationRowsJson(List stations) {
+ List> rows = new ArrayList<>(stations.size());
+ for (Station station : stations) {
+ Map row = new LinkedHashMap<>(4);
+ row.put("address", station.address());
+ // 站点行没有独立的站名,展示地址与地址取同一份文本
+ row.put("displayAddress", station.address());
+ row.put("tags", station.tags());
+ // locked 只影响 PC 编辑弹窗能否拖动,司机端不读
+ row.put("locked", false);
+ rows.add(row);
+ }
+ return JsonUtil.toJson(rows);
+ }
+
+ /**
+ * 按路线段改写运单路线:每段的出发地对应第 i 站、到达地对应第 i+1 站,其它键(segmentNo、
+ * transportType、未知键)原样保留。
+ *
+ * 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写;
+ * 站数与段数不匹配由调用方拒绝。
+ */
+ private String rewriteWaybillRouteSegments(List> segments, List targetAddresses) {
+ if (segments.isEmpty()) {
+ return null;
+ }
+ List> rewritten = new ArrayList<>(segments.size());
+ for (int index = 0; index < segments.size(); index++) {
+ Map segment = new LinkedHashMap<>(segments.get(index));
+ rewriteSegmentPoint(segment, "departure", targetAddresses.get(index));
+ rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
+ rewritten.add(segment);
+ }
+ return JsonUtil.toJson(rewritten);
+ }
+
+ /** 站点地址与站名写同一份文本 */
+ private void rewriteSegmentPoint(Map segment, String pointKey, String targetAddress) {
+ String currentAddress = firstNonBlank(text(segment.get(pointKey + "Address")),
+ text(segment.get(pointKey + "Name")), "");
+ if (Objects.equals(currentAddress, targetAddress)) {
+ return;
+ }
+ segment.put(pointKey + "Address", targetAddress);
+ segment.put(pointKey + "Name", targetAddress);
+ }
+
+ /**
+ * 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。
+ * 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。
+ */
+ private List> routeSegments(String routeJson) {
+ List> elements = jsonObjectList(routeJson);
+ for (Map element : elements) {
+ if (element.containsKey("address") || element.containsKey("tags")) {
+ return new ArrayList<>();
+ }
+ if (!element.containsKey("departureAddress") && !element.containsKey("departureName")
+ && !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) {
+ return new ArrayList<>();
+ }
+ }
+ return elements;
+ }
+
+ /**
+ * 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。
+ * 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建。
+ */
+ private String appendRouteChangeRecord(String taskInfoJson, String content) {
+ Map taskInfo = parseTaskInfo(taskInfoJson);
+ List records = new ArrayList<>();
+ Map record = new LinkedHashMap<>(4);
+ record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now()));
+ record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
+ record.put("content", content);
+ record.put("remark", "");
+ records.add(record);
+ Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS);
+ if (existing instanceof List> existingRecords) {
+ records.addAll(existingRecords);
+ }
+ taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records);
+ return JsonUtil.toJson(taskInfo);
+ }
+
+ /** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建 */
+ private Map parseTaskInfo(String taskInfoJson) {
+ Map taskInfo = new LinkedHashMap<>();
+ if (Func.isBlank(taskInfoJson)) {
+ return taskInfo;
+ }
+ try {
+ Object parsed = JsonUtil.parse(taskInfoJson, Object.class);
+ if (parsed instanceof Map, ?> map) {
+ map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value));
+ }
+ } catch (Exception ignored) {
+ taskInfo.clear();
+ }
+ return taskInfo;
+ }
+
+ private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) {
+ loading.setRouteJson(plan.routeJson());
+ loading.setDepartureAddress(plan.departureAddress());
+ loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress()));
+ loading.setArrivalAddress(plan.arrivalAddress());
+ loading.setTaskInfoJson(plan.taskInfoJson());
+ if (loadingManageMapper.updateById(loading) <= 0) {
+ throw new ServiceException("变更运输路线失败");
+ }
+ }
+
+ private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) {
+ if (Func.isNotBlank(plan.routeJson())) {
+ waybill.setRouteJson(plan.routeJson());
+ }
+ waybill.setDepartureAddress(plan.departureAddress());
+ waybill.setArrivalAddress(plan.arrivalAddress());
+ waybill.setTaskInfoJson(plan.taskInfoJson());
+ if (!waybillService.updateById(waybill)) {
+ throw new ServiceException("变更运输路线失败");
+ }
+ }
+
+ /** 解析成对象数组;坏 JSON 与空值都按空数组处理 */
+ private List> jsonObjectList(String json) {
+ List> result = new ArrayList<>();
+ if (Func.isBlank(json)) {
+ return result;
+ }
+ try {
+ Object parsed = JsonUtil.parse(json, List.class);
+ if (parsed instanceof List> list) {
+ for (Object item : list) {
+ if (item instanceof Map, ?> map) {
+ Map element = new LinkedHashMap<>();
+ map.forEach((key, value) -> element.put(String.valueOf(key), value));
+ result.add(element);
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ return new ArrayList<>();
+ }
+ return result;
+ }
+
+ private String text(Object value) {
+ return value == null ? "" : String.valueOf(value).trim();
+ }
+
+ private String firstNonBlank(String addressValue, String nameValue, String fallbackValue) {
+ if (Func.isNotBlank(addressValue)) {
+ return addressValue;
+ }
+ return Func.isNotBlank(nameValue) ? nameValue : fallbackValue;
+ }
+
+ /** 规范化后的一个站点:地址 + 该站的装卸标记 */
+ private record Station(String address, List tags) {
+ }
+
+ /** 一次路线调整要落库的全部内容 */
+ private record RouteChangePlan(String followContent,
+ String routeJson,
+ String departureAddress,
+ String transitAddress,
+ String arrivalAddress,
+ String taskInfoJson) {
+ }
+
private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) {
LambdaQueryWrapper wrapper = Wrappers.lambdaQuery()
.eq(ExceptionDisposal::getIsDeleted, 0)
@@ -429,7 +949,14 @@ public class ExceptionDisposalServiceImpl
return disposal;
}
+ /** 列表与上报结果共用的转换:不含详情页的配载单号、子运单号与可调整站点 */
private ExceptionDisposalVO toVO(ExceptionDisposal entity) {
+ ExceptionDisposalVO vo = convertVO(entity);
+ fillRouteAndCargo(vo, entity.getWaybillId());
+ return vo;
+ }
+
+ private ExceptionDisposalVO convertVO(ExceptionDisposal entity) {
ExceptionDisposalVO vo = BeanUtil.copyProperties(entity, ExceptionDisposalVO.class);
if (vo == null) {
return null;
@@ -438,18 +965,17 @@ public class ExceptionDisposalServiceImpl
vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser()));
vo.setDisposalStatusName(statusName(entity.getDisposalStatus()));
vo.setScenePhotoList(splitPhotos(entity.getScenePhotos()));
- fillRouteAndCargo(vo, entity.getWaybillId());
return vo;
}
- /** 按关联运单补齐详情页路线 / 货物展示字段 */
- private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
+ /** 按关联运单补齐路线与货物展示字段,返回本次取到的运单;运单不存在时返回 null */
+ private Waybill fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
if (vo == null || waybillId == null) {
- return;
+ return null;
}
Waybill waybill = waybillService.getById(waybillId);
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
- return;
+ return null;
}
Map route = new HashMap<>(2);
route.put("start", Func.toStr(waybill.getDepartureName(), ""));
@@ -460,6 +986,213 @@ public class ExceptionDisposalServiceImpl
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
vo.setCargo(cargo);
+ return waybill;
+ }
+
+ /**
+ * 详情页的可编辑站点与配载单信息。
+ *
+ * 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。
+ * 运单不存在时三项各给空值。
+ */
+ private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) {
+ if (vo == null) {
+ return;
+ }
+ if (waybill == null) {
+ vo.setChildWaybillNos(List.of());
+ vo.setRouteNodes(List.of());
+ return;
+ }
+ LoadingManage loading = findLoading(waybill.getLoadingNo());
+ if (loading == null) {
+ vo.setChildWaybillNos(new ArrayList<>());
+ vo.setRouteNodes(waybillRouteNodes(waybill));
+ return;
+ }
+ Map childWaybills = childWaybillsByOrder(loading);
+ vo.setLoadingNo(loading.getLoadingNo());
+ vo.setChildWaybillNos(childWaybillNos(childWaybills));
+ List> rows = loadingStationRows(loading);
+ vo.setRouteNodes(loadingRouteNodes(rows, childWaybills));
+ vo.setRoute(loadingRoute(rows, loading, waybill));
+ }
+
+ /** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */
+ private List> loadingStationRows(LoadingManage loading) {
+ List> rows = new ArrayList<>();
+ for (Map row : jsonObjectList(loading.getRouteJson())) {
+ if (Func.isNotBlank(text(row.get("address")))) {
+ rows.add(row);
+ }
+ }
+ return rows;
+ }
+
+ private List loadingRouteNodes(List> rows,
+ Map childWaybills) {
+ List nodes = new ArrayList<>(rows.size());
+ for (Map row : rows) {
+ List tags = stringList(row.get("tags"));
+ ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
+ node.setAddress(text(row.get("address")));
+ node.setTags(tags);
+ node.setProjects(stationProjects(tags, childWaybills));
+ nodes.add(node);
+ }
+ return stationKinds(nodes);
+ }
+
+ /**
+ * 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。
+ * 地址口径与改路线时的比对一致(地址优先、站名兜底)。
+ */
+ private List waybillRouteNodes(Waybill waybill) {
+ List nodes = new ArrayList<>();
+ for (String address : waybillStationAddresses(waybill)) {
+ if (Func.isBlank(address)) {
+ continue;
+ }
+ ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
+ node.setAddress(address);
+ node.setTags(new ArrayList<>());
+ node.setProjects(new ArrayList<>());
+ nodes.add(node);
+ }
+ return stationKinds(nodes);
+ }
+
+ /** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */
+ private List stationKinds(List nodes) {
+ if (nodes.size() < 2) {
+ return new ArrayList<>();
+ }
+ for (int index = 0; index < nodes.size(); index++) {
+ nodes.get(index).setKind(index == 0 ? NODE_KIND_START
+ : (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT));
+ }
+ return nodes;
+ }
+
+ /**
+ * 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。
+ * 标记对不上子运单(越界、子运单已不存在)时跳过。
+ */
+ private List stationProjects(List tags, Map childWaybills) {
+ Set projects = new LinkedHashSet<>();
+ for (String tag : tags) {
+ Waybill child = childWaybills.get(tagOrder(tag) - 1);
+ if (child == null) {
+ continue;
+ }
+ String projectName = Func.toStr(child.getProjectName(), "").trim();
+ if (Func.isNotBlank(projectName)) {
+ projects.add(projectName);
+ }
+ }
+ return new ArrayList<>(projects);
+ }
+
+ /**
+ * 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。
+ * 位次不因缺子运单而前移。
+ */
+ private Map childWaybillsByOrder(LoadingManage loading) {
+ Map childWaybills = new LinkedHashMap<>();
+ List waybillIds = waybillIds(loading.getWaybillIdsJson());
+ List queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList();
+ if (queryIds.isEmpty()) {
+ return childWaybills;
+ }
+ Map waybillsById = new HashMap<>();
+ List found = waybillService.listByIds(queryIds);
+ if (found != null) {
+ for (Waybill child : found) {
+ if (child != null && !Objects.equals(child.getIsDeleted(), 1)) {
+ waybillsById.put(child.getId(), child);
+ }
+ }
+ }
+ for (int order = 0; order < waybillIds.size(); order++) {
+ Waybill child = waybillsById.get(waybillIds.get(order));
+ if (child != null) {
+ childWaybills.put(order, child);
+ }
+ }
+ return childWaybills;
+ }
+
+ /** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */
+ private List waybillIds(String waybillIdsJson) {
+ List waybillIds = new ArrayList<>();
+ if (Func.isBlank(waybillIdsJson)) {
+ return waybillIds;
+ }
+ try {
+ Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
+ if (parsed instanceof List> list) {
+ for (Object item : list) {
+ long waybillId = Func.toLong(text(item), 0L);
+ waybillIds.add(waybillId > 0 ? waybillId : null);
+ }
+ }
+ } catch (Exception ignored) {
+ // 运单集合不是 JSON 数组时按没有子运单处理
+ return new ArrayList<>();
+ }
+ return waybillIds;
+ }
+
+ /** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */
+ private List childWaybillNos(Map childWaybills) {
+ List childWaybillNos = new ArrayList<>(childWaybills.size());
+ for (Waybill child : childWaybills.values()) {
+ String waybillNo = Func.toStr(child.getWaybillNo(), "").trim();
+ if (Func.isNotBlank(waybillNo)) {
+ childWaybillNos.add(waybillNo);
+ }
+ }
+ return childWaybillNos;
+ }
+
+ /** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */
+ private Map loadingRoute(List> rows, LoadingManage loading, Waybill waybill) {
+ String start;
+ String end;
+ if (rows.isEmpty()) {
+ start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), "");
+ end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), "");
+ } else {
+ start = stationDisplayAddress(rows.get(0));
+ end = stationDisplayAddress(rows.get(rows.size() - 1));
+ }
+ Map route = new HashMap<>(2);
+ route.put("start", start);
+ route.put("end", end);
+ return route;
+ }
+
+ private String stationDisplayAddress(Map row) {
+ return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), "");
+ }
+
+ /** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */
+ private int tagOrder(String tag) {
+ String digits = Func.toStr(tag, "").replaceAll("\\D+", "");
+ return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0);
+ }
+
+ private List stringList(Object value) {
+ List texts = new ArrayList<>();
+ if (value instanceof List> list) {
+ for (Object item : list) {
+ String text = text(item);
+ if (Func.isNotBlank(text)) {
+ texts.add(text);
+ }
+ }
+ }
+ return texts;
}
private String formatWeight(BigDecimal quantity, String unit) {
@@ -494,6 +1227,7 @@ public class ExceptionDisposalServiceImpl
case STATUS_PENDING -> "待处理";
case STATUS_PROCESSING -> "处理中";
case STATUS_COMPLETED -> "已完成";
+ case STATUS_REVOKED -> "已撤销";
default -> "";
};
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java
index 065e2920..73fbe0f4 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java
@@ -114,6 +114,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
private static final String TODO_REASSIGN = "reassign";
/** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */
private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0";
+ /** 异常条目跳转页码:参数 id 是异常处置记录主键,该页直接按 id 拉详情 */
+ private static final String EXCEPTION_TARGET_URL = "/subpackages/admin/disposal-detail?id=%s";
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
@@ -726,13 +728,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return loading == null || loading.getId() == null ? fallback : loading.getId();
}
- /** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
+ /**
+ * 分组查询的关键字:逐行模糊匹配运单号 / 司机 / 车牌 / 项目。
+ *
+ * 这里是逐行 LIKE,不是运次级匹配:命中哪一行只决定这趟车要不要出现,不缩小它的子单集合。
+ */
private void applyGroupKeyword(QueryWrapper wrapper, String keyword) {
if (Func.isBlank(keyword)) {
return;
}
String key = keyword.trim();
- wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
+ wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key)
+ .or().like("vehicle_no", key).or().like("project_name", key));
}
private boolean hasExceptionIds(Waybill waybill, Set exceptionWaybillIds) {
@@ -744,6 +751,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setId(waybill.getId());
detail.setWaybillNo(waybill.getWaybillNo());
detail.setStatus(toAppStatus(waybill.getBusinessStatus()));
+ detail.setBusinessStatus(waybill.getBusinessStatus());
String mode = Func.toStr(waybill.getTransportType(), "");
detail.setTransportMode(mode);
@@ -927,7 +935,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
/**
- * 系统用户可见组织:普通账号只出所属组织,超管不过滤;组织为空不拦截。
+ * 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
+ *
+ * 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
+ * 详情与重新派单本身不走这里。
*/
private List manageableDeptIds() {
if (TransportBusinessSupport.isAdministrator()) {
@@ -1011,6 +1022,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime()));
card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime()));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
+ card.setBusinessStatus(waybill.getBusinessStatus());
card.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
card.setDriverName(Func.toStr(waybill.getDriverName(), ""));
card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
@@ -1068,8 +1080,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
return switch (businessStatus) {
case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0;
- case STATUS_RUNNING -> 1;
- case WaybillProcessSupport.STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
+ // 数字枚举与司机端 toAppStatus 同源:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消;
+ // 待回单确认属进行中,落 1。给 null 时前端显示「未知」。
+ case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
+ case STATUS_COMPLETED -> 2;
case STATUS_CANCELLED -> 3;
default -> null;
};
@@ -1220,8 +1234,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
item.setDesc(buildExceptionDesc(disposal));
item.setWaybillNo(Func.toStr(disposal.getWaybillNo(), ""));
item.setActionLabel("立即处置");
- String status = Func.toStr(disposal.getDisposalStatus(), DISPOSAL_PENDING);
- item.setTargetUrl("/subpackages/admin/exception?status=" + status);
+ // 待处理事项的「立即处置」直接落到该条异常的处置详情页,不再先到异常列表
+ item.setTargetUrl(String.format(EXCEPTION_TARGET_URL, disposal.getId()));
return item;
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java
index 51491538..3c3dc747 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java
@@ -362,7 +362,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl dispatchedGoods = dispatchedGoods(routeWaybills, routePlans);
route.put("dispatchedQuantity", dispatchedGoods.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
route.put("dispatchedGoods", dispatchedGoods);
- route.put("arrivedQuantity", routeWaybills.stream().filter(item -> "completed".equals(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
+ route.put("arrivedQuantity", routeWaybills.stream().filter(item -> arrived(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
route.put("waybills", routeWaybills);
route.put("transportPlans", routePlans);
}
@@ -541,12 +541,22 @@ public class MasterOrderServiceImpl extends BaseServiceImpl route) {
return waybillsByMasterNo(masterNo).stream()
- .filter(item -> "completed".equals(item.getBusinessStatus()))
+ .filter(item -> arrived(item.getBusinessStatus()))
.filter(item -> belongsToRoute(item, route))
.flatMap(item -> waybillGoods(item).stream())
.map(item -> decimal(item, "quantity"))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
+
+ /**
+ * 已到达口径:待回单确认也算到达——货物确实到了,回单只是单据。
+ * 不计入会按「调度总量不能大于上一段已到达数量」把多联总单的下一段挡在门外。
+ */
+ static boolean arrived(String businessStatus) {
+ return "completed".equals(businessStatus)
+ || WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
+ }
+
private Map availableGoods(MasterOrder masterOrder) {
Map available = new LinkedHashMap<>();
for (Map goods : parseArray(masterOrder.getGoodsJson())) available.merge(goodsKey(goods), decimal(goods, "quantity"), BigDecimal::add);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
index b43a54df..c9865f90 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
@@ -61,6 +61,7 @@ import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.IWaybillService;
+import org.springblade.transport.support.WaybillProcessSupport;
import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -3295,9 +3296,9 @@ public class ReceivablePayableDetailServiceImpl
private String waybillStatusName(String status) {
return switch (status == null ? "" : status) {
case "completed" -> "已完成";
- case "pending_return" -> "待确认回单";
case "processing", "running", "in_progress", "inProgress" -> "进行中";
case "pending", "created" -> "待执行";
+ case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认";
case "cancelled", "canceled" -> "已取消";
default -> status;
};
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java
index d55d700a..6eb4689a 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripAggregation.java
@@ -45,6 +45,7 @@ final class TripAggregation {
static final String RUNNING = "running";
static final String COMPLETED = "completed";
static final String CANCELLED = "cancelled";
+ static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
static final List PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
@@ -53,9 +54,10 @@ final class TripAggregation {
/**
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
* 否则「子单全已取消」会被算成运输中而不是已取消。
+ * 待回单确认要进:子单漏在运次外,剩下那几张会被判成整趟车已走完。
*/
static final List CHILD_STATUSES = List.of(
- PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
+ PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, PENDING_RETURN_CONFIRM, COMPLETED, CANCELLED
);
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java
index 8dfe420f..abf0fc92 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java
@@ -90,10 +90,14 @@ public class TripQueryServiceImpl implements ITripQueryService {
private static final String EXCEPTION_YES = "exception";
private static final String EXCEPTION_NO = "normal";
- /** 关键字在子单上匹配的字段 */
+ /**
+ * 关键字在子单上匹配的字段。
+ *
+ * project_name 是运单创建时写入的快照列:项目改名后旧运单仍是旧名,按新名搜不到这些单。
+ */
private static final String[] KEYWORD_COLUMNS = {
"waybill_no", "loading_no", "departure_name", "arrival_name",
- "departure_address", "arrival_address", "vehicle_no"
+ "departure_address", "arrival_address", "vehicle_no", "driver_name", "project_name"
};
private final IWaybillService waybillService;
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
index 83b0c711..be049adc 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
@@ -531,10 +531,33 @@ public class WaybillServiceImpl extends BaseServiceImpl
}
// 只有显式传 false 才是取消打卡,未传按已打卡处理
boolean punched = !Boolean.FALSE.equals(request.getPunched());
- if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
- return saveEnroutePunchRecord(waybill, request, punched);
+ // 待回单确认的运单司机已无待打卡节点,再补一条打卡流水与状态含义矛盾;
+ // 漏打的出路是先取消该节点的打卡退回进行中(punched=false),再重新补录。
+ if (punched && WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
+ .equalsIgnoreCase(waybill.getBusinessStatus())) {
+ throw new ServiceException("运单待业务员确认回单,不能补录打卡");
}
- return saveNodePunchRecord(waybill, request, punched);
+ if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
+ return revertIfReturnConfirmPreconditionLost(waybill, punched,
+ saveEnroutePunchRecord(waybill, request, punched));
+ }
+ return revertIfReturnConfirmPreconditionLost(waybill, punched,
+ saveNodePunchRecord(waybill, request, punched));
+ }
+
+ /**
+ * 取消打卡撤掉了「司机已无待打卡节点」这个前提,停在待回单确认的运单退回进行中。
+ *
+ * 只认取消打卡后的待回单确认,其它状态一律不动。
+ */
+ private boolean revertIfReturnConfirmPreconditionLost(Waybill waybill, boolean punched, boolean saved) {
+ if (!saved || punched
+ || !WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
+ .equalsIgnoreCase(waybill.getBusinessStatus())) {
+ return saved;
+ }
+ waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING);
+ return updateById(waybill);
}
/**
@@ -962,14 +985,16 @@ public class WaybillServiceImpl extends BaseServiceImpl
/**
* 运单是否已越过接单阶段。
*
- * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、
+ * 正向列举进行中/已完成/已取消/待回单确认,不能用「不是终态」取反:终态里还包含草稿、待调度、
* 调度中这些并未越过接单的阶段,取反会把它们判成已接单。
+ *
+ * 待回单确认下司机已打完末位卡,无需接单配置时接单节点不该再显示未打卡。
*/
private static boolean passedAcceptStage(String businessStatus) {
return "running".equals(businessStatus)
- || WaybillProcessSupport.STATUS_PENDING_RETURN.equals(businessStatus)
|| "completed".equals(businessStatus)
- || "cancelled".equals(businessStatus);
+ || "cancelled".equals(businessStatus)
+ || WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
}
private String formatPunchTime(Date time) {
@@ -1064,10 +1089,11 @@ public class WaybillServiceImpl extends BaseServiceImpl
waybill.setLoadingNo(null);
waybill.setMileageRemark(null);
}
- // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致
+ // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致;
+ // 编辑保存只改资料,business_status 以库里的值为准,请求体里的值不回写
String preservedBusinessStatus = created
? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus())
- : null;
+ : editPreservedBusinessStatus(oldRecord);
if (isCompletedBusinessStatus(preservedBusinessStatus)) {
preservedBusinessStatus = "completed";
}
@@ -1089,6 +1115,17 @@ public class WaybillServiceImpl extends BaseServiceImpl
}
}
+ /**
+ * 编辑保存要回写的业务状态:取库里的值。
+ * 草稿返回 null —— submit 即正式提交,草稿须由过程校正提升为待执行 / 进行中。
+ */
+ private String editPreservedBusinessStatus(Waybill oldRecord) {
+ String currentStatus = oldRecord == null
+ ? null
+ : TransportBusinessSupport.trimToNull(oldRecord.getBusinessStatus());
+ return STATUS_DRAFT.equals(currentStatus) ? null : currentStatus;
+ }
+
private void fillCustomerName(Waybill waybill) {
if (Func.isNotEmpty(waybill.getContractId())) {
ContractManage contract = contractManageService.getById(waybill.getContractId());
@@ -1509,21 +1546,14 @@ public class WaybillServiceImpl extends BaseServiceImpl