Merge branch 'feature/payment-apply-bfi-sync' into feature/hcs-api-p0

# Conflicts:
#	blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessBizHandlers.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceApplicationServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/InvoiceReceiptServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/task/CustomerKingdeeSyncTask.java
#	blade-service/blade-transport/src/main/java/org/springblade/transport/task/PaymentKingdeeResultTask.java
#	doc/nacos/blade.yaml
#	doc/nacos/third-party-api.yaml
This commit is contained in:
刘泉佳
2026-10-15 04:07:13 +08:00
556 changed files with 35208 additions and 6751 deletions
@@ -3,6 +3,7 @@ package org.springblade.file.config;
import com.aliyun.oss.OSSClientBuilder;
import com.obs.services.ObsClient;
import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.file.service.IFileService;
@@ -10,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl;
import org.springblade.file.service.impl.MinioFileServiceImpl;
import org.springblade.file.service.impl.OssFileServiceImpl;
import org.springblade.file.service.impl.ObsFileServiceImpl;
import org.springblade.file.util.MinioClientFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -20,6 +22,7 @@ import org.springframework.core.env.Environment;
* @author bfhuange
* @date 2024/10/21
*/
@Slf4j
@Configuration
public class FileConfig {
@@ -63,10 +66,22 @@ public class FileConfig {
private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) {
validateStoreProperties(STORAGE_MINIO, properties);
MinioClient minioClient = MinioClient.builder()
.endpoint(properties.getEndpoint())
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
.build();
String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint());
String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint());
if (StringUtil.isNotBlank(endpointPathPrefix)) {
log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS",
endpointOrigin, endpointPathPrefix);
}
if (StringUtil.isBlank(properties.getTransformEndpoint())
&& properties.getEndpoint().trim().toLowerCase().startsWith("http://")) {
log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传",
properties.getEndpoint());
} else {
log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}",
endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint());
}
MinioClient minioClient = MinioClientFactory.create(
properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret());
return new MinioFileServiceImpl(properties, minioClient);
}
@@ -95,7 +110,16 @@ public class FileConfig {
FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties();
String fileStoragePrefix = "file.storage." + prefix;
mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint")));
mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint")));
// 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint
mergedProperties.setTransformEndpoint(firstNotBlank(
properties.getTransformEndpoint(),
environment.getProperty(fileStoragePrefix + ".transform-endpoint"),
environment.getProperty(fileStoragePrefix + ".transformEndpoint"),
environment.getProperty(prefix + ".transform-endpoint"),
environment.getProperty(prefix + ".transformEndpoint"),
environment.getProperty("oss.transform-endpoint"),
environment.getProperty("oss.transformEndpoint")
));
mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key")));
mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key")));
mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name")));
@@ -107,7 +131,7 @@ public class FileConfig {
private String firstNotBlank(String... values) {
for (String value : values) {
if (StringUtil.isNotBlank(value)) {
return value;
return value.trim();
}
}
return null;
@@ -111,4 +111,26 @@ public class FileTaskController extends BladeController {
String uploadUrl = fileTaskService.getUploadUrl(id, partNumber);
return R.data(uploadUrl);
}
@ApiLog("上传任务-服务端代传分片")
@PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@Parameters({
@Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int"))
})
@Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储")
public R<String> uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException {
if (file == null || file.isEmpty()) {
return R.fail("分片内容不能为空");
}
java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin");
try {
file.transferTo(tempFile);
return R.data(fileTaskService.uploadPart(id, partNumber, tempFile));
} finally {
if (tempFile.exists() && !tempFile.delete()) {
tempFile.deleteOnExit();
}
}
}
}
@@ -74,6 +74,16 @@ public interface IFileTaskService extends IService<FileTask> {
*/
String getUploadUrl(Long id, Integer partNumber);
/**
* 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用)
*
* @param id 文件任务id
* @param partNumber 分段编号
* @param partFile 分片临时文件
* @return ETag
*/
String uploadPart(Long id, Integer partNumber, java.io.File partFile);
/**
* 取消文件任务
* @param taskIds
@@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl<FileTaskMapper, FileTask> i
return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null);
}
@Override
public String uploadPart(Long id, Integer partNumber, java.io.File partFile) {
FileTask fileTask = this.getById(id);
Func.requireNotNull(fileTask, "文件任务不存在");
if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) {
throw new ServiceException("文件任务已完成");
}
if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) {
throw new ServiceException("文件任务已暂停");
}
if (partFile == null || !partFile.exists()) {
throw new ServiceException("分片文件不存在");
}
org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart(
fileTask.getObjectKey(),
fileTask.getUploadId(),
partFile,
partNumber,
0L,
partFile.length()
);
if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) {
throw new ServiceException("分片上传未返回 ETag");
}
return partEtag.getEtag().replace("\"", "");
}
@Transactional(rollbackFor = Exception.class)
@Override
public void cancelBatchByIds(List<Long> taskIds) {
@@ -25,11 +25,12 @@
*/
package org.springblade.file.service.impl;
import io.minio.ComposeObjectArgs;
import io.minio.ComposeSource;
import io.minio.GetObjectArgs;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient;
import io.minio.ComposeObjectArgs;
import io.minio.ComposeSource;
import io.minio.ObjectWriteResponse;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.UploadObjectArgs;
@@ -39,9 +40,13 @@ import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.file.config.FileStorageProperties;
import org.springblade.file.util.MinioClientFactory;
import org.springblade.file.pojo.vo.PartEtagVO;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -61,9 +66,11 @@ import java.util.UUID;
public class MinioFileServiceImpl extends AbstractFileService {
private static final long PART_SIZE = 10 * 1024 * 1024L;
private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION;
private final FileStorageProperties.StoreProperties properties;
private final MinioClient minioClient;
private volatile MinioClient transformPresignClient;
@Override
public String initiateMultipartUpload(String objectKey) {
@@ -73,15 +80,50 @@ public class MinioFileServiceImpl extends AbstractFileService {
@Override
public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber))
.expiry(Math.toIntExact(resolveExpires(expires))).build());
String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(properties.getBucketName())
.object(partObjectKey(objectKey, uploadId, partNumber))
.region(DEFAULT_REGION)
.expiry(Math.toIntExact(resolveExpires(expires)))
.build());
return applyTransformPathPrefix(presignedUrl);
} catch (Exception exception) {
log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception);
throw new ServiceException("获取分片上传链接异常");
}
}
@Override
public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) {
long start = offset == null ? 0L : offset;
long size = partSize == null ? Math.max(0L, file.length() - start) : partSize;
try (FileInputStream inputStream = new FileInputStream(file)) {
long skipped = inputStream.skip(start);
if (skipped < start) {
throw new ServiceException("MinIO分段上传读取文件偏移失败");
}
ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder()
.bucket(properties.getBucketName())
.object(partObjectKey(objectKey, uploadId, partNumber))
.stream(inputStream, size, PART_SIZE)
.build());
String etag = response.etag() == null ? "" : response.etag().replace("\"", "");
return new PartEtagVO(etag, partNumber);
} catch (ServiceException serviceException) {
throw serviceException;
} catch (Exception exception) {
log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception);
throw new ServiceException("MinIO分段上传异常");
}
}
@Override
public void cancelMultipartUpload(String objectKey, String uploadId) {
// MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可
log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId);
}
@Override
public void completeMultipartUpload(String objectKey, String uploadId, List<org.springblade.file.pojo.vo.PartEtagVO> partEtags) {
try {
@@ -143,12 +185,14 @@ public class MinioFileServiceImpl extends AbstractFileService {
return null;
}
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(properties.getBucketName())
.object(resolveObjectKey(objectKey))
.region(DEFAULT_REGION)
.expiry(Math.toIntExact(resolveExpires(expires)))
.build());
return applyTransformPathPrefix(presignedUrl);
} catch (Exception exception) {
log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception);
throw new ServiceException("MinIO获取文件链接异常");
@@ -170,6 +214,62 @@ public class MinioFileServiceImpl extends AbstractFileService {
}
}
/**
* 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。
* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。
*/
private MinioClient resolvePresignClient() {
String transformEndpoint = properties.getTransformEndpoint();
if (StringUtil.isBlank(transformEndpoint)) {
return minioClient;
}
MinioClient cachedClient = transformPresignClient;
if (cachedClient != null) {
return cachedClient;
}
synchronized (this) {
if (transformPresignClient == null) {
transformPresignClient = MinioClientFactory.create(
transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret());
}
return transformPresignClient;
}
}
private String applyTransformPathPrefix(String presignedUrl) {
String transformEndpoint = properties.getTransformEndpoint();
if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) {
return presignedUrl;
}
String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath());
if (StringUtil.isBlank(pathPrefix)) {
return presignedUrl;
}
URI url = URI.create(presignedUrl);
String path = url.getRawPath() == null ? "" : url.getRawPath();
if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) {
return presignedUrl;
}
StringBuilder result = new StringBuilder();
result.append(url.getScheme()).append("://").append(url.getHost());
if (url.getPort() > 0) {
result.append(':').append(url.getPort());
}
result.append(pathPrefix).append(path);
if (StringUtil.isNotBlank(url.getRawQuery())) {
result.append('?').append(url.getRawQuery());
}
return result.toString();
}
private String normalizePathPrefix(String path) {
if (StringUtil.isBlank(path) || "/".equals(path)) {
return "";
}
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
return normalized.startsWith("/") ? normalized : "/" + normalized;
}
private String resolveObjectKey(String objectKey) {
if (StringUtil.isBlank(properties.getRootDirectory())) {
return objectKey;
@@ -0,0 +1,121 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.file.util;
import io.minio.MinioClient;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import java.net.URI;
import java.util.concurrent.TimeUnit;
/**
* MinIO 客户端构建。
* <p>
* SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则:
* <ul>
* <li>endpoint 使用 origin</li>
* <li>通过 OkHttp 拦截器把 /oss 拼回每个 API 请求</li>
* <li>固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML</li>
* </ul>
*/
public final class MinioClientFactory {
public static final String DEFAULT_REGION = "us-east-1";
private MinioClientFactory() {
}
public static MinioClient create(String endpoint, String accessKey, String secretKey) {
EndpointParts parts = parseEndpoint(endpoint);
MinioClient.Builder builder = MinioClient.builder()
.endpoint(parts.origin())
.credentials(accessKey, secretKey)
.region(DEFAULT_REGION);
if (StringUtil.isNotBlank(parts.pathPrefix())) {
builder.httpClient(buildHttpClient(parts.pathPrefix()), true);
}
return builder.build();
}
public static String resolveOrigin(String endpoint) {
return parseEndpoint(endpoint).origin();
}
public static String resolvePathPrefix(String endpoint) {
return parseEndpoint(endpoint).pathPrefix();
}
private static EndpointParts parseEndpoint(String endpoint) {
if (StringUtil.isBlank(endpoint)) {
throw new ServiceException("MinIO endpoint 不能为空");
}
URI uri = URI.create(endpoint.trim());
if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) {
throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint);
}
StringBuilder origin = new StringBuilder();
origin.append(uri.getScheme()).append("://").append(uri.getHost());
if (uri.getPort() > 0) {
origin.append(':').append(uri.getPort());
}
return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath()));
}
private static String normalizePathPrefix(String path) {
if (StringUtil.isBlank(path) || "/".equals(path)) {
return "";
}
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
return normalized.startsWith("/") ? normalized : "/" + normalized;
}
private static OkHttpClient buildHttpClient(String pathPrefix) {
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(chain -> {
Request original = chain.request();
HttpUrl originalUrl = original.url();
String encodedPath = originalUrl.encodedPath();
if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) {
return chain.proceed(original);
}
HttpUrl newUrl = originalUrl.newBuilder()
.encodedPath(pathPrefix + encodedPath)
.build();
return chain.proceed(original.newBuilder().url(newUrl).build());
})
.build();
}
private record EndpointParts(String origin, String pathPrefix) {
}
}
@@ -13,6 +13,7 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.process.pojo.dto.ApprovalDTO;
import org.springblade.process.pojo.vo.ApprovalVO;
import org.springblade.process.pojo.vo.ProcessApprovedRecordVO;
@@ -55,15 +56,27 @@ public class BusinessProcessController extends BladeController {
@PostMapping("/processSubmit")
@ApiOperationSupport(order = 2)
@Operation(summary = "提交MK审核流", description = "调用mk processSubmit,传入templateCode/submitIdentity/formInstanceId")
@Operation(summary = "提交MK审核流", description = "传入bizId/bizType/templateCode,后端生成或复用formInstanceId后调用mk processSubmit")
public R<String> processSubmit(@Validated @RequestBody MKProcessCreateDTO param) {
return R.data(businessProcessService.processSubmit(param));
}
@GetMapping("/hasMkProcessInstance")
@ApiOperationSupport(order = 8)
@Operation(summary = "查询MK流程实例是否存在", description = "传入业务主键bizId,有数据则无需删除/重新提交")
public R<Boolean> hasMkProcessInstance(@Valid @NotBlank(message = "业务id不能为空") String bizId) {
return R.data(businessProcessService.hasMkProcessInstance(bizId));
}
@PostMapping("/processDelete")
@ApiOperationSupport(order = 7)
@Operation(summary = "删除MK审核流", description = "传入bizId,删除当前业务对应MK流程实例")
public R<Boolean> processDelete(@RequestBody MKProcessCreateDTO param) {
return R.data(businessProcessService.processDelete(param == null ? null : param.getFormInstanceId()));
String bizId = null;
if (param != null) {
bizId = StringUtil.isNotBlank(param.getBizId()) ? param.getBizId() : param.getFormInstanceId();
}
return R.data(businessProcessService.processDelete(bizId));
}
@GetMapping("/isEditView")
@@ -11,6 +11,7 @@ import org.springblade.process.pojo.dto.BusinessProcessDeleteDTO;
import org.springblade.process.pojo.dto.BusinessProcessSubmitDTO;
import org.springblade.process.pojo.dto.BusinessProcessUpdateDTO;
import org.springblade.process.pojo.vo.BusinessProcessVO;
import org.springblade.process.pojo.vo.MkFormBindVO;
import org.springblade.process.pojo.vo.ProcessApprovedRecordVO;
import org.springblade.process.pojo.vo.ProcessTodoVO;
import org.springblade.process.service.IBusinessProcessService;
@@ -94,4 +95,19 @@ public class BusinessProcessClient implements IBusinessProcessClient {
@RequestParam(value = "loginName", required = false) String loginName) {
return FR.data(businessProcessService.getProcessInfo(processInstanceId, loginName));
}
@PreAuth(AuthConstant.PERMIT_ALL)
@GetMapping(RESOLVE_BY_FORM_INSTANCE_ID)
@Override
public FR<MkFormBindVO> resolveByFormInstanceId(@RequestParam("formInstanceId") Long formInstanceId) {
return FR.data(businessProcessService.resolveByFormInstanceId(formInstanceId));
}
@PreAuth(AuthConstant.PERMIT_ALL)
@PostMapping(MARK_PROCESS_FINISHED)
@Override
public FR<Boolean> markProcessFinished(@RequestParam("processInstanceId") String processInstanceId,
@RequestParam("approved") boolean approved) {
return FR.data(businessProcessService.markProcessFinished(processInstanceId, approved));
}
}
@@ -6,6 +6,7 @@
<resultMap id="businessProcessResultMap" type="org.springblade.process.pojo.entity.BusinessProcess">
<result column="id" property="id"/>
<result column="biz_id" property="bizId"/>
<result column="form_instance_id" property="formInstanceId"/>
<result column="process_instance_id" property="processInstanceId"/>
<result column="process_type" property="processType"/>
<result column="doc_code" property="docCode"/>
@@ -0,0 +1,38 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.process.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.process.pojo.entity.MkFormBind;
/**
* MK表单实例绑定表 Mapper 接口
*
* @author BladeX
*/
public interface MkFormBindMapper extends BaseMapper<MkFormBind> {
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.process.mapper.MkFormBindMapper">
<resultMap id="mkFormBindResultMap" type="org.springblade.process.pojo.entity.MkFormBind">
<result column="id" property="id"/>
<result column="tenant_id" property="tenantId"/>
<result column="biz_type" property="bizType"/>
<result column="biz_id" property="bizId"/>
<result column="form_instance_id" property="formInstanceId"/>
<result column="process_instance_id" property="processInstanceId"/>
<result column="is_current" property="isCurrent"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
</mapper>
@@ -29,18 +29,43 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
/**
* 直接调用 MK processSubmit 提交流程
*
* @param param MK 流程创建参数
* @param param MK 流程创建参数(传 bizId+bizType,后端生成/复用 formInstanceId)
* @return 流程实例 id
*/
String processSubmit(MKProcessCreateDTO param);
/**
* 按业务表单实例 id 删除 MK 流程(不删除本地业务流程记录,供驳回后重新提交使用)
* 按业务 id 查询 MK 流程实例是否仍有数据(本地有 processInstanceId 且 MK getProcessInfo 有返回)
*
* @param formInstanceId 业务表单实例 id
* @param bizId 业务主键
* @return true=流程实例仍存在
*/
boolean hasMkProcessInstance(String bizId);
/**
* 按业务主键删除 MK 流程(不删除本地业务流程记录与表单绑定,供驳回后重新提交使用)
*
* @param bizId 业务主键
* @return 是否成功
*/
boolean processDelete(String formInstanceId);
boolean processDelete(String bizId);
/**
* 按表单实例id解析业务绑定
*
* @param formInstanceId 表单实例id
* @return 绑定信息
*/
MkFormBindVO resolveByFormInstanceId(Long formInstanceId);
/**
* 公开页流程结束时标记业务流程通过/驳回并完结
*
* @param processInstanceId 流程实例id
* @param approved true=通过 false=驳回
* @return 是否更新成功
*/
boolean markProcessFinished(String processInstanceId, boolean approved);
/**
* 获取流程当前节点详情
@@ -0,0 +1,81 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.process.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springblade.process.pojo.entity.MkFormBind;
import org.springblade.process.pojo.vo.MkFormBindVO;
/**
* MK表单实例绑定表 服务类
*
* @author BladeX
*/
public interface IMkFormBindService extends IService<MkFormBind> {
/**
* 查询业务当前生效的表单实例绑定
*
* @param bizType 业务类型(可空,空时仅按业务id查)
* @param bizId 业务主键
* @return 当前绑定,不存在返回 null
*/
MkFormBind getCurrentBind(String bizType, Long bizId);
/**
* 按表单实例id查询绑定
*
* @param formInstanceId 表单实例id
* @return 绑定记录
*/
MkFormBind getByFormInstanceId(Long formInstanceId);
/**
* 按表单实例id解析业务主键等信息
*
* @param formInstanceId 表单实例id
* @return 绑定 VO
*/
MkFormBindVO resolveByFormInstanceId(Long formInstanceId);
/**
* 解析或生成表单实例id:无当前绑定则新建;强制新建/当前流程已通过或已完结则新建;否则复用
*
* @param bizType 业务类型
* @param bizId 业务主键
* @param forceNew 是否强制生成新表单实例(审核通过后再次提交)
* @return 本次应使用的表单实例id
*/
Long resolveOrCreateFormInstanceId(String bizType, Long bizId, boolean forceNew);
/**
* 回写流程实例id到当前绑定
*
* @param formInstanceId 表单实例id
* @param processInstanceId 流程实例id
*/
void updateProcessInstanceId(Long formInstanceId, String processInstanceId);
}
@@ -25,6 +25,7 @@ import org.springblade.process.pojo.entity.BusinessProcess;
import org.springblade.process.pojo.enums.TodoStatus;
import org.springblade.process.pojo.vo.*;
import org.springblade.process.service.IBusinessProcessService;
import org.springblade.process.service.IMkFormBindService;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.service.IUserService;
import org.springblade.thirdparty.mk.config.MKProperties;
@@ -72,6 +73,7 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
private final IUserService userService;
private final ICustomerArchiveClient customerArchiveClient;
private final IMkProcessClient mkProcessClient;
private final IMkFormBindService mkFormBindService;
@Transactional(rollbackFor = Exception.class)
@Override
@@ -129,7 +131,6 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
@Override
public String processSubmit(MKProcessCreateDTO param) {
AssertUtils.notNull(param, "提交流程参数不能为空");
AssertUtils.notBlank(param.getFormInstanceId(), "表单实例id不能为空");
AssertUtils.notBlank(param.getTemplateCode(), "模板编码不能为空");
// 前端拿到的手机号可能经 @Sensitive 脱敏(如 137****8880),这里从库取真实手机号覆盖
String loginName = resolveCurrentUserPhone();
@@ -137,8 +138,47 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
param.setSubmitIdentity(loginName);
param.setLoginName(loginName);
String bizType = param.getBizType();
Long bizId = resolveBizIdFromSubmitParam(param);
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, bizId)
.orderByDesc(BusinessProcess::getUpdateTime)
.last("limit 1")
);
boolean forceNew = Boolean.TRUE.equals(param.getForceNewFormInstance())
|| shouldForceNewFormInstance(businessProcess, loginName);
Long formInstanceId = mkFormBindService.resolveOrCreateFormInstanceId(bizType, bizId, forceNew);
String formInstanceIdText = String.valueOf(formInstanceId);
param.setFormInstanceId(formInstanceIdText);
// 已有流程实例且 MK 仍能查到详情时,不再删除、不再重新创建提交(强制新建或流程已结束时跳过短路)
if (!forceNew && businessProcess != null && StringUtil.isNotBlank(businessProcess.getProcessInstanceId())
&& Objects.equals(formInstanceId, businessProcess.getFormInstanceId())) {
String existingProcessInstanceId = businessProcess.getProcessInstanceId();
Object existingProcessInfo = this.getProcessInfo(existingProcessInstanceId, loginName);
if (hasProcessInfoData(existingProcessInfo) && !isMkProcessFinished(existingProcessInfo)) {
log.info("MK流程实例已存在,跳过创建并提交流程 bizId={} formInstanceId={} processInstanceId={}",
bizId, formInstanceIdText, existingProcessInstanceId);
businessProcess.setApproveStatus(ApproveStatusEnum.APPROVING.getValue());
if (StringUtil.isNotBlank(param.getSubject())) {
businessProcess.setSubject(param.getSubject());
}
businessProcess.setPromoterLoginName(loginName);
businessProcess.setFormInstanceId(formInstanceId);
this.updateById(businessProcess);
this.getCurrentNodes(existingProcessInstanceId, loginName);
this.syncBizFromProcessInfo(bizType, String.valueOf(bizId), existingProcessInfo);
return existingProcessInstanceId;
}
// 已结束但仍能查到详情:继续走新建提交流程
forceNew = true;
formInstanceId = mkFormBindService.resolveOrCreateFormInstanceId(bizType, bizId, true);
formInstanceIdText = String.valueOf(formInstanceId);
param.setFormInstanceId(formInstanceIdText);
}
MKProcessCreateDTO mkParam = new MKProcessCreateDTO();
mkParam.setFormInstanceId(param.getFormInstanceId());
mkParam.setFormInstanceId(formInstanceIdText);
mkParam.setSubject(param.getSubject());
mkParam.setSubmitIdentity(loginName);
mkParam.setLoginName(loginName);
@@ -149,15 +189,6 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
String processInstanceId = mkService.processSubmit(mkParam);
AssertUtils.notBlank(processInstanceId, "提交流程失败,未返回流程实例id");
Long bizId;
try {
bizId = Long.valueOf(param.getFormInstanceId());
} catch (NumberFormatException e) {
throw new ServiceException("表单实例id格式不正确");
}
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, bizId)
);
if (businessProcess == null) {
businessProcess = new BusinessProcess();
businessProcess.setBizId(bizId);
@@ -177,30 +208,151 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
businessProcess.setSubmitTime(new Date());
}
}
businessProcess.setFormInstanceId(formInstanceId);
businessProcess.setProcessInstanceId(processInstanceId);
businessProcess.setApproveStatus(ApproveStatusEnum.APPROVING.getValue());
businessProcess.setIsCompleted(0);
businessProcess.setCompleteTime(null);
this.saveOrUpdate(businessProcess);
mkFormBindService.updateProcessInstanceId(formInstanceId, processInstanceId);
this.getCurrentNodes(processInstanceId, loginName);
Object processInfo = this.getProcessInfo(processInstanceId, loginName);
this.syncBizFromProcessInfo(bizType, param.getFormInstanceId(), processInfo);
this.syncBizFromProcessInfo(bizType, String.valueOf(bizId), processInfo);
return processInstanceId;
}
/**
* 从提交参数解析业务主键:优先 bizId,兼容旧调用把业务id放在 formInstanceId
*/
private Long resolveBizIdFromSubmitParam(MKProcessCreateDTO param) {
String bizIdText = param.getBizId();
if (StringUtil.isBlank(bizIdText)) {
bizIdText = param.getFormInstanceId();
}
AssertUtils.notBlank(bizIdText, "业务id不能为空");
try {
return Long.valueOf(bizIdText);
} catch (NumberFormatException e) {
throw new ServiceException("业务id格式不正确");
}
}
/**
* 是否需要强制新建表单实例:本地已通过/已完结,或 MK 流程已结束
*/
private boolean shouldForceNewFormInstance(BusinessProcess businessProcess, String loginName) {
if (businessProcess == null) {
return false;
}
if (Objects.equals(ApproveStatusEnum.APPROVED.getValue(), businessProcess.getApproveStatus())) {
return true;
}
if (businessProcess.getIsCompleted() != null && businessProcess.getIsCompleted() == 1) {
return true;
}
if (StringUtil.isBlank(businessProcess.getProcessInstanceId())) {
return false;
}
String resolvedLoginName = loginName;
if (StringUtil.isBlank(resolvedLoginName)) {
resolvedLoginName = businessProcess.getPromoterLoginName();
}
if (StringUtil.isBlank(resolvedLoginName)) {
resolvedLoginName = resolveCurrentUserPhone();
}
if (StringUtil.isBlank(resolvedLoginName)) {
return false;
}
Object processInfo = this.getProcessInfo(businessProcess.getProcessInstanceId(), resolvedLoginName);
return isMkProcessFinished(processInfo);
}
@Override
public boolean hasMkProcessInstance(String bizId) {
AssertUtils.notBlank(bizId, "业务id不能为空");
Long id;
try {
id = Long.valueOf(bizId);
} catch (NumberFormatException e) {
throw new ServiceException("业务id格式不正确");
}
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, id)
.orderByDesc(BusinessProcess::getUpdateTime)
.last("limit 1")
);
if (businessProcess == null || StringUtil.isBlank(businessProcess.getProcessInstanceId())) {
return false;
}
// 审批通过 / 已完结:允许再次提交新表单实例
if (Objects.equals(ApproveStatusEnum.APPROVED.getValue(), businessProcess.getApproveStatus())
|| (businessProcess.getIsCompleted() != null && businessProcess.getIsCompleted() == 1)) {
return false;
}
String loginName = businessProcess.getPromoterLoginName();
if (StringUtil.isBlank(loginName)) {
loginName = resolveCurrentUserPhone();
}
if (StringUtil.isBlank(loginName)) {
log.warn("查询MK流程实例跳过,登录名为空 bizId={} processInstanceId={}",
bizId, businessProcess.getProcessInstanceId());
return false;
}
Object processInfo = this.getProcessInfo(businessProcess.getProcessInstanceId(), loginName);
// MK 流程已结束(fdProcessStatus=30)时视为无有效待办,需重新提交新实例
if (isMkProcessFinished(processInfo)) {
return false;
}
return hasProcessInfoData(processInfo);
}
/**
* MK getProcessInfo 是否为已结束流程
*/
private boolean isMkProcessFinished(Object processInfo) {
if (!(processInfo instanceof Map<?, ?> map)) {
return false;
}
Object status = map.get("fdProcessStatus");
return status != null && "30".equals(String.valueOf(status));
}
/**
* MK getProcessInfo 是否返回有效数据
*/
private boolean hasProcessInfoData(Object processInfo) {
if (processInfo == null) {
return false;
}
if (processInfo instanceof Map<?, ?> map) {
return !map.isEmpty();
}
if (processInfo instanceof Collection<?> collection) {
return !collection.isEmpty();
}
if (processInfo instanceof CharSequence text) {
return StringUtil.isNotBlank(text.toString());
}
return true;
}
@Transactional(rollbackFor = Exception.class)
@Override
public boolean processDelete(String formInstanceId) {
AssertUtils.notBlank(formInstanceId, "表单实例id不能为空");
AssertUtils.notBlank(formInstanceId, "业务id不能为空");
Long bizId;
try {
bizId = Long.valueOf(formInstanceId);
} catch (NumberFormatException e) {
throw new ServiceException("表单实例id格式不正确");
throw new ServiceException("业务id格式不正确");
}
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, bizId)
.orderByDesc(BusinessProcess::getUpdateTime)
.last("limit 1")
);
if (businessProcess == null || StringUtil.isBlank(businessProcess.getProcessInstanceId())) {
log.warn("客商驳回后删除流程跳过,未找到流程实例 formInstanceId={}", formInstanceId);
log.warn("驳回后删除流程跳过,未找到流程实例 bizId={}", bizId);
return true;
}
String loginName = businessProcess.getPromoterLoginName();
@@ -208,8 +360,8 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
loginName = resolveCurrentUserPhone();
}
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法删除审核流");
log.info("调用mk processDelete processInstanceId={} loginName={} formInstanceId={}",
businessProcess.getProcessInstanceId(), loginName, formInstanceId);
log.info("调用mk processDelete processInstanceId={} loginName={} bizId={} formInstanceId={}",
businessProcess.getProcessInstanceId(), loginName, bizId, businessProcess.getFormInstanceId());
boolean deleted = mkService.processDelete(businessProcess.getProcessInstanceId(), loginName);
if (!deleted) {
throw new ServiceException("删除MK流程失败");
@@ -217,6 +369,59 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
return true;
}
@Override
public MkFormBindVO resolveByFormInstanceId(Long formInstanceId) {
AssertUtils.notNull(formInstanceId, "表单实例id不能为空");
MkFormBindVO bindVO = mkFormBindService.resolveByFormInstanceId(formInstanceId);
if (bindVO != null) {
return bindVO;
}
// 兼容未迁移历史:formInstanceId 曾等于 bizId
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, formInstanceId)
.last("limit 1")
);
if (businessProcess == null) {
businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getFormInstanceId, formInstanceId)
.last("limit 1")
);
}
if (businessProcess == null) {
return null;
}
MkFormBindVO fallback = new MkFormBindVO();
fallback.setBizId(businessProcess.getBizId());
fallback.setFormInstanceId(businessProcess.getFormInstanceId() != null
? businessProcess.getFormInstanceId() : businessProcess.getBizId());
fallback.setProcessInstanceId(businessProcess.getProcessInstanceId());
return fallback;
}
@Transactional(rollbackFor = Exception.class)
@Override
public boolean markProcessFinished(String processInstanceId, boolean approved) {
AssertUtils.notBlank(processInstanceId, "流程实例id不能为空");
BusinessProcess businessProcess = this.getBusinessProcessByProcessInstanceId(processInstanceId);
if (businessProcess == null) {
log.warn("标记流程完结跳过,未找到业务流程 processInstanceId={}", processInstanceId);
return false;
}
String approveStatus = approved
? ApproveStatusEnum.APPROVED.getValue()
: ApproveStatusEnum.REJECTED.getValue();
businessProcess.setApproveStatus(approveStatus);
businessProcess.setIsCompleted(1);
businessProcess.setCompleteTime(new Date());
businessProcess.setCurrentNodeIds(null);
businessProcess.setCurrentNodeNames(null);
businessProcess.setCurrentHandlers(null);
boolean updated = this.updateById(businessProcess);
log.info("标记流程完结 processInstanceId={} approved={} approveStatus={} result={}",
processInstanceId, approved, approveStatus, updated);
return updated;
}
@Override
public List<?> getCurrentNodes(String processInstanceId, String loginName) {
if (StringUtils.isBlank(processInstanceId)) {
@@ -358,17 +563,20 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
}
/**
* 从当前登录用户实体读取真实手机号(绕过接口返回脱敏)
* 从当前登录用户实体读取真实手机号(库内 SM2 密文先解密,绕过接口脱敏)
*/
private String resolveCurrentUserPhone() {
Long userId = AuthUtil.getUserId();
if (userId != null) {
User user = userService.getById(userId);
if (user != null && StringUtil.isNotBlank(user.getPhone()) && !user.getPhone().contains("*")) {
return user.getPhone().trim();
}
if (user != null && StringUtil.isNotBlank(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
return user.getAccount().trim();
if (user != null) {
userService.decryptPhone(user);
if (StringUtil.isNotBlank(user.getPhone()) && !user.getPhone().contains("*")) {
return user.getPhone().trim();
}
if (StringUtil.isNotBlank(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
return user.getAccount().trim();
}
}
}
String account = AuthUtil.getUserAccount();
@@ -488,13 +696,16 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
if (processInstanceIdBlank) {
BusinessProcess businessProcess = this.getOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, bizId)
.orderByDesc(BusinessProcess::getUpdateTime)
.last("limit 1")
);
AssertUtils.notNull(businessProcess, "业务流程不存在");
processInstanceId = businessProcess.getProcessInstanceId();
}
try {
String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, AuthUtil.getUserAccount());
String loginName = resolveCurrentUserPhone();
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法获取审核跳转地址");
String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, loginName);
AssertUtils.notBlank(mkApprovalUrl, "获取mk审批页面链接异常");
return mkApprovalUrl;
} catch (MKException e) {
@@ -0,0 +1,177 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.process.service.impl;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.log.utils.AssertUtils;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.process.mapper.BusinessProcessMapper;
import org.springblade.process.mapper.MkFormBindMapper;
import org.springblade.process.pojo.entity.BusinessProcess;
import org.springblade.process.pojo.entity.MkFormBind;
import org.springblade.process.pojo.enums.ApproveStatusEnum;
import org.springblade.process.pojo.vo.MkFormBindVO;
import org.springblade.process.service.IMkFormBindService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.Objects;
/**
* MK表单实例绑定表 服务实现类
*
* @author BladeX
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class MkFormBindServiceImpl extends ServiceImpl<MkFormBindMapper, MkFormBind> implements IMkFormBindService {
private final BusinessProcessMapper businessProcessMapper;
@Override
public MkFormBind getCurrentBind(String bizType, Long bizId) {
AssertUtils.notNull(bizId, "业务id不能为空");
MkFormBind bind = null;
if (StringUtil.isNotBlank(bizType)) {
bind = this.getOne(Wrappers.<MkFormBind>lambdaQuery()
.eq(MkFormBind::getBizType, bizType)
.eq(MkFormBind::getBizId, bizId)
.eq(MkFormBind::getIsCurrent, 1)
.orderByDesc(MkFormBind::getCreateTime)
.last("limit 1")
);
}
if (bind == null) {
// 兼容历史迁移数据 biz_type 为空,或未传 bizType
bind = this.getOne(Wrappers.<MkFormBind>lambdaQuery()
.eq(MkFormBind::getBizId, bizId)
.eq(MkFormBind::getIsCurrent, 1)
.orderByDesc(MkFormBind::getCreateTime)
.last("limit 1")
);
}
return bind;
}
@Override
public MkFormBind getByFormInstanceId(Long formInstanceId) {
AssertUtils.notNull(formInstanceId, "表单实例id不能为空");
return this.getOne(Wrappers.<MkFormBind>lambdaQuery()
.eq(MkFormBind::getFormInstanceId, formInstanceId)
.last("limit 1")
);
}
@Override
public MkFormBindVO resolveByFormInstanceId(Long formInstanceId) {
MkFormBind bind = this.getByFormInstanceId(formInstanceId);
if (bind == null) {
return null;
}
MkFormBindVO vo = new MkFormBindVO();
vo.setBizId(bind.getBizId());
vo.setBizType(bind.getBizType());
vo.setFormInstanceId(bind.getFormInstanceId());
vo.setProcessInstanceId(bind.getProcessInstanceId());
return vo;
}
@Transactional(rollbackFor = Exception.class)
@Override
public Long resolveOrCreateFormInstanceId(String bizType, Long bizId, boolean forceNew) {
AssertUtils.notNull(bizId, "业务id不能为空");
MkFormBind current = this.getCurrentBind(bizType, bizId);
if (current == null) {
return this.createNewBind(bizType, bizId);
}
BusinessProcess businessProcess = businessProcessMapper.selectOne(Wrappers.<BusinessProcess>lambdaQuery()
.eq(BusinessProcess::getBizId, bizId)
.orderByDesc(BusinessProcess::getUpdateTime)
.last("limit 1")
);
boolean approvedPass = businessProcess != null
&& Objects.equals(ApproveStatusEnum.APPROVED.getValue(), businessProcess.getApproveStatus());
boolean completed = businessProcess != null
&& businessProcess.getIsCompleted() != null
&& businessProcess.getIsCompleted() == 1;
if (forceNew || approvedPass || completed) {
log.info("生成新表单实例 forceNew={} approvedPass={} completed={} bizType={} bizId={} oldFormInstanceId={}",
forceNew, approvedPass, completed, bizType, bizId, current.getFormInstanceId());
this.lambdaUpdate()
.eq(MkFormBind::getBizId, bizId)
.eq(MkFormBind::getIsCurrent, 1)
.set(MkFormBind::getIsCurrent, 0)
.set(MkFormBind::getUpdateTime, new Date())
.update();
return this.createNewBind(bizType, bizId);
}
log.info("复用当前表单实例 bizType={} bizId={} formInstanceId={}",
bizType, bizId, current.getFormInstanceId());
if (StringUtil.isNotBlank(bizType) && StringUtil.isBlank(current.getBizType())) {
current.setBizType(bizType);
current.setUpdateTime(new Date());
this.updateById(current);
}
return current.getFormInstanceId();
}
@Override
public void updateProcessInstanceId(Long formInstanceId, String processInstanceId) {
AssertUtils.notNull(formInstanceId, "表单实例id不能为空");
this.lambdaUpdate()
.eq(MkFormBind::getFormInstanceId, formInstanceId)
.set(MkFormBind::getProcessInstanceId, processInstanceId)
.set(MkFormBind::getUpdateTime, new Date())
.update();
}
private Long createNewBind(String bizType, Long bizId) {
Long formInstanceId = IdWorker.getId();
MkFormBind bind = new MkFormBind();
bind.setId(IdWorker.getId());
bind.setTenantId(StringUtil.isBlank(AuthUtil.getTenantId()) ? "000000" : AuthUtil.getTenantId());
bind.setBizType(StringUtil.isBlank(bizType) ? "" : bizType);
bind.setBizId(bizId);
bind.setFormInstanceId(formInstanceId);
bind.setIsCurrent(1);
bind.setCreateTime(new Date());
bind.setUpdateTime(new Date());
boolean saved = this.save(bind);
if (!saved) {
throw new ServiceException("保存表单实例绑定失败");
}
log.info("创建表单实例绑定 bizType={} bizId={} formInstanceId={}", bizType, bizId, formInstanceId);
return formInstanceId;
}
}
@@ -157,10 +157,21 @@ public class AirportMasterController extends BladeController {
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
List<AirportMasterExcel> failureList = airportMasterService.importAirportMaster(ExcelUtil.read(file, AirportMasterExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "空港机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, AirportMasterExcel.class);
try {
airportMasterService.importAirportMaster(ExcelUtil.read(file, AirportMasterExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), AirportMasterExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "机场主数据导入失败明细" + DateUtil.time(), "导入失败明细", file, AirportMasterExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -46,9 +46,9 @@ import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.system.excel.CargoTypeExcel;
import org.springblade.system.excel.CargoTypeExportExcel;
import org.springblade.system.excel.CargoTypeImportFailureExcel;
import org.springblade.system.pojo.entity.CargoType;
import org.springblade.system.pojo.vo.CargoTypeVO;
import org.springblade.system.service.ICargoTypeService;
@@ -156,15 +156,41 @@ public class CargoTypeController extends BladeController {
@ApiOperationSupport(order = 7)
@Operation(summary = "导入货物类型", description = "传入excel")
public R importCargoType(MultipartFile file, HttpServletResponse response) {
List<CargoTypeExcel> data = ExcelUtil.read(file, CargoTypeExcel.class);
List<CargoTypeImportFailureExcel> failureList = cargoTypeService.importCargoType(data);
if (Func.isNotEmpty(failureList)) {
ImportFailureExcelUtil.export(response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CargoTypeExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
cargoTypeService.importCargoType(ExcelUtil.read(file, CargoTypeExcel.class));
} catch (ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
exportFailure(response, exception.getFailureList());
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", file, CargoTypeExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("导入数据成功");
}
/**
* 导出导入失败明细,内容为原表全部数据并在末尾追加失败原因列。
* <p>
* 失败数据仅标红出错单元格与失败原因列,表头保持默认样式。
*/
private void exportFailure(HttpServletResponse response, List<?> failureList) {
ImportFailureExcelUtil.exportFailureReasonOnly(response, "货物类型导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CargoTypeExcel.class);
}
/**
* 导出货物类型
*/
@@ -149,10 +149,28 @@ public class CurrencyController extends BladeController {
@ApiOperationSupport(order = 7)
@Operation(summary = "导入币种汇率", description = "传入excel")
public R importCurrency(MultipartFile file, HttpServletResponse response) {
List<CurrencyExcel> failureList = currencyService.importCurrency(ExcelUtil.read(file, CurrencyExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CurrencyExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
currencyService.importCurrency(ExcelUtil.read(file, CurrencyExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), CurrencyExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "币种汇率导入失败明细" + DateUtil.time(), "导入失败明细", file, CurrencyExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -44,11 +44,10 @@ import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.support.Kv;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.enums.DictEnum;
import org.springblade.system.pojo.vo.DeptVO;
import org.springblade.system.pojo.vo.OaOrgSyncPageVO;
import org.springblade.system.pojo.vo.UserVO;
@@ -94,7 +93,8 @@ public class DeptController extends BladeController {
@PreAuth(menu = "dept")
@GetMapping("/list")
@Parameters({
@Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
@ApiOperationSupport(order = 2)
@@ -109,7 +109,8 @@ public class DeptController extends BladeController {
@PreAuth(menu = "dept")
@GetMapping("/lazy-list")
@Parameters({
@Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
@ApiOperationSupport(order = 3)
@@ -156,7 +157,7 @@ public class DeptController extends BladeController {
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
// 返回懒加载树更新节点所需字段
Kv kv = Kv.create().set("id", String.valueOf(dept.getId())).set("tenantId", dept.getTenantId())
.set("deptCategoryName", DictCache.getValue(DictEnum.ORG_CATEGORY, dept.getDeptCategory()));
.set("deptCategoryName", DictBizCache.getValue("organization_type", dept.getDeptCategory()));
return R.data(kv);
}
return R.fail("操作失败");
@@ -196,8 +197,10 @@ public class DeptController extends BladeController {
@Operation(summary = "同步OA公司")
public R<OaOrgSyncPageVO> syncOaCompany(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size) {
return R.data(oaSyncService.syncCompanyPage(current, size));
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(defaultValue = "false") Boolean incremental,
@RequestParam(required = false) String modifiedSince) {
return R.data(oaSyncService.syncCompanyPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
}
/**
@@ -209,8 +212,10 @@ public class DeptController extends BladeController {
@Operation(summary = "同步OA部门")
public R<OaOrgSyncPageVO> syncOaDepartment(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size) {
return R.data(oaSyncService.syncDepartmentPage(current, size));
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(defaultValue = "false") Boolean incremental,
@RequestParam(required = false) String modifiedSince) {
return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
}
/**
@@ -36,6 +36,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.mp.support.Condition;
@@ -47,7 +48,6 @@ import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.excel.FeeItemExcel;
import org.springblade.system.excel.FeeItemExportExcel;
import org.springblade.system.excel.FeeItemImportFailureExcel;
import org.springblade.system.pojo.entity.FeeItem;
import org.springblade.system.pojo.vo.FeeItemVO;
import org.springblade.system.service.IFeeItemService;
@@ -138,11 +138,28 @@ public class FeeItemController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "导入费用项", description = "传入excel")
public R importFeeItem(MultipartFile file, HttpServletResponse response) {
List<FeeItemExcel> data = ExcelUtil.read(file, FeeItemExcel.class);
List<FeeItemImportFailureExcel> failureList = feeItemService.importFeeItem(data);
if (Func.isNotEmpty(failureList)) {
ImportFailureExcelUtil.export(response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", failureList, FeeItemExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
feeItemService.importFeeItem(ExcelUtil.read(file, FeeItemExcel.class));
} catch (ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
ImportFailureExcelUtil.exportFailureReasonOnly(response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), FeeItemExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "费用项导入失败明细" + DateUtil.time(), "导入失败明细", file, FeeItemExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("导入数据成功");
}
@@ -0,0 +1,134 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
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.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.vo.MpUserVO;
import org.springblade.system.service.IMpUserService;
import org.springblade.system.wrapper.MpUserWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 小程序用户管理 控制器
*
* @author Chill
*/
@NonDS
@RestController
@AllArgsConstructor
@PreAuth(menu = "mp_user")
@RequestMapping("mp-user")
@Tag(name = "小程序用户", description = "小程序用户管理")
public class MpUserController extends BladeController {
private final IMpUserService mpUserService;
/**
* 详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 1)
@Operation(summary = "详情", description = "传入id")
public R<MpUserVO> detail(MpUser mpUser) {
MpUser detail = mpUserService.getOne(Condition.getQueryWrapper(mpUser));
if (detail == null) {
return R.fail("小程序用户不存在");
}
return R.data(MpUserWrapper.build().entityVO(detail));
}
/**
* 分页
*/
@GetMapping("/list")
@ApiOperationSupport(order = 2)
@Operation(summary = "分页", description = "传入查询条件")
public R<IPage<MpUserVO>> list(MpUser mpUser, Query query) {
IPage<MpUser> pages = mpUserService.selectMpUserPage(Condition.getPage(query), mpUser);
return R.data(MpUserWrapper.build().pageVO(pages));
}
/**
* 新增或修改
*/
@PostMapping("/submit")
@ApiOperationSupport(order = 3)
@Operation(summary = "新增或修改", description = "传入MpUser")
public R submit(@Valid @RequestBody MpUser mpUser) {
return R.status(mpUserService.submitOrUpdate(mpUser));
}
/**
* 逻辑删除
*/
@PostMapping("/remove")
@ApiOperationSupport(order = 4)
@Operation(summary = "逻辑删除", description = "传入ids")
public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) {
return R.status(mpUserService.removeMpUser(Func.toLongList(ids)));
}
/**
* 启用 / 停用
*/
@PostMapping("/change-status")
@ApiOperationSupport(order = 5)
@Operation(summary = "修改状态", description = "status:1启用,0停用")
public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) {
return R.status(mpUserService.changeStatus(id, status));
}
/**
* 重置密码
*/
@PostMapping("/reset-password")
@ApiOperationSupport(order = 6)
@Operation(summary = "重置密码", description = "传入userId集合,返回账号与明文密码")
public R<Map<String, String>> resetPassword(
@Parameter(description = "userId集合", required = true) @RequestParam String userIds) {
return R.data(mpUserService.resetPassword(Func.toLongList(userIds)));
}
}
@@ -44,7 +44,7 @@ import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.excel.PortTerminalExportExcel;
import org.springblade.system.excel.PortTerminalImporter;
@@ -160,10 +160,21 @@ public class RailwayStationController extends BladeController {
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
List<RailwayStationExcel> failureList = railwayStationService.importRailwayStation(ExcelUtil.read(file, RailwayStationExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "铁路车站主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, RailwayStationExcel.class);
try {
railwayStationService.importRailwayStation(ExcelUtil.read(file, RailwayStationExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "铁路站点导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), RailwayStationExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "铁路站点导入失败明细" + DateUtil.time(), "导入失败明细", file, RailwayStationExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -46,6 +46,7 @@ import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.pojo.entity.Region;
import org.springblade.system.excel.RegionExcel;
import org.springblade.system.excel.RegionExportExcel;
@@ -191,10 +192,28 @@ public class RegionController extends BladeController {
@ApiOperationSupport(order = 10)
@Operation(summary = "导入行政区划", description = "传入excel")
public R importRegion(MultipartFile file, Integer isCovered, HttpServletResponse response) {
List<RegionExcel> failureList = regionService.importRegion(ExcelUtil.read(file, RegionExcel.class), Objects.equals(isCovered, 1));
if (!failureList.isEmpty()) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", failureList, RegionExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
regionService.importRegion(ExcelUtil.read(file, RegionExcel.class), Objects.equals(isCovered, 1));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), RegionExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "行政区划导入失败明细" + DateUtil.time(), "导入失败明细", file, RegionExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -53,11 +53,14 @@ import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.StringPool;
import org.springblade.system.excel.UserExcel;
import org.springblade.system.excel.UserImporter;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
import org.springblade.system.pojo.vo.UserVO;
import org.springblade.system.service.IOASyncService;
import org.springblade.system.service.IMpUserService;
import org.springblade.system.service.IUserService;
import org.springblade.system.utils.MpUserAuth;
import org.springblade.system.wrapper.UserWrapper;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -79,6 +82,7 @@ import java.util.Map;
public class UserController {
private final IUserService userService;
private final IMpUserService mpUserService;
private final IOASyncService oaSyncService;
/**
@@ -101,6 +105,10 @@ public class UserController {
@Operation(summary = "查看详情", description = "传入id")
@GetMapping("/info")
public R<UserVO> info(BladeUser user) {
if (MpUserAuth.isMpUser(user)) {
MpUser mpUser = mpUserService.getById(user.getUserId());
return R.data(toMpUserVO(mpUser));
}
User detail = userService.getById(user.getUserId());
return R.data(UserWrapper.build().entityVO(detail));
}
@@ -112,11 +120,12 @@ public class UserController {
@GetMapping("/list")
@BladeView(Views.Admin.class)
@Parameters({
@Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
@Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
@ApiOperationSupport(order = 3)
@Operation(summary = "列表", description = "传入account和realName")
@Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索")
public R<IPage<UserVO>> list(@Parameter(hidden = true) @RequestParam Map<String, Object> user, Query query) {
return R.data(userService.selectPage(user, query));
}
@@ -128,11 +137,12 @@ public class UserController {
@GetMapping("/page")
@BladeView(Views.Admin.class)
@Parameters({
@Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
@Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
@Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
@ApiOperationSupport(order = 4)
@Operation(summary = "列表", description = "传入account和realName")
@Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索")
public R<IPage<UserVO>> page(@Parameter(hidden = true) User user, Query query, Long deptId, BladeUser bladeUser) {
IPage<User> pages = userService.selectUserPage(Condition.getPage(query), user, deptId, (AuthUtil.isAdministrator() ? StringPool.EMPTY : bladeUser.getTenantId()));
return R.data(UserWrapper.build().pageVO(pages));
@@ -169,8 +179,10 @@ public class UserController {
@Operation(summary = "同步OA人员")
public R<OaPersonSyncPageVO> syncIamAccounts(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "20") Integer size) {
return R.data(oaSyncService.syncPersonFromUserList(current, size));
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(defaultValue = "false") Boolean incremental,
@RequestParam(required = false) String modifiedSince) {
return R.data(oaSyncService.syncPersonFromUserList(current, size, Boolean.TRUE.equals(incremental), modifiedSince));
}
/**
@@ -184,6 +196,18 @@ public class UserController {
return R.status(userService.updateUser(user));
}
/**
* 启用或停用
*/
@IsAdmin
@PostMapping("/change-status")
@ApiOperationSupport(order = 6)
@Operation(summary = "启用停用", description = "传入id和status")
public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id,
@Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) {
return R.status(userService.changeUserStatus(id, status));
}
/**
* 删除
*/
@@ -236,6 +260,7 @@ public class UserController {
* 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密)
* <p>
* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。
* 小程序用户写入 blade_mp_user,系统用户仍写入 blade_user。
*/
@PostMapping("/password")
@ApiOperationSupport(order = 10)
@@ -243,6 +268,9 @@ public class UserController {
public R password(BladeUser user,
@Parameter(description = "新密码", required = true) @RequestParam String password,
@Parameter(description = "确认密码", required = true) @RequestParam String password2) {
if (MpUserAuth.isMpUser(user)) {
return R.status(mpUserService.setPassword(user.getUserId(), password, password2));
}
return R.status(userService.setPassword(user.getUserId(), password, password2));
}
@@ -422,4 +450,34 @@ public class UserController {
return R.data(list);
}
/**
* 小程序用户转 UserVO(兼容原 /user/info 响应结构)
*/
private UserVO toMpUserVO(MpUser mpUser) {
if (mpUser == null) {
return null;
}
UserVO userVO = new UserVO();
userVO.setId(mpUser.getId());
userVO.setTenantId(mpUser.getTenantId());
userVO.setCode(mpUser.getCode());
userVO.setAccount(mpUser.getAccount());
userVO.setName(mpUser.getName());
userVO.setRealName(mpUser.getRealName());
userVO.setAvatar(mpUser.getAvatar());
userVO.setEmail(mpUser.getEmail());
userVO.setPhone(mpUser.getPhone());
userVO.setBirthday(mpUser.getBirthday());
userVO.setSex(mpUser.getSex());
userVO.setDeptId(mpUser.getDeptId());
userVO.setPostId(mpUser.getPostId());
userVO.setRemark(mpUser.getRemark());
userVO.setStatus(mpUser.getStatus());
userVO.setCreateTime(mpUser.getCreateTime());
userVO.setUpdateTime(mpUser.getUpdateTime());
// 身份放入 roleName,便于前端兼容展示
userVO.setRoleName(mpUser.getIdentities());
return userVO;
}
}
@@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 用户手机号变更(小程序「修改手机号」)
* 小程序用户手机号变更(写入 blade_mp_user)
* <p>
* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。
*
@@ -44,92 +44,79 @@ public interface DeptConvert {
@Mapping(source = "subcompanyname", target = "deptName")
@Mapping(source = "subcompanydesc", target = "fullName")
// 父公司id转为父部门编码,要拼接前缀
@Mapping(source = "supsubcomid", target = "parentCode")
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "modified", target = "oaModified")
Dept baseConvert(OACompanyResponse company);
@Mapping(source = "departmentname", target = "deptName")
@Mapping(source = "departmentmark", target = "fullName")
// 父部门id转为父部门编码,要拼接前缀
@Mapping(source = "supdepid", target = "parentCode")
// 所属子公司id转为所属公司编码,要拼接前缀
@Mapping(source = "subcompanyid1", target = "belongCompanyCode")
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
@Mapping(source = "modified", target = "oaModified")
Dept baseConvert(OADepartmentResponse department);
/**
* oa公司转本系统部门
* @param company
* @return
* oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。
*/
default Dept company2dept(OACompanyResponse company) {
Dept dept = this.baseConvert(company);
// 部门编码添加前缀
dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId());
// 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级
dept.setDeptCode(null);
String parentCode = dept.getParentCode();
if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) {
// 父部门编码不是0,拼接前缀
dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode);
// 父部门id后续处理
// 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码
dept.setParentCode(parentCode);
dept.setParentId(null);
} else {
// 父部门编码是0,父id也设置为0
dept.setParentId(OAConvertConstant.ROOT_PARENT_ID);
dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
}
// 公司所属公司编码就是他自己的部门编码
dept.setBelongCompanyCode(dept.getDeptCode());
// OA主键与上级OA主键,分批同步结束后用于重建层级
dept.setBelongCompanyCode(null);
dept.setOaId(company.getId());
dept.setOaSupSubComId(company.getSupsubcomid());
// 显示顺序处理
dept.setSort(OAUtils.parseInt(company.getShoworder()));
// 部门类型,公司
dept.setDeptCategory(DeptCategory.COMPANY.getCode());
dept.setSyncTime(new Date());
// 是oa的
dept.setIsOa(CommonConstant.YES);
return dept;
}
/**
* oa部门转本系统部门
* @param oaDept
* @param companyDeptMap 所有公司的部门编码与id map
* @return
* oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。
*
* @param oaDept OA部门
* @param companyByOaId 已同步公司(oaId → 本地公司)
*/
default Dept dept2dept(OADepartmentResponse oaDept, Map<String, Long> companyDeptMap) {
default Dept dept2dept(OADepartmentResponse oaDept, Map<String, Dept> companyByOaId) {
Dept dept = this.baseConvert(oaDept);
// 部门编码添加前缀
dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId());
// OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键
dept.setDeptCode(null);
dept.setOaId(oaDept.getId());
if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) {
dept.setOaSupSubComId(oaDept.getSubcompanyid1());
} else {
dept.setOaSupSubComId(oaDept.getSupdepid());
}
// 部门所属公司编码就是他所属公司编码加前缀
dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode());
String parentCode = dept.getParentCode();
if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) {
// 父部门编码不是0,拼接前缀
dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode);
// 父部门id后续处理
Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1());
if (belongCompany != null) {
dept.setBelongCompanyCode(belongCompany.getDeptCode());
dept.setParentId(null);
if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) {
dept.setParentCode(belongCompany.getDeptCode());
dept.setParentId(belongCompany.getId());
} else {
// 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析
dept.setParentCode(oaDept.getSupdepid());
}
} else {
// 父部门编码是0,设置为公司编码
dept.setParentCode(dept.getBelongCompanyCode());
// 根据父部门编码获取对应的id
dept.setParentId(companyDeptMap.get(dept.getParentCode()));
// 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码
dept.setBelongCompanyCode(oaDept.getSubcompanyid1());
dept.setParentCode(oaDept.getSupdepid());
}
// 显示顺序处理
dept.setSort(OAUtils.parseInt(oaDept.getShoworder()));
// 部门类型,部门
dept.setDeptCategory(DeptCategory.DEPT.getCode());
dept.setSyncTime(new Date());
// 是oa的
dept.setIsOa(CommonConstant.YES);
return dept;
}
@@ -70,6 +70,7 @@ public interface UserConvert {
@Mapping(source = "lastname", target = "realName")
@Mapping(source = "mobile", target = "phone")
@Mapping(source = "email", target = "email")
@Mapping(source = "modified", target = "oaModified")
User baseConvert(OAPersonResponse person);
@Mapping(source = "subcompanyname", target = "companyName")
@@ -87,6 +88,8 @@ public interface UserConvert {
default User person2user(OAPersonResponse person, String defaultPassword) {
User user = baseConvert(person);
user.setAccount(resolveAccount(person));
// OA 可能返回多号(如 133.../137...),只保留首个号码再入库加密
user.setPhone(normalizeMobile(user.getPhone()));
// 密码
user.setPassword(defaultPassword);
// 性别
@@ -102,6 +105,23 @@ public interface UserConvert {
return user;
}
/**
* 规范化手机号:去空白,多号时取第一个
*/
default String normalizeMobile(String mobile) {
if (StringUtils.isBlank(mobile)) {
return null;
}
String trimmed = mobile.trim();
String[] parts = trimmed.split("[/|,;;,\\s]+");
for (String part : parts) {
if (StringUtils.isNotBlank(part)) {
return part.trim();
}
}
return null;
}
/**
* 解析本系统登录账号:优先 OA loginid,其次工号,最后手机号
*
@@ -102,6 +102,9 @@ public class AirportMasterExportExcel implements Serializable {
@ExcelProperty("备注")
private String remark;
@ExcelProperty("创建人")
private String createUserName;
@ExcelProperty("更新人")
private String updateUserName;
@@ -76,7 +76,7 @@ public class CurrencyExcel implements Serializable {
@ExcelProperty("备注")
private String remark;
@ExcelProperty
@ExcelIgnore
private String errorMessage;
}
@@ -50,6 +50,9 @@ public class FeeItemExportExcel implements Serializable {
@ExcelProperty("组织")
private String createDeptName;
@ExcelProperty("创建人")
private String createUserName;
@ExcelProperty("更新人")
private String updateUserName;
@@ -80,6 +80,9 @@ public class PortTerminalExportExcel implements Serializable {
@ExcelProperty("备注")
private String remark;
@ExcelProperty("创建人")
private String createUserName;
@ExcelProperty("更新人")
private String updateUserName;
@@ -54,7 +54,7 @@ public class RailwayStationExcel implements Serializable {
@ExcelProperty("TMIS国标编码*")
private String tmisCode;
@ExcelProperty("电报略码*")
@ExcelProperty("电报略码")
private String telegraphCode;
@ExcelProperty("车站名称*")
@@ -56,7 +56,7 @@ public class RailwayStationExportExcel implements Serializable {
@ExcelProperty("TMIS国标编码*")
private String tmisCode;
@ExcelProperty("电报略码*")
@ExcelProperty("电报略码")
private String telegraphCode;
@ExcelProperty("车站名称*")
@@ -92,6 +92,9 @@ public class RailwayStationExportExcel implements Serializable {
@ExcelProperty("备注")
private String remark;
@ExcelProperty("创建人")
private String createUserName;
@ExcelProperty("更新人")
private String updateUserName;
@@ -0,0 +1,110 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.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.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.service.IMpUserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 小程序用户 Feign 实现类
*
* @author Chill
*/
@NonDS
@Hidden
@RestController
@AllArgsConstructor
public class MpUserClient implements IMpUserClient {
private final IMpUserService mpUserService;
@Override
@GetMapping(USER_BY_ID)
public R<MpUser> userById(Long userId) {
return R.data(mpUserService.getById(userId));
}
@Override
@GetMapping(USER_BY_PHONE)
public R<MpUser> userByPhone(String tenantId, String phone) {
return R.data(mpUserService.getByPhone(tenantId, phone));
}
@Override
@GetMapping(USER_BY_ACCOUNT)
public R<MpUser> userByAccount(String tenantId, String account) {
return R.data(mpUserService.getByAccount(tenantId, account));
}
@Override
@GetMapping(USER_INFO_BY_ID)
public R<MpUserInfo> userInfoById(Long userId) {
return R.data(mpUserService.userInfo(userId));
}
@Override
@GetMapping(USER_INFO_BY_PHONE)
public R<MpUserInfo> userInfoByPhone(String tenantId, String phone) {
return R.data(mpUserService.userInfoByPhone(tenantId, phone));
}
@Override
@GetMapping(USER_INFO_BY_ACCOUNT)
public R<MpUserInfo> userInfoByAccount(String tenantId, String account) {
return R.data(mpUserService.userInfoByAccount(tenantId, account));
}
@Override
@PostMapping(SAVE_USER)
public R<Boolean> saveUser(@RequestBody MpUser user) {
return R.data(mpUserService.submit(user));
}
@Override
@PostMapping(UPDATE_USER)
public R<Boolean> updateUser(@RequestBody MpUser user) {
return R.data(mpUserService.updateUser(user));
}
@Override
@PostMapping(BIND_OPENID)
public R<Boolean> bindOpenId(String tenantId, Long userId, String openid) {
return R.data(mpUserService.bindOpenId(tenantId, userId, openid));
}
@Override
@PostMapping(SET_PASSWORD)
public R<Boolean> setPassword(Long userId, String password, String password2) {
return R.data(mpUserService.setPassword(userId, password, password2));
}
}
@@ -26,6 +26,7 @@
package org.springblade.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.vo.DeptVO;
@@ -66,6 +67,18 @@ public interface DeptMapper extends BaseMapper<Dept> {
*/
List<DeptVO> lazyTree(String tenantId, Long parentId);
/**
* 按组织 ID 集合查询树节点(用于数据权限过滤后的根节点回显)
*
* @param tenantId 租户ID,空表示不限
* @param ids 需要返回的组织 ID
* @param childIds 计算 hasChildren 时允许计入的子组织 ID;为空则 hasChildren 恒为 false
* @return 树节点列表
*/
List<DeptVO> treeNodesByIds(@Param("tenantId") String tenantId,
@Param("ids") List<Long> ids,
@Param("childIds") List<Long> childIds);
/**
* 获取部门名
*
@@ -12,6 +12,8 @@
<result column="leader_id" property="leaderId"/>
<result column="dept_category" property="deptCategory"/>
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -32,6 +34,8 @@
<result column="leader_id" property="leaderId"/>
<result column="dept_category" property="deptCategory"/>
<result column="dept_code" property="deptCode"/>
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
<result column="contract_code_prefix" property="contractCodePrefix"/>
<result column="short_name" property="shortName"/>
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
<result column="mnemonic_code" property="mnemonicCode"/>
@@ -76,6 +80,9 @@
<if test="param3.deptName!=null and param3.deptName!=''">
and dept.dept_name like concat(concat('%', #{param3.deptName}),'%')
</if>
<if test="param3.shortName!=null and param3.shortName!=''">
and dept.short_name like concat(concat('%', #{param3.shortName}),'%')
</if>
<if test="param3.fullName!=null and param3.fullName!=''">
and dept.full_name like concat(concat('%', #{param3.fullName}),'%')
</if>
@@ -115,6 +122,47 @@
ORDER BY dept.sort
</select>
<select id="treeNodesByIds" resultMap="treeNodeResultMap">
SELECT
dept.id,
dept.parent_id,
dept.dept_name AS title,
dept.id AS "value",
dept.id AS "key",
(
SELECT
CASE WHEN count(1) > 0 THEN 1 ELSE 0 END
FROM
blade_dept child
WHERE
child.parent_id = dept.id
AND child.is_deleted = 0
<choose>
<when test="childIds != null and childIds.size() &gt; 0">
AND child.id IN
<foreach collection="childIds" item="childId" open="(" close=")" separator=",">
#{childId}
</foreach>
</when>
<otherwise>
AND 1 = 0
</otherwise>
</choose>
) AS "has_children"
FROM
blade_dept dept
WHERE
dept.is_deleted = 0
AND dept.id IN
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
<if test="tenantId != null and tenantId != ''">
AND dept.tenant_id = #{tenantId}
</if>
ORDER BY dept.sort, dept.id
</select>
<select id="getDeptNames" resultType="java.lang.String">
SELECT
dept_name
@@ -0,0 +1,34 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springblade.system.pojo.entity.MpUser;
/**
* 小程序用户 Mapper
*
* @author Chill
*/
public interface MpUserMapper extends BaseMapper<MpUser> {
}
@@ -45,6 +45,11 @@ public interface RailwayStationMapper extends BaseMapper<RailwayStation> {
*/
RailwayStation selectByCodeIncludingDeleted(@Param("code") String code);
/**
* 按 TMIS 国标编码查询铁路车站(包含逻辑删除记录,用于导入恢复)。
*/
RailwayStation selectByTmisCodeIncludingDeleted(@Param("tmisCode") String tmisCode);
/**
* 恢复逻辑删除铁路车站。
*/
@@ -36,6 +36,14 @@
SELECT *
FROM blade_railway_station
WHERE code = #{code}
LIMIT 1
</select>
<select id="selectByTmisCodeIncludingDeleted" resultType="org.springblade.system.pojo.entity.RailwayStation">
SELECT *
FROM blade_railway_station
WHERE tmis_code = #{tmisCode}
LIMIT 1
</select>
<update id="restoreById">
@@ -40,6 +40,13 @@ import java.util.List;
*/
public interface UserDeptMapper extends BaseMapper<UserDept> {
/**
* 提高当前会话 group_concat 上限,需与 queryUserDeptIds 在同一事务/连接中调用
*
* @param maxLen 最大长度
*/
void setGroupConcatMaxLen(@Param("maxLen") int maxLen);
/**
* 根据用户id列表查询对应的部门id列表拼接的字符串
* @param userIds
@@ -2,6 +2,10 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.system.mapper.UserDeptMapper">
<update id="setGroupConcatMaxLen">
SET SESSION group_concat_max_len = #{maxLen}
</update>
<select id="queryUserDeptIds" resultType="org.springblade.system.pojo.vo.UserDeptIdsVO">
select
group_concat(distinct bd.id) deptIds,
@@ -44,13 +44,16 @@
and bu.tenant_id = #{user.tenantId}
</if>
<if test="user.account!=null and user.account != ''">
and bu.account = #{user.account}
and bu.account like concat('%', #{user.account}, '%')
</if>
<if test="user.realName!=null and user.realName != ''">
and bu.real_name = #{user.realName}
and (
bu.real_name like concat('%', #{user.realName}, '%')
or bu.name like concat('%', #{user.realName}, '%')
)
</if>
<if test="user.phone!=null and user.phone != ''">
and bu.phone = #{user.phone}
and bu.phone like concat('%', #{user.phone}, '%')
</if>
<if test="user.userType!=null and user.userType != ''">
and bu.user_type = #{user.userType}
@@ -30,6 +30,7 @@ import org.springblade.core.literule.core.RuleComponent;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.Tenant;
import org.springblade.system.pojo.enums.DeptCategory;
import org.springblade.system.rule.context.TenantContext;
import static org.springblade.system.rule.constant.TenantRuleConstant.TENANT_DEPT_RULE;
@@ -54,7 +55,7 @@ public class TenantDeptRule extends RuleComponent {
dept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
dept.setDeptName(tenant.getTenantName());
dept.setFullName(tenant.getTenantName());
dept.setDeptCategory(1);
dept.setDeptCategory(DeptCategory.GROUP.getCode());
dept.setSort(2);
dept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
@@ -68,10 +68,10 @@ public interface IDeptService extends IService<Dept> {
List<DeptVO> lazyList(String tenantId, Long parentId, Map<String, Object> param);
/**
* 树形结构(含超管判定 + 当前租户隔离)
* 树形结构(含超管判定 + 当前租户隔离 + 用户组织数据权限)
* <p>
* 仅超级管理员可指定任意 tenantId 查询;其他用户传入的 tenantId 一律被忽略,
* 强制使用当前会话租户。
* 强制使用当前会话租户,并按用户「数据权限范围」「数据层级范围」过滤可见组织。
*
* @param tenantId 入参 tenantId(仅超管生效)
* @return 部门树
@@ -79,9 +79,11 @@ public interface IDeptService extends IService<Dept> {
List<DeptVO> tree(String tenantId);
/**
* 懒加载树形结构(含超管判定 + 当前租户隔离)
* 懒加载树形结构(含超管判定 + 当前租户隔离 + 用户组织数据权限)
* <p>
* 仅超级管理员可指定任意 tenantId,空值表示查询全部;其他用户强制使用当前会话租户。
* 非超管按用户「数据权限范围」「数据层级范围」过滤可见组织:
* 仅所属组织 / 自定义组织,并可选择是否包含无限下级。
*
* @param tenantId 入参 tenantId(仅超管生效)
* @param parentId 父级部门ID
@@ -147,12 +149,19 @@ public interface IDeptService extends IService<Dept> {
boolean removeDept(String ids);
/**
* 删除非顶级组织,顶级组织(父级为 0)保留
* 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级
*
* @return 删除数量
*/
int clearNonTopDepts();
/**
* 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级
*
* @return 集团顶级组织(id=135)
*/
Dept ensureOaSyncRootDept();
/**
* 新增或修改部门
* <p>
@@ -0,0 +1,110 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import java.util.List;
import java.util.Map;
/**
* 小程序用户 服务类
*
* @author Chill
*/
public interface IMpUserService extends BaseService<MpUser> {
/**
* 按租户 + 手机号查询
*/
MpUser getByPhone(String tenantId, String phone);
/**
* 按租户 + 账号查询
*/
MpUser getByAccount(String tenantId, String account);
/**
* 按主键组装登录信息
*/
MpUserInfo userInfo(Long userId);
/**
* 按租户 + 手机号组装登录信息
*/
MpUserInfo userInfoByPhone(String tenantId, String phone);
/**
* 按租户 + 账号组装登录信息
*/
MpUserInfo userInfoByAccount(String tenantId, String account);
/**
* 后台分页查询
*/
IPage<MpUser> selectMpUserPage(IPage<MpUser> page, MpUser query);
/**
* 保存小程序用户
*/
boolean submit(MpUser user);
/**
* 更新小程序用户
*/
boolean updateUser(MpUser user);
/**
* 新增或修改(后台管理)
*/
boolean submitOrUpdate(MpUser user);
/**
* 逻辑删除
*/
boolean removeMpUser(List<Long> ids);
/**
* 启用 / 停用
*/
boolean changeStatus(Long id, Integer status);
/**
* 重置密码为随机密码,返回 account -> 明文密码
*/
Map<String, String> resetPassword(List<Long> userIds);
/**
* 绑定/更新 openid
*/
boolean bindOpenId(String tenantId, Long userId, String openid);
/**
* 当前小程序用户设置密码
*/
boolean setPassword(Long userId, String password, String password2);
}
@@ -32,27 +32,33 @@ public interface IOASyncService {
/**
* 按页从 OA 人员接口同步组织与人员
*
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
* @return 本页同步结果
*/
OaPersonSyncPageVO syncPersonFromUserList(int current, int size);
OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince);
/**
* 按页从 OA 公司接口同步公司
*
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
* @return 本页同步结果
*/
OaOrgSyncPageVO syncCompanyPage(int current, int size);
OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince);
/**
* 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表
*
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param current 当前页,从 1 开始
* @param size 每页条数
* @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA)
* @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值
* @return 本页同步结果
*/
OaOrgSyncPageVO syncDepartmentPage(int current, int size);
OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince);
}
@@ -93,6 +93,15 @@ public interface IUserService extends BaseService<User> {
*/
boolean updateUser(User user);
/**
* 启用或停用用户(租户守卫校验用户归属,仅更新状态)
*
* @param userId 用户主键
* @param status 状态:1 启用,0 停用
* @return 是否成功
*/
boolean changeUserStatus(Long userId, Integer status);
/**
* 修改用户基本信息(仅限当前登录用户自身,忽略角色 / 部门 / 岗位 / 账号等敏感字段)
*
@@ -127,7 +127,7 @@ public class AirportMasterServiceImpl extends BaseServiceImpl<AirportMasterMappe
@Transactional(rollbackFor = Exception.class)
public List<AirportMasterExcel> importAirportMaster(List<AirportMasterExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
List<AirportMasterExcel> errorList = new ArrayList<>();
List<AirportMaster> airportMasterList = new ArrayList<>();
@@ -160,8 +160,9 @@ public class AirportMasterServiceImpl extends BaseServiceImpl<AirportMasterMappe
}
}
if (Func.isNotEmpty(errorList)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return errorList;
// 抛出携带失败明细的异常,触发事务回滚(替代旧的 setRollbackOnly)。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new org.springblade.common.excel.ImportFailureException(data);
}
for (AirportMaster airportMaster : airportMasterList) {
// 编码命中逻辑删除记录时复用原主键,恢复后更新,避免唯一索引冲突。
@@ -322,6 +323,7 @@ public class AirportMasterServiceImpl extends BaseServiceImpl<AirportMasterMappe
excel.setLatitude(scaleCoordinate(airportMaster.getLatitude(), MIN_LATITUDE, MAX_LATITUDE));
excel.setDataSource(normalizeDataSource(airportMaster.getDataSource()));
excel.setStatusName(Objects.equals(airportMaster.getStatus(), STATUS_ENABLED) ? "启用" : "停用");
excel.setCreateUserName(UserCache.getUserRealName(airportMaster.getCreateUser()));
excel.setUpdateUserName(UserCache.getUserRealName(airportMaster.getUpdateUser()));
return excel;
}).toList();
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.Func;
@@ -134,10 +135,14 @@ public class CargoTypeServiceImpl extends BaseServiceImpl<CargoTypeMapper, Cargo
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<CargoTypeImportFailureExcel> importCargoType(List<CargoTypeExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
// 注意不能只往 errorList 里塞数据就 return——异常被吞掉后方法会正常返回,
// Spring 会照常提交事务,@Transactional 起不到回滚作用。
List<CargoTypeImportFailureExcel> failureList = new ArrayList<>();
for (int index = 0; index < data.size(); index++) {
CargoTypeExcel excel = data.get(index);
@@ -152,6 +157,11 @@ public class CargoTypeServiceImpl extends BaseServiceImpl<CargoTypeMapper, Cargo
failureList.add(toImportFailureExcel(excel, message));
}
}
if (Func.isNotEmpty(failureList)) {
// 抛出携带失败明细的异常,触发事务回滚。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new ImportFailureException(data);
}
return failureList;
}
@@ -115,10 +115,16 @@ public class CurrencyServiceImpl extends BaseServiceImpl<CurrencyMapper, Currenc
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<CurrencyExcel> importCurrency(List<CurrencyExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
// 注意:循环内除 save 外还有 rebuildEnabledValidity 这个派生写——
// 若吞掉异常正常 return,主表数据和派生的有效期区间都会落库;
// 必须抛异常让两者一起回滚,否则会出现「主表回滚了、有效期区间
// 却按失败批次算过」的脏数据。
List<CurrencyExcel> errorList = new ArrayList<>();
int successCount = 0;
for (int index = 0; index < data.size(); index++) {
@@ -140,6 +146,11 @@ public class CurrencyServiceImpl extends BaseServiceImpl<CurrencyMapper, Currenc
errorList.add(excel);
}
}
if (Func.isNotEmpty(errorList)) {
// 抛出携带失败明细的异常,触发事务回滚(含 rebuildEnabledValidity 的派生写)。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new org.springblade.common.excel.ImportFailureException(data);
}
return errorList;
}
@@ -27,6 +27,7 @@ package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
@@ -44,8 +45,10 @@ import org.springblade.core.tool.node.ForestNodeMerger;
import org.springblade.core.tool.utils.*;
import org.springblade.system.cache.SysCache;
import org.springblade.system.mapper.DeptMapper;
import org.springblade.system.mapper.UserDataScopeMapper;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.entity.UserDataScope;
import org.springblade.system.pojo.enums.DeptCategory;
import org.springblade.system.pojo.vo.DeptVO;
import org.springblade.system.pojo.vo.UserVO;
@@ -90,8 +93,19 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
private static final String IAM_SYNC_TENANT_ID = "000000";
private static final Long IAM_SYNC_PARENT_ID = 1123598813738675201L;
private static final HttpClient IAM_HTTP_CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/** 数据权限范围:仅所属组织 */
private static final int DATA_SCOPE_OWN = 1;
/** 数据权限范围:全部组织 */
private static final int DATA_SCOPE_ALL = 2;
/** 数据权限范围:自定义 */
private static final int DATA_SCOPE_CUSTOM = 3;
/** 数据层级范围:包含下级 */
private static final int DATA_LEVEL_INCLUDE_CHILDREN = 1;
/** 数据层级范围:仅本级 */
private static final int DATA_LEVEL_CURRENT_ONLY = 2;
private final IUserService userService;
private final UserDataScopeMapper userDataScopeMapper;
private final IamSyncProperties iamSyncProperties;
private final ObjectMapper objectMapper;
@@ -144,7 +158,11 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
String resolvedTenantId = AuthUtil.isAdministrator()
? Func.toStrWithEmpty(tenantId, AuthUtil.getTenantId())
: AuthUtil.getTenantId();
return ForestNodeMerger.merge(baseMapper.tree(resolvedTenantId));
DeptDataScopeFilter scopeFilter = resolveDeptDataScopeFilter();
if (scopeFilter == null || scopeFilter.allDept()) {
return ForestNodeMerger.merge(baseMapper.tree(resolvedTenantId));
}
return buildScopedDeptTree(resolvedTenantId, scopeFilter);
}
@Override
@@ -153,7 +171,142 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
String resolvedTenantId = AuthUtil.isAdministrator()
? Func.toStr(tenantId, StringPool.EMPTY)
: AuthUtil.getTenantId();
return ForestNodeMerger.merge(baseMapper.lazyTree(resolvedTenantId, parentId));
Long resolvedParentId = parentId == null ? BladeConstant.TOP_PARENT_ID : parentId;
DeptDataScopeFilter scopeFilter = resolveDeptDataScopeFilter();
if (scopeFilter == null || scopeFilter.allDept()) {
return ForestNodeMerger.merge(baseMapper.lazyTree(resolvedTenantId, resolvedParentId));
}
Set<Long> allowedIds = scopeFilter.allowedDeptIds();
if (Func.isEmpty(allowedIds)) {
return Collections.emptyList();
}
boolean includeChildren = scopeFilter.includeChildren();
List<Long> childIdsForHasChildren = includeChildren ? new ArrayList<>(allowedIds) : Collections.emptyList();
// 根层:返回「可见根」——权限集合中父节点不在集合内的组织,避免用户看不到深层权限节点。
if (Objects.equals(resolvedParentId, BladeConstant.TOP_PARENT_ID)) {
List<Long> visibleRootIds = resolveVisibleRootIds(allowedIds);
if (Func.isEmpty(visibleRootIds)) {
return Collections.emptyList();
}
return ForestNodeMerger.merge(baseMapper.treeNodesByIds(resolvedTenantId, visibleRootIds, childIdsForHasChildren));
}
// 非根层:无权限的父节点不允许展开;仅本级时不允许继续下钻。
if (!includeChildren || !allowedIds.contains(resolvedParentId)) {
return Collections.emptyList();
}
List<DeptVO> children = baseMapper.lazyTree(resolvedTenantId, resolvedParentId);
List<Long> filteredChildIds = children.stream()
.map(DeptVO::getId)
.filter(allowedIds::contains)
.toList();
if (Func.isEmpty(filteredChildIds)) {
return Collections.emptyList();
}
return ForestNodeMerger.merge(baseMapper.treeNodesByIds(resolvedTenantId, filteredChildIds, childIdsForHasChildren));
}
/**
* 按数据权限构建完整组织树(用于 /dept/tree)。
*/
private List<DeptVO> buildScopedDeptTree(String tenantId, DeptDataScopeFilter scopeFilter) {
Set<Long> allowedIds = scopeFilter.allowedDeptIds();
if (Func.isEmpty(allowedIds)) {
return Collections.emptyList();
}
List<Long> childIdsForHasChildren = scopeFilter.includeChildren()
? new ArrayList<>(allowedIds)
: Collections.emptyList();
return ForestNodeMerger.merge(baseMapper.treeNodesByIds(tenantId, new ArrayList<>(allowedIds), childIdsForHasChildren));
}
/**
* 计算懒加载首层可见根:父级不在权限集合内的组织。
*/
private List<Long> resolveVisibleRootIds(Set<Long> allowedIds) {
return listByIds(allowedIds).stream()
.filter(dept -> dept.getParentId() == null
|| Objects.equals(dept.getParentId(), BladeConstant.TOP_PARENT_ID)
|| !allowedIds.contains(dept.getParentId()))
.sorted(Comparator.comparing(Dept::getSort, Comparator.nullsLast(Integer::compareTo))
.thenComparing(Dept::getId, Comparator.nullsLast(Long::compareTo)))
.map(Dept::getId)
.toList();
}
/**
* 解析当前登录用户的组织树数据权限。
* <p>
* 超管或「全部组织」返回 allDept=true;其余按所属组织/自定义组织,并结合「包含下级/仅本级」展开。
*
* @return 过滤上下文;无法识别用户时按空权限处理
*/
private DeptDataScopeFilter resolveDeptDataScopeFilter() {
if (AuthUtil.isAdministrator()) {
return DeptDataScopeFilter.all();
}
Long userId = AuthUtil.getUserId();
if (Func.isEmpty(userId) || userId <= 0) {
return DeptDataScopeFilter.empty();
}
User user = userService.getById(userId);
if (Func.isEmpty(user)) {
return DeptDataScopeFilter.empty();
}
Integer dataScopeRange = user.getDataScopeRange() == null ? DATA_SCOPE_OWN : user.getDataScopeRange();
Integer dataLevelRange = user.getDataLevelRange() == null ? DATA_LEVEL_INCLUDE_CHILDREN : user.getDataLevelRange();
if (Objects.equals(dataScopeRange, DATA_SCOPE_ALL)) {
return DeptDataScopeFilter.all();
}
List<Long> baseDeptIds;
if (Objects.equals(dataScopeRange, DATA_SCOPE_CUSTOM)) {
baseDeptIds = userDataScopeMapper.selectList(Wrappers.<UserDataScope>lambdaQuery()
.eq(UserDataScope::getUserId, userId))
.stream()
.map(UserDataScope::getDeptId)
.filter(Objects::nonNull)
.distinct()
.toList();
} else {
// 仅所属组织:优先用户档案中的所属组织,其次当前会话组织。
baseDeptIds = Func.toLongList(user.getDeptId());
if (Func.isEmpty(baseDeptIds)) {
baseDeptIds = Func.toLongList(AuthUtil.getDeptId());
}
}
if (Func.isEmpty(baseDeptIds)) {
return DeptDataScopeFilter.empty();
}
boolean includeChildren = !Objects.equals(dataLevelRange, DATA_LEVEL_CURRENT_ONLY);
LinkedHashSet<Long> allowedDeptIds = new LinkedHashSet<>();
for (Long deptId : baseDeptIds) {
if (Func.isEmpty(deptId) || deptId <= 0) {
continue;
}
if (includeChildren) {
List<Long> childIds = SysCache.getDeptChildIds(deptId);
if (Func.isNotEmpty(childIds)) {
allowedDeptIds.addAll(childIds);
} else {
allowedDeptIds.add(deptId);
}
} else {
allowedDeptIds.add(deptId);
}
}
return new DeptDataScopeFilter(false, includeChildren, allowedDeptIds);
}
/**
* 组织懒加载树的数据权限过滤上下文。
*/
private record DeptDataScopeFilter(boolean allDept, boolean includeChildren, Set<Long> allowedDeptIds) {
private static DeptDataScopeFilter all() {
return new DeptDataScopeFilter(true, true, Collections.emptySet());
}
private static DeptDataScopeFilter empty() {
return new DeptDataScopeFilter(false, false, Collections.emptySet());
}
}
@Override
@@ -237,16 +390,193 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
@Override
@Transactional(rollbackFor = Exception.class)
public int clearNonTopDepts() {
Long nonTopCount = this.count(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getParentId)
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
if (nonTopCount == null || nonTopCount == 0L) {
// 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除
Dept groupRoot = this.ensureOaSyncRootDept();
Dept externalRoot = this.ensureExternalOrgTopDept();
Set<Long> keepIds = new HashSet<>();
if (groupRoot != null && groupRoot.getId() != null) {
keepIds.add(groupRoot.getId());
}
if (externalRoot != null && externalRoot.getId() != null) {
keepIds.add(externalRoot.getId());
}
if (keepIds.isEmpty()) {
return 0;
}
this.remove(Wrappers.<Dept>lambdaQuery()
.isNotNull(Dept::getParentId)
.ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID));
return nonTopCount.intValue();
Long clearCount = this.count(Wrappers.<Dept>lambdaQuery().notIn(Dept::getId, keepIds));
if (clearCount == null || clearCount == 0L) {
return 0;
}
this.remove(Wrappers.<Dept>lambdaQuery().notIn(Dept::getId, keepIds));
return clearCount.intValue();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Dept ensureOaSyncRootDept() {
this.migrateLegacyOaDeptCategory();
this.removeLegacyOaSyncRoots();
Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
String tenantId = AuthUtil.getTenantId();
if (StringUtil.isBlank(tenantId)) {
tenantId = BladeConstant.ADMIN_TENANT_ID;
}
if (rootDept == null) {
rootDept = new Dept();
rootDept.setId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
rootDept.setTenantId(tenantId);
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
rootDept.setDeptCategory(DeptCategory.GROUP.getCode());
rootDept.setSort(0);
rootDept.setStatus(DataStatusEnum.ENABLE.getCode());
rootDept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
rootDept.setIsOa(0);
rootDept.setIsPlatformCompany(0);
rootDept.setSyncTime(new Date());
this.save(rootDept);
log.info("已创建 OA 同步集团顶级 id={} name={} code={}",
OAConvertConstant.OA_SYNC_ROOT_DEPT_ID,
OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME,
OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
} else {
boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName());
boolean needUpdate = wronglyNamedAsExternal
|| !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode())
|| !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory())
|| !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId());
if (needUpdate) {
Dept updateParam = new Dept();
updateParam.setId(rootDept.getId());
if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) {
updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME);
}
updateParam.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
updateParam.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
updateParam.setDeptCategory(DeptCategory.GROUP.getCode());
updateParam.setParentId(BladeConstant.TOP_PARENT_ID);
updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
this.updateById(updateParam);
rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
rootDept.setDeptCategory(DeptCategory.GROUP.getCode());
rootDept.setParentId(BladeConstant.TOP_PARENT_ID);
rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}",
rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory());
}
}
this.ensureExternalOrgTopDept();
return rootDept;
}
/**
* 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级)
*/
private Dept ensureExternalOrgTopDept() {
List<Dept> externalTops = this.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptName, OAConvertConstant.EXTERNAL_ORG_NAME)
.and(wrapper -> wrapper
.isNull(Dept::getParentId)
.or()
.eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID))
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
.orderByAsc(Dept::getId));
if (CollectionUtil.isNotEmpty(externalTops)) {
Dept keep = externalTops.get(0);
if (externalTops.size() > 1) {
List<Long> duplicateIds = externalTops.stream()
.skip(1)
.map(Dept::getId)
.filter(Objects::nonNull)
.toList();
if (CollectionUtil.isNotEmpty(duplicateIds)) {
this.update(Wrappers.<Dept>lambdaUpdate()
.set(Dept::getParentId, keep.getId())
.in(Dept::getParentId, duplicateIds));
this.removeByIds(duplicateIds);
log.info("已合并重复的外部组织顶级,保留 id={},删除{}", keep.getId(), duplicateIds);
}
}
return keep;
}
String tenantId = AuthUtil.getTenantId();
if (StringUtil.isBlank(tenantId)) {
tenantId = BladeConstant.ADMIN_TENANT_ID;
}
Dept externalDept = new Dept();
externalDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
externalDept.setTenantId(tenantId);
externalDept.setParentId(BladeConstant.TOP_PARENT_ID);
externalDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
externalDept.setDeptName(OAConvertConstant.EXTERNAL_ORG_NAME);
externalDept.setFullName(OAConvertConstant.EXTERNAL_ORG_NAME);
externalDept.setShortName(OAConvertConstant.EXTERNAL_ORG_NAME);
externalDept.setDeptCode(null);
externalDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
externalDept.setDeptCategory(DeptCategory.GROUP.getCode());
externalDept.setSort(0);
externalDept.setStatus(DataStatusEnum.ENABLE.getCode());
externalDept.setIsDeleted(BladeConstant.DB_NOT_DELETED);
externalDept.setIsOa(0);
externalDept.setIsPlatformCompany(0);
externalDept.setSyncTime(new Date());
this.save(externalDept);
log.info("已创建外部组织顶级 id={}", externalDept.getId());
return externalDept;
}
/**
* 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级
*/
private void removeLegacyOaSyncRoots() {
List<Dept> legacyRoots = this.list(Wrappers.<Dept>lambdaQuery()
.ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
.and(wrapper -> wrapper
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
.or(nameWrapper -> nameWrapper
.eq(Dept::getDeptName, OAConvertConstant.LEGACY_ROOT_DEPT_NAME)
.eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)
.and(parentWrapper -> parentWrapper
.isNull(Dept::getParentId)
.or()
.eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID)))));
if (CollectionUtil.isEmpty(legacyRoots)) {
return;
}
Set<Long> legacyIds = legacyRoots.stream().map(Dept::getId).filter(Objects::nonNull).collect(Collectors.toSet());
this.update(Wrappers.<Dept>lambdaUpdate()
.set(Dept::getParentId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID)
.set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
.in(Dept::getParentId, legacyIds));
this.removeByIds(legacyIds);
log.info("已清理历史伪顶级组织{}条:{}", legacyIds.size(), legacyIds);
}
/**
* 按编码前缀对齐组织类型:OAC→公司、OAD→部门(覆盖历史 1/2 映射)
*/
private void migrateLegacyOaDeptCategory() {
this.update(Wrappers.<Dept>lambdaUpdate()
.set(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
.likeRight(Dept::getDeptCode, OAConvertConstant.DEPARTMENT_OA_PREFIX));
this.update(Wrappers.<Dept>lambdaUpdate()
.set(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.likeRight(Dept::getDeptCode, OAConvertConstant.COMPANY_OA_PREFIX)
.ne(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE));
}
@Override
@@ -282,10 +612,14 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (dept.getIsPlatformCompany() == null) {
dept.setIsPlatformCompany(0);
}
// 排序未填时默认 1
if (dept.getSort() == null) {
dept.setSort(1);
}
if (Func.isEmpty(dept.getTenantId())) {
throw new ServiceException("租户ID不能为空");
}
validateDeptCategory(dept, parent);
validateDeptCategory(dept);
validateDeptCode(dept, parent);
return saveOrUpdate(dept);
}
@@ -467,27 +801,8 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
return "Basic " + value;
}
private void validateDeptCategory(Dept dept, Dept parent) {
// 顶级组织不要求选择上级组织
if (parent == null) {
if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) {
throw new ServiceException("请选择承运商");
}
return;
}
List<Integer> allowedCategories;
if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) {
allowedCategories = "外部组织".equals(parent.getDeptName()) ? List.of(6) : List.of(1);
} else if (Integer.valueOf(1).equals(parent.getDeptCategory())) {
allowedCategories = List.of(2, 3);
} else if (List.of(2, 3).contains(parent.getDeptCategory())) {
allowedCategories = List.of(4, 5, 6);
} else {
throw new ServiceException("上级组织层级不支持新增下级组织");
}
if (!allowedCategories.contains(dept.getDeptCategory())) {
throw new ServiceException("组织类型不符合上级组织层级规则");
}
private void validateDeptCategory(Dept dept) {
// 组织树层级仅由 parentId / ancestors 决定,组织类型不再做上下级约束
if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) {
throw new ServiceException("请选择承运商");
}
@@ -513,9 +828,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (StringUtil.isBlank(parent.getDeptCode())) {
throw new ServiceException("请选择已配置组织编码的上级组织");
}
String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+";
if (!deptCode.matches(pattern)) {
throw new ServiceException("组织编码格式应为:上级编码-分段数字");
// 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字
String expectedParentCode = parent.getDeptCode();
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE;
} else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())
&& StringUtil.isNotBlank(dept.getBelongCompanyCode())) {
expectedParentCode = dept.getBelongCompanyCode();
}
if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) {
throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)");
}
this.ensureDeptCodeUnique(dept, deptCode);
dept.setDeptCode(deptCode);
@@ -587,8 +909,8 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
log.info("没有记录OA主键的组织,跳过层级重建");
return;
}
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
Map<String, Dept> companyByOaId = this.indexOaCompanies(oaDeptList);
Map<String, Dept> departmentByOaId = this.indexOaDepartments(oaDeptList);
Map<Long, Long> desiredParentIdMap = new LinkedHashMap<>();
Map<Long, String> desiredParentCodeMap = new HashMap<>();
for (Dept dept : oaDeptList) {
@@ -641,21 +963,20 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (CollectionUtil.isEmpty(oaDeptList)) {
return 0;
}
Map<String, Dept> companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode());
Map<String, Dept> departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode());
Map<String, Dept> companyByOaId = this.indexOaCompanies(oaDeptList);
Map<String, Dept> departmentByOaId = this.indexOaDepartments(oaDeptList);
Set<Long> deleteIds = new HashSet<>();
for (Dept dept : oaDeptList) {
String parentOaId = dept.getOaSupSubComId();
// 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除
// 上级为空或为 0:OA 顶级公司挂本地锚点,不作为孤儿
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
continue;
}
boolean parentExists;
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
parentExists = companyByOaId.containsKey(parentOaId);
} else {
parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
}
boolean isCompany = this.isOaCompany(dept);
boolean parentExists = isCompany
? companyByOaId.containsKey(parentOaId)
: departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId);
// 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留
if (!parentExists) {
deleteIds.add(dept.getId());
}
@@ -676,7 +997,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) {
continue;
}
Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())
Dept parent = this.isOaCompany(dept)
? companyByOaId.get(parentOaId)
: Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId));
if (parent != null && deleteIds.contains(parent.getId())) {
@@ -705,6 +1026,72 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
return deleteIds.size();
}
/**
* 索引 OA 公司:同时兼容类型迁移前后的公司记录
*/
private Map<String, Dept> indexOaCompanies(List<Dept> oaDeptList) {
Map<String, Dept> oaIdMap = new HashMap<>();
for (Dept dept : oaDeptList) {
if (!this.isOaCompany(dept) || StringUtil.isBlank(dept.getOaId())) {
continue;
}
Dept previous = oaIdMap.put(dept.getOaId(), dept);
if (previous != null) {
log.warn("OA公司主键重复,层级重建使用后一条 oaId={} id={} previousId={}",
dept.getOaId(), dept.getId(), previous.getId());
}
}
return oaIdMap;
}
private boolean isOaCompany(Dept dept) {
if (dept == null) {
return false;
}
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
return true;
}
if (org.springblade.system.util.DeptCodeUtil.isCompanyCode(dept.getDeptCode())) {
return true;
}
return StringUtil.isNotBlank(dept.getDeptCode())
&& dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX)
&& !OAConvertConstant.LEGACY_ROOT_DEPT_CODE.equals(dept.getDeptCode());
}
private boolean isOaDepartment(Dept dept) {
if (dept == null) {
return false;
}
if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) {
return true;
}
if (StringUtil.isNotBlank(dept.getBelongCompanyCode())
&& org.springblade.system.util.DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())) {
return true;
}
return StringUtil.isNotBlank(dept.getDeptCode())
&& dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX);
}
/**
* 索引 OA 部门:兼容类型迁移前后
*/
private Map<String, Dept> indexOaDepartments(List<Dept> oaDeptList) {
Map<String, Dept> oaIdMap = new HashMap<>();
for (Dept dept : oaDeptList) {
if (!this.isOaDepartment(dept) || StringUtil.isBlank(dept.getOaId())) {
continue;
}
Dept previous = oaIdMap.put(dept.getOaId(), dept);
if (previous != null) {
log.warn("OA部门主键重复,层级重建使用后一条 oaId={} id={} previousId={}",
dept.getOaId(), dept.getId(), previous.getId());
}
}
return oaIdMap;
}
/**
* 按机构类型建立 OA 主键索引
*/
@@ -729,19 +1116,22 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
private OaParentLink resolveOaParentLink(Dept dept, Map<String, Dept> companyByOaId, Map<String, Dept> departmentByOaId) {
String parentOaId = dept.getOaSupSubComId();
boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId);
if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) {
if (this.isOaCompany(dept)) {
if (rootParent) {
return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID);
// OA 顶级公司统一挂到本地顶级锚点下
return new OaParentLink(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
}
Dept parent = companyByOaId.get(parentOaId);
if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) {
// 全量整理后仍无上级:跳过重建,交由孤儿清理直接删除
log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}",
dept.getOaId(), parentOaId, dept.getId());
return null;
}
return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId));
}
if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) {
if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())
&& !(StringUtil.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX))) {
return null;
}
if (rootParent) {
@@ -106,8 +106,11 @@ public class FeeItemServiceImpl extends BaseServiceImpl<FeeItemMapper, FeeItem>
@Transactional(rollbackFor = Exception.class)
public List<FeeItemImportFailureExcel> importFeeItem(List<FeeItemExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
// 注意不能只往 failureList 里塞数据就 return——异常被吞掉后方法会正常返回,
// Spring 会照常提交事务,@Transactional 起不到回滚作用。
List<FeeItemImportFailureExcel> failureList = new ArrayList<>();
for (int index = 0; index < data.size(); index++) {
FeeItemExcel excel = data.get(index);
@@ -118,9 +121,18 @@ public class FeeItemServiceImpl extends BaseServiceImpl<FeeItemMapper, FeeItem>
save(feeItem);
} catch (Exception exception) {
String message = exception instanceof ServiceException ? exception.getMessage() : "导入失败";
// 除收集到 failureList 外,还必须把原因写回原行对象:
// 抛出异常时携带的是原表 data,导出工具从行对象的 errorMessage 字段取原因,
// 只写在副本上会导致失败明细里错误行的原因为空。
excel.setErrorMessage("第" + (index + 2) + "行:" + message);
failureList.add(toImportFailureExcel(excel, "第" + (index + 2) + "行:" + message));
}
}
if (Func.isNotEmpty(failureList)) {
// 抛出携带失败明细的异常,触发事务回滚。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new org.springblade.common.excel.ImportFailureException(data);
}
return failureList;
}
@@ -216,6 +228,7 @@ public class FeeItemServiceImpl extends BaseServiceImpl<FeeItemMapper, FeeItem>
excel.setCreateDeptName(
Func.isEmpty(feeItem.getCreateDept()) ? "" : SysCache.getDeptName(feeItem.getCreateDept())
);
excel.setCreateUserName(UserCache.getUserRealName(feeItem.getCreateUser()));
excel.setUpdateUserName(UserCache.getUserRealName(feeItem.getUpdateUser()));
excel.setStatusName(Objects.equals(feeItem.getStatus(), STATUS_ENABLED) ? "启用" : "停用");
return excel;
@@ -50,7 +50,7 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl<MeasurementUnitM
private static final int UNIT_NAME_MAX_LENGTH = 50;
private static final int DIMENSION_MAX_LENGTH = 20;
private static final int REMARK_MAX_LENGTH = 200;
private static final Set<String> DIMENSIONS = Set.of("重量", "体积", "数量");
private static final Set<String> DIMENSIONS = Set.of("重量", "体积", "数量", "整车", "里程", "吨·公里");
@Override
public IPage<MeasurementUnitVO> selectMeasurementUnitPage(IPage<MeasurementUnitVO> page,
@@ -0,0 +1,380 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.common.utils.PasswordRuleUtil;
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.support.Kv;
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.system.cache.MpUserCache;
import org.springblade.system.mapper.MpUserMapper;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.entity.MpUserInfo;
import org.springblade.system.pojo.enums.MpUserIdentity;
import org.springblade.system.service.IMpUserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 小程序用户 服务实现类
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> implements IMpUserService {
@Override
public MpUser getByPhone(String tenantId, String phone) {
if (Func.isBlank(tenantId) || Func.isBlank(phone)) {
return null;
}
return getOne(Wrappers.<MpUser>lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getPhone, phone)
.orderByDesc(MpUser::getCreateTime)
.last("LIMIT 1"));
}
@Override
public MpUser getByAccount(String tenantId, String account) {
if (Func.isBlank(tenantId) || Func.isBlank(account)) {
return null;
}
return getOne(Wrappers.<MpUser>lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getAccount, account)
.orderByDesc(MpUser::getCreateTime)
.last("LIMIT 1"));
}
@Override
public MpUserInfo userInfo(Long userId) {
if (Func.isEmpty(userId)) {
return null;
}
return buildUserInfo(getById(userId));
}
@Override
public MpUserInfo userInfoByPhone(String tenantId, String phone) {
return buildUserInfo(getByPhone(tenantId, phone));
}
@Override
public MpUserInfo userInfoByAccount(String tenantId, String account) {
return buildUserInfo(getByAccount(tenantId, account));
}
@Override
public IPage<MpUser> selectMpUserPage(IPage<MpUser> page, MpUser query) {
MpUser condition = query == null ? new MpUser() : query;
return page(page, Wrappers.<MpUser>lambdaQuery()
.like(Func.isNotBlank(condition.getPhone()), MpUser::getPhone, condition.getPhone())
.like(Func.isNotBlank(condition.getAccount()), MpUser::getAccount, condition.getAccount())
.like(Func.isNotBlank(condition.getRealName()), MpUser::getRealName, condition.getRealName())
.like(Func.isNotBlank(condition.getName()), MpUser::getName, condition.getName())
.like(Func.isNotBlank(condition.getIdentities()), MpUser::getIdentities, condition.getIdentities())
.eq(condition.getStatus() != null, MpUser::getStatus, condition.getStatus())
.orderByDesc(MpUser::getCreateTime));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(MpUser user) {
prepare(user);
validate(user);
MpUser existed = getByPhone(user.getTenantId(), user.getPhone());
if (existed != null && Func.isNotEmpty(existed.getId())) {
throw new ServiceException("该手机号已存在小程序用户");
}
encryptPasswordIfPresent(user);
boolean saved = save(user);
if (saved) {
MpUserCache.clear();
}
return saved;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateUser(MpUser user) {
if (user == null || Func.isEmpty(user.getId())) {
throw new ServiceException("小程序用户主键不能为空");
}
prepare(user);
validate(user);
MpUser existed = getByPhone(user.getTenantId(), user.getPhone());
if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) {
throw new ServiceException("该手机号已存在小程序用户");
}
// 后台编辑不改密码,避免空串覆盖
user.setPassword(null);
boolean updated = updateById(user);
if (updated) {
MpUserCache.clear();
}
return updated;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submitOrUpdate(MpUser user) {
if (user != null && Func.isNotEmpty(user.getId())) {
return updateUser(user);
}
return submit(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeMpUser(List<Long> ids) {
if (Func.isEmpty(ids)) {
throw new ServiceException("请选择要删除的小程序用户");
}
boolean removed = deleteLogic(ids);
if (removed) {
MpUserCache.clear();
}
return removed;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeStatus(Long id, Integer status) {
if (Func.isEmpty(id)) {
throw new ServiceException("小程序用户主键不能为空");
}
if (status == null || (status != 0 && status != 1)) {
throw new ServiceException("状态仅支持启用(1)或停用(0)");
}
MpUser user = getById(id);
if (user == null || Func.isEmpty(user.getId())) {
throw new ServiceException("小程序用户不存在");
}
MpUser patch = new MpUser();
patch.setId(id);
patch.setStatus(status);
boolean updated = updateById(patch);
if (updated) {
MpUserCache.clear();
}
return updated;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, String> resetPassword(List<Long> userIds) {
if (Func.isEmpty(userIds)) {
throw new ServiceException("请选择要重置密码的小程序用户");
}
Map<String, String> passwordMap = new LinkedHashMap<>();
for (Long userId : userIds) {
MpUser user = getById(userId);
if (user == null || Func.isEmpty(user.getId())) {
continue;
}
String password = PasswordRuleUtil.generate();
MpUser patch = new MpUser();
patch.setId(userId);
patch.setPassword(DigestUtil.encrypt(password));
patch.setUpdateTime(DateUtil.now());
updateById(patch);
String account = Func.toStr(user.getAccount(), user.getPhone());
passwordMap.put(account, password);
}
if (passwordMap.isEmpty()) {
throw new ServiceException("未找到可重置密码的小程序用户");
}
MpUserCache.clear();
return passwordMap;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean bindOpenId(String tenantId, Long userId, String openid) {
if (Func.isBlank(tenantId) || Func.isEmpty(userId) || Func.isBlank(openid)) {
throw new ServiceException("绑定 openid 参数不完整");
}
MpUser user = getById(userId);
if (user == null || Func.isEmpty(user.getId())) {
throw new ServiceException("小程序用户不存在");
}
if (!tenantId.equals(user.getTenantId())) {
throw new ServiceException("租户不匹配,无法绑定 openid");
}
MpUser byOpenId = getOne(Wrappers.<MpUser>lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getOpenid, openid)
.last("LIMIT 1"));
if (byOpenId != null && !byOpenId.getId().equals(userId)) {
throw new ServiceException("该微信已绑定其他小程序用户");
}
MpUser patch = new MpUser();
patch.setId(userId);
patch.setOpenid(openid);
boolean updated = updateById(patch);
if (updated) {
MpUserCache.clear();
}
return updated;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean setPassword(Long userId, String password, String password2) {
if (!Objects.equals(password, password2)) {
throw new ServiceException("两次输入密码不一致!");
}
if (!PasswordRuleUtil.isValid(password)) {
throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
}
MpUser user = getById(userId);
if (user == null || Func.isEmpty(user.getId())) {
throw new ServiceException("小程序用户不存在");
}
MpUser patch = new MpUser();
patch.setId(userId);
patch.setPassword(DigestUtil.encrypt(password));
patch.setUpdateTime(DateUtil.now());
boolean updated = updateById(patch);
if (updated) {
MpUserCache.clear();
}
return updated;
}
private MpUserInfo buildUserInfo(MpUser user) {
if (user == null || Func.isEmpty(user.getId())) {
return null;
}
List<String> identities = MpUserIdentity.split(user.getIdentities());
if (identities.isEmpty()) {
throw new ServiceException("小程序用户未配置身份,无法登录");
}
MpUserInfo userInfo = new MpUserInfo();
userInfo.setUser(user);
userInfo.setIdentities(identities);
userInfo.setPermissions(MpUserIdentity.toPermissions(user.getIdentities()));
userInfo.setDetail(Kv.create()
.set("type", MpUserIdentity.DETAIL_TYPE)
.set("identities", user.getIdentities())
.set("openid", Func.toStr(user.getOpenid(), "")));
return userInfo;
}
private void prepare(MpUser user) {
if (user == null) {
return;
}
if (Func.isBlank(user.getTenantId())) {
user.setTenantId(AuthUtil.getTenantId());
}
user.setAccount(trimToNull(user.getAccount()));
user.setPhone(trimToNull(user.getPhone()));
user.setName(trimToNull(user.getName()));
user.setRealName(trimToNull(user.getRealName()));
user.setAvatar(trimToNull(user.getAvatar()));
user.setEmail(trimToNull(user.getEmail()));
user.setOpenid(trimToNull(user.getOpenid()));
user.setDeptId(trimToNull(user.getDeptId()));
user.setPostId(trimToNull(user.getPostId()));
user.setRemark(trimToNull(user.getRemark()));
user.setIdentities(normalizeIdentities(user.getIdentities()));
if (Func.isBlank(user.getAccount()) && Func.isNotBlank(user.getPhone())) {
user.setAccount(user.getPhone());
}
if (Func.isBlank(user.getName()) && Func.isNotBlank(user.getRealName())) {
user.setName(user.getRealName());
}
if (user.getStatus() == null) {
user.setStatus(1);
}
}
private void encryptPasswordIfPresent(MpUser user) {
if (user == null || Func.isBlank(user.getPassword())) {
user.setPassword(null);
return;
}
String password = user.getPassword().trim();
if (!PasswordRuleUtil.isValid(password)) {
throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE);
}
user.setPassword(DigestUtil.encrypt(password));
}
private void validate(MpUser user) {
if (user == null) {
throw new ServiceException("小程序用户不能为空");
}
if (Func.isBlank(user.getTenantId())) {
throw new ServiceException("租户信息不能为空");
}
if (Func.isBlank(user.getPhone())) {
throw new ServiceException("手机号不能为空");
}
if (Func.isBlank(user.getIdentities())) {
throw new ServiceException("小程序身份不能为空");
}
List<String> identities = MpUserIdentity.split(user.getIdentities());
if (identities.isEmpty()) {
throw new ServiceException("小程序身份不能为空");
}
for (String code : identities) {
if (MpUserIdentity.of(code) == null) {
throw new ServiceException("不支持的小程序身份:" + code);
}
}
}
private String normalizeIdentities(String identities) {
List<String> identityList = MpUserIdentity.split(identities);
if (identityList.isEmpty()) {
return null;
}
return StringUtil.join(identityList);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimValue = value.trim();
return trimValue.isEmpty() ? null : trimValue;
}
}
@@ -22,6 +22,7 @@ import org.springblade.system.pojo.vo.OaOrgSyncPageVO;
import org.springblade.system.pojo.vo.OaPersonSyncPageVO;
import org.springblade.system.service.*;
import org.springblade.system.util.DataSyncRecordUtils;
import org.springblade.system.util.DeptCodeUtil;
import org.springblade.thirdparty.oa.constant.OAConstant;
import org.springblade.thirdparty.oa.constant.OAConvertConstant;
import org.springblade.thirdparty.oa.feign.IOAClient;
@@ -60,6 +61,7 @@ public class OASyncServiceImpl implements IOASyncService {
private final IOAClient oaClient;
private final DeptConvert deptConvert;
private final IDeptService deptService;
private final IUserService userService;
private final IRoleService roleService;
private final IMKPushService mkPushService;
private final IDataSyncRecordService dataSyncRecordService;
@@ -72,6 +74,8 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.addLog(log);
// 1. 同步公司和部门并记录
Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> {
// 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
deptService.ensureOaSyncRootDept();
// 1. 同步公司
this.syncCompany(queryStartTime);
// 公司分批入库后,重建层级并清理无法挂载的公司
@@ -124,10 +128,10 @@ public class OASyncServiceImpl implements IOASyncService {
@Transactional(rollbackFor = Exception.class)
@Override
public OaPersonSyncPageVO syncPersonFromUserList(int current, int size) {
public OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince) {
try {
ComposeLogUtil.addLog(log);
return this.syncPersonFromOaPage(current, size);
return this.syncPersonFromOaPage(current, size, incremental, modifiedSince);
} finally {
ComposeLogUtil.removeLastLog();
}
@@ -135,10 +139,10 @@ public class OASyncServiceImpl implements IOASyncService {
@Transactional(rollbackFor = Exception.class)
@Override
public OaOrgSyncPageVO syncCompanyPage(int current, int size) {
public OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince) {
try {
ComposeLogUtil.addLog(log);
return this.syncCompanyFromOaPage(current, size);
return this.syncCompanyFromOaPage(current, size, incremental, modifiedSince);
} finally {
ComposeLogUtil.removeLastLog();
}
@@ -146,10 +150,10 @@ public class OASyncServiceImpl implements IOASyncService {
@Transactional(rollbackFor = Exception.class)
@Override
public OaOrgSyncPageVO syncDepartmentPage(int current, int size) {
public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) {
try {
ComposeLogUtil.addLog(log);
return this.syncDepartmentFromOaPage(current, size);
return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince);
} finally {
ComposeLogUtil.removeLastLog();
}
@@ -280,14 +284,17 @@ public class OASyncServiceImpl implements IOASyncService {
/**
* 按页从 OA 人员列表同步组织与人员
*
* @param current 当前页
* @param size 每页条数
* @param current 当前页
* @param size 每页条数
* @param incremental 是否增量同步
* @param modifiedSince 增量起始时间(后续分页回传)
* @return 本页同步结果
*/
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size) {
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);
OAPersonSearch personSearch = buildPersonSearch(null);
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
personSearch.setCurPage(pageNo);
personSearch.setPageSize(pageSize);
OAResponse<OAPersonResponse> oaResponse = oaClient.queryPersonPage(new OASearch<>(personSearch));
@@ -311,26 +318,35 @@ public class OASyncServiceImpl implements IOASyncService {
pageVO.setFetchedCount(oaPersons.size());
pageVO.setSyncedCount(personSyncCount.getSyncedCount());
pageVO.setSkippedCount(personSyncCount.getSkippedCount());
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
boolean finished = oaPersons.isEmpty()
|| oaPersons.size() < pageSize
|| (long) pageNo * pageSize >= totalSize;
pageVO.setFinished(finished);
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
incremental ? "incremental" : "full", pageNo, totalSize,
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
return pageVO;
}
/**
* 按页从 OA 公司接口同步公司
*
* @param current 当前页
* @param size 每页条数
* @param current 当前页
* @param size 每页条数
* @param incremental 是否增量同步
* @param modifiedSince 增量起始时间(后续分页回传)
* @return 本页同步结果
*/
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size) {
private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200);
OACompanySearch companySearch = buildCompanySearch(null);
// 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」
if (pageNo == 1) {
deptService.ensureOaSyncRootDept();
}
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY);
OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime());
companySearch.setCurPage(pageNo);
companySearch.setPageSize(pageSize);
OAResponse<OACompanyResponse> oaResponse = oaClient.queryCompanyPage(new OASearch<>(companySearch));
@@ -347,6 +363,7 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
}
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount);
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
if (Boolean.TRUE.equals(pageVO.getFinished())) {
// 公司全部分批入库后:重建层级,并删除上级不存在的数据
deptService.rebuildOaHierarchy();
@@ -354,19 +371,22 @@ public class OASyncServiceImpl implements IOASyncService {
}
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
ComposeLogUtil.getLastLog().info("OA公司分页同步完成 mode={} {}/{},成功{},跳过{}",
incremental ? "incremental" : "full", pageNo, totalSize,
syncCount.getSyncedCount(), syncCount.getSkippedCount());
return pageVO;
}
/**
* 按页从 OA 部门接口同步部门
*
* @param current 当前页
* @param size 每页条数
* @param current 当前页
* @param size 每页条数
* @param incremental 是否增量同步
* @param modifiedSince 增量起始时间(后续分页回传)
* @return 本页同步结果
*/
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) {
private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
int pageNo = current < 1 ? 1 : current;
int pageSize = size < 1 ? 20 : Math.min(size, 200);
String subCompanyIds = getOaSubCompanyIds(true);
@@ -376,7 +396,8 @@ public class OASyncServiceImpl implements IOASyncService {
emptyPageVO.setFinished(true);
return emptyPageVO;
}
OADepartmentSearch departmentSearch = buildDepartmentSearch(null, subCompanyIds);
IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT);
OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds);
departmentSearch.setCurPage(pageNo);
departmentSearch.setPageSize(pageSize);
OAResponse<OADepartmentResponse> oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch));
@@ -393,6 +414,7 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList()));
}
OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount);
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
if (Boolean.TRUE.equals(pageVO.getFinished())) {
// 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表
deptService.rebuildOaHierarchy();
@@ -401,11 +423,118 @@ public class OASyncServiceImpl implements IOASyncService {
}
CacheUtil.clear(SYS_CACHE);
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}",
pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount());
ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}",
incremental ? "incremental" : "full", pageNo, totalSize,
syncCount.getSyncedCount(), syncCount.getSkippedCount());
return pageVO;
}
/**
* 解析增量同步起始时间:优先使用前端回传值,保证同阶段分页起点不变
*/
private IncrementalStart resolveIncrementalStart(boolean incremental, String modifiedSince, DeptCategory deptCategory) {
if (!incremental) {
return IncrementalStart.empty();
}
if (StringUtils.isNotBlank(modifiedSince)) {
try {
Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME);
return new IncrementalStart(startTime, modifiedSince.trim());
} catch (Exception exception) {
ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince);
}
}
String codePrefix = DeptCategory.COMPANY.equals(deptCategory)
? OAConvertConstant.COMPANY_OA_PREFIX
: OAConvertConstant.DEPARTMENT_OA_PREFIX;
Dept latest = deptService.getOne(Wrappers.<Dept>lambdaQuery()
.select(Dept::getOaModified)
.eq(Dept::getIsOa, YES)
.and(wrapper -> wrapper
.eq(Dept::getDeptCategory, deptCategory.getCode())
.or()
.likeRight(Dept::getDeptCode, codePrefix))
.isNotNull(Dept::getOaModified)
.ne(Dept::getOaModified, "")
.orderByDesc(Dept::getOaModified)
.last("limit 1"), false);
if (latest == null || StringUtils.isBlank(latest.getOaModified())) {
ComposeLogUtil.getLastLog().info("本地无 {} oaModified 记录,增量同步按全量拉取后按字段比对跳过",
deptCategory.getName());
return IncrementalStart.empty();
}
try {
Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME);
ComposeLogUtil.getLastLog().info("增量同步{} OA modified 起始时间:{}",
deptCategory.getName(), latest.getOaModified());
return new IncrementalStart(startTime, latest.getOaModified());
} catch (Exception exception) {
ComposeLogUtil.getLastLog().warn("解析本地最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified());
return IncrementalStart.empty();
}
}
/**
* 人员增量同步起始时间:优先回传值,否则取本地 OA 用户最大 oaModified
*/
private IncrementalStart resolvePersonIncrementalStart(boolean incremental, String modifiedSince) {
if (!incremental) {
return IncrementalStart.empty();
}
if (StringUtils.isNotBlank(modifiedSince)) {
try {
Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME);
return new IncrementalStart(startTime, modifiedSince.trim());
} catch (Exception exception) {
ComposeLogUtil.getLastLog().warn("解析人员回传 modifiedSince 失败:{},重新计算", modifiedSince);
}
}
User latest = userService.getOne(Wrappers.<User>lambdaQuery()
.select(User::getOaModified)
.eq(User::getIsOa, YES)
.isNotNull(User::getOaModified)
.ne(User::getOaModified, "")
.orderByDesc(User::getOaModified)
.last("limit 1"), false);
if (latest == null || StringUtils.isBlank(latest.getOaModified())) {
ComposeLogUtil.getLastLog().info("本地无人员 oaModified 记录,增量同步按全量拉取后按字段比对跳过");
return IncrementalStart.empty();
}
try {
Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME);
ComposeLogUtil.getLastLog().info("增量同步人员 OA modified 起始时间:{}", latest.getOaModified());
return new IncrementalStart(startTime, latest.getOaModified());
} catch (Exception exception) {
ComposeLogUtil.getLastLog().warn("解析本地人员最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified());
return IncrementalStart.empty();
}
}
/**
* 增量同步起点
*/
private static class IncrementalStart {
private final Date startTime;
private final String modifiedSince;
private IncrementalStart(Date startTime, String modifiedSince) {
this.startTime = startTime;
this.modifiedSince = modifiedSince;
}
private static IncrementalStart empty() {
return new IncrementalStart(null, null);
}
private Date getStartTime() {
return startTime;
}
private String getModifiedSince() {
return modifiedSince;
}
}
/**
* 组装组织分页同步结果
*/
@@ -492,13 +621,8 @@ public class OASyncServiceImpl implements IOASyncService {
if (CollectionUtil.isEmpty(oaCompanies)) {
return OrgSyncCount.empty();
}
// 获取需要的公司名称
Set<String> companyNames = syncAll ? Collections.emptySet() : getCompanyNames();
// 转换数据
List<Dept> allParam = oaCompanies.stream()
// 强制跳过上级公司为 0 的数据
.filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid()))
// 非全量同步时,筛选只要配置的公司
.filter(company -> syncAll || companyNames.contains(company.getSubcompanyname()))
.map(deptConvert::company2dept)
.toList();
@@ -507,14 +631,41 @@ public class OASyncServiceImpl implements IOASyncService {
return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList());
}
// 查询数据库的部门,转换成map
Map<String, Long> deptMap = getAllCompanyDeptMap();
// 处理部门
List<Dept> notHandledList = handleDept(allParam, deptMap, DeptCategory.COMPANY);
int syncedCount = allParam.size() - notHandledList.size();
int skippedCount = filteredSkipCount + notHandledList.size();
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
Map<String, Dept> existingByOaId = loadExistingByOaId(DeptCategory.COMPANY);
Map<String, Dept> codeOwnerMap = loadCompanyCodeOwnerMap();
Set<String> usedCodes = new HashSet<>(codeOwnerMap.keySet());
Map<String, Dept> batchByOaId = new LinkedHashMap<>();
for (Dept dept : allParam) {
this.assignCompanyCode(dept, existingByOaId, usedCodes);
batchByOaId.put(dept.getOaId(), dept);
}
// 上级公司编码:OA 上级为 0 → GWWL;否则取上级公司已分配编码
for (Dept dept : allParam) {
if (OAConstant.ROOT_COMPANY_ID.equals(dept.getOaSupSubComId()) || StringUtils.isBlank(dept.getOaSupSubComId())) {
dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE);
dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
continue;
}
Dept parent = batchByOaId.get(dept.getOaSupSubComId());
if (parent == null) {
parent = existingByOaId.get(dept.getOaSupSubComId());
}
if (parent != null && StringUtils.isNotBlank(parent.getDeptCode())) {
dept.setParentCode(parent.getDeptCode());
dept.setParentId(parent.getId());
} else {
dept.setParentCode(null);
dept.setParentId(null);
}
}
Map<String, Long> deptIdByCode = new HashMap<>();
existingByOaId.values().forEach(dept -> {
if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) {
deptIdByCode.put(dept.getDeptCode(), dept.getId());
}
});
deptIdByCode.put(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID);
return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount);
}
/**
@@ -527,80 +678,239 @@ public class OASyncServiceImpl implements IOASyncService {
if (CollectionUtil.isEmpty(oaDepts)) {
return OrgSyncCount.empty();
}
// 查询所有公司的编码和id的map
Map<String, Long> companyDeptMap = getAllCompanyDeptMap();
// 根公司id
Map<String, Dept> companyByOaId = loadCompanyByOaId();
String rootCompanyId = getRootCompanyId();
if (!syncAll && rootCompanyId == null) {
return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList());
}
// 根公司下要同步的部门名称
Set<String> rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames();
// 转换数据
List<Dept> allParam = oaDepts.stream()
// 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门
.filter(oaDept -> syncAll
|| rootCompanyId == null
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
.map(dept -> deptConvert.dept2dept(dept, companyDeptMap))
.map(dept -> deptConvert.dept2dept(dept, companyByOaId))
.toList();
int filteredSkipCount = oaDepts.size() - allParam.size();
if (CollectionUtil.isEmpty(allParam)) {
return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList());
}
Set<String> deptCodes = new HashSet<>();
allParam.forEach(dept -> {
if (StringUtils.isNotEmpty(dept.getDeptCode())) {
deptCodes.add(dept.getDeptCode());
Map<String, Dept> existingByOaId = loadExistingByOaId(DeptCategory.DEPT);
Map<String, Integer> nextSerialByCompany = new HashMap<>();
Map<String, Set<String>> usedCodesByCompany = new HashMap<>();
// 预载各公司下已有部门编码,保证分段连续
for (Dept existing : existingByOaId.values()) {
if (StringUtils.isBlank(existing.getBelongCompanyCode()) || StringUtils.isBlank(existing.getDeptCode())) {
continue;
}
if (StringUtils.isNotEmpty(dept.getParentCode())) {
deptCodes.add(dept.getParentCode());
usedCodesByCompany
.computeIfAbsent(existing.getBelongCompanyCode(), key -> new HashSet<>())
.add(existing.getDeptCode());
}
Map<String, Dept> batchByOaId = new LinkedHashMap<>();
for (Dept dept : allParam) {
this.assignDepartmentCode(dept, existingByOaId, companyByOaId, usedCodesByCompany, nextSerialByCompany);
batchByOaId.put(dept.getOaId(), dept);
}
// 解析上级:根部门挂公司;子部门挂上级部门(本批或已存在)
Map<String, Long> deptIdByCode = new HashMap<>();
companyByOaId.values().forEach(company -> {
if (StringUtils.isNotBlank(company.getDeptCode()) && company.getId() != null) {
deptIdByCode.put(company.getDeptCode(), company.getId());
}
});
// 查询数据库的部门,转换成map。包含本页及上级编码,便于命中已入库的上级
Map<String, Long> deptMap = deptCodes.isEmpty() ? new HashMap<>() : deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
.in(Dept::getDeptCode, deptCodes)
).stream()
.filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode()))
.collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b));
// 公司编码也放入 map,根部门挂到公司时才能解析父级
companyDeptMap.forEach(deptMap::putIfAbsent);
// 处理部门
List<Dept> notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT);
int syncedCount = allParam.size() - notHandledList.size();
int skippedCount = filteredSkipCount + notHandledList.size();
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
existingByOaId.values().forEach(dept -> {
if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) {
deptIdByCode.put(dept.getDeptCode(), dept.getId());
}
});
for (Dept dept : allParam) {
if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getParentCode())
&& DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())
&& dept.getBelongCompanyCode().equals(dept.getParentCode())) {
continue;
}
String parentOaId = dept.getOaSupSubComId();
Dept belongCompany = companyByOaId.values().stream()
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()))
.findFirst()
.orElse(null);
if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId)
|| StringUtils.equals(parentOaId, belongCompany.getOaId()))) {
dept.setParentCode(belongCompany.getDeptCode());
dept.setParentId(belongCompany.getId());
continue;
}
Dept parentDept = batchByOaId.get(parentOaId);
if (parentDept == null) {
parentDept = existingByOaId.get(parentOaId);
}
if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) {
dept.setParentCode(parentDept.getDeptCode());
dept.setParentId(parentDept.getId());
} else if (belongCompany != null) {
// 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建
dept.setParentCode(belongCompany.getDeptCode());
dept.setParentId(belongCompany.getId());
} else {
dept.setParentCode(null);
dept.setParentId(null);
}
}
return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.DEPT, filteredSkipCount);
}
/**
* 处理部门
* @param allParam 待处理的部门
* @param deptMap 数据库所有部门编码和id map
* @return 未处理的数据
* 公司编码:GWWL+三位数字;已有合规编码则复用
*/
private List<Dept> handleDept(List<Dept> allParam, Map<String, Long> deptMap, DeptCategory deptCategory) {
// 数据库存在的所有部门id
Set<Long> existsDeptIds = new HashSet<>(deptMap.values());
private void assignCompanyCode(Dept dept, Map<String, Dept> existingByOaId, Set<String> usedCodes) {
Dept existing = existingByOaId.get(dept.getOaId());
if (existing != null && DeptCodeUtil.isCompanyCode(existing.getDeptCode())) {
dept.setDeptCode(existing.getDeptCode());
dept.setBelongCompanyCode(existing.getDeptCode());
usedCodes.add(existing.getDeptCode());
return;
}
int serial = DeptCodeUtil.nextSerial(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, usedCodes);
String deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial);
while (usedCodes.contains(deptCode)) {
serial++;
deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial);
}
usedCodes.add(deptCode);
dept.setDeptCode(deptCode);
dept.setBelongCompanyCode(deptCode);
}
/**
* 部门编码:所属公司编码+三位数字;已有合规编码则复用
*/
private void assignDepartmentCode(Dept dept, Map<String, Dept> existingByOaId, Map<String, Dept> companyByOaId,
Map<String, Set<String>> usedCodesByCompany, Map<String, Integer> nextSerialByCompany) {
Dept existing = existingByOaId.get(dept.getOaId());
Dept belongCompany = null;
if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) {
belongCompany = companyByOaId.values().stream()
.filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())
|| StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode()))
.findFirst()
.orElse(null);
}
if (belongCompany == null && existing != null && StringUtils.isNotBlank(existing.getBelongCompanyCode())) {
String belongCode = existing.getBelongCompanyCode();
belongCompany = companyByOaId.values().stream()
.filter(company -> StringUtils.equals(company.getDeptCode(), belongCode))
.findFirst()
.orElse(null);
}
if (belongCompany == null) {
return;
}
final Dept resolvedCompany = belongCompany;
final String companyCode = resolvedCompany.getDeptCode();
dept.setBelongCompanyCode(companyCode);
if (existing != null && DeptCodeUtil.matchesChildCode(companyCode, existing.getDeptCode())) {
dept.setDeptCode(existing.getDeptCode());
usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()).add(existing.getDeptCode());
return;
}
Set<String> usedCodes = usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>());
int serial = nextSerialByCompany.computeIfAbsent(companyCode,
key -> DeptCodeUtil.nextSerial(companyCode, usedCodes));
String deptCode = DeptCodeUtil.formatChildCode(companyCode, serial);
while (usedCodes.contains(deptCode)) {
serial++;
deptCode = DeptCodeUtil.formatChildCode(companyCode, serial);
}
nextSerialByCompany.put(companyCode, serial + 1);
usedCodes.add(deptCode);
dept.setDeptCode(deptCode);
}
/**
* 按 oaId 加载已存在组织
*/
private Map<String, Dept> loadExistingByOaId(DeptCategory deptCategory) {
List<Dept> deptList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, deptCategory.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, "")
.select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId,
Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId));
Map<String, Dept> byOaId = new HashMap<>();
for (Dept dept : deptList) {
if (StringUtils.isNotBlank(dept.getOaId())) {
byOaId.put(dept.getOaId(), dept);
}
}
return byOaId;
}
/**
* 已占用的公司编码(GWWLxxx)
*/
private Map<String, Dept> loadCompanyCodeOwnerMap() {
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.likeRight(Dept::getDeptCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE)
.select(Dept::getId, Dept::getDeptCode, Dept::getOaId));
Map<String, Dept> codeMap = new HashMap<>();
for (Dept dept : companyList) {
if (DeptCodeUtil.isCompanyCode(dept.getDeptCode())) {
codeMap.put(dept.getDeptCode(), dept);
}
}
return codeMap;
}
/**
* 已同步公司:oaId → 公司
*/
private Map<String, Dept> loadCompanyByOaId() {
List<Dept> companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, ""));
Map<String, Dept> byOaId = new HashMap<>();
for (Dept dept : companyList) {
if (StringUtils.isNotBlank(dept.getOaId())) {
byOaId.put(dept.getOaId(), dept);
}
}
return byOaId;
}
/**
* 持久化同步数据:按 oaId 匹配;不存在则新增;存在且 oaModified 不一致才修改
*/
private OrgSyncCount persistSyncedDepts(List<Dept> allParam, Map<String, Dept> existingByOaId,
Map<String, Long> deptIdByCode, DeptCategory deptCategory,
int filteredSkipCount) {
Set<Long> existsDeptIds = existingByOaId.values().stream()
.map(Dept::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
allParam.forEach(dept -> {
if (deptMap.containsKey(dept.getDeptCode())) {
// 根据部门编码获取对应的部门id
dept.setId(deptMap.get(dept.getDeptCode()));
Dept existing = existingByOaId.get(dept.getOaId());
if (existing != null && existing.getId() != null) {
dept.setId(existing.getId());
} else if (StringUtils.isNotBlank(dept.getDeptCode()) && deptIdByCode.containsKey(dept.getDeptCode())) {
dept.setId(deptIdByCode.get(dept.getDeptCode()));
} else {
// 没有就生成一个id
dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
deptMap.put(dept.getDeptCode(), dept.getId());
}
if (StringUtils.isNotBlank(dept.getDeptCode())) {
deptIdByCode.put(dept.getDeptCode(), dept.getId());
}
});
// 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据
int unresolvedParentCount = 0;
for (Dept dept : allParam) {
if (dept.getParentId() != null) {
continue;
}
Long parentId = deptMap.get(dept.getParentCode());
Long parentId = StringUtils.isNotBlank(dept.getParentCode()) ? deptIdByCode.get(dept.getParentCode()) : null;
if (parentId != null) {
dept.setParentId(parentId);
continue;
@@ -615,11 +925,10 @@ public class OASyncServiceImpl implements IOASyncService {
deptCategory.getName(), unresolvedParentCount);
}
// 父id不为空才新增;已存在的记录即使父id未解析也更新(空父id不会覆盖原值)
List<Dept> list = allParam.stream()
.filter(dept -> StringUtils.isNotBlank(dept.getDeptCode()))
.filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId()))
.toList();
// 不存在的新增
List<Dept> addList = list.stream()
.filter(dept -> !existsDeptIds.contains(dept.getId()))
.toList();
@@ -627,18 +936,48 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size());
deptService.saveBatch(addList);
}
// 存在的修改
List<Dept> updateList = list.stream()
.filter(dept -> existsDeptIds.contains(dept.getId()))
.filter(dept -> !shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept))
.toList();
if (CollectionUtil.isNotEmpty(updateList)) {
ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size());
deptService.updateBatchById(updateList);
}
// 返回仍未保存的数据
return allParam.stream()
.filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))
int unchangedCount = (int) list.stream()
.filter(dept -> existsDeptIds.contains(dept.getId()))
.filter(dept -> shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept))
.count();
List<Dept> notHandledList = allParam.stream()
.filter(dept -> StringUtils.isBlank(dept.getDeptCode())
|| (dept.getParentId() == null && !existsDeptIds.contains(dept.getId())))
.toList();
int syncedCount = addList.size() + updateList.size();
int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size();
if (unchangedCount > 0) {
ComposeLogUtil.getLastLog().info("跳过未变更{}:{}", deptCategory.getName(), unchangedCount);
}
return new OrgSyncCount(syncedCount, skippedCount, notHandledList);
}
/**
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
* 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。
*/
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
if (existing == null) {
return false;
}
if (incoming != null && StringUtils.isNotBlank(incoming.getDeptCode())
&& !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) {
return false;
}
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
if (localModified.isEmpty() || remoteModified.isEmpty()) {
return false;
}
return localModified.equals(remoteModified);
}
/**
@@ -683,6 +1022,8 @@ public class OASyncServiceImpl implements IOASyncService {
if (syncAll) {
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, "")
);
} else {
Set<String> companyNames = this.getCompanyNames();
@@ -692,6 +1033,8 @@ public class OASyncServiceImpl implements IOASyncService {
companyList = deptService.list(Wrappers.<Dept>lambdaQuery()
.in(Dept::getDeptName, companyNames)
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.isNotNull(Dept::getOaId)
.ne(Dept::getOaId, "")
);
}
return companyList.stream()
@@ -792,13 +1135,20 @@ public class OASyncServiceImpl implements IOASyncService {
return Collections.emptySet();
}
// 查询父部门编码是根公司的,部门类型的,根公司下部门名称的部门
Dept rootCompany = deptService.getOne(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getOaId, rootCompanyId)
.eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode())
.last("limit 1"), false);
if (rootCompany == null || StringUtils.isBlank(rootCompany.getDeptCode())) {
return Collections.emptySet();
}
return deptService.list(Wrappers.<Dept>lambdaQuery()
.in(Dept::getDeptName, rootCompanyDeptNames)
.eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode())
.eq(Dept::getParentCode, OAConvertConstant.COMPANY_OA_PREFIX + rootCompanyId)
.eq(Dept::getParentCode, rootCompany.getDeptCode())
).stream()
.filter(dept -> StringUtils.isNotBlank(dept.getDeptCode()))
.map(dept -> dept.getDeptCode().replace(OAConvertConstant.DEPARTMENT_OA_PREFIX, ""))
.map(Dept::getOaId)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}
@@ -821,11 +1171,11 @@ public class OASyncServiceImpl implements IOASyncService {
ComposeLogUtil.getLastLog().warn("未查询到根公司 名称:{}", companyName);
return null;
}
if (StringUtils.isBlank(dept.getDeptCode())) {
if (StringUtils.isBlank(dept.getOaId()) && StringUtils.isBlank(dept.getDeptCode())) {
ComposeLogUtil.getLastLog().warn("根公司编码为空:{}", companyName);
return null;
}
// 部门编码去掉前缀,就是oa的id
return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
return StringUtils.isNotBlank(dept.getOaId()) ? dept.getOaId()
: dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, "");
}
}
@@ -86,7 +86,7 @@ public class OaUserListSyncHelper {
private final IRoleService roleService;
/**
* 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下
* 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下
*
* @param oaPersons OA人员
* @return 组织索引
@@ -249,6 +249,7 @@ public class OaUserListSyncHelper {
});
Map<String, Long> userMap = new HashMap<>();
Set<Long> existsUserIds = new HashSet<>();
Set<Long> unchangedUserIds = new HashSet<>();
users.forEach(user -> {
User existingUser = existingByAccount.get(user.getAccount());
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
@@ -258,6 +259,9 @@ public class OaUserListSyncHelper {
user.setId(existingUser.getId());
user.setPassword(null);
existsUserIds.add(existingUser.getId());
if (shouldSkipOaUpdate(existingUser, user)) {
unchangedUserIds.add(existingUser.getId());
}
} else {
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
user.setPostId("-1");
@@ -279,12 +283,18 @@ public class OaUserListSyncHelper {
}
List<User> updateUsers = users.stream()
.filter(user -> existsUserIds.contains(user.getId()))
.filter(user -> !unchangedUserIds.contains(user.getId()))
.toList();
if (CollectionUtil.isNotEmpty(updateUsers)) {
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
userService.updateBatchById(updateUsers);
}
if (!unchangedUserIds.isEmpty()) {
ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size());
}
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
int syncedCount = addUsers.size() + updateUsers.size();
skippedCount += unchangedUserIds.size();
if (userMap.isEmpty()) {
return new PersonSyncCount(0, skippedCount);
}
@@ -295,7 +305,13 @@ public class OaUserListSyncHelper {
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second));
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
List<UserDept> userDeptList = oaPersons.stream()
.filter(oaPerson -> userMap.containsKey(userConvert.resolveAccount(oaPerson)))
.filter(oaPerson -> {
String account = userConvert.resolveAccount(oaPerson);
if (!userMap.containsKey(account)) {
return false;
}
return !unchangedUserIds.contains(userMap.get(account));
})
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
.filter(Objects::nonNull)
.toList();
@@ -333,7 +349,21 @@ public class OaUserListSyncHelper {
.collect(Collectors.toSet());
String defaultRoleId = getDefaultRoleId();
List<User> updateUserParams = list.stream()
.filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH)
.filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId()))
.filter(userDeptIds -> {
if (StringUtils.isBlank(userDeptIds.getDeptIds())) {
return false;
}
int deptIdLength = userDeptIds.getDeptIds().length();
int deptCodesLength = StringUtils.length(userDeptIds.getDeptCodes());
if (deptIdLength > MAX_DEPT_ID_LENGTH || deptCodesLength > MAX_DEPT_ID_LENGTH) {
ComposeLogUtil.getLastLog().warn(
"用户部门字段超长已跳过回写 userId={} deptIdLen={} deptCodesLen={} max={}",
userDeptIds.getUserId(), deptIdLength, deptCodesLength, MAX_DEPT_ID_LENGTH);
return false;
}
return true;
})
.map(userDeptIds -> {
User user = new User();
user.setId(userDeptIds.getUserId());
@@ -347,37 +377,26 @@ public class OaUserListSyncHelper {
if (CollectionUtil.isNotEmpty(updateUserParams)) {
userService.updateBatchById(updateUserParams);
}
return new PersonSyncCount(users.size(), skippedCount);
return new PersonSyncCount(syncedCount, skippedCount);
}
/**
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段
*/
private boolean shouldSkipOaUpdate(User existing, User incoming) {
if (existing == null) {
return false;
}
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
if (localModified.isEmpty() || remoteModified.isEmpty()) {
return false;
}
return localModified.equals(remoteModified);
}
private Dept getOrCreateRootCompany() {
Dept rootCompany = deptService.getOne(Wrappers.<Dept>lambdaQuery()
.eq(Dept::getDeptName, OAConvertConstant.ROOT_COMPANY_NAME)
.last("limit 1"), false);
if (rootCompany != null) {
return rootCompany;
}
rootCompany = new Dept();
rootCompany.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
rootCompany.setTenantId(resolveTenantId());
rootCompany.setParentId(BladeConstant.TOP_PARENT_ID);
rootCompany.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootCompany.setDeptName(OAConvertConstant.ROOT_COMPANY_NAME);
rootCompany.setFullName(OAConvertConstant.ROOT_COMPANY_NAME);
rootCompany.setShortName(OAConvertConstant.ROOT_COMPANY_NAME);
rootCompany.setDeptCode("OACROOT");
rootCompany.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID));
rootCompany.setBelongCompanyCode("OACROOT");
rootCompany.setDeptCategory(DeptCategory.COMPANY.getCode());
rootCompany.setSort(0);
rootCompany.setStatus(DataStatusEnum.ENABLE.getCode());
rootCompany.setIsDeleted(BladeConstant.DB_NOT_DELETED);
rootCompany.setIsOa(YES);
rootCompany.setIsPlatformCompany(0);
rootCompany.setSyncTime(new Date());
deptService.save(rootCompany);
ComposeLogUtil.getLastLog().info("已创建顶级组织:{}", OAConvertConstant.ROOT_COMPANY_NAME);
return rootCompany;
return deptService.ensureOaSyncRootDept();
}
private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId,
@@ -29,13 +29,14 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.AllArgsConstructor;
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.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.system.cache.UserCache;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.excel.PortTerminalExportExcel;
import org.springblade.system.mapper.PortTerminalMapper;
@@ -44,6 +45,7 @@ import org.springblade.system.pojo.entity.Region;
import org.springblade.system.pojo.vo.PortTerminalVO;
import org.springblade.system.service.IPortTerminalService;
import org.springblade.system.service.IRegionService;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -66,8 +68,9 @@ import java.util.regex.Pattern;
*
* @author Chill
*/
@Slf4j
@Service
@AllArgsConstructor
@RequiredArgsConstructor
public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper, PortTerminal> implements IPortTerminalService {
private static final String CATEGORY_PORT = "港口";
@@ -76,8 +79,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
private static final String SOURCE_INITIAL_OLD = "初始导入";
private static final String SOURCE_BATCH = "批量导入";
private static final String SOURCE_MANUAL = "手工导入";
private static final String SOURCE_MANUAL_OLD = "手动录入";
private static final int STATUS_ENABLED = 1;
private static final int STATUS_DISABLED = 2;
private static final int PROVINCE_REGION_LEVEL = 1;
private static final int CITY_REGION_LEVEL = 2;
private static final int DISTRICT_REGION_LEVEL = 3;
private static final int CODE_MAX_LENGTH = 30;
private static final int NAME_MAX_LENGTH = 100;
private static final int REGION_MAX_LENGTH = 50;
@@ -102,6 +109,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
@Transactional(rollbackFor = Exception.class)
public boolean submit(PortTerminal portTerminal) {
prepare(portTerminal, SOURCE_MANUAL);
prepareSubmitTarget(portTerminal);
validate(portTerminal);
return saveOrUpdate(portTerminal);
}
@@ -140,7 +148,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
@Transactional(rollbackFor = Exception.class)
public List<PortTerminalExcel> importPortTerminal(List<PortTerminalExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
List<PortTerminalExcel> failureList = new ArrayList<>();
@@ -206,7 +214,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
? PORT_CODE_PATTERN.matcher(code).matches()
: TERMINAL_CODE_PATTERN.matcher(code).matches();
if (!codeValid) {
String message = CATEGORY_PORT.equals(category) ? "港口编码为5位大写字母" : "码头编码格式为港口编码-码头标识";
String message = CATEGORY_PORT.equals(category)
? "港口编码为5位大写字母,当前为「" + code + "」"
: "码头编码格式为港口编码-码头标识,当前为「" + code + "」";
failureList.add(buildFailure(data, index, message));
continue;
}
@@ -239,8 +249,11 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setDataSource(SOURCE_BATCH);
portTerminal.setStatus(STATUS_ENABLED);
prepare(portTerminal, SOURCE_BATCH);
prepareSubmitTarget(portTerminal);
validate(portTerminal);
save(portTerminal);
if (!saveOrUpdate(portTerminal)) {
throw new ServiceException("港口码头保存失败");
}
batchPortMap.put(portTerminal.getCode(), portTerminal);
} catch (Exception exception) {
failureList.add(buildFailure(data, index, resolveMessage(exception)));
@@ -265,8 +278,11 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setDataSource(SOURCE_BATCH);
portTerminal.setStatus(STATUS_ENABLED);
prepareTerminal(portTerminal, batchPortMap);
prepareSubmitTarget(portTerminal);
validate(portTerminal);
save(portTerminal);
if (!saveOrUpdate(portTerminal)) {
throw new ServiceException("港口码头保存失败");
}
} catch (ParentPortNotFoundException exception) {
// 上级港口就声明在本文件里,只是那一行自己校验失败(已被标红)。
// 此时码头行本身没有问题,不再连带报错,避免用户看到"两行都错"的假象。
@@ -308,6 +324,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setCountry(parent.getCountry());
}
if (Func.isNotEmpty(parent.getProvinceCode())) {
portTerminal.setProvinceCode(parent.getProvinceCode());
}
if (Func.isNotEmpty(parent.getProvinceName())) {
portTerminal.setProvinceName(parent.getProvinceName());
}
if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setCity(parent.getCity());
}
@@ -341,10 +363,59 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
}
/**
* 解析异常信息,非业务异常统一提示导入失败。
* 解析异常信息:优先取业务异常,其次解包数据访问异常,避免失败明细只剩「导入失败」。
*/
private String resolveMessage(Exception exception) {
return exception instanceof ServiceException ? exception.getMessage() : "导入失败";
ServiceException serviceException = findCause(exception, ServiceException.class);
if (serviceException != null && Func.isNotEmpty(serviceException.getMessage())
&& !(serviceException instanceof ImportFailureException)) {
return serviceException.getMessage();
}
DataAccessException dataAccessException = findCause(exception, DataAccessException.class);
if (dataAccessException != null) {
Throwable root = rootCause(dataAccessException);
String rootMessage = root.getMessage();
log.warn("港口码头导入数据访问异常", exception);
if (Func.isNotEmpty(rootMessage)) {
if (rootMessage.contains("Duplicate") || rootMessage.contains("duplicate") || rootMessage.contains("uk_port_terminal_code")) {
return "该编码已存在";
}
return "数据保存失败:" + shortenMessage(rootMessage);
}
return "数据保存失败";
}
Throwable root = rootCause(exception);
String rootMessage = root.getMessage();
log.warn("港口码头导入非业务异常, type={}", root.getClass().getName(), exception);
if (Func.isNotEmpty(rootMessage)) {
return shortenMessage(rootMessage);
}
return "导入失败:" + root.getClass().getSimpleName();
}
private <T extends Throwable> T findCause(Throwable throwable, Class<T> type) {
for (Throwable current = throwable; current != null; current = current.getCause()) {
if (type.isInstance(current)) {
return type.cast(current);
}
if (current.getCause() == current) {
break;
}
}
return null;
}
private Throwable rootCause(Throwable throwable) {
Throwable root = throwable;
while (root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
return root;
}
private String shortenMessage(String message) {
String value = trimToEmpty(message);
return value.length() > 200 ? value.substring(0, 200) + "..." : value;
}
@Override
@@ -357,6 +428,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
excel.setLatitude(scaleCoordinate(portTerminal.getLatitude(), MIN_LATITUDE, MAX_LATITUDE));
excel.setDataSource(normalizeDataSource(portTerminal.getDataSource()));
excel.setStatusName(Objects.equals(portTerminal.getStatus(), STATUS_ENABLED) ? "启用" : "停用");
excel.setCreateUserName(UserCache.getUserRealName(portTerminal.getCreateUser()));
excel.setUpdateUserName(UserCache.getUserRealName(portTerminal.getUpdateUser()));
return excel;
}).toList();
@@ -369,6 +441,26 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
return validRange(value, min, max) ? value.setScale(6, RoundingMode.HALF_UP) : null;
}
/**
* 编码已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
* 编码存在且未删除时仍报「该编码已存在」。
*/
private void prepareSubmitTarget(PortTerminal portTerminal) {
if (Func.isNotEmpty(portTerminal.getId())) {
return;
}
PortTerminal existingPortTerminal = baseMapper.selectByCodeIncludingDeleted(portTerminal.getCode());
if (existingPortTerminal == null) {
return;
}
if (!Objects.equals(existingPortTerminal.getIsDeleted(), 1)) {
throw new ServiceException("该编码已存在");
}
baseMapper.restoreById(existingPortTerminal.getId());
portTerminal.setId(existingPortTerminal.getId());
portTerminal.setIsDeleted(0);
}
private void prepare(PortTerminal portTerminal, String defaultDataSource) {
prepare(portTerminal, defaultDataSource, false);
}
@@ -385,6 +477,8 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setCategory(trimToEmpty(portTerminal.getCategory()));
portTerminal.setName(trimToEmpty(portTerminal.getName()));
portTerminal.setCountry(trimToEmpty(portTerminal.getCountry()));
portTerminal.setProvinceCode(trimToNull(portTerminal.getProvinceCode()));
portTerminal.setProvinceName(trimToNull(portTerminal.getProvinceName()));
portTerminal.setCity(trimToEmpty(portTerminal.getCity()));
portTerminal.setDistrictCode(trimToNull(portTerminal.getDistrictCode()));
portTerminal.setDistrictName(trimToNull(portTerminal.getDistrictName()));
@@ -430,68 +524,99 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (!CATEGORY_PORT.equals(parent.getCategory())) {
throw new ServiceException("上级港口类型不正确");
}
portTerminal.setParentId(parent.getId());
portTerminal.setParentCode(parent.getCode());
portTerminal.setParentName(parent.getName());
// 父港口区域信息为空时保留码头自身填写的值,避免历史数据(区县为空)导致码头无法导入。
if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setCountry(parent.getCountry());
}
if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setCity(parent.getCity());
}
if (Func.isNotEmpty(parent.getDistrictCode())) {
portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setRegionCode(parent.getDistrictCode());
} else if (Func.isNotEmpty(portTerminal.getRegionCode())) {
applyParent(portTerminal, parent);
if (Func.isEmpty(portTerminal.getDistrictCode()) && Func.isNotEmpty(portTerminal.getRegionCode())) {
portTerminal.setDistrictCode(portTerminal.getRegionCode());
}
if (Func.isNotEmpty(parent.getDistrictName())) {
portTerminal.setDistrictName(parent.getDistrictName());
}
}
private void fillRegion(PortTerminal portTerminal) {
Region province = null;
if (Func.isNotEmpty(portTerminal.getProvinceCode())) {
province = regionService.getById(portTerminal.getProvinceCode());
}
if (Func.isEmpty(province) && Func.isNotEmpty(portTerminal.getProvinceName())) {
province = findRegionByName(portTerminal.getProvinceName(), PROVINCE_REGION_LEVEL, null);
}
if (Func.isEmpty(province)) {
throw new ServiceException("请选择所属省份");
}
Region city = null;
if (Func.isNotEmpty(portTerminal.getCity())) {
city = findRegionByName(portTerminal.getCity(), CITY_REGION_LEVEL, province.getCode());
}
if (Func.isEmpty(city)) {
throw new ServiceException("请选择城市");
}
if (!Objects.equals(city.getParentCode(), province.getCode())) {
throw new ServiceException("所属城市与所属省份不匹配");
}
Region district = null;
if (Func.isNotEmpty(portTerminal.getDistrictCode())) {
district = regionService.getById(portTerminal.getDistrictCode());
}
if (Func.isEmpty(district) && Func.isNotEmpty(portTerminal.getDistrictName())) {
if (Func.isNotEmpty(portTerminal.getCity())) {
List<Region> cityList = regionService.list(Wrappers.<Region>lambdaQuery()
.eq(Region::getName, portTerminal.getCity())
.eq(Region::getRegionLevel, 2));
for (Region city : cityList) {
district = regionService.getOne(Wrappers.<Region>lambdaQuery()
.eq(Region::getParentCode, city.getCode())
.eq(Region::getName, portTerminal.getDistrictName()), false);
if (Func.isNotEmpty(district)) {
break;
}
}
}
if (Func.isEmpty(district)) {
district = regionService.getOne(Wrappers.<Region>lambdaQuery()
.eq(Region::getName, portTerminal.getDistrictName())
.eq(Region::getRegionLevel, 3), false);
}
district = findRegionByName(portTerminal.getDistrictName(), DISTRICT_REGION_LEVEL, city.getCode());
}
if (Func.isEmpty(district)) {
throw new ServiceException("请选择区县");
}
Region city = regionService.getById(district.getParentCode());
if (Func.isEmpty(city)) {
throw new ServiceException("区县所属城市不存在");
}
if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) {
throw new ServiceException("区县与城市不匹配");
if (!Objects.equals(district.getParentCode(), city.getCode())) {
throw new ServiceException("所属区县与所属城市不匹配");
}
portTerminal.setProvinceCode(province.getCode());
portTerminal.setProvinceName(province.getName());
portTerminal.setCity(city.getName());
portTerminal.setDistrictCode(district.getCode());
portTerminal.setDistrictName(district.getName());
portTerminal.setRegionCode(district.getCode());
}
/**
* 按名称查找区划,兼容「福建/福建省」「泉州/泉州市」等简写。
*/
private Region findRegionByName(String name, int regionLevel, String parentCode) {
String normalized = trimToEmpty(name);
if (Func.isEmpty(normalized)) {
return null;
}
for (String candidate : regionNameCandidates(normalized)) {
LambdaQueryWrapper<Region> queryWrapper = Wrappers.<Region>lambdaQuery()
.eq(Region::getRegionLevel, regionLevel)
.eq(Region::getName, candidate);
if (Func.isNotEmpty(parentCode)) {
queryWrapper.eq(Region::getParentCode, parentCode);
}
Region region = regionService.getOne(queryWrapper, false);
if (Func.isNotEmpty(region)) {
return region;
}
}
return null;
}
private List<String> regionNameCandidates(String name) {
List<String> candidates = new ArrayList<>();
candidates.add(name);
if (name.endsWith("省") || name.endsWith("市") || name.endsWith("区") || name.endsWith("县") || name.endsWith("州")) {
String shortName = name.substring(0, name.length() - 1);
if (Func.isNotEmpty(shortName) && !candidates.contains(shortName)) {
candidates.add(shortName);
}
} else {
for (String suffix : List.of("省", "市", "区", "县", "州")) {
String fullName = name + suffix;
if (!candidates.contains(fullName)) {
candidates.add(fullName);
}
}
}
return candidates;
}
private void validate(PortTerminal portTerminal) {
if (!CATEGORY_PORT.equals(portTerminal.getCategory()) && !CATEGORY_TERMINAL.equals(portTerminal.getCategory())) {
throw new ServiceException("类型只能为港口或码头");
@@ -505,16 +630,17 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
validateLength(portTerminal.getCode(), CODE_MAX_LENGTH, "编码不能超过30字");
validateLength(portTerminal.getName(), NAME_MAX_LENGTH, "港口/码头名称不能超过100字");
validateLength(portTerminal.getCountry(), REGION_MAX_LENGTH, "国家不能超过50字");
validateLength(portTerminal.getProvinceName(), REGION_MAX_LENGTH, "所属省份不能超过50字");
validateLength(portTerminal.getCity(), REGION_MAX_LENGTH, "城市不能超过50字");
validateLength(portTerminal.getDistrictName(), REGION_MAX_LENGTH, "区县不能超过50字");
validateLength(portTerminal.getRegionCode(), REGION_CODE_MAX_LENGTH, "行政区划编码不能超过32字");
validateLength(portTerminal.getDetailAddress(), DETAIL_ADDRESS_MAX_LENGTH, "详细地址不能超过255字");
validateLength(portTerminal.getRemark(), REMARK_MAX_LENGTH, "备注不能超过200个字");
if (CATEGORY_PORT.equals(portTerminal.getCategory()) && !PORT_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
throw new ServiceException("港口编码为5位大写字母");
throw new ServiceException("港口编码为5位大写字母,当前为「" + portTerminal.getCode() + "」");
}
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory()) && !TERMINAL_CODE_PATTERN.matcher(portTerminal.getCode()).matches()) {
throw new ServiceException("码头编码格式为港口编码-码头标识");
throw new ServiceException("码头编码格式为港口编码-码头标识,当前为「" + portTerminal.getCode() + "」");
}
if (CATEGORY_TERMINAL.equals(portTerminal.getCategory()) && !portTerminal.getCode().startsWith(portTerminal.getParentCode() + "-")) {
throw new ServiceException("码头编码必须以所属港口编码开头");
@@ -522,12 +648,24 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isEmpty(portTerminal.getCountry())) {
throw new ServiceException("国家不能为空");
}
if (Func.isEmpty(portTerminal.getProvinceCode()) && Func.isEmpty(portTerminal.getProvinceName())) {
throw new ServiceException("所属省份不能为空");
}
if (Func.isEmpty(portTerminal.getCity())) {
throw new ServiceException("城市不能为空");
}
if (Func.isEmpty(portTerminal.getDistrictCode())) {
throw new ServiceException("区县不能为空");
}
if (Func.isEmpty(portTerminal.getDetailAddress())) {
throw new ServiceException("详细地址不能为空");
}
if (Func.isEmpty(portTerminal.getLongitude())) {
throw new ServiceException("经度不能为空");
}
if (Func.isEmpty(portTerminal.getLatitude())) {
throw new ServiceException("纬度不能为空");
}
validateDataSource(portTerminal.getDataSource());
validateStatus(portTerminal.getStatus());
validateRange(portTerminal.getLongitude(), MIN_LONGITUDE, MAX_LONGITUDE, "经度范围为 -180 到 180");
@@ -592,7 +730,13 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
private String normalizeDataSource(String dataSource) {
String value = trimToEmpty(dataSource);
return SOURCE_INITIAL_OLD.equals(value) ? SOURCE_INITIAL : value;
if (SOURCE_INITIAL_OLD.equals(value)) {
return SOURCE_INITIAL;
}
if (SOURCE_MANUAL_OLD.equals(value)) {
return SOURCE_MANUAL;
}
return value;
}
private void validateEnabledTerminal(Long parentId) {
@@ -610,7 +754,7 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
}
private String trimToEmpty(String value) {
return value == null ? "" : value.trim();
return value == null ? "" : value.replace("\uFEFF", "").strip();
}
private String trimToNull(String value) {
@@ -102,6 +102,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
@Transactional(rollbackFor = Exception.class)
public boolean submit(RailwayStation railwayStation) {
prepare(railwayStation, SOURCE_MANUAL);
prepareSubmitTarget(railwayStation);
validate(railwayStation);
return saveOrUpdate(railwayStation);
}
@@ -129,7 +130,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
@Transactional(rollbackFor = Exception.class)
public List<RailwayStationExcel> importRailwayStation(List<RailwayStationExcel> data) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
List<RailwayStationExcel> errorList = new ArrayList<>();
List<RailwayStation> railwayStationList = new ArrayList<>();
@@ -165,11 +166,13 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
}
}
if (Func.isNotEmpty(errorList)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return errorList;
// 抛出携带失败明细的异常,触发事务回滚(替代旧的 setRollbackOnly)。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new org.springblade.common.excel.ImportFailureException(data);
}
for (RailwayStation railwayStation : railwayStationList) {
prepareImportTarget(railwayStation);
// 编码/TMIS 命中逻辑删除记录时复用原主键,恢复后更新,避免唯一索引冲突。
prepareSubmitTarget(railwayStation);
if (!saveOrUpdate(railwayStation)) {
throw new ServiceException("铁路车站保存失败");
}
@@ -177,11 +180,18 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
return errorList;
}
private void prepareImportTarget(RailwayStation railwayStation) {
/**
* 编码或 TMIS 已存在且为软删除时:取消软删除并复用原主键,后续按更新落库;
* 存在且未删除时仍报「该编码已存在」。
*/
private void prepareSubmitTarget(RailwayStation railwayStation) {
if (Func.isNotEmpty(railwayStation.getId())) {
return;
}
RailwayStation existingRailwayStation = baseMapper.selectByCodeIncludingDeleted(railwayStation.getCode());
if (existingRailwayStation == null && Func.isNotEmpty(railwayStation.getTmisCode())) {
existingRailwayStation = baseMapper.selectByTmisCodeIncludingDeleted(railwayStation.getTmisCode());
}
if (existingRailwayStation == null) {
return;
}
@@ -206,7 +216,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
private void normalizeImportRailwayStation(RailwayStation railwayStation) {
railwayStation.setTmisCode(trimToEmpty(railwayStation.getTmisCode()));
railwayStation.setCode(CODE_PREFIX + railwayStation.getTmisCode());
railwayStation.setTelegraphCode(trimToEmpty(railwayStation.getTelegraphCode()).toUpperCase(Locale.ROOT));
railwayStation.setTelegraphCode(normalizeTelegraphCode(railwayStation.getTelegraphCode()));
railwayStation.setName(trimToEmpty(railwayStation.getName()));
railwayStation.setProvinceCode(trimToNull(railwayStation.getProvinceCode()));
railwayStation.setProvinceName(trimToNull(railwayStation.getProvinceName()));
@@ -246,9 +256,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
validateImportUnique(RailwayStation::getTmisCode, railwayStation.getTmisCode(), "该TMIS国标编码已存在", validationErrors);
validateImportUnique(RailwayStation::getCode, railwayStation.getCode(), "该编码已存在", validationErrors);
}
if (Func.isEmpty(railwayStation.getTelegraphCode())) {
addValidationError(validationErrors, "电报略码不能为空");
} else {
if (Func.isNotEmpty(railwayStation.getTelegraphCode())) {
if (!TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
addValidationError(validationErrors, "电报略码为3位大写字母");
}
@@ -348,6 +356,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
excel.setLatitude(formatCoordinate(railwayStation.getLatitude(), MIN_LATITUDE, MAX_LATITUDE));
excel.setDataSource(normalizeDataSource(railwayStation.getDataSource()));
excel.setStatusName(Objects.equals(railwayStation.getStatus(), STATUS_ENABLED) ? "启用" : "停用");
excel.setCreateUserName(UserCache.getUserRealName(railwayStation.getCreateUser()));
excel.setUpdateUserName(UserCache.getUserRealName(railwayStation.getUpdateUser()));
return excel;
}).toList();
@@ -375,7 +384,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
private void prepare(RailwayStation railwayStation, String defaultDataSource) {
railwayStation.setTmisCode(trimToEmpty(railwayStation.getTmisCode()));
railwayStation.setCode(CODE_PREFIX + railwayStation.getTmisCode());
railwayStation.setTelegraphCode(trimToEmpty(railwayStation.getTelegraphCode()).toUpperCase(Locale.ROOT));
railwayStation.setTelegraphCode(normalizeTelegraphCode(railwayStation.getTelegraphCode()));
railwayStation.setName(trimToEmpty(railwayStation.getName()));
railwayStation.setRailwayLine(trimToNull(railwayStation.getRailwayLine()));
railwayStation.setProvinceCode(trimToNull(railwayStation.getProvinceCode()));
@@ -408,10 +417,8 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
if (!TMIS_CODE_PATTERN.matcher(railwayStation.getTmisCode()).matches()) {
throw new ServiceException("TMIS国标编码为5位数字");
}
if (Func.isEmpty(railwayStation.getTelegraphCode())) {
throw new ServiceException("电报略码格式不正确或已存在");
}
if (!TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
if (Func.isNotEmpty(railwayStation.getTelegraphCode())
&& !TELEGRAPH_CODE_PATTERN.matcher(railwayStation.getTelegraphCode()).matches()) {
throw new ServiceException("电报略码格式不正确或已存在");
}
if (Func.isEmpty(railwayStation.getName())) {
@@ -444,7 +451,9 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
validateDataSource(railwayStation.getDataSource());
validateStatus(railwayStation.getStatus());
validateUnique(railwayStation, RailwayStation::getTmisCode, railwayStation.getTmisCode(), "该TMIS编码已存在");
validateUnique(railwayStation, RailwayStation::getTelegraphCode, railwayStation.getTelegraphCode(), "电报略码格式不正确或已存在");
if (Func.isNotEmpty(railwayStation.getTelegraphCode())) {
validateUnique(railwayStation, RailwayStation::getTelegraphCode, railwayStation.getTelegraphCode(), "电报略码格式不正确或已存在");
}
validateUnique(railwayStation, RailwayStation::getCode, railwayStation.getCode(), "该编码已存在");
}
@@ -560,6 +569,11 @@ public class RailwayStationServiceImpl extends BaseServiceImpl<RailwayStationMap
return trimValue.isEmpty() ? null : trimValue;
}
private String normalizeTelegraphCode(String value) {
String telegraphCode = trimToNull(value);
return telegraphCode == null ? null : telegraphCode.toUpperCase(Locale.ROOT);
}
private String normalizeDataSource(String dataSource) {
String value = trimToEmpty(dataSource);
if (SOURCE_INITIAL_OLD.equals(value)) {
@@ -41,6 +41,7 @@ import org.springblade.system.mapper.RegionMapper;
import org.springblade.system.service.IRegionService;
import org.springblade.system.pojo.vo.RegionVO;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
@@ -60,6 +61,17 @@ public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> impleme
@Override
public boolean submit(Region region) {
return submit(region, true);
}
/**
* 提交区划,clearCache 控制是否在保存成功后立即清理惰性树缓存。
* <p>
* 批量导入路径传 false:导入内部会收集全部错误,只有整批成功才统一清缓存;
* 若逐行清缓存,失败批次中已成功行会把缓存清掉/重算成中间状态,
* 与随后的事务回滚形成「库回滚了、缓存却是脏的」。
*/
private boolean submit(Region region, boolean clearCache) {
Date now = new Date();
Long currentUserId = AuthUtil.getUserId();
boolean isNew = StringUtil.isBlank(region.getOriginalCode());
@@ -98,6 +110,9 @@ public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> impleme
validateUniqueCode(region);
// 设置祖区划编号
Region parent = getByCode(region.getParentCode());
if (level != null && level > COUNTRY_LEVEL && (Func.isEmpty(parent) || Func.isEmpty(parent.getCode()))) {
throw new ServiceException("父区划不存在,请重新选择父区划");
}
if (Func.isNotEmpty(parent) && Func.isNotEmpty(parent.getCode())) {
String ancestors = parent.getRegionLevel() != null && parent.getRegionLevel() == COUNTRY_LEVEL
? parent.getCode()
@@ -138,7 +153,7 @@ public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> impleme
region.setVillageName(name);
}
boolean result = StringUtil.isNotBlank(region.getOriginalCode()) ? this.updateById(region) : this.save(region);
if (result) {
if (result && clearCache) {
clearLazyTree();
}
return result;
@@ -189,12 +204,15 @@ public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> impleme
}
@Override
@Transactional(rollbackFor = Exception.class)
public List<RegionExcel> importRegion(List<RegionExcel> data, Boolean isCovered) {
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
throw new ServiceException("未读取到数据行,请确认表头下方已填写内容");
}
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
// 注意不能只往 errorList 里塞数据就 return——异常被吞掉后方法会正常返回,
// Spring 会照常提交事务,@Transactional 起不到回滚作用。
List<RegionExcel> errorList = new ArrayList<>();
boolean cacheChanged = false;
for (int index = 0; index < data.size(); index++) {
RegionExcel excel = data.get(index);
try {
@@ -210,15 +228,20 @@ public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> impleme
if (Boolean.TRUE.equals(isCovered) && this.getById(region.getCode()) != null) {
region.setOriginalCode(region.getCode());
}
cacheChanged = this.submit(region) || cacheChanged;
// 导入路径不逐行清缓存,缓存只在整批成功后统一清一次。
this.submit(region, false);
} catch (Exception exception) {
excel.setErrorMessage(exception.getMessage());
errorList.add(excel);
}
}
if (cacheChanged) {
clearLazyTree();
if (Func.isNotEmpty(errorList)) {
// 抛出携带失败明细的异常,触发事务回滚。缓存清理在异常抛出后不可达,
// 只会发生在成功路径上——避免「库回滚了、惰性树缓存却按失败批次
// 的中间状态被清掉/重算」的脏缓存。
throw new org.springblade.common.excel.ImportFailureException(data);
}
clearLazyTree();
return errorList;
}
@@ -46,11 +46,18 @@ import java.util.List;
@Service
public class UserDeptServiceImpl extends ServiceImpl<UserDeptMapper, UserDept> implements IUserDeptService {
/**
* 与 blade_user.dept_id / dept_codes 扩容后的字段上限一致
*/
private static final int GROUP_CONCAT_MAX_LEN = 2000;
@Override
public List<UserDeptIdsVO> queryUserDeptIds(Collection<Long> userIds) {
if (CollectionUtil.isEmpty(userIds)) {
return Collections.emptyList();
}
// 需与后续查询在同一连接中生效(外层事务内调用)
baseMapper.setGroupConcatMaxLen(GROUP_CONCAT_MAX_LEN);
return baseMapper.queryUserDeptIds(userIds);
}
@@ -18,9 +18,6 @@
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.service.impl;
@@ -28,7 +25,6 @@ package org.springblade.system.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.cache.utils.CacheUtil;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.redis.cache.BladeRedis;
import org.springblade.core.secure.utils.AuthUtil;
@@ -37,21 +33,21 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.core.tool.utils.StringUtil;
import org.springblade.resource.feign.ISmsClient;
import org.springblade.resource.utils.SmsUtil;
import org.springblade.system.cache.MpUserCache;
import org.springblade.system.pojo.dto.PhoneChangeDTO;
import org.springblade.system.pojo.dto.PhoneVerifyDTO;
import org.springblade.system.pojo.entity.User;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.service.IMpUserService;
import org.springblade.system.service.IUserPhoneService;
import org.springblade.system.service.IUserService;
import org.springblade.system.utils.MpUserAuth;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.regex.Pattern;
import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE;
/**
* 用户手机号变更服务实现
* 小程序用户手机号变更服务实现(写入 blade_mp_user)
*
* @author Chill
*/
@@ -68,24 +64,22 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
/**
* 原手机号已校验凭证(Redis)
*/
private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:user:phone:change:verified:";
private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:mp-user:phone:change:verified:";
private static final Duration PHONE_CHANGE_VERIFIED_TTL = Duration.ofMinutes(15);
private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
private final IUserService userService;
private final IMpUserService mpUserService;
private final ISmsClient smsClient;
private final BladeRedis bladeRedis;
@Override
public R sendCode(String phone) {
assertMpLogin();
String normalizedPhone = normalizePhone(phone);
Long userId = AuthUtil.getUserId();
if (Func.isEmpty(userId)) {
throw new ServiceException("请先登录");
}
User currentUser = requireCurrentUser(userId);
MpUser currentUser = requireCurrentUser(userId);
String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId());
boolean isCurrentPhone = StringUtil.equals(normalizedPhone, Func.toStr(currentUser.getPhone()));
if (!isCurrentPhone) {
@@ -100,16 +94,14 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
@Override
public boolean verifyOldPhone(PhoneVerifyDTO phoneVerify) {
assertMpLogin();
Long userId = AuthUtil.getUserId();
if (Func.isEmpty(userId)) {
throw new ServiceException("请先登录");
}
String id = Func.toStr(phoneVerify.getId()).trim();
String code = Func.toStr(phoneVerify.getCode()).trim();
if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) {
throw new ServiceException("请先获取并填写验证码");
}
User currentUser = requireCurrentUser(userId);
MpUser currentUser = requireCurrentUser(userId);
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
if (StringUtil.isBlank(oldPhone)) {
throw new ServiceException("当前账号未绑定手机号");
@@ -122,10 +114,8 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changePhone(PhoneChangeDTO phoneChange) {
assertMpLogin();
Long userId = AuthUtil.getUserId();
if (Func.isEmpty(userId)) {
throw new ServiceException("请先登录");
}
String verified = Func.toStr(bladeRedis.get(PHONE_CHANGE_VERIFIED_KEY + userId));
if (!StringUtil.equals(verified, "1")) {
throw new ServiceException("请先完成原手机号验证");
@@ -136,7 +126,7 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) {
throw new ServiceException("请先获取并填写验证码");
}
User currentUser = requireCurrentUser(userId);
MpUser currentUser = requireCurrentUser(userId);
String oldPhone = Func.toStr(currentUser.getPhone()).trim();
if (StringUtil.equals(newPhone, oldPhone)) {
throw new ServiceException("新手机号不可与当前手机号相同");
@@ -145,27 +135,49 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
assertPhoneAvailable(tenantId, newPhone, userId);
validateSms(tenantId, id, code, newPhone);
User updateUser = new User();
MpUser updateUser = new MpUser();
updateUser.setId(userId);
updateUser.setTenantId(tenantId);
updateUser.setPhone(newPhone);
updateUser.setName(currentUser.getName());
updateUser.setRealName(currentUser.getRealName());
updateUser.setIdentities(currentUser.getIdentities());
updateUser.setSex(currentUser.getSex());
updateUser.setDeptId(currentUser.getDeptId());
updateUser.setPostId(currentUser.getPostId());
updateUser.setAvatar(currentUser.getAvatar());
updateUser.setEmail(currentUser.getEmail());
updateUser.setRemark(currentUser.getRemark());
updateUser.setStatus(currentUser.getStatus());
// 账号若等于原手机号,同步更新,保证短信登录可用
if (StringUtil.isNotBlank(oldPhone) && StringUtil.equals(oldPhone, Func.toStr(currentUser.getAccount()))) {
assertAccountAvailable(tenantId, newPhone, userId);
updateUser.setAccount(newPhone);
} else {
updateUser.setAccount(currentUser.getAccount());
}
boolean updated = userService.updateById(updateUser);
boolean updated = mpUserService.updateUser(updateUser);
if (!updated) {
throw new ServiceException("手机号修改失败");
}
bladeRedis.del(PHONE_CHANGE_VERIFIED_KEY + userId);
CacheUtil.clear(USER_CACHE);
MpUserCache.clear();
return true;
}
private User requireCurrentUser(Long userId) {
User user = userService.getById(userId);
private void assertMpLogin() {
if (Func.isEmpty(AuthUtil.getUserId())) {
throw new ServiceException("请先登录");
}
if (!MpUserAuth.isMpUser()) {
throw new ServiceException("仅支持小程序用户修改手机号");
}
}
private MpUser requireCurrentUser(Long userId) {
MpUser user = mpUserService.getById(userId);
if (user == null) {
throw new ServiceException("用户不存在");
throw new ServiceException("小程序用户不存在");
}
return user;
}
@@ -178,11 +190,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
}
private void assertPhoneAvailable(String tenantId, String phone, Long excludeUserId) {
Long phoneCount = userService.count(
Wrappers.<User>lambdaQuery()
.eq(User::getTenantId, tenantId)
.eq(User::getPhone, phone)
.ne(User::getId, excludeUserId)
Long phoneCount = mpUserService.count(
Wrappers.<MpUser>lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getPhone, phone)
.ne(MpUser::getId, excludeUserId)
);
if (phoneCount != null && phoneCount > 0L) {
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", phone));
@@ -190,11 +202,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService {
}
private void assertAccountAvailable(String tenantId, String account, Long excludeUserId) {
Long accountCount = userService.count(
Wrappers.<User>lambdaQuery()
.eq(User::getTenantId, tenantId)
.eq(User::getAccount, account)
.ne(User::getId, excludeUserId)
Long accountCount = mpUserService.count(
Wrappers.<MpUser>lambdaQuery()
.eq(MpUser::getTenantId, tenantId)
.eq(MpUser::getAccount, account)
.ne(MpUser::getId, excludeUserId)
);
if (accountCount != null && accountCount > 0L) {
throw new ServiceException(StringUtil.format("当前用户 [{}] 已存在!", account));
@@ -133,8 +133,12 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
@Override
public IPage<UserVO> selectPage(Map<String, Object> user, Query query) {
String account = Func.toStr(user.remove("account")).trim();
String realName = Func.toStr(user.remove("realName")).trim();
String phone = Func.toStr(user.remove("phone")).trim();
QueryWrapper<User> queryWrapper = Condition.getQueryWrapper(user, User.class);
applyTenantScope(queryWrapper);
applyUserFuzzyFilters(queryWrapper, account, realName, phone);
return UserWrapper.build().pageVO(page(Condition.getPage(query), queryWrapper));
}
@@ -335,6 +339,23 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
return submitUserInfo(user) && submitUserDept(user) && submitUserScopes(user);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean changeUserStatus(Long userId, Integer status) {
if (Func.isEmpty(userId)) {
throw new ServiceException("用户主键不能为空");
}
if (!Objects.equals(status, DataStatusEnum.ENABLE.getCode()) && !Objects.equals(status, 0)
&& !Objects.equals(status, DataStatusEnum.DISABLE.getCode())) {
throw new ServiceException("启停状态不正确");
}
TenantGuard.verify(this, userId, USER);
Integer targetStatus = Objects.equals(status, DataStatusEnum.ENABLE.getCode())
? DataStatusEnum.ENABLE.getCode() : 0;
CacheUtil.clear(USER_CACHE);
return changeStatus(Collections.singletonList(userId), targetStatus);
}
@Override
public boolean updateUserInfo(User user) {
// 用户修改自身信息强制指定当前请求账号的ID
@@ -360,7 +381,14 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone()));
}
user.setPassword(null);
return updateById(user);
Integer originalSex = user.getSex();
Integer normalizedSex = normalizeUserSex(originalSex);
user.setSex(normalizedSex);
boolean updated = updateById(user);
if (user.getId() != null && originalSex != null && normalizedSex == null) {
this.update(Wrappers.<User>lambdaUpdate().set(User::getSex, null).eq(User::getId, user.getId()));
}
return updated;
}
private boolean submitUserDept(User user) {
@@ -378,6 +406,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
@Override
public IPage<User> selectUserPage(IPage<User> page, User user, Long deptId, String tenantId) {
normalizeUserSearch(user);
List<Long> deptIdList = SysCache.getDeptChildIds(deptId);
return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId));
}
@@ -713,8 +742,12 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
@Override
public List<UserExcel> exportUser(Map<String, Object> user) {
String account = Func.toStr(user.remove("account")).trim();
String realName = Func.toStr(user.remove("realName")).trim();
String phone = Func.toStr(user.remove("phone")).trim();
QueryWrapper<User> queryWrapper = Condition.getQueryWrapper(user, User.class);
applyTenantScope(queryWrapper);
applyUserFuzzyFilters(queryWrapper, account, realName, phone);
queryWrapper.lambda().eq(User::getIsDeleted, BladeConstant.DB_NOT_DELETED);
return exportUser(queryWrapper);
}
@@ -1077,6 +1110,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone()));
}
CacheUtil.clear(USER_CACHE);
user.setSex(normalizeUserSex(user.getSex()));
return save(user) && submitUserDept(user) && submitUserScopes(user);
}
@@ -1087,6 +1121,16 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
if (user.getIncludeNewCustomer() == null) user.setIncludeNewCustomer(0);
}
/**
* 性别仅保留男 / 女 / 未知;空值或历史占位 -1 不回填,按空处理。
*/
private Integer normalizeUserSex(Integer sex) {
if (Objects.equals(sex, 1) || Objects.equals(sex, 2) || Objects.equals(sex, 3)) {
return sex;
}
return null;
}
private boolean submitUserScopes(User user) {
userDataScopeMapper.delete(Wrappers.<UserDataScope>lambdaQuery().eq(UserDataScope::getUserId, user.getId()));
userCustomerScopeMapper.delete(Wrappers.<UserCustomerScope>lambdaQuery().eq(UserCustomerScope::getUserId, user.getId()));
@@ -1127,25 +1171,81 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
}
}
/**
* 归一化用户列表搜索条件,避免首尾空格导致模糊匹配失效。
*/
private void normalizeUserSearch(User user) {
if (user == null) {
return;
}
if (user.getAccount() != null) {
user.setAccount(user.getAccount().trim());
}
if (user.getRealName() != null) {
user.setRealName(user.getRealName().trim());
}
if (user.getPhone() != null) {
user.setPhone(user.getPhone().trim());
}
}
/**
* 账号名、姓名、手机号统一按模糊匹配过滤。
*/
private void applyUserFuzzyFilters(QueryWrapper<User> queryWrapper, String account, String realName, String phone) {
queryWrapper.lambda().like(StringUtil.isNotBlank(account), User::getAccount, account);
if (StringUtil.isNotBlank(realName)) {
queryWrapper.lambda().and(wrapper -> wrapper.like(User::getRealName, realName).or().like(User::getName, realName));
}
queryWrapper.lambda().like(StringUtil.isNotBlank(phone), User::getPhone, phone);
}
@Override
public void decryptPhone(IPhone user) {
// if (user == null) {
// return;
// }
// if (user.getPhone() != null && user.getPhone().length() > 20) {
// user.setPhone(SM2Util.decrypt(user.getPhone(), properties.getPublicKey(), properties.getPrivateKey()));
// }
if (user == null) {
return;
}
String phone = user.getPhone();
// 密文 Hex 通常远长于普通手机号;含 / 等非 Hex 字符的明文(如双号)直接跳过
if (StringUtil.isBlank(phone) || phone.length() <= 20 || !isSm2HexCipher(phone)) {
return;
}
try {
user.setPhone(SM2Util.decrypt(phone, properties.getPublicKey(), properties.getPrivateKey()));
} catch (Exception exception) {
log.warn("手机号 SM2 解密失败,保留原值 length={}", phone.length(), exception);
}
}
/**
* 判断是否为 SM2 密文 Hex(仅 0-9a-f,且偶数长度)
*/
private boolean isSm2HexCipher(String value) {
int length = value.length();
if ((length & 1) != 0) {
return false;
}
for (int index = 0; index < length; index++) {
char character = value.charAt(index);
boolean isDigit = character >= '0' && character <= '9';
boolean isLowerHex = character >= 'a' && character <= 'f';
boolean isUpperHex = character >= 'A' && character <= 'F';
if (!isDigit && !isLowerHex && !isUpperHex) {
return false;
}
}
return true;
}
@Override
public void encryptPhone(IPhone user) {
// if (user == null) {
// return;
// }
// if(StringUtil.isNotBlank(user.getPhone())) {
// user.setPhone(Hex.toHexString(SM2Util.encrypt(user.getPhone(),properties.getPublicKey())));
// }
if (user == null) {
return;
}
if (StringUtil.isNotBlank(user.getPhone())) {
user.setPhone(Hex.toHexString(SM2Util.encrypt(user.getPhone(), properties.getPublicKey())));
}
}
private void validatePlainPassword(String password) {
@@ -0,0 +1,97 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.util;
import org.apache.commons.lang3.StringUtils;
import org.springblade.thirdparty.oa.constant.OAConvertConstant;
import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 组织编码工具:上级编码直接拼接固定 3 位分段数字(不足补零,中间不加 "-")
*
* @author Chill
*/
public final class DeptCodeUtil {
/**
* 分段数字位数
*/
public static final int SEGMENT_WIDTH = 3;
private DeptCodeUtil() {
}
/**
* 生成子级编码:上级编码 + 三位数字
*/
public static String formatChildCode(String parentCode, int serial) {
return parentCode + String.format("%0" + SEGMENT_WIDTH + "d", serial);
}
/**
* 是否符合「上级编码 + 三位数字」格式(恰好多出 3 位数字)
*/
public static boolean matchesChildCode(String parentCode, String deptCode) {
if (StringUtils.isBlank(parentCode) || StringUtils.isBlank(deptCode)) {
return false;
}
return deptCode.matches("^" + Pattern.quote(parentCode) + "\\d{" + SEGMENT_WIDTH + "}$");
}
/**
* 是否为公司编码(GWWL + 三位数字)
*/
public static boolean isCompanyCode(String deptCode) {
return matchesChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, deptCode);
}
/**
* 在已有编码中取下一分段序号(从 1 起)
*/
public static int nextSerial(String parentCode, Collection<String> existingCodes) {
if (StringUtils.isBlank(parentCode)) {
return 1;
}
Pattern pattern = Pattern.compile("^" + Pattern.quote(parentCode) + "(\\d{" + SEGMENT_WIDTH + "})$");
int maxSerial = 0;
if (existingCodes != null) {
for (String code : existingCodes) {
if (StringUtils.isBlank(code)) {
continue;
}
Matcher matcher = pattern.matcher(code.trim());
if (!matcher.matches()) {
continue;
}
maxSerial = Math.max(maxSerial, Integer.parseInt(matcher.group(1)));
}
}
return maxSerial + 1;
}
}
@@ -48,6 +48,7 @@ public class AirportMasterWrapper extends BaseEntityWrapper<AirportMaster, Airpo
public AirportMasterVO entityVO(AirportMaster airportMaster) {
AirportMasterVO airportMasterVO = Objects.requireNonNull(BeanUtil.copyProperties(airportMaster, AirportMasterVO.class));
airportMasterVO.setRegionCode(airportMaster.getDistrictCode());
airportMasterVO.setCreateUserName(UserCache.getUserRealName(airportMaster.getCreateUser()));
airportMasterVO.setUpdateUserName(UserCache.getUserRealName(airportMaster.getUpdateUser()));
return airportMasterVO;
}
@@ -30,10 +30,9 @@ import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.node.ForestNodeMerger;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.DictBizCache;
import org.springblade.system.cache.SysCache;
import org.springblade.system.pojo.entity.Dept;
import org.springblade.system.pojo.enums.DictEnum;
import org.springblade.system.pojo.vo.DeptVO;
import java.util.List;
@@ -47,6 +46,11 @@ import java.util.stream.Collectors;
*/
public class DeptWrapper extends BaseEntityWrapper<Dept, DeptVO> {
/**
* 组织类型业务字典编码
*/
private static final String ORGANIZATION_TYPE_CODE = "organization_type";
public static DeptWrapper build() {
return new DeptWrapper();
}
@@ -60,7 +64,7 @@ public class DeptWrapper extends BaseEntityWrapper<Dept, DeptVO> {
Dept parent = SysCache.getDept(dept.getParentId());
deptVO.setParentName(parent.getDeptName());
}
String category = DictCache.getValue(DictEnum.ORG_CATEGORY, dept.getDeptCategory());
String category = DictBizCache.getValue(ORGANIZATION_TYPE_CODE, dept.getDeptCategory());
deptVO.setDeptCategoryName(category);
return deptVO;
}
@@ -69,7 +73,7 @@ public class DeptWrapper extends BaseEntityWrapper<Dept, DeptVO> {
public List<DeptVO> listNodeVO(List<Dept> list) {
List<DeptVO> collect = list.stream().map(dept -> {
DeptVO deptVO = BeanUtil.copyProperties(dept, DeptVO.class);
String category = DictCache.getValue(DictEnum.ORG_CATEGORY, dept.getDeptCategory());
String category = DictBizCache.getValue(ORGANIZATION_TYPE_CODE, dept.getDeptCategory());
Objects.requireNonNull(deptVO).setDeptCategoryName(category);
return deptVO;
}).collect(Collectors.toList());
@@ -78,7 +82,7 @@ public class DeptWrapper extends BaseEntityWrapper<Dept, DeptVO> {
public List<DeptVO> listNodeLazyVO(List<DeptVO> list) {
List<DeptVO> collect = list.stream().peek(dept -> {
String category = DictCache.getValue(DictEnum.ORG_CATEGORY, dept.getDeptCategory());
String category = DictBizCache.getValue(ORGANIZATION_TYPE_CODE, dept.getDeptCategory());
Objects.requireNonNull(dept).setDeptCategoryName(category);
}).collect(Collectors.toList());
return ForestNodeMerger.merge(collect);
@@ -52,6 +52,7 @@ public class FeeItemWrapper extends BaseEntityWrapper<FeeItem, FeeItemVO> {
feeItemVO.setCreateDeptName(
Func.isEmpty(feeItem.getCreateDept()) ? "" : SysCache.getDeptName(feeItem.getCreateDept())
);
feeItemVO.setCreateUserName(UserCache.getUserRealName(feeItem.getCreateUser()));
feeItemVO.setUpdateUserName(UserCache.getUserRealName(feeItem.getUpdateUser()));
return feeItemVO;
}
@@ -0,0 +1,78 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.system.wrapper;
import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.DictCache;
import org.springblade.system.cache.UserCache;
import org.springblade.system.pojo.entity.MpUser;
import org.springblade.system.pojo.enums.DictEnum;
import org.springblade.system.pojo.enums.MpUserIdentity;
import org.springblade.system.pojo.vo.MpUserVO;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 小程序用户包装类
*
* @author Chill
*/
public class MpUserWrapper extends BaseEntityWrapper<MpUser, MpUserVO> {
public static MpUserWrapper build() {
return new MpUserWrapper();
}
@Override
public MpUserVO entityVO(MpUser mpUser) {
MpUserVO mpUserVO = Objects.requireNonNull(BeanUtil.copyProperties(mpUser, MpUserVO.class));
// 密码哈希不下发前端
mpUserVO.setPassword(null);
mpUserVO.setIdentitiesName(resolveIdentitiesName(mpUser.getIdentities()));
if (mpUser.getSex() != null) {
mpUserVO.setSexName(DictCache.getValue(DictEnum.SEX, mpUser.getSex()));
}
mpUserVO.setCreateUserName(UserCache.getUserRealName(mpUser.getCreateUser()));
mpUserVO.setUpdateUserName(UserCache.getUserRealName(mpUser.getUpdateUser()));
return mpUserVO;
}
private String resolveIdentitiesName(String identities) {
List<String> identityList = MpUserIdentity.split(identities);
if (identityList.isEmpty()) {
return "";
}
return identityList.stream()
.map(code -> {
MpUserIdentity identity = MpUserIdentity.of(code);
return identity == null ? code : identity.getLabel();
})
.filter(Func::isNotBlank)
.collect(Collectors.joining(","));
}
}
@@ -48,6 +48,7 @@ public class PortTerminalWrapper extends BaseEntityWrapper<PortTerminal, PortTer
public PortTerminalVO entityVO(PortTerminal portTerminal) {
PortTerminalVO portTerminalVO = Objects.requireNonNull(BeanUtil.copyProperties(portTerminal, PortTerminalVO.class));
portTerminalVO.setRegionCode(portTerminal.getDistrictCode());
portTerminalVO.setCreateUserName(UserCache.getUserRealName(portTerminal.getCreateUser()));
portTerminalVO.setUpdateUserName(UserCache.getUserRealName(portTerminal.getUpdateUser()));
return portTerminalVO;
}
@@ -48,6 +48,7 @@ public class RailwayStationWrapper extends BaseEntityWrapper<RailwayStation, Rai
public RailwayStationVO entityVO(RailwayStation railwayStation) {
RailwayStationVO railwayStationVO = Objects.requireNonNull(BeanUtil.copyProperties(railwayStation, RailwayStationVO.class));
railwayStationVO.setRegionCode(railwayStation.getDistrictCode());
railwayStationVO.setCreateUserName(UserCache.getUserRealName(railwayStation.getCreateUser()));
railwayStationVO.setUpdateUserName(UserCache.getUserRealName(railwayStation.getUpdateUser()));
return railwayStationVO;
}
@@ -38,6 +38,7 @@ import org.springblade.system.pojo.enums.DictEnum;
import org.springblade.system.pojo.vo.UserVO;
import java.util.List;
import java.util.Objects;
/**
* 包装类,返回视图层所需的字段
@@ -81,7 +82,11 @@ public class UserWrapper extends BaseEntityWrapper<User, UserVO> {
userVO.setPostId(StringPool.EMPTY);
userVO.setPostName("暂未分配");
}
userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex()));
if (user.getSex() == null || Objects.equals(user.getSex(), -1)) {
userVO.setSexName("");
} else {
userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex()));
}
userVO.setUserTypeName(DictCache.getValue(DictEnum.USER_TYPE, user.getUserType()));
return userVO;
}
+9
View File
@@ -68,6 +68,15 @@
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<!-- 测试:本模块此前的 src/test 下只有 main() 手跑脚本,没有可自动执行的测试。
认证改造的提交/审核链路带状态机语义(同对象至多一条待审、提交不得改生效快照
等),必须用可重复执行的单元测试守住,故引入 JUnit 5 与 Mockito。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -31,6 +31,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 业财融合(BFI)推送金蝶配置属性,统一收口 Nacos 配置 thirdParty.bfi.*:
* <p>
@@ -67,6 +70,68 @@ public class BfiProperties {
*/
private ReceivedInvoice receivedInvoice = new ReceivedInvoice();
/**
* 收款认领公告查询配置(A301-2)
*/
private ClaimAnnounce claimAnnounce = new ClaimAnnounce();
/**
* 付款结果定时回写配置(A103-2/A103-4,总开关同时控制应收勾稽、开票结果回写)
*/
private PayResult payResult = new PayResult();
/**
* 收票单结果定时回写开关(A801-3)
*/
private ReceivedInvoiceResult receivedInvoiceResult = new ReceivedInvoiceResult();
/**
* 收款认领推送配置(A301-3)
*/
private ReceiptClaimPush receiptClaimPush = new ReceiptClaimPush();
/**
* 全票池发票定时同步配置(A801-1)
*/
private InvoiceReceipt invoiceReceipt = new InvoiceReceipt();
/**
* 客商金蝶数据定时同步配置(A902-1/A902-2)
*/
private Customer customer = new Customer();
/**
* 金蝶黑名单查询配置
*/
private Blacklist blacklist = new Blacklist();
/**
* 开票结果回写锁配置(A303-5)
*/
private InvoiceResult invoiceResult = new InvoiceResult();
/**
* 应收单勾稽回写锁配置(A302-4)
*/
private ArResult arResult = new ArResult();
/**
* 金蝶单据锁定补偿配置
*/
private BillLock billLock = new BillLock();
/**
* 审批通过自动同步金蝶配置(结算单/付款申请/开票申请/收票登记)
*/
private AutoSync autoSync = new AutoSync();
/**
* 应付/应收/收票单分录物料映射配置(A102-2/A302-2/A303-2 共用):
* 运输类按项目业务模式映射——国内运输按运单运输方式映射、国际运输映射国际货代服务;
* 装卸大类及手动添加的非运费杂费映射装卸物料
*/
private Material material = new Material();
/**
* 付款申请推送配置项
*/
@@ -114,6 +179,245 @@ public class BfiProperties {
* 真实物料口径与金蝶对齐后替换(与应收/应付联调临时值同源)
*/
private String materialNumber = "SP0324";
/**
* A801-3 推送幂等预检回看窗口天数(金蝶修改时间窗口,契约约束不超过 1 个月)
*/
private int queryWindowDays = 30;
/**
* A801-3 定时回写轮询窗口天数(同样受金蝶 1 个月窗口约束)
*/
private int resultWindowDays = 30;
/**
* A801-3 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(批量远程查询 + 逐单回写)
*/
private long lockLeaseSeconds = 1800;
}
/**
* 收款认领公告查询配置项(A301-2)
*/
@Data
public static class ClaimAnnounce {
/**
* 查询时间窗口(月数),默认 6(近半年)
*/
private int months = 6;
/**
* 认领人(claimusers):配置后公告查询固定使用该值,不再按当前登录用户姓名查询
*/
private String claimusers;
}
/**
* 分录物料映射配置项(thirdparty.bfi.material.*):默认值与金蝶测试档案对齐,
* 金蝶正式档案确认后直接在 Nacos 覆盖
*/
@Data
public static class Material {
/**
* 国内运输:运单运输方式 -> 金蝶物料编码(公路运输/铁路运输/水路运输/航空运输四种),
* 运输方式未配置映射时直接拦截报错
*/
private Map<String, String> domestic = new LinkedHashMap<>() {{
put("公路运输", "XDWL11010001");
put("铁路运输", "XDWL11010002");
put("水路运输", "XDWL11010003");
put("航空运输", "Item-00001082");
}};
/**
* 国际运输业务模式物料编码(国际货运代理服务)
*/
private String international = "Item-00001081";
/**
* 装卸大类、手动添加非运费杂费物料编码(装卸)
*/
private String loading = "XDWL11002004";
}
/**
* 付款结果回写配置项(A103-2/A103-4)
*/
@Data
public static class PayResult {
/**
* 回写任务开关,代码默认关闭(安全兜底),由 Nacos 按环境显式开启
*/
private boolean syncEnabled = false;
/**
* 回写 single-flight 锁租约秒数,须大于最坏一轮耗时(分批远程查询 + 逐单回写)
*/
private long lockLeaseSeconds = 1800;
}
/**
* 收票单结果回写配置项(A801-3)
*/
@Data
public static class ReceivedInvoiceResult {
/**
* 回写任务开关,默认关闭(联调稳定后开启)
*/
private boolean syncEnabled = false;
}
/**
* 收款认领推送配置项(A301-3)
*/
@Data
public static class ReceiptClaimPush {
/**
* 推送任务开关,默认关闭(联调稳定后开启)
*/
private boolean syncEnabled = false;
/**
* 推送 single-flight 锁租约秒数,须大于最坏一轮耗时(逐认领人远程查公告 + 逐条推送)
*/
private long lockLeaseSeconds = 1800;
}
/**
* 全票池发票同步配置项(A801-1)
*/
@Data
public static class InvoiceReceipt {
/**
* 同步任务开关,默认关闭(上线先手动跑一次全窗口铺底)
*/
private boolean syncEnabled = false;
/**
* 增量窗口天数(按票池更新时间滚动)
*/
private int incrementDays = 30;
/**
* 同步发票种类白名单(逗号分隔票易通编码,如 26,27 数电普票/专票),为空不限制
*/
private String invoiceTypes = "";
/**
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时;进程异常退出时锁在租约到期后自动释放
*/
private long lockLeaseSeconds = 1800;
}
/**
* 客商金蝶数据同步配置项(A902-1/A902-2)
*/
@Data
public static class Customer {
/**
* 同步任务开关,默认关闭
*/
private boolean syncEnabled = false;
/**
* 同步 single-flight 锁租约秒数,须大于最坏一轮耗时(复查 + 批量推送 + 停用联动)
*/
private long lockLeaseSeconds = 1800;
}
/**
* 金蝶黑名单查询配置项
*/
@Data
public static class Blacklist {
/**
* 客商提审命中风险警示类(B)时是否一并拦截;默认 false 仅放行告警,禁止交易类(A)始终拦截
*/
private boolean blockRiskWarning = false;
}
/**
* 开票结果回写锁配置项(A303-5)
*/
@Data
public static class InvoiceResult {
/**
* 回写 single-flight 锁租约秒数
*/
private long lockLeaseSeconds = 1800;
}
/**
* 应收单勾稽回写锁配置项(A302-4)
*/
@Data
public static class ArResult {
/**
* 勾稽 single-flight 锁租约秒数
*/
private long lockLeaseSeconds = 1800;
}
/**
* 金蝶单据锁定补偿配置项
*/
@Data
public static class BillLock {
/**
* 锁定/解锁总开关(thirdParty.bfi.billLock.enabled):推送后锁定 lockBusiData、
* 金蝶解锁回调 unLockNotifyBusiData、锁定补偿重锁三处共用;
* 默认开启保持既有锁定行为,Nacos 置 false 一键停用
* (推送后不再锁单、解锁回调只应答不处理、补偿跳过),存量锁记录守卫(不可编辑校验)不受影响
*/
private boolean enabled = true;
/**
* 锁定补偿开关,默认关闭(安全兜底)
*/
private boolean retryEnabled = false;
}
/**
* 审批通过自动同步金蝶配置项(结算单 A102-2/A302-2、付款申请 A103-1/A103-3、开票申请 A303-1、收票 A303-2+A302-6)
*/
@Data
public static class AutoSync {
/**
* 结算单审批后自动推送开关
*/
private boolean settlement = false;
/**
* 付款申请审批后自动推送开关
*/
private boolean payment = false;
/**
* 开票申请审批后自动推送开关
*/
private boolean invoiceApply = false;
/**
* 收票登记审批后自动推送开关
*/
private boolean receipt = false;
/**
* failed 单据定时重推开关(仅重推生成失败,不扫 unsynced 存量)
*/
private boolean retryEnabled = false;
}
}
@@ -0,0 +1,58 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.config;
import org.springblade.core.tool.utils.Func;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.InitBinder;
import java.beans.PropertyEditorSupport;
/**
* 列表查询 deptId 绑定:用户可能绑定多个组织,前端会传入逗号分隔字符串。
* 组织筛选项为单选,这里取第一个有效 ID,避免 Long 类型转换失败。
*
* @author Chill
*/
@ControllerAdvice(basePackages = "org.springblade.transport.controller")
public class DeptIdBindingAdvice {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(Long.class, "deptId", new PropertyEditorSupport() {
@Override
public void setAsText(String text) {
if (Func.isBlank(text)) {
setValue(null);
return;
}
setValue(Func.firstLong(text));
}
});
}
}
@@ -5,28 +5,109 @@
package org.springblade.transport.config;
import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.StringUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.URI;
import java.util.concurrent.TimeUnit;
/**
* 凭证图片 MinIO 客户端配置。
* <p>
* 连接参数由 Nacos 的 file.storage.minio 配置提供。
* 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端,
* 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动;
* 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。
* <ul>
* <li>{@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回</li>
* <li>{@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名</li>
* </ul>
*/
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint")
public class VoucherMinioConfig {
private static final String DEFAULT_REGION = "us-east-1";
@Bean
public MinioClient voucherMinioClient(
@Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint,
@Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey,
@Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) {
return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build();
String origin = resolveClientEndpoint(endpoint);
String pathPrefix = resolvePathPrefix(endpoint);
if (StringUtil.isNotBlank(pathPrefix)) {
log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址",
origin, pathPrefix);
}
return create(endpoint, accessKey, secretKey);
}
public static MinioClient create(String endpoint, String accessKey, String secretKey) {
String origin = resolveClientEndpoint(endpoint);
String pathPrefix = resolvePathPrefix(endpoint);
MinioClient.Builder builder = MinioClient.builder()
.endpoint(origin)
.credentials(accessKey, secretKey)
.region(DEFAULT_REGION);
if (StringUtil.isNotBlank(pathPrefix)) {
builder.httpClient(buildHttpClient(pathPrefix), true);
}
return builder.build();
}
/**
* MinIO SDK 不允许 endpoint 带 path(例如 https://host/oss)。
*/
public static String resolveClientEndpoint(String endpoint) {
if (StringUtil.isBlank(endpoint)) {
throw new ServiceException("MinIO endpoint 不能为空");
}
URI uri = URI.create(endpoint.trim());
if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) {
throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint);
}
StringBuilder origin = new StringBuilder();
origin.append(uri.getScheme()).append("://").append(uri.getHost());
if (uri.getPort() > 0) {
origin.append(':').append(uri.getPort());
}
return origin.toString();
}
private static String resolvePathPrefix(String endpoint) {
URI uri = URI.create(endpoint.trim());
String path = uri.getPath();
if (StringUtil.isBlank(path) || "/".equals(path)) {
return "";
}
String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
return normalized.startsWith("/") ? normalized : "/" + normalized;
}
private static OkHttpClient buildHttpClient(String pathPrefix) {
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(chain -> {
Request original = chain.request();
HttpUrl originalUrl = original.url();
String encodedPath = originalUrl.encodedPath();
if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) {
return chain.proceed(original);
}
HttpUrl newUrl = originalUrl.newBuilder()
.encodedPath(pathPrefix + encodedPath)
.build();
return chain.proceed(original.newBuilder().url(newUrl).build());
})
.build();
}
}
@@ -0,0 +1,176 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
import org.springblade.core.log.exception.ServiceException;
/**
* 金蝶附件同步(A902)常量
* <p>entityNumber/controlKey 映射以常量维护,二期客商(资质附件)等金蝶确认后补充条目即可,不改架构</p>
*
* @author Chill
*/
public class KingdeeAttachmentConstant {
// ==================== 业务单据类型(biz_type) ====================
/**
* 付款申请单
*/
public static final String BIZ_PAY_APPLICATION = "pay_application";
/**
* 正式结算-财务应付单面板
*/
public static final String BIZ_FORMAL_SETTLEMENT_AP = "formal_settlement_ap";
/**
* 正式结算-财务应收单面板
*/
public static final String BIZ_FORMAL_SETTLEMENT_AR = "formal_settlement_ar";
/**
* 收票单
*/
public static final String BIZ_INVOICE_RECEIPT = "invoice_receipt";
/**
* 开票申请单
*/
public static final String BIZ_INVOICE_APPLICATION = "invoice_application";
// ==================== 星瀚 entityNumber(A902 定位单据类型) ====================
/**
* 付款申请单
*/
public static final String ENTITY_PAY_APPLICATION = "ap_payapply";
/**
* 财务应付单(正式结算应付面板)
*/
public static final String ENTITY_AP_BILL = "ap_finapbill";
/**
* 财务应收单(正式结算应收面板)
*/
public static final String ENTITY_AR_BILL = "ar_finarbill";
/**
* 收票单
*/
public static final String ENTITY_INVOICE = "ap_invoice";
/**
* 开票申请单
*/
public static final String ENTITY_INVOICE_APPLICATION = "sim_original_bill";
/**
* 业务单据类型 -> 星瀚 entityNumber 映射;
* 客商(资质附件)二期接入:待金蝶确认后补充专用 biz_type 与 entityNumber 条目
*/
public static final Map<String, String> ENTITY_NUMBERS = Map.of(
BIZ_PAY_APPLICATION, ENTITY_PAY_APPLICATION,
BIZ_FORMAL_SETTLEMENT_AP, ENTITY_AP_BILL,
BIZ_FORMAL_SETTLEMENT_AR, ENTITY_AR_BILL,
BIZ_INVOICE_RECEIPT, ENTITY_INVOICE,
BIZ_INVOICE_APPLICATION, ENTITY_INVOICE_APPLICATION
);
/**
* 星瀚附件面板/附件字段标识(各单据统一)
*/
public static final String CONTROL_KEY = "attachmentpanel";
// ==================== 限制 ====================
/**
* 单文件软限制 20MB(待金蝶确认后调整);超限记失败原因跳过,不阻塞同单其余附件
*/
public static final long MAX_FILE_SIZE_BYTES = 20L * 1024 * 1024;
// ==================== 同步行状态(sync_status) ====================
/**
* 同步失败
*/
public static final int SYNC_STATUS_FAILED = 0;
/**
* 同步成功(终态,幂等跳过)
*/
public static final int SYNC_STATUS_SUCCESS = 1;
// ==================== 附件同步聚合态(列表展示,运行时聚合非存储字段) ====================
/**
* 无附件:单据 attachmentsJson 无可上传附件
*/
public static final String AGGREGATE_NONE = "none";
/**
* 未上传:有附件且成功数为 0
*/
public static final String AGGREGATE_PENDING = "pending";
/**
* 部分未传:有附件且部分上传成功
*/
public static final String AGGREGATE_PARTIAL = "partial";
/**
* 已上传:有附件且全部上传成功
*/
public static final String AGGREGATE_DONE = "done";
/**
* 聚合态 -> 展示名称
*/
public static final Map<String, String> AGGREGATE_NAMES = Map.of(
AGGREGATE_NONE, "无附件",
AGGREGATE_PENDING, "未上传",
AGGREGATE_PARTIAL, "部分未传",
AGGREGATE_DONE, "已上传"
);
/**
* 业务单据类型对应星瀚 entityNumber
*
* @param bizType 业务单据类型
* @return entityNumber,未配置时抛业务异常(entityNumber 是硬约束,猜错即传不上)
*/
public static String entityNumber(String bizType) {
String entityNumber = ENTITY_NUMBERS.get(bizType);
if (entityNumber == null) {
throw new ServiceException("金蝶附件上传失败:业务单据类型[" + bizType + "]未配置 entityNumber 映射");
}
return entityNumber;
}
/**
* 聚合态展示名称(未知态兜底返回原文)
*
* @param aggregateStatus 聚合态
* @return 展示名称
*/
public static String aggregateName(String aggregateStatus) {
return AGGREGATE_NAMES.getOrDefault(aggregateStatus, aggregateStatus);
}
private KingdeeAttachmentConstant() {
}
}
@@ -25,6 +25,7 @@
*/
package org.springblade.transport.constant;
import java.util.List;
import java.util.Map;
/**
@@ -58,27 +59,6 @@ public final class KingdeeBfiConstant {
/** 联调默认项目编码:金蝶测试档案值,项目档案与金蝶对齐后替换。 */
public static final String BFI_AP_BILL_PROJECT_NUMBER_TEST = "999";
/** 应付/应收单物料编码:装卸大类费用映射物料。 */
public static final String BFI_MATERIAL_LOADING = "装卸";
/** 应付/应收单物料编码:国际运输业务模式、国际货物运输代理服务项目业务类型映射物料。 */
public static final String BFI_MATERIAL_INTERNATIONAL = "国际货运代理服务费";
/** 应付/应收单物料编码:国内道路货物运输服务前缀,拼接运单运输方式(如 国内道路货物运输服务-公路运输)。 */
public static final String BFI_MATERIAL_DOMESTIC_PREFIX = "国内道路货物运输服务-";
/** 金蝶物料编码映射:物料名称 -> 金蝶物料编码 */
public static final Map<String, String> BFI_MATERIAL_NUMBER_MAP = Map.ofEntries(
Map.entry("国际货运代理服务费", "Item-00001081"),
Map.entry("物流配送", "Item-00001080"),
Map.entry("国内道路货物运输服务-公路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-水路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-铁路运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-航空运输", "XDWL11010001"),
Map.entry("国内道路货物运输服务-多式联运", "XDWL11010001"),
Map.entry("装卸", "XDWL11010002")
);
/** 付款类型编码:正式付款(结算付款)。 */
public static final String BFI_PAYMENT_TYPE_FORMAL = "201";
@@ -140,12 +120,30 @@ public final class KingdeeBfiConstant {
/** A303-1 上游来源单据类型:财务应收单(awrp_src_bt)。 */
public static final String BFI_AR_BILL_SRC_BT = "ar_finarbill";
/** A301-3 认领推送核心单据类型:其他应付(e_corebilltype)。 */
public static final String BFI_CLAIM_CORE_BILL_TYPE = "ar_finarbill";
/** A303-1 发票种类:数电发票(普通发票)。 */
public static final String BFI_INVOICE_TYPE_DIGITAL_GENERAL = "10xdp";
/** A303-1 发票种类:数电发票(增值税专用发票)。 */
public static final String BFI_INVOICE_TYPE_DIGITAL_SPECIAL = "08xdp";
/** A303-1 特殊票种:货物运输(运费/货物运输类发票)。 */
public static final String BFI_INVOICE_SPECIAL_TYPE_FREIGHT = "E04";
/** A303-1 特殊票种:非特殊票种(其余费用类发票,如其他、非货物运输的运输类)。 */
public static final String BFI_INVOICE_SPECIAL_TYPE_NONE = "00";
/** A303-1 特殊票种货物运输判定关键字:命中商品名称或商品和服务分类简称即视为货物运输类。 */
public static final List<String> BFI_INVOICE_FREIGHT_KEYWORDS = List.of("货物运输", "运费");
/** A303-1 货物运输单据体运输工具种类合法值:字典 transport_type 翻译名称命中则直送,否则兜底其它运输工具。 */
public static final List<String> BFI_FREIGHT_TRANSPORT_TYPES = List.of("铁路运输", "公路运输", "水路运输", "航空运输", "管道运输", "其它运输工具");
/** A303-1 货物运输单据体运输工具种类兜底值。 */
public static final String BFI_FREIGHT_TRANSPORT_TYPE_OTHER = "其它运输工具";
/** 金蝶同步状态:开票完成(A303-5 查到发票,终态,开票申请专用)。 */
public static final String KINGDEE_STATUS_INVOICED = "invoiced";
@@ -207,13 +205,22 @@ public final class KingdeeBfiConstant {
public static final String POOL_INVOICE_STATUS_NORMAL = "0";
/** A301-3 认领推送:收款类型.编码(示例 101,待金蝶确认后如有调整直接改此常量)。 */
public static final String BFI_CLAIM_REC_PAY_TYPE = "101";
public static final String BFI_CLAIM_REC_PAY_TYPE = "999";
/** A301-3 认领推送:付款人类型(默认客户 bd_customer,待金蝶确认后如有调整直接改此常量)。 */
public static final String BFI_CLAIM_PAYMENT_TYPE = BFI_CUSTOMER_ASSTACT_TYPE;
public static final String BFI_CLAIM_PAYMENT_TYPE = "bd_customer";
/** A301-3 认领推送来源单据编码前缀:SKCL + yyyyMMddHHmmss + 认领id尾号,作 A301-1 回写匹配键。 */
public static final String BFI_CLAIM_SRC_CODE_PREFIX = "SKCL";
/** 收款流水推送状态:未推送(未满足推送条件或尚未轮到推送)。 */
public static final String RECEIPT_CLAIM_PUSH_NONE = "none";
/** 收款流水推送状态:已推送(终态闸门,不允许二次推送)。 */
public static final String RECEIPT_CLAIM_PUSH_PUSHED = "pushed";
/** 收款流水推送状态:推送失败(本地认领保留,可重新推送,重试复用同一来源编码)。 */
public static final String RECEIPT_CLAIM_PUSH_FAILED = "failed";
/** 收款流水金蝶回写状态:未推送(认领完成推送前的初始状态)。 */
public static final String RECEIPT_CLAIM_BILL_NONE = "none";
/** 金蝶同步状态:认领结果已推送(A301-3 成功,等待金蝶收款单审核/收款,可继续回写)。 */
public static final String KINGDEE_STATUS_PUSHED = "pushed";
@@ -0,0 +1,91 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.constant;
import java.util.Map;
/**
* 金蝶单据锁定/解锁(lockBusiData / unLockNotifyBusiData)固定值。
* <p>lockBusiData 由 WLTMS 调金蝶(源系统 WLTMS、目标系统 XH),
* unLockNotifyBusiData 由金蝶调 WLTMS(源系统 XH、目标系统 WLTMS);
* WLTMS 单据类型自定义,金蝶目标单据类型为固定值</p>
*
* @author Chill
*/
public final class KingdeeLockConstant {
/** 锁动作:锁定 */
public static final String ACTION_LOCK = "LOCK";
/** 锁动作:解锁 */
public static final String ACTION_UNLOCK = "UNLOCK";
/** 来源操作类型:单据新增 */
public static final String OPERATION_SAVE = "SAVE";
/** 来源操作类型:单据删除(解锁回调携带 DELETE 时 WLTMS 废除源单并还原关联数据) */
public static final String OPERATION_DELETE = "DELETE";
/** 系统标识:WLTMS(运输管理系统,即本系统) */
public static final String SYSTEM_WLTMS = "WLTMS";
/** 系统标识:金蝶星瀚 XH */
public static final String SYSTEM_XH = "XH";
/** 锁状态:锁定失败(补偿定时任务的重试对象) */
public static final String STATUS_FAILED = "failed";
/** 锁状态:金蝶锁定中(源单冻结,不可编辑/作废) */
public static final String STATUS_LOCKED = "locked";
/** 锁状态:已解锁(终态;同单重推生成新锁记录) */
public static final String STATUS_UNLOCKED = "unlocked";
/** WLTMS 单据类型:正式结算单(应付/应收共用,按结算单 settlementType 区分金蝶目标类型) */
public static final String SOURCE_SETTLEMENT = "SETTLEMENT";
/** WLTMS 单据类型:开票申请单 */
public static final String SOURCE_INVOICE_APPLY = "INVOICE_APPLY";
/** WLTMS 单据类型:付款申请单(映射预留,本期不锁) */
public static final String SOURCE_PAY_APPLY = "PAY_APPLY";
/** WLTMS 单据类型:收票单(映射预留,本期不锁) */
public static final String SOURCE_RECEIVED_INVOICE = "RECEIVED_INVOICE";
/** 金蝶目标单据类型:财务应付单(A102-2,应付结算单推送后锁定) */
public static final String TARGET_AP_BILL = "ApFin_other_BT_S";
/** 金蝶目标单据类型:财务应收单(A302-2,应收结算单推送后锁定) */
public static final String TARGET_AR_BILL = "arfin_other_BT_S";
/** 金蝶目标单据类型:开票单(A303-1 开票申请单,按发票张次逐张一行锁记录) */
public static final String TARGET_INVOICE_BILL = "cdm_paybill_bank_elect_BT_S";
/** 金蝶目标单据类型:付款申请单(映射预留,本期不锁) */
public static final String TARGET_PAY_APPLY = "ap_payapply_oth_BT_S";
/** 金蝶目标单据类型:收票单(映射预留,本期不锁) */
public static final String TARGET_RECEIVED_INVOICE = "cdm_recbill_bank_BT_S";
/** 锁定重试上限:补偿任务超过该次数不再自动重试,人工排查(失败原因见 fail_reason) */
public static final int RETRY_MAX_COUNT = 5;
/** 金蝶目标单据类型 -> WLTMS 单据类型反查映射(解锁回调按 targetBillType 分发废除还原用) */
public static final Map<String, String> TARGET_TO_SOURCE_MAP = Map.ofEntries(
Map.entry(TARGET_AP_BILL, SOURCE_SETTLEMENT),
Map.entry(TARGET_AR_BILL, SOURCE_SETTLEMENT),
Map.entry(TARGET_INVOICE_BILL, SOURCE_INVOICE_APPLY),
Map.entry(TARGET_PAY_APPLY, SOURCE_PAY_APPLY),
Map.entry(TARGET_RECEIVED_INVOICE, SOURCE_RECEIVED_INVOICE)
);
private KingdeeLockConstant() {
}
}
@@ -128,10 +128,28 @@ public class AccidentRecordController extends BladeController {
@ApiOperationSupport(order = 5)
@Operation(summary = "导入事故记录", description = "传入excel")
public R importAccidentRecord(MultipartFile file, HttpServletResponse response) {
List<AccidentRecordExcel> failureList = accidentRecordService.importAccidentRecord(ExcelUtil.read(file, AccidentRecordExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "事故记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, AccidentRecordExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
accidentRecordService.importAccidentRecord(ExcelUtil.read(file, AccidentRecordExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红),用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "事故记录导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), AccidentRecordExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "事故记录导入失败明细" + DateUtil.time(), "导入失败明细", file, AccidentRecordExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -125,10 +125,28 @@ public class AnnualInspectionRecordController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "导入年检记录", description = "传入excel")
public R importAnnualInspectionRecord(MultipartFile file, HttpServletResponse response) {
List<AnnualInspectionRecordExcel> failureList = annualInspectionRecordService.importAnnualInspectionRecord(ExcelUtil.read(file, AnnualInspectionRecordExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "年检记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, AnnualInspectionRecordExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
annualInspectionRecordService.importAnnualInspectionRecord(ExcelUtil.read(file, AnnualInspectionRecordExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红),用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "年检记录导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), AnnualInspectionRecordExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "年检记录导入失败明细" + DateUtil.time(), "导入失败明细", file, AnnualInspectionRecordExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -0,0 +1,103 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.ICertAuditService;
import org.springblade.transport.support.CertAuditPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证审核(管理端)
* <p>
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
* 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖,
* 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/certification-audit", "/blade-transport/certification-audit"})
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
public class CertificationAuditController extends BladeController {
private final ICertAuditService certAuditService;
@GetMapping("/pending-page")
@ApiOperationSupport(order = 1)
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)")
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
public R<IPage<TransportCertRecordVO>> pendingPage(
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
@RequestParam String bizType,
@Parameter(description = "展示键模糊匹配:提交时的司机姓名 / 车牌号")
@RequestParam(required = false) String bizKey,
@Parameter(description = "司机姓名模糊匹配:当前姓名,同时兼顾提交时的旧名")
@RequestParam(required = false) String driverName,
@Parameter(description = "司机手机号模糊匹配:支持只输后四位")
@RequestParam(required = false) String driverMobile,
@Parameter(description = "审核状态:0审核中 1已通过 2已驳回 3已作废;不传按0(审核中)处理")
@RequestParam(required = false) Integer status,
Query query) {
return R.data(certAuditService.pendingPage(bizType, bizKey, driverName, driverMobile, status, Condition.getPage(query)));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
public R<CertAuditDetailVO> detail(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
return R.data(certAuditService.detail(id));
}
@PostMapping("/audit")
@ApiOperationSupport(order = 3)
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
public R<Boolean> audit(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
}
}
@@ -33,11 +33,9 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.log.exception.ServiceException;
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.utils.AuthUtil;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
@@ -46,6 +44,7 @@ import org.springblade.transport.pojo.entity.CommonAddress;
import org.springblade.transport.pojo.vo.CommonAddressRemoveResultVO;
import org.springblade.transport.pojo.vo.CommonAddressVO;
import org.springblade.transport.service.ICommonAddressService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -54,7 +53,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Objects;
/**
* 常用地址 控制器
@@ -164,15 +162,9 @@ public class CommonAddressController extends BladeController {
if (Func.isNotEmpty(ids)) {
queryWrapper.in(CommonAddress::getId, Func.toLongList(ids));
}
if (Objects.equals(commonAddress.getAllDept(), 1)) {
if (!AuthUtil.isAdministrator()) {
throw new ServiceException("无权导出全部组织常用地址");
}
} else {
queryWrapper.eq(CommonAddress::getDeptId, currentDeptId());
}
if (Objects.equals(commonAddress.getAllDept(), 1) && Func.isNotEmpty(commonAddress.getDeptId())) {
queryWrapper.eq(CommonAddress::getDeptId, commonAddress.getDeptId());
List<Long> deptIdList = TransportBusinessSupport.resolveDeptFilterIds(commonAddress.getAllDept(), commonAddress.getDeptId(), "常用地址");
if (Func.isNotEmpty(deptIdList)) {
queryWrapper.eq(CommonAddress::getDeptId, deptIdList.get(0));
}
if (Func.isNotEmpty(commonAddress.getAddressName())) {
queryWrapper.like(CommonAddress::getAddressName, commonAddress.getAddressName().trim());
@@ -198,12 +190,4 @@ public class CommonAddressController extends BladeController {
return queryWrapper;
}
private Long currentDeptId() {
Long deptId = Func.firstLong(AuthUtil.getDeptId());
if (Func.isEmpty(deptId) || deptId <= 0) {
throw new ServiceException("当前用户所属组织为空,无法导出常用地址");
}
return deptId;
}
}
@@ -38,9 +38,9 @@ import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.transport.excel.CommonCargoExcel;
import org.springblade.transport.excel.CommonCargoExportExcel;
import org.springblade.transport.excel.CommonCargoImportFailureExcel;
import org.springframework.web.multipart.MultipartFile;
import org.springblade.transport.pojo.entity.CommonCargo;
import org.springblade.transport.pojo.vo.BusinessRemoveResultVO;
@@ -110,14 +110,41 @@ public class CommonCargoController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "导入常用货物", description = "传入excel")
public R importCommonCargo(MultipartFile file, HttpServletResponse response) {
List<CommonCargoImportFailureExcel> failureList = commonCargoService.importCommonCargo(ExcelUtil.read(file, CommonCargoExcel.class));
if (Func.isNotEmpty(failureList)) {
ImportFailureExcelUtil.export(response, "常用货物导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CommonCargoImportFailureExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
commonCargoService.importCommonCargo(ExcelUtil.read(file, CommonCargoExcel.class));
} catch (ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
exportFailure(response, exception.getFailureList());
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "常用货物导入失败明细" + DateUtil.time(), "导入失败明细", file, CommonCargoExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("导入数据成功");
}
/**
* 导出导入失败明细,内容为原表全部数据并在末尾追加失败原因列。
* <p>
* 失败数据仅标红出错单元格与失败原因列,表头保持默认样式。
*/
private void exportFailure(HttpServletResponse response, List<?> failureList) {
ImportFailureExcelUtil.exportFailureReasonOnly(response, "常用货物导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CommonCargoExcel.class);
}
@GetMapping("/export-template")
@ApiOperationSupport(order = 7)
@Operation(summary = "导出模板")
@@ -38,6 +38,7 @@ import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.common.excel.ImportFailureException;
import org.springblade.transport.excel.CommonRouteExportExcel;
import org.springblade.transport.excel.CommonRouteImportExcel;
import org.springblade.transport.pojo.entity.CommonRoute;
@@ -109,18 +110,34 @@ public class CommonRouteController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "导入常用线路", description = "传入excel")
public R importCommonRoute(MultipartFile file, HttpServletResponse response) {
List<CommonRouteImportExcel> failureList = commonRouteService.importCommonRoute(
ExcelUtil.read(file, CommonRouteImportExcel.class)
);
if (Func.isNotEmpty(failureList)) {
ImportFailureExcelUtil.export(
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
commonRouteService.importCommonRoute(ExcelUtil.read(file, CommonRouteImportExcel.class));
} catch (ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
ImportFailureExcelUtil.exportFailureReasonOnly(
response,
"常用线路导入失败明细" + DateUtil.time(),
"导入失败明细",
failureList,
exception.getFailureList(),
CommonRouteImportExcel.class
);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "常用线路导入失败明细" + DateUtil.time(), "导入失败明细", file, CommonRouteImportExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("导入数据成功");
}
@@ -177,8 +177,16 @@ public class ContractManageController extends BladeController {
return R.data(contractManageService.expireStats(contractManage));
}
@GetMapping("/export-contract-manage")
@GetMapping("/next-contract-no")
@ApiOperationSupport(order = 16)
@Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览")
public R<String> nextContractNo(
@Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) {
return R.data(contractManageService.nextContractNo(organizationId));
}
@GetMapping("/export-contract-manage")
@ApiOperationSupport(order = 17)
@Operation(summary = "导出合同管理")
public void exportContractManage(ContractManageVO contractManage, @RequestParam(required = false) String ids, HttpServletResponse response) {
List<ContractManageExcel> list = contractManageService.exportContractManage(contractManage, ids);
@@ -138,10 +138,28 @@ public class CreditScoreQuantificationController extends BladeController {
@ApiOperationSupport(order = 7)
@Operation(summary = "导入评分量化表", description = "传入excel")
public R importCreditScoreQuantification(MultipartFile file, HttpServletResponse response) {
List<CreditScoreQuantificationExcel> failureList = creditScoreQuantificationService.importCreditScoreQuantification(ExcelUtil.read(file, CreditScoreQuantificationExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "评分量化表导入失败明细" + DateUtil.time(), "导入失败明细", failureList, CreditScoreQuantificationExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
creditScoreQuantificationService.importCreditScoreQuantification(ExcelUtil.read(file, CreditScoreQuantificationExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一分组失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.exportFailureReasonOnly(response, "评分量化表导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), CreditScoreQuantificationExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "评分量化表导入失败明细" + DateUtil.time(), "导入失败明细", file, CreditScoreQuantificationExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -38,6 +38,7 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
@@ -220,10 +221,12 @@ public class CustomerArchiveController extends BladeController {
LambdaQueryWrapper<CustomerArchive> queryWrapper = Wrappers.<CustomerArchive>lambdaQuery()
.eq(CustomerArchive::getIsDeleted, 0)
.orderByDesc(CustomerArchive::getCreateTime);
Long userId = AuthUtil.getUserId();
queryWrapper.and(wrapper -> wrapper
.exists("SELECT 1 FROM blade_user_customer_scope bucs WHERE bucs.user_id = " + userId
+ " AND bucs.customer_id = blade_customer_archive.id"));
if (!TransportBusinessSupport.isAdministrator()) {
Long userId = AuthUtil.getUserId();
queryWrapper.and(wrapper -> wrapper
.exists("SELECT 1 FROM blade_user_customer_scope bucs WHERE bucs.user_id = " + userId
+ " AND bucs.customer_id = blade_customer_archive.id"));
}
if (Func.isNotEmpty(ids)) {
queryWrapper.in(CustomerArchive::getId, Func.toLongList(ids));
}
@@ -30,12 +30,16 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.log.exception.ServiceException;
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.TenantIgnore;
import org.springblade.core.tool.api.FR;
import org.springblade.core.tool.api.R;
import org.springblade.process.feign.IBusinessProcessClient;
import org.springblade.process.pojo.vo.MkFormBindVO;
import org.springblade.transport.pojo.vo.CustomerArchiveVO;
import org.springblade.transport.pojo.vo.CustomerChangeRecordVO;
import org.springblade.transport.service.ICustomerArchiveService;
@@ -65,15 +69,34 @@ public class CustomerArchivePublicController {
private final ICustomerArchiveService customerArchiveService;
private final CustomerArchivePublicProcessService customerArchivePublicProcessService;
private final IBusinessProcessClient businessProcessClient;
/**
* 公开详情
*/
@GetMapping("/detail")
@ApiOperationSupport(order = 1)
@Operation(summary = "公开详情", description = "传入id,无需登录")
public R<CustomerArchiveVO> detail(@Parameter(description = "主键", required = true) @RequestParam Long id) {
return R.data(customerArchiveService.publicDetail(id));
@Operation(summary = "公开详情", description = "传入表单实例id,无需登录;内部反查业务主键后加载详情")
public R<CustomerArchiveVO> detail(@Parameter(description = "表单实例id", required = true) @RequestParam Long id) {
return R.data(customerArchiveService.publicDetail(resolveBizId(id)));
}
/**
* 公开页传入的 id 为 formInstanceId,反查业务主键;查不到时兼容历史(id 即 bizId)
*/
private Long resolveBizId(Long formInstanceId) {
if (formInstanceId == null) {
throw new ServiceException("表单实例id不能为空");
}
try {
FR<MkFormBindVO> result = businessProcessClient.resolveByFormInstanceId(formInstanceId);
if (result != null && result.isSuccess() && result.getData() != null && result.getData().getBizId() != null) {
return result.getData().getBizId();
}
} catch (Exception e) {
log.warn("按表单实例id解析客商业务主键失败,回退按业务主键查询 formInstanceId={}", formInstanceId, e);
}
return formInstanceId;
}
/**
@@ -0,0 +1,153 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.tool.api.R;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.ocr.service.IBaiduOcrService;
import org.springblade.transport.pojo.dto.OcrRecognizeRequest;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.IDriverCertService;
import org.springblade.transport.support.OcrTypeMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 司机端认证(小程序)
* <p>
* 对外路径:{@code /api/blade-transport/driver/auth/**}
* 同时兼容未去前缀直连 {@code /blade-transport/driver/auth/**}。
* <p>
* 提交审核**不影响**该对象当前的可用性:待审内容只落认证记录,
* 审核通过前对外呈现的仍是旧的生效值。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/driver/auth", "/blade-transport/driver/auth"})
@Tag(name = "司机端认证", description = "小程序司机个人认证与车辆认证")
public class DriverAuthController extends BladeController {
private final IDriverCertService driverCertService;
private final IBaiduOcrService baiduOcrService;
// ------------------------------------------------------------ 个人认证
@PostMapping("/person")
@ApiOperationSupport(order = 1)
@Operation(summary = "提交个人认证", description = "三证(身份证+驾驶证+从业资格证)一起提交,生成待审记录")
public R<Long> submitPerson(@RequestBody PersonCertSubmitRequest request) {
return R.data(driverCertService.submitPersonCert(request));
}
@GetMapping("/person")
@ApiOperationSupport(order = 2)
@Operation(summary = "个人认证生效值", description = "返回当前生效快照的内容;从未认证通过时 data 为 null")
public R<PersonCertSubmitRequest> person() {
return R.data(driverCertService.getEffectivePersonCert());
}
// ------------------------------------------------------------ 车辆认证
@PostMapping("/vehicle")
@ApiOperationSupport(order = 3)
@Operation(summary = "提交车辆认证", description = "生成待审记录;不改变该车已生效的认证内容")
public R<Long> submitVehicle(@RequestBody VehicleCertSubmitRequest request) {
return R.data(driverCertService.submitVehicleCert(request));
}
@PutMapping("/vehicle")
@ApiOperationSupport(order = 4)
@Operation(summary = "重新认证车辆", description = "与提交同一语义,语义上用于已有车辆重新认证")
public R<Long> updateVehicle(@RequestBody VehicleCertSubmitRequest request) {
return R.data(driverCertService.submitVehicleCert(request));
}
@DeleteMapping("/vehicle/{id}")
@ApiOperationSupport(order = 5)
@Operation(summary = "删除车辆认证", description = "对车辆做逻辑删除;认证记录保留,不物理删除")
public R<Boolean> deleteVehicle(@PathVariable Long id) {
return R.status(driverCertService.deleteVehicleCert(id));
}
@GetMapping("/vehicle/effective")
@ApiOperationSupport(order = 5)
@Operation(summary = "车辆认证生效值", description = "返回该车当前生效快照的内容")
public R<VehicleCertSubmitRequest> vehicleEffective(@RequestParam Long vehicleId) {
return R.data(driverCertService.getEffectiveVehicleCert(vehicleId));
}
// ------------------------------------------------------------ 认证记录
@GetMapping("/record/list")
@ApiOperationSupport(order = 6)
@Operation(summary = "认证记录列表", description = "按提交时间倒序;只能查本人或本人绑定车辆的记录")
public R<List<TransportCertRecordVO>> recordList(@RequestParam String bizType,
@RequestParam Long bizId) {
return R.data(driverCertService.listRecords(bizType, bizId));
}
@GetMapping("/record/detail")
@ApiOperationSupport(order = 7)
@Operation(summary = "认证记录详情", description = "内容取自该次提交的完整快照")
public R<TransportCertRecordVO> recordDetail(@RequestParam Long id) {
return R.data(driverCertService.getRecord(id));
}
// ------------------------------------------------------------ OCR
@PostMapping("/ocr")
@ApiOperationSupport(order = 8)
@Operation(summary = "证件OCR识别",
description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。"
+ "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证")
public R<BaiduOcrResultVO> ocr(@RequestBody OcrRecognizeRequest request) {
// 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游
BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType());
// 未指定时传 null,不要在这里补 front:道路运输证没有正副面参数,
// 补了会变成「传了 side 但该证件不支持」,让这条链路恒失败。
// 「默认取正面」由知晓证件类型的一侧(OCR 服务)决定。
String side = OcrTypeMapping.normalizeSide(request.getSide());
return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl()));
}
}
@@ -38,6 +38,7 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IDriverWaybillService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -64,14 +65,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/current-task")
@ApiOperationSupport(order = 1)
@Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单")
@Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单")
public R<DriverWaybillCardVO> currentTask() {
return R.data(driverWaybillService.currentTask());
}
@GetMapping("/pending-preview")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数")
@Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数")
public R<DriverWaybillPreviewVO> pendingPreview(
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
return R.data(driverWaybillService.pendingPreview(size));
@@ -79,14 +80,14 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/counts")
@ApiOperationSupport(order = 3)
@Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成")
@Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成")
public R<DriverWaybillTabCountsVO> counts() {
return R.data(driverWaybillService.tabCounts());
}
@GetMapping("/page")
@ApiOperationSupport(order = 4)
@Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成")
@Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成")
public R<IPage<DriverWaybillCardVO>> page(
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
@@ -104,6 +105,13 @@ public class DriverWaybillController extends BladeController {
return R.data(driverWaybillService.detail(id));
}
@PostMapping("/track")
@ApiOperationSupport(order = 11)
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
public R<WaybillTrackVO> track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(driverWaybillService.track(id));
}
@PostMapping("/accept")
@ApiOperationSupport(order = 6)
@Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中")
@@ -90,10 +90,28 @@ public class EquipmentLedgerController extends BladeController {
@ApiOperationSupport(order = 6)
@Operation(summary = "导入设备台账")
public R importEquipmentLedger(MultipartFile file, HttpServletResponse response) {
List<EquipmentLedgerExcel> failureList = equipmentLedgerService.importEquipmentLedger(ExcelUtil.read(file, EquipmentLedgerExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "设备台账导入失败明细" + DateUtil.time(), "导入失败明细", failureList, EquipmentLedgerExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
equipmentLedgerService.importEquipmentLedger(ExcelUtil.read(file, EquipmentLedgerExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "设备台账导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), EquipmentLedgerExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "设备台账导入失败明细" + DateUtil.time(), "导入失败明细", file, EquipmentLedgerExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -94,10 +94,28 @@ public class EtcRecordController extends BladeController {
@ApiOperationSupport(order = 5)
@Operation(summary = "导入ETC记录", description = "传入excel")
public R importEtcRecord(MultipartFile file, HttpServletResponse response) {
List<EtcRecordExcel> failureList = etcRecordService.importEtcRecord(ExcelUtil.read(file, EtcRecordExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "ETC记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, EtcRecordExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
etcRecordService.importEtcRecord(ExcelUtil.read(file, EtcRecordExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红),用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "ETC记录导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), EtcRecordExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "ETC记录导入失败明细" + DateUtil.time(), "导入失败明细", file, EtcRecordExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -30,11 +30,14 @@ import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
import org.springblade.transport.service.IExceptionDisposalService;
import org.springblade.transport.support.ExceptionDisposalPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController {
return R.success("批量完成成功");
}
@PostMapping("/update")
@PreAuth(ExceptionDisposalPermissions.EDIT)
@ApiOperationSupport(order = 7)
@Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态")
public R update(@RequestBody ExceptionDisposalUpdateRequest request) {
exceptionDisposalService.update(request);
return R.success("编辑成功");
}
}
@@ -22,6 +22,7 @@ import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
import org.springblade.transport.pojo.dto.FormalSettlementPaymentRequest;
import org.springblade.transport.pojo.dto.FormalSettlementBatchPaymentRequest;
@@ -30,12 +31,13 @@ import org.springblade.transport.pojo.dto.FormalSettlementInvoiceClaimRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.dto.PreSettlementDetailAdjustRequest;
import org.springblade.transport.pojo.entity.FormalSettlementDetailFee;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
import org.springblade.transport.pojo.vo.FormalSettlementVO;
import org.springblade.transport.excel.FormalSettlementExcel;
import org.springblade.transport.pojo.vo.PreSettlementVO;
import org.springblade.transport.service.IFormalSettlementService;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springblade.transport.service.IPreSettlementService;
import org.springblade.transport.service.IReceiptFlowService;
import org.springframework.web.bind.annotation.GetMapping;
@@ -62,6 +64,7 @@ import java.math.RoundingMode;
@Tag(name = "正式结算单", description = "正式结算单管理")
public class FormalSettlementController extends BladeController {
private final IFormalSettlementService formalSettlementService;
private final IKingdeeBillLockService kingdeeBillLockService;
private final IPreSettlementService preSettlementService;
private final IReceiptFlowService receiptFlowService;
private final IInvoiceReceiptService invoiceReceiptService;
@@ -194,7 +197,19 @@ public class FormalSettlementController extends BladeController {
@PostMapping("/approve")
@ApiOperationSupport(order = 11)
@Operation(summary = "审批通过")
public R approve(@RequestBody FormalSettlementStatusRequest request) { formalSettlementService.approve(request); return R.success("审批通过"); }
public R approve(@RequestBody FormalSettlementStatusRequest request) {
formalSettlementService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
return R.success("审批通过");
}
@PostMapping("/return")
@ApiOperationSupport(order = 12)
@@ -216,6 +231,13 @@ public class FormalSettlementController extends BladeController {
@Operation(summary = "手动勾稽金蝶应收单(A302-4 回写纠偏)")
public R<String> syncKingdeeArResult() { return R.data(formalSettlementService.syncKingdeeArResult()); }
@PostMapping("/retry-kingdee-bill-lock")
@ApiOperationSupport(order = 17)
@Operation(summary = "手动补偿金蝶单据锁定(扫锁定失败锁记录重调lockBusiData,应付/应收/开票通用)")
public R<String> retryKingdeeBillLock() {
return R.data(kingdeeBillLockService.retryFailedLocks());
}
@GetMapping("/detail-fees")
@ApiOperationSupport(order = 16)
@Operation(summary = "正式结算货物费用快照")
@@ -262,8 +284,8 @@ public class FormalSettlementController extends BladeController {
@GetMapping("/invoice-pool")
@ApiOperationSupport(order = 21)
@Operation(summary = "金蝶票池实时分页查询(认领查询)")
public R<KingdeeInvoicePoolPageVO> invoicePool(KingdeeInvoicePoolQueryDTO query) {
@Operation(summary = "金蝶票据池镜像分页查询(认领查询)")
public R<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
}
@@ -129,10 +129,28 @@ public class InsuranceRecordController extends BladeController {
@ApiOperationSupport(order = 5)
@Operation(summary = "导入保险记录", description = "传入excel")
public R importInsuranceRecord(MultipartFile file, HttpServletResponse response) {
List<InsuranceRecordExcel> failureList = insuranceRecordService.importInsuranceRecord(ExcelUtil.read(file, InsuranceRecordExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "保险记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, InsuranceRecordExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
insuranceRecordService.importInsuranceRecord(ExcelUtil.read(file, InsuranceRecordExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红),用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "保险记录导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), InsuranceRecordExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "保险记录导入失败明细" + DateUtil.time(), "导入失败明细", file, InsuranceRecordExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -35,6 +35,7 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.InvoiceApplicationSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceApplicationStatusRequest;
import org.springblade.transport.pojo.entity.FormalSettlementDetail;
@@ -131,6 +132,15 @@ public class InvoiceApplicationController extends BladeController {
@Operation(summary = "审批通过开票申请")
public R approve(@RequestBody InvoiceApplicationStatusRequest request) {
invoiceApplicationService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
return R.success("审批通过");
}
@@ -29,17 +29,19 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.listener.KingdeeApprovalSyncListener;
import org.springblade.transport.pojo.dto.InvoiceReceiptSaveRequest;
import org.springblade.transport.pojo.dto.InvoiceReceiptStatusRequest;
import org.springblade.transport.pojo.dto.KingdeeInvoicePoolQueryDTO;
import org.springblade.transport.pojo.vo.InvoiceReceiptVO;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolPageVO;
import org.springblade.transport.pojo.vo.KingdeeInvoicePoolVO;
import org.springblade.transport.service.IInvoiceReceiptService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -81,11 +83,26 @@ public class InvoiceReceiptController extends BladeController {
@GetMapping("/invoice-pool")
@ApiOperationSupport(order = 3)
@Operation(summary = "金蝶票池实时分页查询(含认领/登记标记)")
public R<KingdeeInvoicePoolPageVO> invoicePool(KingdeeInvoicePoolQueryDTO query) {
@Operation(summary = "金蝶票据池镜像分页查询(含认领/登记标记)")
public R<IPage<KingdeeInvoicePoolVO>> invoicePool(KingdeeInvoicePoolQueryDTO query) {
return R.data(invoiceReceiptService.invoicePoolPage(query));
}
@PostMapping("/sync-invoice-pool")
@ApiOperationSupport(order = 13)
@Operation(summary = "手动触发金蝶全票池发票同步(窗口拉取+镜像全列upsert)")
public R<String> syncInvoicePool() {
return R.data(invoiceReceiptService.syncKingdeeInvoicePool());
}
@GetMapping("/invoice-pool/attachment/download")
@ApiOperationSupport(order = 17)
@Operation(summary = "金蝶票池发票文件下载(A801-2 流式回源)",
description = "按发票号定位金蝶侧文件:本地镜像优先,镜像未落库实时回源 A801-1;成功返回文件流,失败返回 JSON 错误")
public void downloadPoolAttachment(@RequestParam String invoiceNo, HttpServletResponse response) {
invoiceReceiptService.downloadPoolAttachment(invoiceNo, response);
}
@GetMapping("/settlement-candidates")
@ApiOperationSupport(order = 4)
@Operation(summary = "可关联的应付正式结算单")
@@ -130,6 +147,15 @@ public class InvoiceReceiptController extends BladeController {
@Operation(summary = "审批通过收票登记")
public R approve(@RequestBody InvoiceReceiptStatusRequest request) {
invoiceReceiptService.approve(request);
return approveResponse();
}
/** 审批接口统一响应:审批已生效,自动推送失败原因以警告文案返回(data=false 供前端区分提示样式) */
private R approveResponse() {
String syncFailure = KingdeeApprovalSyncListener.pollSyncFailure();
if (syncFailure != null) {
return R.data(Boolean.FALSE, "审批通过,但自动同步金蝶失败:" + syncFailure);
}
return R.success("审批通过");
}
@@ -0,0 +1,65 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement obtained after purchasing a license from BladeX.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.alibaba.fastjson2.JSON;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.tenant.annotation.TenantIgnore;
import org.springblade.thirdparty.bfi.pojo.dto.BfiBillLockDTO;
import org.springblade.thirdparty.bfi.pojo.vo.BfiBillLockResultVO;
import org.springblade.transport.service.IKingdeeBillLockService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 金蝶单据锁定回调 控制器
* <p>
* unLockNotifyBusiData:金蝶(星瀚)侧解锁/删单后回调 WLTMS 送结果,
* 无登录态公开端点(模式同 CustomerArchivePublicController);
* operationType=DELETE 时 WLTMS 废除源单并还原关联数据。
* 应答与 lockBusiData 出参同构:{updateCount, updateResultList},逐单处理(1-成功,0-未匹配/失败)
*
* @author Chill
*/
@Slf4j
@RestController
@AllArgsConstructor
@TenantIgnore
// @PreAuth(AuthConstant.PERMIT_ALL)
@RequestMapping("kingdee-bfi")
@Tag(name = "金蝶单据锁定回调", description = "金蝶星瀚调用:单据解锁/删单通知")
public class KingdeeBfiPublicController {
private final IKingdeeBillLockService kingdeeBillLockService;
/**
* 单据解锁通知(unLockNotifyBusiData):多张单据编号逗号拼接批量回调,逐张处理
*
* @param param 金蝶回调参数
* @return 处理结果,与 lockBusiData 出参同构
*/
@PostMapping("unLockNotifyBusiData")
@Operation(summary = "单据解锁通知", description = "金蝶侧解锁/删单后回调;operationType=DELETE 时废除源单并还原关联数据")
public BfiBillLockResultVO unLockNotifyBusiData(@RequestBody BfiBillLockDTO param) {
log.info("金蝶单据解锁回调:{}", JSON.toJSONString(param));
try {
return BfiBillLockResultVO.of(kingdeeBillLockService.onUnlockNotify(param));
} catch (Exception exception) {
log.error("金蝶单据解锁回调处理失败:lockAction={},operationType={},targetBillType={},targetBillNo={}",
param == null ? null : param.getLockAction(), param == null ? null : param.getOperationType(),
param == null ? null : param.getTargetBillType(), param == null ? null : param.getTargetBillNo(), exception);
return BfiBillLockResultVO.of(0);
}
}
}
@@ -128,10 +128,28 @@ public class MaintenancePlanController extends BladeController {
@ApiOperationSupport(order = 5)
@Operation(summary = "导入保养记录", description = "传入excel")
public R importMaintenancePlan(MultipartFile file, HttpServletResponse response) {
List<MaintenancePlanExcel> failureList = maintenancePlanService.importMaintenancePlan(ExcelUtil.read(file, MaintenancePlanExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "保养记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, MaintenancePlanExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
maintenancePlanService.importMaintenancePlan(ExcelUtil.read(file, MaintenancePlanExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "保养计划导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), MaintenancePlanExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "保养计划导入失败明细" + DateUtil.time(), "导入失败明细", file, MaintenancePlanExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}
@@ -128,10 +128,28 @@ public class MaintenanceRecordController extends BladeController {
@ApiOperationSupport(order = 5)
@Operation(summary = "导入维修记录", description = "传入excel")
public R importMaintenanceRecord(MultipartFile file, HttpServletResponse response) {
List<MaintenanceRecordExcel> failureList = maintenanceRecordService.importMaintenanceRecord(ExcelUtil.read(file, MaintenanceRecordExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "维修记录导入失败明细" + DateUtil.time(), "导入失败明细", failureList, MaintenanceRecordExcel.class);
if (file == null || file.isEmpty()) {
return R.fail("上传文件不能为空");
}
String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase();
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
try {
maintenanceRecordService.importMaintenanceRecord(ExcelUtil.read(file, MaintenanceRecordExcel.class));
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红),用户修正后重新导入。
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "维修记录导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), MaintenanceRecordExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
// EasyExcel 在 ExcelUtil.read 阶段即抛异常,走不到行级校验。
// 此处用原始文本重读原表,导出「全量行 + 出错行原因」,避免用户看到 500 技术文案。
if (org.springblade.common.excel.ImportFailureExcelUtil.exportReadFailure(
response, "维修记录导入失败明细" + DateUtil.time(), "导入失败明细", file, MaintenanceRecordExcel.class, readException)) {
return null;
}
throw readException;
}
return R.success("操作成功");
}

Some files were not shown because too many files have changed in this diff Show More