From e761990ebf43e66cb4f891361e4d8473bdff1bb1 Mon Sep 17 00:00:00 2001 From: songqq Date: Wed, 12 Nov 2025 16:32:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0SMB=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=B9=B6=E4=BC=98=E5=8C=96=E5=90=88=E5=90=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增SmbFileService服务类,支持SMB/CIFS协议的文件操作 - 修改合同文件管理逻辑,支持SMB路径检查与目录创建 - 优化BankTableCell实现工厂模式并更新相关文档 - 调整Redis配置并添加连接测试 - 修复合同发票视图模型的时间处理问题 - 更新项目版本至0.0.134-SNAPSHOT --- .trae/rules/client_controller_rules.md | 68 ++- client/pom.xml | 6 +- .../ecep/contract/WebSocketClientService.java | 3 +- .../tab/CompanyTabSkinBankAccount.java | 3 +- .../controller/table/cell/BankTableCell.java | 25 +- .../contract/vm/ContractInvoiceViewModel.java | 31 +- .../src/main/resources/application.properties | 1 + client/src/main/resources/ui/contact.fxml | 2 +- common/pom.xml | 4 +- config.properties | 2 + pom.xml | 2 +- server/pom.xml | 12 +- .../contract/cloud/u8/ctx/ContractCtx.java | 62 ++- .../contract/cloud/u8/ctx/CustomerCtx.java | 4 +- .../ecep/contract/cloud/u8/ctx/VendorCtx.java | 4 +- .../com/ecep/contract/config/SmbConfig.java | 30 ++ .../ecep/contract/config/WebSocketConfig.java | 3 + .../contract/ds/company/CompanyFileUtils.java | 20 + .../tasker/AbstContractRepairTasker.java | 6 + .../ecep/contract/service/SmbFileService.java | 493 ++++++++++++++++++ .../src/main/resources/application.properties | 19 +- .../contract/util/RedisConnectionTest.java | 127 +++++ 22 files changed, 877 insertions(+), 50 deletions(-) create mode 100644 server/src/main/java/com/ecep/contract/config/SmbConfig.java create mode 100644 server/src/main/java/com/ecep/contract/service/SmbFileService.java create mode 100644 server/src/test/java/com/ecep/contract/util/RedisConnectionTest.java diff --git a/.trae/rules/client_controller_rules.md b/.trae/rules/client_controller_rules.md index cb4933c..06481fd 100644 --- a/.trae/rules/client_controller_rules.md +++ b/.trae/rules/client_controller_rules.md @@ -154,4 +154,70 @@ public class [业务]WindowController extends AbstEntityController<[Vo类型], [ // 窗口显示后的逻辑 } } -``` \ No newline at end of file +``` + +## 13. TableCell 工厂模式规范 + +### 13.1 工厂方法命名 + +- TableCell类应提供静态工厂方法`forTableColumn`,用于创建单元格工厂 +- 方法命名必须为`forTableColumn`,保持一致性 + +### 13.2 工厂方法参数 + +- 工厂方法应接收服务层参数,如`BankService` +- 参数类型应与TableCell构造函数所需的服务类型一致 + +### 13.3 工厂方法返回类型 + +- 返回类型应为`Callback, javafx.scene.control.TableCell>` +- 其中`V`为ViewModel类型,`T`为单元格值类型 + +### 13.4 工厂方法实现 + +- 在工厂方法内部,创建并返回TableCell实例 +- 使用泛型参数确保类型安全 + +### 13.5 使用方式 + +- 在设置表格列的单元格工厂时,应调用TableCell的静态工厂方法 +- 避免直接使用`new TableCell<>(service)`的方式创建实例 + +### 13.6 示例代码 + +```java +/** + * 银行单元格 + */ +@NoArgsConstructor +public class BankTableCell extends AsyncUpdateTableCell { + /** + * 创建单元格工厂 + * + * @param bankService 银行服务 + * @return 单元格工厂 + */ + public static Callback, javafx.scene.control.TableCell> forTableColumn( + BankService bankService) { + return param -> new BankTableCell(bankService); + } + + public BankTableCell(BankService service) { + setService(service); + } + + @Override + protected BankService getServiceBean() { + return getBean(BankService.class); + } +} +``` + +### 13.7 使用示例 + +```java +// 推荐方式:使用工厂方法 +column.setCellFactory(BankTableCell.forTableColumn(getBankService())); + +// 不推荐方式:直接实例化 +// column.setCellFactory(param -> new BankTableCell<>(getBankService())); \ No newline at end of file diff --git a/client/pom.xml b/client/pom.xml index fa550b5..1772f98 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -6,12 +6,12 @@ com.ecep.contract Contract-Manager - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT com.ecep.contract client - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT ${java.version} @@ -22,7 +22,7 @@ com.ecep.contract common - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT org.springframework.boot diff --git a/client/src/main/java/com/ecep/contract/WebSocketClientService.java b/client/src/main/java/com/ecep/contract/WebSocketClientService.java index 7ea37bd..0b85b40 100644 --- a/client/src/main/java/com/ecep/contract/WebSocketClientService.java +++ b/client/src/main/java/com/ecep/contract/WebSocketClientService.java @@ -53,7 +53,6 @@ public class WebSocketClientService { @Getter @Setter private long readTimeout = 30000; - private String webSocketUrl = "ws://localhost:8080/ws"; private boolean isActive = false; // 标记连接是否活跃 private ScheduledFuture heartbeatTask; // 心跳任务 private ScheduledFuture reconnectFuture; // 修改类型为CompletableFuture @@ -248,6 +247,8 @@ public class WebSocketClientService { try { // 构建WebSocket请求,包含认证信息 + var myProperties = SpringApp.getBean(MyProperties.class); + String webSocketUrl = "ws://" + myProperties.getServerHost() + ":" + myProperties.getServerPort() + "/ws"; Request request = new Request.Builder() .url(webSocketUrl) .build(); diff --git a/client/src/main/java/com/ecep/contract/controller/tab/CompanyTabSkinBankAccount.java b/client/src/main/java/com/ecep/contract/controller/tab/CompanyTabSkinBankAccount.java index 871e5f5..bc56085 100644 --- a/client/src/main/java/com/ecep/contract/controller/tab/CompanyTabSkinBankAccount.java +++ b/client/src/main/java/com/ecep/contract/controller/tab/CompanyTabSkinBankAccount.java @@ -62,8 +62,9 @@ public class CompanyTabSkinBankAccount bankAccountSearchBtn.setOnAction(this::onTableRefreshAction); bankAccountTable_idColumn.setCellValueFactory(param -> param.getValue().getId()); + bankAccountTable_bankColumn.setCellValueFactory(param -> param.getValue().getBankId()); - bankAccountTable_bankColumn.setCellFactory(param -> new BankTableCell<>(getBankService())); + bankAccountTable_bankColumn.setCellFactory(BankTableCell.forTableColumn(getBankService())); bankAccountTable_openingBankColumn.setCellValueFactory(param -> param.getValue().getOpeningBank()); bankAccountTable_accountColumn.setCellValueFactory(param -> param.getValue().getAccount()); diff --git a/client/src/main/java/com/ecep/contract/controller/table/cell/BankTableCell.java b/client/src/main/java/com/ecep/contract/controller/table/cell/BankTableCell.java index 8fecfe7..b51954a 100644 --- a/client/src/main/java/com/ecep/contract/controller/table/cell/BankTableCell.java +++ b/client/src/main/java/com/ecep/contract/controller/table/cell/BankTableCell.java @@ -1,20 +1,39 @@ package com.ecep.contract.controller.table.cell; -import com.ecep.contract.SpringApp; import com.ecep.contract.service.BankService; import com.ecep.contract.vo.BankVo; - +import javafx.util.Callback; import lombok.NoArgsConstructor; +import static com.ecep.contract.SpringApp.getBean; + +/** + * 银行单元格 + */ @NoArgsConstructor public class BankTableCell extends AsyncUpdateTableCell { + /** + * 创建单元格工厂 + * + * @param bankService 银行服务 + * @return 单元格工厂 + */ + public static Callback, javafx.scene.control.TableCell> forTableColumn( + BankService bankService) { + return param -> new BankTableCell(bankService); + } + public BankTableCell(BankService service) { setService(service); } @Override protected BankService getServiceBean() { - return SpringApp.getBean(BankService.class); + return getBean(BankService.class); } + @Override + public String format(BankVo entity) { + return getService().getStringConverter().toString(entity); + } } diff --git a/client/src/main/java/com/ecep/contract/vm/ContractInvoiceViewModel.java b/client/src/main/java/com/ecep/contract/vm/ContractInvoiceViewModel.java index 0f33a68..e133767 100644 --- a/client/src/main/java/com/ecep/contract/vm/ContractInvoiceViewModel.java +++ b/client/src/main/java/com/ecep/contract/vm/ContractInvoiceViewModel.java @@ -1,11 +1,13 @@ package com.ecep.contract.vm; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Objects; import com.ecep.contract.util.NumberUtils; import com.ecep.contract.vo.ContractInvoiceVo; - +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import lombok.Data; @@ -20,6 +22,11 @@ public class ContractInvoiceViewModel extends IdentityViewModel contract = new SimpleObjectProperty<>(); + /** + * 关联的合同项目对象, ContractItem + */ + private final SimpleObjectProperty contractItem = new SimpleObjectProperty<>(); + /** * 关联的发票对象, Invoice */ @@ -46,7 +53,7 @@ public class ContractInvoiceViewModel extends IdentityViewModel - +
diff --git a/common/pom.xml b/common/pom.xml index d4723b6..57ae604 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -6,12 +6,12 @@ com.ecep.contract Contract-Manager - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT com.ecep.contract common - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT ${java.version} diff --git a/config.properties b/config.properties index 9953a57..29a82c3 100644 --- a/config.properties +++ b/config.properties @@ -1,5 +1,7 @@ #Contract Manager \u5E94\u7528\u7A0B\u5E8F\u914D\u7F6E #Sat Sep 27 00:34:02 CST 2025 +#server.host=cms.ecctrl.com +#server.port=80 server.host=127.0.0.1 server.port=8080 user.name=qiqing.song diff --git a/pom.xml b/pom.xml index 07e9cd5..f14dcb9 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ com.ecep.contract Contract-Manager - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT pom server diff --git a/server/pom.xml b/server/pom.xml index 26c2479..15bcc0c 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -6,12 +6,12 @@ com.ecep.contract Contract-Manager - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT com.ecep.contract server - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT ${java.version} @@ -22,7 +22,7 @@ com.ecep.contract common - 0.0.129-SNAPSHOT + 0.0.134-SNAPSHOT org.springframework.boot @@ -93,6 +93,12 @@ mssql-jdbc runtime + + + com.hierynomus + smbj + 0.11.5 + diff --git a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/ContractCtx.java b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/ContractCtx.java index e4e1ab7..b7831a8 100644 --- a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/ContractCtx.java +++ b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/ContractCtx.java @@ -1,6 +1,7 @@ package com.ecep.contract.cloud.u8.ctx; import java.io.File; +import java.io.IOException; import java.text.NumberFormat; import java.time.LocalDate; import java.time.LocalDateTime; @@ -50,6 +51,7 @@ import com.ecep.contract.ds.vendor.model.Vendor; import com.ecep.contract.ds.vendor.model.VendorEntity; import com.ecep.contract.ds.vendor.service.VendorEntityService; import com.ecep.contract.ds.vendor.service.VendorService; +import com.ecep.contract.service.SmbFileService; import com.ecep.contract.util.BeanContext; import com.ecep.contract.util.FileUtils; import com.ecep.contract.util.NumberUtils; @@ -63,6 +65,7 @@ import com.ecep.contract.vo.CustomerVo; import com.ecep.contract.vo.ProjectSaleTypeVo; import com.ecep.contract.vo.ProjectVo; import com.ecep.contract.vo.VendorEntityVo; +import com.hierynomus.smbj.common.SmbPath; import lombok.Getter; import lombok.Setter; @@ -995,13 +998,20 @@ public class ContractCtx extends AbstractYongYouU8Ctx { } public boolean updateContractPath(ContractVo contract, MessageHolder holder) { - // 如果合同路径存在 - if (CompanyFileUtils.exists(contract.getPath())) { - File dir = new File(contract.getPath()); - if (dir.exists()) { - return false; + // 如果设置了合同路径,检查路径是否存在 + SmbFileService smbFileService = getCachedBean(SmbFileService.class); + if (StringUtils.hasText(contract.getPath())) { + var smbPath = SmbPath.parse(contract.getPath()); + try { + if (smbFileService.exists(smbPath)) { + return false; + } + } catch (IOException e) { + holder.warn("检查合同路径 " + smbPath + " 异常:" + e.getMessage()); } + return false; } + // 尝试创建合同路径 holder.debug("合同目录不存在,尝试创建"); @@ -1022,15 +1032,17 @@ public class ContractCtx extends AbstractYongYouU8Ctx { return false; } File contractPath = new File(parent.getPath()); - if (!contractPath.exists()) { + if (!smbFileService.exists(contractPath)) { holder.debug("父合同 " + parentCode + " 目录不存在 " + parent.getPath()); return false; } String contractDirName = contract.getCode(); File path = new File(contractPath, contractDirName); - if (!path.exists()) { - if (!path.mkdir()) { - holder.warn("创建目录失败 = " + path.getAbsolutePath()); + if (!smbFileService.exists(path)) { + try { + smbFileService.mkdir(path); + } catch (IOException e) { + holder.warn("创建目录失败 = " + path.getAbsolutePath() + ", 异常: " + e.getMessage()); return false; } } @@ -1049,9 +1061,12 @@ public class ContractCtx extends AbstractYongYouU8Ctx { File dir = new File(saleType.getPath()); if (saleType.isStoreByYear()) { dir = new File(dir, "20" + projectVo.getCodeYear()); - if (!dir.exists()) { - if (dir.mkdir()) { - holder.info("新建目录 " + dir.getAbsolutePath()); + if (!smbFileService.exists(dir)) { + try { + smbFileService.mkdir(dir); + } catch (IOException e) { + holder.warn("创建目录失败 = " + dir.getAbsolutePath() + ", 异常: " + e.getMessage()); + return false; } } } @@ -1074,10 +1089,11 @@ public class ContractCtx extends AbstractYongYouU8Ctx { String contractDirName = contract.getCode() + "-" + FileUtils.escapeFileName(contract.getName()); File path = new File(contractPath, contractDirName); - if (!path.exists()) { - if (!path.mkdir()) { - // - holder.warn("创建目录失败 = " + path.getAbsolutePath()); + if (!smbFileService.exists(path)) { + try { + smbFileService.mkdir(path); + } catch (IOException e) { + holder.warn("创建目录失败 = " + path.getAbsolutePath() + ", 异常: " + e.getMessage()); return false; } } @@ -1086,6 +1102,7 @@ public class ContractCtx extends AbstractYongYouU8Ctx { return true; } return false; + } public boolean updateContractAmount(ContractVo contract, MessageHolder holder) { @@ -1190,8 +1207,10 @@ public class ContractCtx extends AbstractYongYouU8Ctx { if (!StringUtils.hasText(contractPath)) { return false; } + SmbFileService smbFileService = getCachedBean(SmbFileService.class); + // 检查合同目录是否存在 File dir = new File(contractPath); - if (!dir.exists()) { + if (!smbFileService.exists(dir)) { holder.warn("合同目录不存在:" + contractPath); return false; } @@ -1228,11 +1247,11 @@ public class ContractCtx extends AbstractYongYouU8Ctx { } // 遍历合同目录下的文件,如果未创建,创建 - File[] files = dir.listFiles(); - if (files != null) { + try { + List files = smbFileService.listFiles(dir); for (File file : files) { // 只处理文件 - if (!file.isFile()) { + if (!smbFileService.isFile(file)) { continue; } String fileName = file.getName(); @@ -1247,6 +1266,9 @@ public class ContractCtx extends AbstractYongYouU8Ctx { syncContractFile(contractFile, file, holder); retrieveFiles.add(contractFile); } + } catch (IOException e) { + holder.error("遍历合同目录下的文件失败:" + contractPath + ",错误:" + e.getMessage()); + return false; } if (retrieveFiles.isEmpty()) { diff --git a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/CustomerCtx.java b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/CustomerCtx.java index 440a2d7..4186b7b 100644 --- a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/CustomerCtx.java +++ b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/CustomerCtx.java @@ -253,8 +253,10 @@ public class CustomerCtx extends AbstractYongYouU8Ctx { updated = true; } + ContractCtx ctx = getContractCtx(); + ctx.initializeRepository(holder); for (CompanyCustomerEntity entity : entities) { - if (getContractCtx().syncByCustomerEntity(companyCustomer, entity, holder)) { + if (ctx.syncByCustomerEntity(companyCustomer, entity, holder)) { updated = true; } } diff --git a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/VendorCtx.java b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/VendorCtx.java index ebe8d1f..5497929 100644 --- a/server/src/main/java/com/ecep/contract/cloud/u8/ctx/VendorCtx.java +++ b/server/src/main/java/com/ecep/contract/cloud/u8/ctx/VendorCtx.java @@ -249,8 +249,10 @@ public class VendorCtx extends AbstractYongYouU8Ctx { } // 同步供应商关联的合同 + ContractCtx ctx = getContractCtx(); + ctx.initializeRepository(holder); for (VendorEntity entity : entities) { - if (getContractCtx().syncByVendorEntity(vendor, entity, holder)) { + if (ctx.syncByVendorEntity(vendor, entity, holder)) { updated = true; } } diff --git a/server/src/main/java/com/ecep/contract/config/SmbConfig.java b/server/src/main/java/com/ecep/contract/config/SmbConfig.java new file mode 100644 index 0000000..279b80f --- /dev/null +++ b/server/src/main/java/com/ecep/contract/config/SmbConfig.java @@ -0,0 +1,30 @@ +package com.ecep.contract.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.hierynomus.smbj.SMBClient; +import com.hierynomus.smbj.auth.NtlmAuthenticator; + +@Configuration() +public class SmbConfig { + + @Value("${smb.server.username}") + @Getter + private String username; + + @Value("${smb.server.password}") + @Getter + private String password; + + @Bean + public SMBClient smbClient() { + var smbConfig = com.hierynomus.smbj.SmbConfig.builder() + .withMultiProtocolNegotiate(true).withSigningRequired(true) + // .withAuthenticators(new NtlmAuthenticator(username, password)) + .build(); + return new SMBClient(smbConfig); + } +} diff --git a/server/src/main/java/com/ecep/contract/config/WebSocketConfig.java b/server/src/main/java/com/ecep/contract/config/WebSocketConfig.java index 70166e2..9f74499 100644 --- a/server/src/main/java/com/ecep/contract/config/WebSocketConfig.java +++ b/server/src/main/java/com/ecep/contract/config/WebSocketConfig.java @@ -34,4 +34,7 @@ public class WebSocketConfig implements WebSocketConfigurer { new HttpSessionHandshakeInterceptor(List.of("JSESSIONID", "loginHistoryId", "employeeId"))) .setAllowedOrigins("*"); } + + + } \ No newline at end of file diff --git a/server/src/main/java/com/ecep/contract/ds/company/CompanyFileUtils.java b/server/src/main/java/com/ecep/contract/ds/company/CompanyFileUtils.java index 691b8ab..023e7f2 100644 --- a/server/src/main/java/com/ecep/contract/ds/company/CompanyFileUtils.java +++ b/server/src/main/java/com/ecep/contract/ds/company/CompanyFileUtils.java @@ -1,6 +1,7 @@ package com.ecep.contract.ds.company; import java.io.File; +import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -9,12 +10,17 @@ import java.util.function.Function; import com.ecep.contract.constant.CompanyConstant; import com.ecep.contract.util.FileUtils; +import com.hierynomus.smbj.common.SmbPath; + +import org.springframework.beans.BeansException; import org.springframework.util.StringUtils; import com.ecep.contract.CompanyFileType; import com.ecep.contract.MyDateTimeUtils; +import com.ecep.contract.SpringApp; import com.ecep.contract.constant.CloudServiceConstant; import com.ecep.contract.ds.company.model.CompanyFile; +import com.ecep.contract.service.SmbFileService; public class CompanyFileUtils { @@ -130,10 +136,24 @@ public class CompanyFileUtils { return modified; } + public static boolean exists(SmbPath path) throws IOException { + return SpringApp.getBean(SmbFileService.class).exists(path); + } + public static boolean exists(String path) { if (!StringUtils.hasText(path)) { return false; } + + if (path.startsWith("\\\\")) { + var smbPath = SmbPath.parse(path); + try { + return SpringApp.getBean(SmbFileService.class).exists(smbPath); + } catch (IOException e) { + return false; + } + } + File file = new File(path); return file.exists(); } diff --git a/server/src/main/java/com/ecep/contract/ds/contract/tasker/AbstContractRepairTasker.java b/server/src/main/java/com/ecep/contract/ds/contract/tasker/AbstContractRepairTasker.java index 9df2e01..731a1a7 100644 --- a/server/src/main/java/com/ecep/contract/ds/contract/tasker/AbstContractRepairTasker.java +++ b/server/src/main/java/com/ecep/contract/ds/contract/tasker/AbstContractRepairTasker.java @@ -287,6 +287,7 @@ public abstract class AbstContractRepairTasker extends Tasker { SalesOrderCtx ctx = getSalesOrderCtx(); ctx.initializeRepository(holder); if (ctx.getRepository() == null) { + holder.warn("未启用 " + CloudServiceConstant.U8_NAME + " 服务"); return; } List orders = ctx.syncByContract(contract, holder); @@ -294,6 +295,11 @@ public abstract class AbstContractRepairTasker extends Tasker { return; } SalesBillVoucherCtx voucherCtx = getSalesBillVoucherCtx(); + voucherCtx.initializeRepository(holder); + if (voucherCtx.getRepository() == null) { + holder.warn("未启用 " + CloudServiceConstant.U8_NAME + " 服务"); + return; + } for (SalesOrder order : orders) { voucherCtx.syncBySalesOrder(order, holder); } diff --git a/server/src/main/java/com/ecep/contract/service/SmbFileService.java b/server/src/main/java/com/ecep/contract/service/SmbFileService.java new file mode 100644 index 0000000..c985783 --- /dev/null +++ b/server/src/main/java/com/ecep/contract/service/SmbFileService.java @@ -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 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 操作返回类型 + * @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 idleHostnames = new java.util.ArrayList<>(); + + // 查找所有空闲连接 + for (Map.Entry 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 操作返回类型 + * @return 操作结果 + * @throws IOException 如果操作失败 + */ + private T executeSmbOperation(SmbPath smbPath, SmbOperation 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 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 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 execute(DiskShare share, String path) throws IOException; + } +} diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 3b45b15..82ac33a 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -25,9 +25,21 @@ spring.jpa.show-sql=false spring.data.jpa.repositories.enabled=true spring.lifecycle.timeout-per-shutdown-phase=10s -# my.downloadsPath = C:\\Users\\SQQ\\Downloads\\ -spring.data.redis.host=10.84.209.229 + +spring.data.redis.client-type=lettuce +spring.data.redis.host=10.84.210.110 +spring.data.redis.port=6379 +spring.data.redis.username=default +spring.data.redis.password=redis_NFYa7z spring.data.redis.database=3 +spring.data.redis.repositories.enabled=false + +# Redis连接池和容错配置 +spring.data.redis.lettuce.pool.max-active=8 +spring.data.redis.lettuce.pool.max-wait=10000 +spring.data.redis.lettuce.pool.max-idle=8 +spring.data.redis.lettuce.pool.min-idle=0 +spring.data.redis.timeout=60000 logging.level.org.hibernate.tool.hbm2ddl=DEBUG @@ -53,3 +65,6 @@ spring.cache.redis.cache-null-values=true server.error.whitelabel.enabled=false # 设置错误处理路径,确保404等错误能被全局异常处理器捕获 spring.web.resources.add-mappings=true + +smb.server.username=qiqing.song +smb.server.password=huez8310 diff --git a/server/src/test/java/com/ecep/contract/util/RedisConnectionTest.java b/server/src/test/java/com/ecep/contract/util/RedisConnectionTest.java new file mode 100644 index 0000000..e2e4db3 --- /dev/null +++ b/server/src/test/java/com/ecep/contract/util/RedisConnectionTest.java @@ -0,0 +1,127 @@ +package com.ecep.contract.util; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Redis连接测试类 + * 用于测试基于application.properties中配置的Redis连接参数是否能正常连接Redis服务器 + */ +@SpringJUnitConfig +@SpringBootTest +public class RedisConnectionTest { + + private static final Logger logger = LoggerFactory.getLogger(RedisConnectionTest.class); + + @Autowired + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private RedisConnectionFactory redisConnectionFactory; + + /** + * 测试Redis连接是否正常 + * 验证StringRedisTemplate是否成功注入,并且能够执行基本的Redis操作 + */ + @Test + public void testRedisConnection() { + logger.info("开始测试Redis连接..."); + + // 验证RedisConnectionFactory是否成功注入 + assertNotNull(redisConnectionFactory, "RedisConnectionFactory注入失败"); + logger.info("RedisConnectionFactory注入成功"); + + // 验证StringRedisTemplate是否成功注入 + assertNotNull(stringRedisTemplate, "StringRedisTemplate注入失败"); + logger.info("StringRedisTemplate注入成功"); + + try { + // 尝试获取原生连接以验证连接是否正常 + logger.info("尝试获取Redis原生连接..."); + redisConnectionFactory.getConnection().close(); + logger.info("成功获取并关闭Redis原生连接"); + + // 执行一个简单的Redis操作来验证连接是否正常 + String testKey = "test:connection"; + String testValue = "connection-test-value"; + + logger.info("执行Redis写操作,key: {}", testKey); + // 写入测试数据 + stringRedisTemplate.opsForValue().set(testKey, testValue); + logger.info("Redis写操作成功"); + + logger.info("执行Redis读操作,key: {}", testKey); + // 读取测试数据并验证 + String retrievedValue = stringRedisTemplate.opsForValue().get(testKey); + logger.info("Redis读操作成功,获取到值: {}", retrievedValue); + + assertTrue(testValue.equals(retrievedValue), "Redis写入和读取的值不匹配"); + logger.info("Redis读写值验证成功"); + + // 清理测试数据 + stringRedisTemplate.delete(testKey); + logger.info("Redis删除操作成功"); + + logger.info("Redis连接测试成功!能够正常执行set、get和delete操作"); + } catch (Exception e) { + // 记录详细的异常信息 + logger.error("Redis连接测试失败: {}", e.getMessage(), e); + // 打印异常堆栈以方便调试 + e.printStackTrace(); + // 测试失败,但提供详细信息 + assertTrue(false, "Redis连接测试失败: " + e.getMessage() + ", 请检查Redis服务器是否运行以及配置是否正确"); + } + } + + /** + * 测试Redis连接池状态 + * 验证Redis连接池是否正常工作 + */ + @Test + public void testRedisConnectionPool() { + logger.info("开始测试Redis连接池..."); + + assertNotNull(stringRedisTemplate, "StringRedisTemplate注入失败"); + logger.info("StringRedisTemplate注入成功"); + + try { + // 执行多次Redis操作来测试连接池 + logger.info("执行多次Redis操作以测试连接池..."); + for (int i = 0; i < 10; i++) { + String testKey = "test:pool:" + i; + String testValue = "pool-test-value:" + i; + + logger.info("第{}次测试 - 执行Redis写操作,key: {}", i, testKey); + // 写入数据 + stringRedisTemplate.opsForValue().set(testKey, testValue); + + logger.info("第{}次测试 - 执行Redis读操作,key: {}", i, testKey); + // 读取并验证 + String retrievedValue = stringRedisTemplate.opsForValue().get(testKey); + logger.info("第{}次测试 - 获取到值: {}", i, retrievedValue); + + assertTrue(testValue.equals(retrievedValue), "Redis连接池测试失败,第" + i + "次操作出错"); + logger.info("第{}次测试 - 值验证成功", i); + + // 清理数据 + stringRedisTemplate.delete(testKey); + logger.info("第{}次测试 - 删除操作成功", i); + } + + logger.info("Redis连接池测试成功!能够正常处理多个连接请求"); + } catch (Exception e) { + logger.error("Redis连接池测试失败: {}", e.getMessage(), e); + e.printStackTrace(); + assertTrue(false, "Redis连接池测试失败: " + e.getMessage() + ", 请检查Redis连接池配置和服务器状态"); + } + } +} \ No newline at end of file