feat: 实现SMB文件服务并优化合同文件管理

- 新增SmbFileService服务类,支持SMB/CIFS协议的文件操作
- 修改合同文件管理逻辑,支持SMB路径检查与目录创建
- 优化BankTableCell实现工厂模式并更新相关文档
- 调整Redis配置并添加连接测试
- 修复合同发票视图模型的时间处理问题
- 更新项目版本至0.0.134-SNAPSHOT
This commit is contained in:
2025-11-12 16:32:03 +08:00
parent 1cb0edbd07
commit e761990ebf
22 changed files with 877 additions and 50 deletions

View File

@@ -0,0 +1,493 @@
package com.ecep.contract.service;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ecep.contract.SpringApp;
import com.ecep.contract.config.SmbConfig;
import com.hierynomus.msdtyp.AccessMask;
import com.hierynomus.mserref.NtStatus;
import com.hierynomus.msfscc.FileAttributes;
import com.hierynomus.msfscc.fileinformation.FileAllInformation;
import com.hierynomus.mssmb2.SMB2CreateDisposition;
import com.hierynomus.mssmb2.SMB2CreateOptions;
import com.hierynomus.mssmb2.SMB2ShareAccess;
import com.hierynomus.mssmb2.SMBApiException;
import com.hierynomus.smbj.SMBClient;
import com.hierynomus.smbj.auth.AuthenticationContext;
import com.hierynomus.smbj.common.SmbPath;
import com.hierynomus.smbj.share.DiskShare;
import com.hierynomus.smbj.share.File;
import lombok.extern.slf4j.Slf4j;
/**
* SMB文件服务类提供SMB/CIFS协议的文件操作功能
*/
@Slf4j
@Service
public class SmbFileService implements DisposableBean {
private final SMBClient client;
private AuthenticationContext authContext;
private final SmbConfig smbConfig;
private final ReentrantLock authLock = new ReentrantLock();
// 连接空闲超时时间3分钟
private static final long CONNECTION_IDLE_TIMEOUT_MS = 3 * 60 * 1000;
// 连接信息内部类,用于存储连接和最后使用时间
private static class ConnectionInfo {
private final com.hierynomus.smbj.connection.Connection connection;
private volatile long lastUsedTimestamp;
public ConnectionInfo(com.hierynomus.smbj.connection.Connection connection) {
this.connection = connection;
this.lastUsedTimestamp = System.currentTimeMillis();
}
public com.hierynomus.smbj.connection.Connection getConnection() {
return connection;
}
public long getLastUsedTimestamp() {
return lastUsedTimestamp;
}
public void updateLastUsedTimestamp() {
this.lastUsedTimestamp = System.currentTimeMillis();
}
public boolean isIdle(long timeoutMs) {
return (System.currentTimeMillis() - lastUsedTimestamp) > timeoutMs;
}
}
// 连接池使用ConcurrentHashMap确保线程安全
private final Map<String, ConnectionInfo> connectionPool = new ConcurrentHashMap<>();
// 连接池锁,用于同步连接的创建和关闭
private final ReentrantLock connectionPoolLock = new ReentrantLock();
// 定时清理线程池
private final ScheduledExecutorService cleanupScheduler;
/**
* 构造函数注入SMB客户端和配置初始化定时清理任务
*
* @param smbClient SMB客户端实例
*/
public SmbFileService(@Autowired SMBClient smbClient, @Autowired ScheduledExecutorService executor) {
this.client = smbClient;
this.smbConfig = SpringApp.getBean(SmbConfig.class);
// 初始化定时清理任务每30秒运行一次
this.cleanupScheduler = executor;
// 启动定时清理任务延迟1分钟后开始每30秒执行一次
this.cleanupScheduler.scheduleAtFixedRate(this::cleanupIdleConnections, 1, 30, TimeUnit.SECONDS);
}
/**
* 获取认证上下文,线程安全实现
*
* @param host 主机名
* @return 认证上下文
*/
private AuthenticationContext getAuthenticationContext(String host) {
// 双重检查锁定模式,确保线程安全
if (authContext == null) {
authLock.lock();
try {
if (authContext == null) {
log.debug("Creating new AuthenticationContext for host: {}", host);
authContext = new AuthenticationContext(
smbConfig.getUsername(),
smbConfig.getPassword().toCharArray(),
"");
}
} finally {
authLock.unlock();
}
}
return authContext;
}
/**
* 执行SMB操作的通用方法简化连接和会话的创建
*
* @param smbPath SMB路径
* @param operation 要执行的操作
* @param <T> 操作返回类型
* @return 操作结果
* @throws IOException 如果操作失败
*/
/**
* 从连接池获取或创建连接
*
* @param hostname 主机名
* @return SMB连接
* @throws IOException 如果创建连接失败
*/
/**
* 从连接池获取或创建连接
*
* @param hostname 主机名
* @return SMB连接
* @throws IOException 如果创建连接失败
*/
private com.hierynomus.smbj.connection.Connection getConnection(String hostname) throws IOException {
// 首先检查连接池是否已有该主机的连接
ConnectionInfo connectionInfo = connectionPool.get(hostname);
com.hierynomus.smbj.connection.Connection connection = null;
// 如果连接存在且有效,则更新最后使用时间并返回
if (connectionInfo != null) {
connection = connectionInfo.getConnection();
if (connection != null && connection.isConnected()) {
// 更新连接的最后使用时间
connectionInfo.updateLastUsedTimestamp();
log.debug("Reusing SMB connection for host: {}", hostname);
return connection;
}
}
// 如果连接不存在或已关闭,则创建新连接
connectionPoolLock.lock();
try {
// 双重检查锁定模式
connectionInfo = connectionPool.get(hostname);
if (connectionInfo != null) {
connection = connectionInfo.getConnection();
if (connection != null && connection.isConnected()) {
connectionInfo.updateLastUsedTimestamp();
log.debug("Reusing SMB connection for host: {}", hostname);
return connection;
}
// 如果连接已失效,从池中移除
connectionPool.remove(hostname);
}
// 创建新连接
log.debug("Creating new SMB connection for host: {}", hostname);
connection = client.connect(hostname);
connectionInfo = new ConnectionInfo(connection);
connectionPool.put(hostname, connectionInfo);
} finally {
connectionPoolLock.unlock();
}
return connection;
}
/**
* 清理空闲连接的定时任务
*/
private void cleanupIdleConnections() {
log.debug("Running idle connections cleanup task");
// 创建要移除的连接列表避免在迭代时修改Map
List<String> idleHostnames = new java.util.ArrayList<>();
// 查找所有空闲连接
for (Map.Entry<String, ConnectionInfo> entry : connectionPool.entrySet()) {
String hostname = entry.getKey();
ConnectionInfo connectionInfo = entry.getValue();
// 检查连接是否空闲超时
if (connectionInfo != null && connectionInfo.isIdle(CONNECTION_IDLE_TIMEOUT_MS)) {
idleHostnames.add(hostname);
log.debug("Found idle connection for host: {}, will be closed", hostname);
}
}
// 关闭并移除空闲连接
if (!idleHostnames.isEmpty()) {
connectionPoolLock.lock();
try {
for (String hostname : idleHostnames) {
ConnectionInfo connectionInfo = connectionPool.get(hostname);
if (connectionInfo != null) {
try {
log.debug("Closing idle connection for host: {}", hostname);
connectionInfo.getConnection().close();
} catch (IOException e) {
log.error("Error closing idle connection for host: {}", hostname, e);
}
connectionPool.remove(hostname);
}
}
} finally {
connectionPoolLock.unlock();
}
}
log.debug("Idle connections cleanup completed, closed {} connections", idleHostnames.size());
}
/**
* 执行SMB操作的通用方法使用连接池
*
* @param smbPath SMB路径
* @param operation 要执行的操作
* @param <T> 操作返回类型
* @return 操作结果
* @throws IOException 如果操作失败
*/
private <T> T executeSmbOperation(SmbPath smbPath, SmbOperation<T> operation) throws IOException {
String hostname = smbPath.getHostname();
com.hierynomus.smbj.connection.Connection connection = null;
try {
// 从连接池获取连接
connection = getConnection(hostname);
// 使用获取的连接进行身份验证
var session = connection.authenticate(getAuthenticationContext(hostname));
try (var share = (DiskShare) session.connectShare(smbPath.getShareName())) {
return operation.execute(share, smbPath.getPath());
}
} catch (IOException e) {
// 如果操作失败且连接存在,检查连接状态
if (connection != null && !connection.isConnected()) {
// 从连接池移除失效的连接
connectionPool.remove(hostname);
}
throw e;
}
}
/**
* 上传文件到SMB服务器
*
* @param filePath 文件路径
* @param fileContent 文件内容字节数组
* @throws IOException 如果上传失败
*/
public void uploadFile(String filePath, byte[] fileContent) throws IOException {
Objects.requireNonNull(filePath, "File path cannot be null");
Objects.requireNonNull(fileContent, "File content cannot be null");
log.debug("Uploading file: {} with size: {} bytes", filePath, fileContent.length);
var smbPath = SmbPath.parse(filePath);
executeSmbOperation(smbPath, (share, path) -> {
// 创建目录(如果不存在)
String directoryPath = path.substring(0, path.lastIndexOf('/'));
if (!share.folderExists(directoryPath)) {
share.mkdir(directoryPath);
log.debug("Created directory: {}", directoryPath);
}
// 上传文件内容
try (File smbFile = share.openFile(smbPath.getPath(),
EnumSet.of(AccessMask.GENERIC_WRITE),
EnumSet.of(FileAttributes.FILE_ATTRIBUTE_NORMAL),
EnumSet.of(SMB2ShareAccess.FILE_SHARE_WRITE),
SMB2CreateDisposition.FILE_CREATE,
EnumSet.noneOf(SMB2CreateOptions.class))) {
try (OutputStream out = smbFile.getOutputStream()) {
out.write(fileContent);
}
}
log.info("Successfully uploaded file: {}", filePath);
return null;
});
}
/**
* 检查SMB路径是否存在
*
* @param smbPath SMB路径
* @return 如果路径存在则返回true
* @throws IOException 如果检查失败
*/
public boolean exists(SmbPath smbPath) throws IOException {
return executeSmbOperation(smbPath, (share, path) -> {
try {
FileAllInformation info = share.getFileInformation(path);
if (info.getStandardInformation().isDirectory()) {
return share.folderExists(path);
}
return share.fileExists(path);
} catch (SMBApiException e) {
if (e.getStatus().equals(NtStatus.STATUS_OBJECT_NAME_NOT_FOUND)) {
return false;
}
log.error("Error checking if path exists: {}", path, e);
throw e;
}
});
}
/**
* 检查文件是否存在基于File对象
*
* @param file 文件对象
* @return 如果文件存在则返回true
*/
public boolean exists(java.io.File file) {
Objects.requireNonNull(file, "File cannot be null");
try {
var smbPath = SmbPath.parse(file.getAbsolutePath());
return exists(smbPath);
} catch (IOException e) {
log.error("Error checking if file exists: {}", file.getAbsolutePath(), e);
return false;
}
}
/**
* 创建目录
*
* @param path 要创建的目录路径
* @throws IOException 如果创建失败
*/
public void mkdir(java.io.File path) throws IOException {
Objects.requireNonNull(path, "Path cannot be null");
var smbPath = SmbPath.parse(path.getAbsolutePath());
executeSmbOperation(smbPath, (share, smbFilePath) -> {
if (!share.folderExists(smbFilePath)) {
share.mkdir(smbFilePath);
log.debug("Created directory: {}", smbFilePath);
}
return null;
});
}
/**
* 列出目录中的文件(不包括子目录)
*
* @param dir 目录对象
* @return 文件列表
* @throws IOException 如果列出失败
*/
public List<java.io.File> listFiles(java.io.File dir) throws IOException {
Objects.requireNonNull(dir, "Directory cannot be null");
var smbPath = SmbPath.parse(dir.getAbsolutePath());
return executeSmbOperation(smbPath, (share, path) -> {
try {
FileAllInformation info = share.getFileInformation(path);
if (info.getStandardInformation().isDirectory() && share.folderExists(path)) {
var files = share.list(path);
return files.stream()
.filter(f -> !f.getFileName().startsWith(".") && !f.getFileName().equals(".."))
.filter(f -> {
try {
String fullPath = path + "\\" + f.getFileName();
return !share.getFileInformation(fullPath).getStandardInformation().isDirectory();
} catch (SMBApiException e) {
log.warn("Error checking file type for: {}", f.getFileName(), e);
return false;
}
})
.map(f -> new java.io.File(dir, f.getFileName()))
.toList();
}
} catch (SMBApiException e) {
log.error("Error listing files in directory: {}", path, e);
}
return Collections.emptyList();
});
}
/**
* 检查是否为文件(非目录)
* 修复:之前的实现逻辑错误,现在正确返回是否为文件
*
* @param file 文件对象
* @return 如果是文件非目录则返回true
* @throws IOException 如果检查失败
*/
public boolean isFile(java.io.File file) throws IOException {
Objects.requireNonNull(file, "File cannot be null");
var smbPath = SmbPath.parse(file.getAbsolutePath());
return executeSmbOperation(smbPath, (share, path) -> {
FileAllInformation fileInformation = share.getFileInformation(path);
// 修复:返回是否不是目录,即是否为文件
return !fileInformation.getStandardInformation().isDirectory();
});
}
/**
* 检查是否为目录
*
* @param file 文件对象
* @return 如果是目录则返回true
* @throws IOException 如果检查失败
*/
public boolean isDirectory(java.io.File file) throws IOException {
Objects.requireNonNull(file, "File cannot be null");
var smbPath = SmbPath.parse(file.getAbsolutePath());
return executeSmbOperation(smbPath, (share, path) -> {
FileAllInformation fileInformation = share.getFileInformation(path);
return fileInformation.getStandardInformation().isDirectory();
});
}
@Override
public void destroy() throws Exception {
shutdown();
}
/**
* 关闭并清理所有连接资源
*/
public void shutdown() {
log.debug("Shutting down SMB connection pool");
// 关闭定时清理任务
try {
cleanupScheduler.shutdown();
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupScheduler.shutdownNow();
}
} catch (InterruptedException e) {
cleanupScheduler.shutdownNow();
Thread.currentThread().interrupt();
}
// 关闭所有连接
connectionPoolLock.lock();
try {
for (Map.Entry<String, ConnectionInfo> entry : connectionPool.entrySet()) {
try {
log.debug("Closing connection to host: {}", entry.getKey());
entry.getValue().getConnection().close();
} catch (IOException e) {
log.error("Error closing connection to host: {}", entry.getKey(), e);
}
}
connectionPool.clear();
// 关闭SMB客户端
client.close();
} finally {
connectionPoolLock.unlock();
}
}
/**
* SMB操作接口用于执行具体的SMB操作
*/
@FunctionalInterface
private interface SmbOperation<T> {
T execute(DiskShare share, String path) throws IOException;
}
}