浏览代码

Merge branch 'main' of http://172.14.1.63:3000/nextosd/ds-yamoto-farm-server

zhangzl 1 周之前
父节点
当前提交
183a56b619

+ 27 - 0
farm-quartz/pom.xml

@@ -35,6 +35,33 @@
             <artifactId>farm-common</artifactId>
         </dependency>
 
+        <!-- Farm Common Biz-->
+        <dependency>
+            <groupId>jp.yamoto</groupId>
+            <artifactId>farm-common-biz</artifactId>
+        </dependency>
+
+        <!-- SFTP support -->
+        <dependency>
+            <groupId>com.jcraft</groupId>
+            <artifactId>jsch</artifactId>
+            <version>0.1.55</version>
+        </dependency>
+
+        <!-- AWS SDK for S3 -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>s3</artifactId>
+            <version>2.20.0</version>
+        </dependency>
+
+        <!-- AWS SDK for SES (Simple Email Service) -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>ses</artifactId>
+            <version>2.20.0</version>
+        </dependency>
+
     </dependencies>
 
 </project>

+ 67 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/config/AwsConfig.java

@@ -0,0 +1,67 @@
+package jp.yamoto.farm.quartz.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * AWS和邮件配置类
+ * 
+ * @author yamoto
+ * @version 1.0
+ */
+@Configuration
+public class AwsConfig {
+    
+    // AWS S3配置
+    @Value("${aws.s3.access-key:test}")
+    private String awsAccessKey;
+    
+    @Value("${aws.s3.secret-key:test}")
+    private String awsSecretKey;
+    
+    @Value("${aws.s3.region:us-east-1}")
+    private String awsRegion;
+    
+    @Value("${aws.s3.bucket-name:my-unencrypted-bucket}")
+    private String bucketName;
+    
+    // AWS SES配置
+    @Value("${aws.ses.access-key:test}")
+    private String sesAccessKey;
+    
+    @Value("${aws.ses.secret-key:test}")
+    private String sesSecretKey;
+    
+    @Value("${aws.ses.region:us-east-1}")
+    private String sesRegion;
+    
+    // Getter方法用于在其他地方获取配置值
+    
+    public String getAwsAccessKey() {
+        return awsAccessKey;
+    }
+    
+    public String getAwsSecretKey() {
+        return awsSecretKey;
+    }
+    
+    public String getAwsRegion() {
+        return awsRegion;
+    }
+    
+    public String getBucketName() {
+        return bucketName;
+    }
+    
+    public String getSesAccessKey() {
+        return sesAccessKey;
+    }
+    
+    public String getSesSecretKey() {
+        return sesSecretKey;
+    }
+    
+    public String getSesRegion() {
+        return sesRegion;
+    }
+}

+ 403 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/task/MastCustomerTask.java

@@ -0,0 +1,403 @@
+package jp.yamoto.farm.quartz.task;
+
+import com.jcraft.jsch.ChannelSftp;
+import com.jcraft.jsch.JSchException;
+import jp.yamoto.farm.quartz.util.SftpUtils;
+import jp.yamoto.farm.quartz.util.ZipUtils;
+import jp.yamoto.farm.quartz.util.AwsS3Utils;
+import jp.yamoto.farm.quartz.util.AwsSesUtils;
+import jp.yamoto.farm.quartz.util.CsvUtils;
+import jp.yamoto.farm.common.biz.domain.entity.MastCustomerEntity;
+import jp.yamoto.farm.common.biz.service.IMastCustomerBaseService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * 顧客マスタ詳細情報CSVファイルを読み込み処理
+ * 
+ * @author yamoto
+ * @version 1.0
+ */
+@Component("mastCustomerTask")
+public class MastCustomerTask {
+
+    private static final Logger logger = LoggerFactory.getLogger(MastCustomerTask.class);
+
+    @Autowired
+    private IMastCustomerBaseService mastCustomerBaseService;
+
+    @Autowired
+    private AwsS3Utils awsS3Utils;
+
+    @Autowired
+    private AwsSesUtils awsSesUtils;
+
+    /**
+     * 定时任务方法 - 顧客マスタ詳細情報CSVファイルを読み込み処理
+     */
+    public void executeMastCustomer(String cooperationDate) {
+        logger.info("开始执行顧客マスタ詳細情報CSVファイルを読み込み処理");
+
+        try {
+            if (cooperationDate == null || cooperationDate.isEmpty()) {
+                cooperationDate = getCurrentDateYYYYMMDD();
+            }
+
+            mastCustomerOperations(cooperationDate);
+            logger.info("顧客マスタ詳細情報CSVファイルを読み込み処理完成");
+        } catch (Exception e) {
+            logger.error("顧客マスタ詳細情報CSVファイルを読み込み処理失敗: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 顧客マスタ詳細情報流程
+     */
+    public void mastCustomerOperations(String cooperationDate) {
+        logger.info("开始読み込处理流程");
+        // 1. 下载SFTP文件到本地
+        String localPath = downloadSftpFile(cooperationDate);
+        String[][] csvData = null;
+        if (localPath.isEmpty()) {
+            logger.error("顧客マスタ詳細情報 SFTP文件下载失败");
+            return;
+        } else {
+            // 2. 解析CSV文件
+            try {
+                csvData = CsvUtils.readCsv(localPath);
+                if (csvData == null || csvData.length == 0) {
+                    logger.error("顧客マスタ詳細情報 CSV文件解析失败");
+                    return;
+                }
+                logger.info("CSV文件解析成功,共读取{}行数据", csvData.length);
+            } catch (java.io.IOException e) {
+                logger.error("顧客マスタ詳細情報 CSV文件读取失败: {}", e.getMessage());
+                return;
+            }
+        }
+
+        // 3. 顧客マスタ詳細情報CSV文件内容检查和数据库插入
+        int successCount = 0;
+        int errorCount = 0;
+
+        if (csvData != null && csvData.length > 1) {
+
+            // 跳过表头行,从第1行开始(索引0是表头)
+            for (int i = 1; i < csvData.length; i++) {
+                String[] rowData = csvData[i];
+
+                try {
+                    // 字段长度检查
+                    if (!validateFieldLengths(rowData)) {
+                        logger.warn("第{}行数据字段长度检查失败,跳过插入", i + 1);
+                        errorCount++;
+                        continue;
+                    }
+
+                    // 创建实体对象
+                    MastCustomerEntity customer = createCustomerEntity(rowData);
+
+                    // 插入数据库
+                    int result = mastCustomerBaseService.insert(customer);
+                    if (result > 0) {
+                        successCount++;
+                        logger.info("第{}行数据插入成功,顾客ID: {}", i + 1, customer.getCustomerId());
+                    } else {
+                        errorCount++;
+                        logger.warn("第{}行数据插入失败", i + 1);
+                    }
+
+                } catch (Exception e) {
+                    // 继续处理下一行
+                    errorCount++;
+                    logger.error("第{}行数据处理异常: {}", i + 1, e.getMessage());
+                }
+            }
+
+            logger.info("顧客マスタデータ插入完成 - 成功: {}行, 失败: {}行", successCount, errorCount);
+        } else {
+            logger.warn("CSV数据为空或只有表头,跳过数据库插入");
+        }
+
+        // 4.将顧客マスタ詳細情報CSV文件ZIP压缩
+        String zipFilePath = localPath.replace(".txt", ".zip");
+        boolean isCompressSuccess = ZipUtils.compressFile(localPath, zipFilePath);
+        if (!isCompressSuccess) {
+            logger.error("顧客マスタ詳細情報 CSV文件ZIP压缩失败");
+            return;
+        } else {
+            logger.info("顧客マスタ詳細情報 CSV文件ZIP压缩成功 - 压缩文件: {}", zipFilePath);
+        }
+
+        // 5.将压缩文件上传到AWS S3服务器
+        boolean isUploadSuccess = awsS3Utils.uploadFile("mast_customer/" + cooperationDate + ".zip", zipFilePath);
+        if (!isUploadSuccess) {
+            logger.error("顧客マスタ詳細情報 ZIP文件上传到AWS S3服务器失败");
+            return;
+        } else {
+            logger.info("顧客マスタ詳細情報 ZIP文件上传到AWS S3服务器成功 - 存储路径: s3://{}/mast_customer/{}.zip", "my-unencrypted-bucket",
+                    cooperationDate);
+        }
+
+        // 6.发送电子邮件通知
+        sendBatchCompletionEmail(cooperationDate, successCount, errorCount);
+
+        logger.info("顧客マスタ詳細情報連携処理流程完成");
+
+    }
+
+    /**
+     * 发送批量处理完成邮件通知
+     * 
+     * @param cooperationDate 合作日期
+     * @param successCount 成功记录数
+     * @param errorCount 失败记录数
+     */
+    private void sendBatchCompletionEmail(String cooperationDate, int successCount, int errorCount) {
+        try {
+            // 获取当前时间
+            java.time.LocalDateTime currentTime = java.time.LocalDateTime.now();
+            java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+            String currentTimeStr = currentTime.format(formatter);
+            
+            // 构建邮件主题
+            String subject = "顧客マスタ連携バッチ実行状況報告" + cooperationDate;
+            
+            // 构建邮件内容(使用您提供的模板)
+            String content = "管理者 様:\n\n" +
+                    "今日の顧客マスタ連携バッチ実行状況を報告致します。\n\n" +
+                    currentTimeStr + "時点に顧客マスタ連携バッチが実施完了しました。\n" +
+                    "・連携成功レコード:" + successCount + "件\n" +
+                    "・連携失敗レコード:" + errorCount + "件\n\n";
+            
+            if (errorCount > 0) {
+                content += "連携失敗がある場合、添付ファイルを確認してください。\n\n";
+            }
+            
+            content += "※このメールは自動的に送信しています。\n" +
+                    "送信専用メールアカウントのため、ご返信することが出来ませんのでご注意下さい。\n\n" +
+                    "以上です。\nよろしくお願い致します。";
+            
+            // 发送邮件(需要配置收件人邮箱)
+            // 这里需要配置实际的收件人邮箱地址
+            String toEmail = "admin@example.com"; // 请修改为实际收件人邮箱
+            
+            boolean emailSent = awsSesUtils.sendSimpleEmail(toEmail, subject, content);
+            
+            if (emailSent) {
+                logger.info("批量处理完成邮件发送成功");
+            } else {
+                logger.error("批量处理完成邮件发送失败");
+            }
+            
+        } catch (Exception e) {
+            logger.error("发送邮件通知异常: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 获取当前系统时间(YYYYMMDD格式)
+     * 
+     * @return 当前日期字符串(YYYYMMDD)
+     */
+    public String getCurrentDateYYYYMMDD() {
+        java.time.LocalDate currentDate = java.time.LocalDate.now();
+        java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd");
+        return currentDate.format(formatter);
+    }
+
+    /**
+     * 下载SFTP文件到本地
+     * 
+     * @param cooperationDate 合作日期,用于生成文件名
+     * @return 下载成功的本地文件路径,失败返回null
+     */
+    public String downloadSftpFile(String cooperationDate) {
+        String host = "172.14.3.40";
+        String username = "ftpshukou";
+        String password = "ScmAy3#w";
+        String remotePath = "/upload/downloaded_" + cooperationDate + "_test.txt";
+        String localPath = "temp/downloaded_" + cooperationDate + "_test.txt";
+
+        ChannelSftp channel = null;
+        try {
+            channel = SftpUtils.connect(host, username, password);
+
+            // 检查文件是否存在
+            if (SftpUtils.fileExists(channel, remotePath)) {
+                boolean success = SftpUtils.downloadFile(channel, remotePath, localPath);
+                if (success) {
+                    logger.info("SFTP文件下载成功 - 远程文件: {}, 本地文件: {}", remotePath, localPath);
+                    return localPath;
+                } else {
+                    logger.error("SFTP文件下载失败");
+                    return "";
+                }
+            } else {
+                logger.warn("远程文件不存在: {}", remotePath);
+                return "";
+            }
+
+        } catch (JSchException e) {
+            logger.error("SFTP连接失败: {}", e.getMessage());
+            return "";
+        } finally {
+            if (channel != null) {
+                SftpUtils.disconnect(channel);
+            }
+        }
+    }
+
+    /**
+     * 验证字段长度是否符合数据库表定义
+     * 
+     * @param rowData CSV行数据
+     * @return 验证通过返回true,否则返回false
+     */
+    private boolean validateFieldLengths(String[] rowData) {
+        if (rowData == null || rowData.length < 2) {
+            logger.warn("CSV行数据为空或列数不足");
+            return false;
+        }
+
+        try {
+            // 根据表定义检查字段长度
+            // id: varchar(20)
+            if (rowData.length > 0 && rowData[0] != null && rowData[0].length() > 20) {
+                logger.warn("ID字段长度超过20字符限制: {}", rowData[0]);
+                return false;
+            }
+
+            // customer_id: varchar(20)
+            if (rowData.length > 1 && rowData[1] != null && rowData[1].length() > 20) {
+                logger.warn("顾客ID字段长度超过20字符限制: {}", rowData[1]);
+                return false;
+            }
+
+            // first_name: varchar(64)
+            if (rowData.length > 2 && rowData[2] != null && rowData[2].length() > 64) {
+                logger.warn("姓名字段长度超过64字符限制: {}", rowData[2]);
+                return false;
+            }
+
+            // last_name: varchar(32)
+            if (rowData.length > 3 && rowData[3] != null && rowData[3].length() > 32) {
+                logger.warn("名字字段长度超过32字符限制: {}", rowData[3]);
+                return false;
+            }
+
+            // customer_name: varchar(128)
+            if (rowData.length > 4 && rowData[4] != null && rowData[4].length() > 128) {
+                logger.warn("顾客名字段长度超过128字符限制: {}", rowData[4]);
+                return false;
+            }
+
+            // phone_number: varchar(256)
+            if (rowData.length > 5 && rowData[5] != null && rowData[5].length() > 256) {
+                logger.warn("电话号码字段长度超过256字符限制: {}", rowData[5]);
+                return false;
+            }
+
+            // 其他字段长度检查...
+            // sys_customer_id: varchar(20)
+            if (rowData.length > 6 && rowData[6] != null && rowData[6].length() > 20) {
+                logger.warn("系统顾客ID字段长度超过20字符限制: {}", rowData[6]);
+                return false;
+            }
+
+            // member_id: varchar(20)
+            if (rowData.length > 7 && rowData[7] != null && rowData[7].length() > 20) {
+                logger.warn("会员ID字段长度超过20字符限制: {}", rowData[7]);
+                return false;
+            }
+
+            // furigana_sei: varchar(56)
+            if (rowData.length > 8 && rowData[8] != null && rowData[8].length() > 56) {
+                logger.warn("姓フリガナ字段长度超过56字符限制: {}", rowData[8]);
+                return false;
+            }
+
+            // furigana_mei: varchar(56)
+            if (rowData.length > 9 && rowData[9] != null && rowData[9].length() > 56) {
+                logger.warn("名フリガナ字段长度超过56字符限制: {}", rowData[9]);
+                return false;
+            }
+
+            // 继续检查其他字段...
+
+            return true;
+
+        } catch (Exception e) {
+            logger.error("字段长度检查异常: {}", e.getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * 根据CSV行数据创建顾客实体对象
+     * 
+     * @param rowData CSV行数据
+     * @return 顾客实体对象
+     */
+    private MastCustomerEntity createCustomerEntity(String[] rowData) {
+        MastCustomerEntity customer = new MastCustomerEntity();
+
+        // 设置必填字段
+        if (rowData.length > 0)
+            customer.setId(rowData[0]);
+        if (rowData.length > 1)
+            customer.setCustomerId(rowData[1]);
+        if (rowData.length > 2)
+            customer.setFirstName(rowData[2]);
+        if (rowData.length > 3)
+            customer.setLastName(rowData[3]);
+        if (rowData.length > 4)
+            customer.setCustomerName(rowData[4]);
+        if (rowData.length > 5)
+            customer.setPhoneNumber(rowData[5]);
+
+        // 设置可选字段
+        if (rowData.length > 6)
+            customer.setSysCustomerId(rowData[6]);
+        if (rowData.length > 7)
+            customer.setMemberId(rowData[7]);
+        if (rowData.length > 8)
+            customer.setFuriganaSei(rowData[8]);
+        if (rowData.length > 9)
+            customer.setFuriganaMei(rowData[9]);
+        if (rowData.length > 10)
+            customer.setCompanyName(rowData[10]);
+        if (rowData.length > 11)
+            customer.setDepartmentName(rowData[11]);
+        if (rowData.length > 12)
+            customer.setPostalCode(rowData[12]);
+        if (rowData.length > 13)
+            customer.setPrefecture(rowData[13]);
+        if (rowData.length > 14)
+            customer.setCity(rowData[14]);
+        if (rowData.length > 15)
+            customer.setTownStreetArea(rowData[15]);
+        if (rowData.length > 16)
+            customer.setBuildingEtc(rowData[16]);
+        if (rowData.length > 17)
+            customer.setAddress(rowData[17]);
+        if (rowData.length > 18)
+            customer.setMailAddress(rowData[18]);
+        if (rowData.length > 19)
+            customer.setMobile(rowData[19]);
+        if (rowData.length > 20)
+            customer.setFax(rowData[20]);
+        if (rowData.length > 21)
+            customer.setFarmerId(rowData[21]);
+
+        // 设置默认值
+        customer.setSystemSourceFlg("0");
+        customer.setDiscontinuedFlg("0");
+        customer.setVersion(1);
+
+        return customer;
+    }
+
+}

+ 285 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/util/AwsS3Utils.java

@@ -0,0 +1,285 @@
+package jp.yamoto.farm.quartz.util;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.*;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+
+/**
+ * AWS S3工具类
+ * 提供文件上传、下载、删除、生成预签名URL等功能
+ * 
+ * @author yamoto
+ * @version 1.0
+ */
+@Component
+public class AwsS3Utils {
+    
+    private static final Logger logger = LoggerFactory.getLogger(AwsS3Utils.class);
+    
+    @Value("${aws.s3.access-key:test}")
+    private String accessKey;
+    
+    @Value("${aws.s3.secret-key:test}")
+    private String secretKey;
+    
+    @Value("${aws.s3.region:us-east-1}")
+    private String region;
+    
+    @Value("${aws.s3.bucket-name:my-unencrypted-bucket}")
+    private String bucketName;
+    
+    /**
+     * 创建S3客户端
+     */
+    private S3Client createS3Client() {
+        return S3Client.builder()
+                .region(Region.of(region))
+                .credentialsProvider(StaticCredentialsProvider.create(
+                    AwsBasicCredentials.create(accessKey, secretKey)))
+                .build();
+    }
+    
+    /**
+     * 上传文件到S3
+     * 
+     * @param key S3对象键
+     * @param filePath 本地文件路径
+     * @return 上传成功返回true
+     */
+    public boolean uploadFile(String key, String filePath) {
+        try (S3Client s3Client = createS3Client()) {
+            File file = new File(filePath);
+            if (!file.exists()) {
+                logger.error("文件不存在: {}", filePath);
+                return false;
+            }
+            
+            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            s3Client.putObject(putObjectRequest, RequestBody.fromFile(file));
+            logger.info("文件上传成功: {} -> {}/{}", filePath, bucketName, key);
+            return true;
+            
+        } catch (S3Exception e) {
+            logger.error("S3文件上传失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("文件上传异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 上传字节数组到S3
+     * 
+     * @param key S3对象键
+     * @param data 字节数组数据
+     * @return 上传成功返回true
+     */
+    public boolean uploadBytes(String key, byte[] data) {
+        try (S3Client s3Client = createS3Client()) {
+            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            s3Client.putObject(putObjectRequest, RequestBody.fromBytes(data));
+            logger.info("字节数据上传成功: {}/{}", bucketName, key);
+            return true;
+            
+        } catch (S3Exception e) {
+            logger.error("S3字节数据上传失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("字节数据上传异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 从S3下载文件
+     * 
+     * @param key S3对象键
+     * @param downloadPath 下载到本地的路径
+     * @return 下载成功返回true
+     */
+    public boolean downloadFile(String key, String downloadPath) {
+        try (S3Client s3Client = createS3Client()) {
+            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            // 确保目录存在
+            Path path = Paths.get(downloadPath);
+            Files.createDirectories(path.getParent());
+            
+            s3Client.getObject(getObjectRequest, path);
+            logger.info("文件下载成功: {}/{} -> {}", bucketName, key, downloadPath);
+            return true;
+            
+        } catch (S3Exception e) {
+            logger.error("S3文件下载失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("文件下载异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 从S3下载文件为字节数组
+     * 
+     * @param key S3对象键
+     * @return 文件字节数组,失败返回null
+     */
+    public byte[] downloadBytes(String key) {
+        try (S3Client s3Client = createS3Client()) {
+            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            try (InputStream inputStream = s3Client.getObject(getObjectRequest)) {
+                return inputStream.readAllBytes();
+            }
+            
+        } catch (S3Exception e) {
+            logger.error("S3文件下载失败: {}", e.awsErrorDetails().errorMessage());
+            return null;
+        } catch (Exception e) {
+            logger.error("文件下载异常: {}", e.getMessage());
+            return null;
+        }
+    }
+    
+    /**
+     * 删除S3文件
+     * 
+     * @param key S3对象键
+     * @return 删除成功返回true
+     */
+    public boolean deleteFile(String key) {
+        try (S3Client s3Client = createS3Client()) {
+            DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            s3Client.deleteObject(deleteObjectRequest);
+            logger.info("文件删除成功: {}/{}", bucketName, key);
+            return true;
+            
+        } catch (S3Exception e) {
+            logger.error("S3文件删除失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("文件删除异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 生成预签名URL(用于临时访问)
+     * 
+     * @param key S3对象键
+     * @param expirationMinutes 过期时间(分钟)
+     * @return 预签名URL
+     */
+    public String generatePresignedUrl(String key, int expirationMinutes) {
+        try (S3Presigner presigner = S3Presigner.builder()
+                .region(Region.of(region))
+                .credentialsProvider(StaticCredentialsProvider.create(
+                    AwsBasicCredentials.create(accessKey, secretKey)))
+                .build()) {
+            
+            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
+                    .signatureDuration(Duration.ofMinutes(expirationMinutes))
+                    .getObjectRequest(getObjectRequest)
+                    .build();
+            
+            PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
+            URL url = presignedRequest.url();
+            
+            logger.info("生成预签名URL: {}", url);
+            return url.toString();
+            
+        } catch (Exception e) {
+            logger.error("生成预签名URL异常: {}", e.getMessage());
+            return null;
+        }
+    }
+    
+    /**
+     * 检查文件是否存在
+     * 
+     * @param key S3对象键
+     * @return 存在返回true
+     */
+    public boolean fileExists(String key) {
+        try (S3Client s3Client = createS3Client()) {
+            HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            s3Client.headObject(headObjectRequest);
+            return true;
+            
+        } catch (NoSuchKeyException e) {
+            return false;
+        } catch (Exception e) {
+            logger.error("检查文件存在异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 获取文件大小
+     * 
+     * @param key S3对象键
+     * @return 文件大小(字节),失败返回-1
+     */
+    public long getFileSize(String key) {
+        try (S3Client s3Client = createS3Client()) {
+            HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
+                    .bucket(bucketName)
+                    .key(key)
+                    .build();
+            
+            HeadObjectResponse response = s3Client.headObject(headObjectRequest);
+            return response.contentLength();
+            
+        } catch (Exception e) {
+            logger.error("获取文件大小异常: {}", e.getMessage());
+            return -1;
+        }
+    }
+}

+ 204 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/util/AwsSesUtils.java

@@ -0,0 +1,204 @@
+package jp.yamoto.farm.quartz.util;
+
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.ses.SesClient;
+import software.amazon.awssdk.services.ses.model.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * AWS SES邮件服务工具类
+ * 提供邮件发送、验证等功能
+ * 
+ * @author yamoto
+ * @version 1.0
+ */
+@Component
+public class AwsSesUtils {
+    
+    private static final Logger logger = LoggerFactory.getLogger(AwsSesUtils.class);
+    
+    @Value("${aws.ses.access-key:test}")
+    private String accessKey;
+    
+    @Value("${aws.ses.secret-key:test}")        
+    private String secretKey;
+    
+    @Value("${aws.ses.region:us-east-1}")
+    private String region;
+    
+    @Value("${aws.ses.from-email:sender@nextosd.com}")
+    private String fromEmail;
+    
+    /**
+     * 创建SES客户端
+     */
+    private SesClient createSesClient() {
+        return SesClient.builder()
+                .region(Region.of(region))
+                .credentialsProvider(StaticCredentialsProvider.create(
+                    AwsBasicCredentials.create(accessKey, secretKey)))
+                .build();
+    }
+    
+    /**
+     * 发送简单文本邮件
+     * 
+     * @param toEmail 收件人邮箱
+     * @param subject 邮件主题
+     * @param content 邮件内容
+     * @return 发送成功返回true
+     */
+    public boolean sendSimpleEmail(String toEmail, String subject, String content) {
+        try (SesClient sesClient = createSesClient()) {
+            SendEmailRequest emailRequest = SendEmailRequest.builder()
+                    .destination(Destination.builder().toAddresses(toEmail).build())
+                    .message(Message.builder()
+                            .subject(Content.builder().data(subject).build())
+                            .body(Body.builder()
+                                    .text(Content.builder().data(content).build())
+                                    .build())
+                            .build())
+                    .source(fromEmail)
+                    .build();
+            
+            sesClient.sendEmail(emailRequest);
+            logger.info("邮件发送成功: {} -> {}", fromEmail, toEmail);
+            return true;
+            
+        } catch (SesException e) {
+            logger.error("SES邮件发送失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("邮件发送异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 发送HTML格式邮件
+     * 
+     * @param toEmail 收件人邮箱
+     * @param subject 邮件主题
+     * @param htmlContent HTML邮件内容
+     * @return 发送成功返回true
+     */
+    public boolean sendHtmlEmail(String toEmail, String subject, String htmlContent) {
+        try (SesClient sesClient = createSesClient()) {
+            SendEmailRequest emailRequest = SendEmailRequest.builder()
+                    .destination(Destination.builder().toAddresses(toEmail).build())
+                    .message(Message.builder()
+                            .subject(Content.builder().data(subject).build())
+                            .body(Body.builder()
+                                    .html(Content.builder().data(htmlContent).build())
+                                    .build())
+                            .build())
+                    .source(fromEmail)
+                    .build();
+            
+            sesClient.sendEmail(emailRequest);
+            logger.info("HTML邮件发送成功: {} -> {}", fromEmail, toEmail);
+            return true;
+            
+        } catch (SesException e) {
+            logger.error("SES HTML邮件发送失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("HTML邮件发送异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 发送带附件的邮件
+     * 
+     * @param toEmail 收件人邮箱
+     * @param subject 邮件主题
+     * @param content 邮件内容
+     * @param attachmentPath 附件文件路径
+     * @return 发送成功返回true
+     */
+    public boolean sendEmailWithAttachment(String toEmail, String subject, String content, String attachmentPath) {
+        try (SesClient sesClient = createSesClient()) {
+            // 这里需要实现MIME格式的邮件发送,简化版本先返回false
+            logger.warn("带附件邮件功能暂未实现");
+            return false;
+            
+        } catch (Exception e) {
+            logger.error("带附件邮件发送异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 批量发送邮件
+     * 
+     * @param toEmails 收件人邮箱列表
+     * @param subject 邮件主题
+     * @param content 邮件内容
+     * @return 成功发送的数量
+     */
+    public int sendBulkEmail(List<String> toEmails, String subject, String content) {
+        int successCount = 0;
+        
+        for (String toEmail : toEmails) {
+            if (sendSimpleEmail(toEmail, subject, content)) {
+                successCount++;
+            }
+        }
+        
+        logger.info("批量邮件发送完成: 成功 {} 封,总共 {} 封", successCount, toEmails.size());
+        return successCount;
+    }
+    
+    /**
+     * 验证邮箱地址
+     * 
+     * @param email 需要验证的邮箱地址
+     * @return 验证成功返回true
+     */
+    public boolean verifyEmail(String email) {
+        try (SesClient sesClient = createSesClient()) {
+            VerifyEmailIdentityRequest verifyRequest = VerifyEmailIdentityRequest.builder()
+                    .emailAddress(email)
+                    .build();
+            
+            sesClient.verifyEmailIdentity(verifyRequest);
+            logger.info("邮箱验证请求已发送: {}", email);
+            return true;
+            
+        } catch (SesException e) {
+            logger.error("邮箱验证失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            logger.error("邮箱验证异常: {}", e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 获取发送统计信息
+     */
+    public void getSendStatistics() {
+        try (SesClient sesClient = createSesClient()) {
+            GetSendStatisticsRequest statisticsRequest = GetSendStatisticsRequest.builder().build();
+            GetSendStatisticsResponse response = sesClient.getSendStatistics();
+            
+            List<SendDataPoint> dataPoints = response.sendDataPoints();
+            for (SendDataPoint dataPoint : dataPoints) {
+                logger.info("发送统计 - 时间: {}, 投递尝试: {}, 退回: {}, 投诉: {}", 
+                        dataPoint.timestamp(), dataPoint.deliveryAttempts(), 
+                        dataPoint.bounces(), dataPoint.complaints());
+            }
+            
+        } catch (Exception e) {
+            logger.error("获取发送统计异常: {}", e.getMessage());
+        }
+    }
+}

+ 192 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/util/CsvUtils.java

@@ -0,0 +1,192 @@
+package jp.yamoto.farm.quartz.util;
+
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * CSVファイル読み書きユーティリティクラス
+ * 
+ * @author nextosd
+ */
+public class CsvUtils
+{
+    /**
+     * CSVファイルを読み込み、2次元文字列配列として返す
+     * 
+     * @param filePath CSVファイルのパス
+     * @return 2次元文字列配列(行と列のデータ)
+     * @throws IOException ファイル読み込みエラーが発生した場合
+     */
+    public static String[][] readCsv(String filePath) throws IOException {
+        return readCsv(filePath, StandardCharsets.UTF_8);
+    }
+    
+    /**
+     * CSVファイルを読み込み、2次元文字列配列として返す
+     * 
+     * @param filePath CSVファイルのパス
+     * @param charset 文字コード
+     * @return 2次元文字列配列(行と列のデータ)
+     * @throws IOException ファイル読み込みエラーが発生した場合
+     */
+    public static String[][] readCsv(String filePath, java.nio.charset.Charset charset) throws IOException {
+        List<String[]> rows = new ArrayList<>();
+        
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(filePath), charset))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                // 空行をスキップ
+                if (line.trim().isEmpty()) {
+                    continue;
+                }
+                
+                // CSV解析(カンマ区切り、ダブルクォート対応)
+                String[] columns = parseCsvLine(line);
+                rows.add(columns);
+            }
+        }
+        
+        return rows.toArray(new String[0][]);
+    }
+    
+    /**
+     * 2次元文字列配列をCSVファイルに書き込む
+     * 
+     * @param filePath CSVファイルのパス
+     * @param data 書き込むデータ(2次元文字列配列)
+     * @throws IOException ファイル書き込みエラーが発生した場合
+     */
+    public static void writeCsv(String filePath, String[][] data) throws IOException {
+        writeCsv(filePath, data, StandardCharsets.UTF_8);
+    }
+    
+    /**
+     * 2次元文字列配列をCSVファイルに書き込む
+     * 
+     * @param filePath CSVファイルのパス
+     * @param data 書き込むデータ(2次元文字列配列)
+     * @param charset 文字コード
+     * @throws IOException ファイル書き込みエラーが発生した場合
+     */
+    public static void writeCsv(String filePath, String[][] data, java.nio.charset.Charset charset) throws IOException {
+        try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(filePath), charset))) {
+            for (int i = 0; i < data.length; i++) {
+                String[] row = data[i];
+                StringBuilder line = new StringBuilder();
+                
+                for (int j = 0; j < row.length; j++) {
+                    String value = row[j] != null ? row[j] : "";
+                    
+                    // 値にカンマ、ダブルクォート、改行が含まれる場合はダブルクォートで囲む
+                    if (value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r")) {
+                        value = "\"" + value.replace("\"", "\"\"") + "\"";
+                    }
+                    
+                    line.append(value);
+                    if (j < row.length - 1) {
+                        line.append(",");
+                    }
+                }
+                
+                writer.write(line.toString());
+                if (i < data.length - 1) {
+                    writer.newLine();
+                }
+            }
+        }
+    }
+    
+    /**
+     * CSV行を解析してカラムに分割する
+     * 
+     * @param line CSV行
+     * @return 分割されたカラム配列
+     */
+    private static String[] parseCsvLine(String line) {
+        List<String> columns = new ArrayList<>();
+        StringBuilder currentColumn = new StringBuilder();
+        boolean inQuotes = false;
+        
+        for (int i = 0; i < line.length(); i++) {
+            char c = line.charAt(i);
+            
+            if (c == '"') {
+                if (inQuotes && i + 1 < line.length() && line.charAt(i + 1) == '"') {
+                    // エスケープされたダブルクォート
+                    currentColumn.append('"');
+                    i++; // 次の文字をスキップ
+                } else {
+                    // クォートの開始/終了
+                    inQuotes = !inQuotes;
+                }
+            } else if (c == ',' && !inQuotes) {
+                // カラムの終了
+                columns.add(currentColumn.toString());
+                currentColumn.setLength(0);
+            } else {
+                currentColumn.append(c);
+            }
+        }
+        
+        // 最後のカラムを追加
+        columns.add(currentColumn.toString());
+        
+        return columns.toArray(new String[0]);
+    }
+    
+    /**
+     * CSVファイルの内容をコンソールに表示する(デバッグ用)
+     * 
+     * @param filePath CSVファイルのパス
+     * @throws IOException ファイル読み込みエラーが発生した場合
+     */
+    public static void printCsv(String filePath) throws IOException {
+        String[][] data = readCsv(filePath);
+        printCsvData(data);
+    }
+    
+    /**
+     * 2次元文字列配列をコンソールに表示する(デバッグ用)
+     * 
+     * @param data 表示するデータ
+     */
+    public static void printCsvData(String[][] data) {
+        for (int i = 0; i < data.length; i++) {
+            System.out.print("行" + (i + 1) + ": ");
+            for (int j = 0; j < data[i].length; j++) {
+                System.out.print("[" + data[i][j] + "] ");
+            }
+            System.out.println();
+        }
+    }
+    
+    /**
+     * テスト用メインメソッド
+     */
+    public static void main(String[] args) {
+        try {
+            // テストデータの作成
+            String[][] testData = {
+                {"システム顧客番号", "会員ID", "氏名(姓)", "氏名(名)", "氏名フリガナ(セイ)", "氏名フリガナ(メイ)"},
+                {"01-28666-M000000016", "00000001", "大和", "太郎", "ヤマト", "タロウ"},
+                {"01-28666-M000000052", "muro0929", "テスト", "室", "テスト", "ムロオカ"}
+            };
+            
+            String testFilePath = "test_output.csv";
+            
+            // CSV書き込みテスト
+            writeCsv(testFilePath, testData);
+            System.out.println("CSVファイルを書き込みました: " + testFilePath);
+            
+            // CSV読み込みテスト
+            String[][] readData = readCsv(testFilePath);
+            System.out.println("CSVファイルを読み込みました:");
+            printCsvData(readData);
+            
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 380 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/util/SftpUtils.java

@@ -0,0 +1,380 @@
+package jp.yamoto.farm.quartz.util;
+
+import com.jcraft.jsch.ChannelSftp;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import com.jcraft.jsch.SftpException;
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.util.Properties;
+import java.util.Vector;
+
+/**
+ * SFTP工具类 - 提供SFTP连接管理、文件下载、上传和存在判断功能
+ * 
+ * @author yamoto
+ * @version 1.0
+ */
+public class SftpUtils {
+    
+    private static final Logger logger = LoggerFactory.getLogger(SftpUtils.class);
+    
+    /**
+     * 默认SFTP端口
+     */
+    private static final int DEFAULT_SFTP_PORT = 22;
+    
+    /**
+     * 默认连接超时时间(毫秒)
+     */
+    private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
+    
+    /**
+     * 默认会话超时时间(毫秒)
+     */
+    private static final int DEFAULT_SESSION_TIMEOUT = 30000;
+    
+    /**
+     * 建立SFTP连接
+     * 
+     * @param host 主机地址
+     * @param port 端口号
+     * @param username 用户名
+     * @param password 密码
+     * @return ChannelSftp对象
+     * @throws JSchException SFTP连接异常
+     */
+    public static ChannelSftp connect(String host, int port, String username, String password) throws JSchException {
+        JSch jsch = new JSch();
+        Session session = jsch.getSession(username, host, port);
+        session.setPassword(password);
+        
+        Properties config = new Properties();
+        config.put("StrictHostKeyChecking", "no");
+        session.setConfig(config);
+        
+        session.setTimeout(DEFAULT_SESSION_TIMEOUT);
+        session.connect(DEFAULT_CONNECT_TIMEOUT);
+        
+        ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
+        channel.connect();
+        
+        logger.info("SFTP连接成功 - 主机: {}, 端口: {}, 用户: {}", host, port, username);
+        return channel;
+    }
+    
+    /**
+     * 建立SFTP连接(使用默认端口22)
+     * 
+     * @param host 主机地址
+     * @param username 用户名
+     * @param password 密码
+     * @return ChannelSftp对象
+     * @throws JSchException SFTP连接异常
+     */
+    public static ChannelSftp connect(String host, String username, String password) throws JSchException {
+        return connect(host, DEFAULT_SFTP_PORT, username, password);
+    }
+    
+    /**
+     * 关闭SFTP连接
+     * 
+     * @param channel SFTP通道
+     */
+    public static void disconnect(ChannelSftp channel) {
+        if (channel != null) {
+            Session session = null;
+            try {
+                // 先获取session,再断开通道
+                session = channel.getSession();
+                channel.disconnect();
+            } catch (Exception e) {
+                logger.warn("关闭SFTP通道时发生异常: {}", e.getMessage());
+            }
+            
+            // 断开session连接
+            if (session != null && session.isConnected()) {
+                try {
+                    session.disconnect();
+                } catch (Exception e) {
+                    logger.warn("关闭SFTP会话时发生异常: {}", e.getMessage());
+                }
+            }
+            
+            logger.info("SFTP连接已关闭");
+        }
+    }
+    
+    /**
+     * 下载SFTP服务器上的文件
+     * 
+     * @param channel SFTP通道
+     * @param remotePath 远程文件路径
+     * @param localPath 本地保存路径
+     * @return 下载是否成功
+     */
+    public static boolean downloadFile(ChannelSftp channel, String remotePath, String localPath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return false;
+        }
+        
+        try (OutputStream outputStream = new FileOutputStream(localPath)) {
+            channel.get(remotePath, outputStream);
+            logger.info("文件下载成功 - 远程路径: {}, 本地路径: {}", remotePath, localPath);
+            return true;
+        } catch (SftpException | IOException e) {
+            logger.error("文件下载失败 - 远程路径: {}, 本地路径: {}, 错误: {}", 
+                remotePath, localPath, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 下载SFTP服务器上的文件到字节数组
+     * 
+     * @param channel SFTP通道
+     * @param remotePath 远程文件路径
+     * @return 文件内容的字节数组,失败返回null
+     */
+    public static byte[] downloadFileToBytes(ChannelSftp channel, String remotePath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return null;
+        }
+        
+        try (InputStream inputStream = channel.get(remotePath)) {
+            byte[] fileData = IOUtils.toByteArray(inputStream);
+            logger.info("文件下载到字节数组成功 - 远程路径: {}, 文件大小: {} bytes", 
+                remotePath, fileData.length);
+            return fileData;
+        } catch (SftpException | IOException e) {
+            logger.error("文件下载到字节数组失败 - 远程路径: {}, 错误: {}", 
+                remotePath, e.getMessage());
+            return null;
+        }
+    }
+    
+    /**
+     * 上传本地文件到SFTP服务器
+     * 
+     * @param channel SFTP通道
+     * @param localPath 本地文件路径
+     * @param remotePath 远程保存路径
+     * @return 上传是否成功
+     */
+    public static boolean uploadFile(ChannelSftp channel, String localPath, String remotePath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return false;
+        }
+        
+        try (InputStream inputStream = new FileInputStream(localPath)) {
+            // 确保远程目录存在
+            createRemoteDirectory(channel, remotePath);
+            
+            channel.put(inputStream, remotePath);
+            logger.info("文件上传成功 - 本地路径: {}, 远程路径: {}", localPath, remotePath);
+            return true;
+        } catch (SftpException | IOException e) {
+            logger.error("文件上传失败 - 本地路径: {}, 远程路径: {}, 错误: {}", 
+                localPath, remotePath, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 上传字节数组到SFTP服务器
+     * 
+     * @param channel SFTP通道
+     * @param fileData 文件数据字节数组
+     * @param remotePath 远程保存路径
+     * @return 上传是否成功
+     */
+    public static boolean uploadBytesToFile(ChannelSftp channel, byte[] fileData, String remotePath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return false;
+        }
+        
+        try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
+            // 确保远程目录存在
+            createRemoteDirectory(channel, remotePath);
+            
+            channel.put(inputStream, remotePath);
+            logger.info("字节数组上传成功 - 远程路径: {}, 数据大小: {} bytes", 
+                remotePath, fileData.length);
+            return true;
+        } catch (SftpException | IOException e) {
+            logger.error("字节数组上传失败 - 远程路径: {}, 错误: {}", 
+                remotePath, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 判断SFTP服务器上的文件是否存在
+     * 
+     * @param channel SFTP通道
+     * @param remotePath 远程文件路径
+     * @return 文件是否存在
+     */
+    public static boolean fileExists(ChannelSftp channel, String remotePath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return false;
+        }
+        
+        try {
+            channel.lstat(remotePath);
+            logger.debug("文件存在 - 远程路径: {}", remotePath);
+            return true;
+        } catch (SftpException e) {
+            if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
+                logger.debug("文件不存在 - 远程路径: {}", remotePath);
+                return false;
+            } else {
+                logger.error("检查文件存在性时发生异常 - 远程路径: {}, 错误: {}", 
+                    remotePath, e.getMessage());
+                return false;
+            }
+        }
+    }
+    
+    /**
+     * 删除SFTP服务器上的文件
+     * 
+     * @param channel SFTP通道
+     * @param remotePath 远程文件路径
+     * @return 删除是否成功
+     */
+    public static boolean deleteFile(ChannelSftp channel, String remotePath) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return false;
+        }
+        
+        try {
+            channel.rm(remotePath);
+            logger.info("文件删除成功 - 远程路径: {}", remotePath);
+            return true;
+        } catch (SftpException e) {
+            logger.error("文件删除失败 - 远程路径: {}, 错误: {}", remotePath, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 列出SFTP服务器上指定目录的文件
+     * 
+     * @param channel SFTP通道
+     * @param remoteDir 远程目录路径
+     * @return 文件列表,失败返回null
+     */
+    public static Vector<ChannelSftp.LsEntry> listFiles(ChannelSftp channel, String remoteDir) {
+        if (channel == null || !channel.isConnected()) {
+            logger.error("SFTP通道未连接或已断开");
+            return null;
+        }
+        
+        try {
+            Vector<ChannelSftp.LsEntry> files = channel.ls(remoteDir);
+            logger.debug("列出文件成功 - 远程目录: {}, 文件数量: {}", remoteDir, files.size());
+            return files;
+        } catch (SftpException e) {
+            logger.error("列出文件失败 - 远程目录: {}, 错误: {}", remoteDir, e.getMessage());
+            return null;
+        }
+    }
+    
+    /**
+     * 创建远程目录(如果不存在)
+     * 
+     * @param channel SFTP通道
+     * @param remotePath 远程路径
+     */
+    private static void createRemoteDirectory(ChannelSftp channel, String remotePath) {
+        try {
+            String directory = remotePath.substring(0, remotePath.lastIndexOf('/'));
+            if (!directory.isEmpty()) {
+                // 递归创建目录
+                String[] dirs = directory.split("/");
+                StringBuilder currentPath = new StringBuilder();
+                
+                for (String dir : dirs) {
+                    if (!dir.isEmpty()) {
+                        currentPath.append("/").append(dir);
+                        try {
+                            channel.mkdir(currentPath.toString());
+                        } catch (SftpException e) {
+                            // 目录已存在,忽略异常
+                            if (e.id != ChannelSftp.SSH_FX_FAILURE) {
+                                throw e;
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (SftpException e) {
+            logger.warn("创建远程目录失败: {}", e.getMessage());
+        }
+    }
+    
+    /**
+     * 测试SFTP连接
+     * 
+     * @param host 主机地址
+     * @param port 端口号
+     * @param username 用户名
+     * @param password 密码
+     * @return 连接是否成功
+     */
+    public static boolean testConnection(String host, int port, String username, String password) {
+        ChannelSftp channel = null;
+        try {
+            channel = connect(host, port, username, password);
+            return channel != null && channel.isConnected();
+        } catch (JSchException e) {
+            logger.error("SFTP连接测试失败 - 主机: {}, 端口: {}, 用户: {}, 错误: {}", 
+                host, port, username, e.getMessage());
+            return false;
+        } finally {
+            if (channel != null) {
+                disconnect(channel);
+            }
+        }
+    }
+    
+    /**
+     * 测试SFTP连接(使用默认端口22)
+     * 
+     * @param host 主机地址
+     * @param username 用户名
+     * @param password 密码
+     * @return 连接是否成功
+     */
+    public static boolean testConnection(String host, String username, String password) {
+        return testConnection(host, DEFAULT_SFTP_PORT, username, password);
+    }
+    
+    /**
+     * 主方法 - 测试SFTP功能
+     */
+    public static void main(String[] args) {
+        // 测试连接
+        String host = "sftp.example.com";
+        String username = "testuser";
+        String password = "testpass";
+        
+        boolean connected = testConnection(host, username, password);
+        System.out.println("SFTP连接测试: " + (connected ? "成功" : "失败"));
+        
+        if (connected) {
+            System.out.println("SFTP工具类功能测试完成");
+        }
+    }
+}

+ 413 - 0
farm-quartz/src/main/java/jp/yamoto/farm/quartz/util/ZipUtils.java

@@ -0,0 +1,413 @@
+package jp.yamoto.farm.quartz.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.*;
+
+/**
+ * ZIP文件压缩和解压共通工具类
+ * 
+ * @author Yamoto Farm
+ * @version 1.0
+ */
+public class ZipUtils {
+    
+    private static final Logger logger = LoggerFactory.getLogger(ZipUtils.class);
+    
+    private static final int BUFFER_SIZE = 1024;
+    
+    /**
+     * 压缩单个文件到ZIP
+     * 
+     * @param sourceFile 源文件路径
+     * @param zipFile ZIP文件路径
+     * @return 压缩是否成功
+     */
+    public static boolean compressFile(String sourceFile, String zipFile) {
+        return compressFiles(new String[]{sourceFile}, zipFile);
+    }
+    
+    /**
+     * 压缩多个文件到ZIP
+     * 
+     * @param sourceFiles 源文件路径数组
+     * @param zipFile ZIP文件路径
+     * @return 压缩是否成功
+     */
+    public static boolean compressFiles(String[] sourceFiles, String zipFile) {
+        if (sourceFiles == null || sourceFiles.length == 0) {
+            logger.error("源文件列表为空");
+            return false;
+        }
+        
+        try (FileOutputStream fos = new FileOutputStream(zipFile);
+             ZipOutputStream zos = new ZipOutputStream(fos)) {
+            
+            for (String sourceFile : sourceFiles) {
+                File file = new File(sourceFile);
+                if (!file.exists()) {
+                    logger.warn("文件不存在: {}", sourceFile);
+                    continue;
+                }
+                
+                addFileToZip(file, file.getName(), zos);
+            }
+            
+            logger.info("文件压缩成功 - ZIP文件: {}, 压缩文件数: {}", 
+                zipFile, sourceFiles.length);
+            return true;
+            
+        } catch (IOException e) {
+            logger.error("文件压缩失败 - ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 压缩目录到ZIP
+     * 
+     * @param sourceDir 源目录路径
+     * @param zipFile ZIP文件路径
+     * @return 压缩是否成功
+     */
+    public static boolean compressDirectory(String sourceDir, String zipFile) {
+        return compressDirectory(sourceDir, zipFile, true);
+    }
+    
+    /**
+     * 压缩目录到ZIP
+     * 
+     * @param sourceDir 源目录路径
+     * @param zipFile ZIP文件路径
+     * @param includeSubdirs 是否包含子目录
+     * @return 压缩是否成功
+     */
+    public static boolean compressDirectory(String sourceDir, String zipFile, boolean includeSubdirs) {
+        File dir = new File(sourceDir);
+        if (!dir.exists() || !dir.isDirectory()) {
+            logger.error("目录不存在或不是目录: {}", sourceDir);
+            return false;
+        }
+        
+        try (FileOutputStream fos = new FileOutputStream(zipFile);
+             ZipOutputStream zos = new ZipOutputStream(fos)) {
+            
+            int fileCount = addDirectoryToZip(dir, dir.getName(), zos, includeSubdirs);
+            
+            logger.info("目录压缩成功 - ZIP文件: {}, 源目录: {}, 压缩文件数: {}", 
+                zipFile, sourceDir, fileCount);
+            return true;
+            
+        } catch (IOException e) {
+            logger.error("目录压缩失败 - ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 解压ZIP文件到指定目录
+     * 
+     * @param zipFile ZIP文件路径
+     * @param destDir 目标目录路径
+     * @return 解压是否成功
+     */
+    public static boolean decompress(String zipFile, String destDir) {
+        return decompress(zipFile, destDir, true);
+    }
+    
+    /**
+     * 解压ZIP文件到指定目录
+     * 
+     * @param zipFile ZIP文件路径
+     * @param destDir 目标目录路径
+     * @param overwriteExisting 是否覆盖已存在的文件
+     * @return 解压是否成功
+     */
+    public static boolean decompress(String zipFile, String destDir, boolean overwriteExisting) {
+        File zip = new File(zipFile);
+        if (!zip.exists()) {
+            logger.error("ZIP文件不存在: {}", zipFile);
+            return false;
+        }
+        
+        File dest = new File(destDir);
+        if (!dest.exists()) {
+            dest.mkdirs();
+        }
+        
+        int fileCount = 0;
+        try (FileInputStream fis = new FileInputStream(zip);
+             ZipInputStream zis = new ZipInputStream(fis)) {
+            
+            ZipEntry entry;
+            byte[] buffer = new byte[BUFFER_SIZE];
+            
+            while ((entry = zis.getNextEntry()) != null) {
+                String entryName = entry.getName();
+                File entryFile = new File(dest, entryName);
+                
+                // 创建目录结构
+                if (entry.isDirectory()) {
+                    entryFile.mkdirs();
+                    continue;
+                }
+                
+                // 检查文件是否已存在
+                if (entryFile.exists() && !overwriteExisting) {
+                    logger.debug("跳过已存在的文件: {}", entryName);
+                    continue;
+                }
+                
+                // 确保父目录存在
+                File parentDir = entryFile.getParentFile();
+                if (!parentDir.exists()) {
+                    parentDir.mkdirs();
+                }
+                
+                // 写入文件
+                try (FileOutputStream fos = new FileOutputStream(entryFile)) {
+                    int length;
+                    while ((length = zis.read(buffer)) > 0) {
+                        fos.write(buffer, 0, length);
+                    }
+                }
+                
+                fileCount++;
+                zis.closeEntry();
+            }
+            
+            logger.info("ZIP文件解压成功 - ZIP文件: {}, 目标目录: {}, 解压文件数: {}", 
+                zipFile, destDir, fileCount);
+            return true;
+            
+        } catch (IOException e) {
+            logger.error("ZIP文件解压失败 - ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 获取ZIP文件中的文件列表
+     * 
+     * @param zipFile ZIP文件路径
+     * @return 文件列表,失败返回null
+     */
+    public static List<String> listZipEntries(String zipFile) {
+        File zip = new File(zipFile);
+        if (!zip.exists()) {
+            logger.error("ZIP文件不存在: {}", zipFile);
+            return null;
+        }
+        
+        List<String> entries = new ArrayList<>();
+        try (FileInputStream fis = new FileInputStream(zip);
+             ZipInputStream zis = new ZipInputStream(fis)) {
+            
+            ZipEntry entry;
+            while ((entry = zis.getNextEntry()) != null) {
+                entries.add(entry.getName());
+                zis.closeEntry();
+            }
+            
+            logger.debug("获取ZIP文件列表成功 - ZIP文件: {}, 文件数: {}", 
+                zipFile, entries.size());
+            return entries;
+            
+        } catch (IOException e) {
+            logger.error("获取ZIP文件列表失败 - ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return null;
+        }
+    }
+    
+    /**
+     * 从ZIP文件中提取单个文件
+     * 
+     * @param zipFile ZIP文件路径
+     * @param entryName ZIP中的文件路径
+     * @param destFile 目标文件路径
+     * @return 提取是否成功
+     */
+    public static boolean extractSingleFile(String zipFile, String entryName, String destFile) {
+        File zip = new File(zipFile);
+        if (!zip.exists()) {
+            logger.error("ZIP文件不存在: {}", zipFile);
+            return false;
+        }
+        
+        try (FileInputStream fis = new FileInputStream(zip);
+             ZipInputStream zis = new ZipInputStream(fis)) {
+            
+            ZipEntry entry;
+            byte[] buffer = new byte[BUFFER_SIZE];
+            
+            while ((entry = zis.getNextEntry()) != null) {
+                if (entry.getName().equals(entryName)) {
+                    File dest = new File(destFile);
+                    File parentDir = dest.getParentFile();
+                    if (!parentDir.exists()) {
+                        parentDir.mkdirs();
+                    }
+                    
+                    try (FileOutputStream fos = new FileOutputStream(dest)) {
+                        int length;
+                        while ((length = zis.read(buffer)) > 0) {
+                            fos.write(buffer, 0, length);
+                        }
+                    }
+                    
+                    logger.info("提取文件成功 - ZIP文件: {}, 源文件: {}, 目标文件: {}", 
+                        zipFile, entryName, destFile);
+                    return true;
+                }
+                zis.closeEntry();
+            }
+            
+            logger.error("ZIP文件中未找到指定文件: {}", entryName);
+            return false;
+            
+        } catch (IOException e) {
+            logger.error("提取文件失败 - ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        }
+    }
+    
+    /**
+     * 检查ZIP文件是否有效
+     * 
+     * @param zipFile ZIP文件路径
+     * @return 是否有效ZIP文件
+     */
+    public static boolean isValidZipFile(String zipFile) {
+        File zip = new File(zipFile);
+        if (!zip.exists()) {
+            return false;
+        }
+        
+        try (FileInputStream fis = new FileInputStream(zip);
+             ZipInputStream zis = new ZipInputStream(fis)) {
+            
+            // 尝试读取第一个条目
+            ZipEntry entry = zis.getNextEntry();
+            return true;
+            
+        } catch (ZipException e) {
+            logger.debug("无效的ZIP文件: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        } catch (IOException e) {
+            logger.debug("检查ZIP文件时发生异常: {}, 错误: {}", zipFile, e.getMessage());
+            return false;
+        }
+    }
+    
+    // ========== 私有辅助方法 ==========
+    
+    /**
+     * 添加文件到ZIP输出流
+     */
+    private static void addFileToZip(File file, String entryName, ZipOutputStream zos) throws IOException {
+        if (file.isDirectory()) {
+            // 添加目录条目
+            if (!entryName.endsWith("/")) {
+                entryName += "/";
+            }
+            zos.putNextEntry(new ZipEntry(entryName));
+            zos.closeEntry();
+        } else {
+            // 添加文件条目
+            zos.putNextEntry(new ZipEntry(entryName));
+            
+            try (FileInputStream fis = new FileInputStream(file)) {
+                byte[] buffer = new byte[BUFFER_SIZE];
+                int length;
+                while ((length = fis.read(buffer)) > 0) {
+                    zos.write(buffer, 0, length);
+                }
+            }
+            
+            zos.closeEntry();
+        }
+    }
+    
+    /**
+     * 递归添加目录到ZIP输出流
+     */
+    private static int addDirectoryToZip(File dir, String basePath, ZipOutputStream zos, boolean includeSubdirs) throws IOException {
+        int fileCount = 0;
+        File[] files = dir.listFiles();
+        
+        if (files != null) {
+            for (File file : files) {
+                String entryName = basePath + "/" + file.getName();
+                
+                if (file.isFile()) {
+                    addFileToZip(file, entryName, zos);
+                    fileCount++;
+                } else if (file.isDirectory() && includeSubdirs) {
+                    // 递归处理子目录
+                    addFileToZip(file, entryName + "/", zos);
+                    fileCount += addDirectoryToZip(file, entryName, zos, includeSubdirs);
+                }
+            }
+        }
+        
+        return fileCount;
+    }
+    
+    /**
+     * 主方法 - 测试ZIP功能
+     */
+    public static void main(String[] args) {
+        // 测试压缩功能
+        String testDir = "test_dir";
+        String zipFile = "test.zip";
+        
+        // 创建测试目录和文件
+        File dir = new File(testDir);
+        dir.mkdirs();
+        
+        try {
+            // 创建测试文件
+            File testFile1 = new File(testDir + "/test1.txt");
+            File testFile2 = new File(testDir + "/test2.txt");
+            
+            try (FileWriter fw1 = new FileWriter(testFile1);
+                 FileWriter fw2 = new FileWriter(testFile2)) {
+                fw1.write("这是测试文件1");
+                fw2.write("这是测试文件2");
+            }
+            
+            // 测试目录压缩
+            boolean compressResult = compressDirectory(testDir, zipFile);
+            System.out.println("目录压缩测试: " + (compressResult ? "成功" : "失败"));
+            
+            // 测试解压
+            String extractDir = "extracted_dir";
+            boolean decompressResult = decompress(zipFile, extractDir);
+            System.out.println("文件解压测试: " + (decompressResult ? "成功" : "失败"));
+            
+            // 测试文件列表
+            List<String> entries = listZipEntries(zipFile);
+            if (entries != null) {
+                System.out.println("ZIP文件内容:");
+                for (String entry : entries) {
+                    System.out.println("  - " + entry);
+                }
+            }
+            
+            // 清理测试文件
+            testFile1.delete();
+            testFile2.delete();
+            dir.delete();
+            new File(zipFile).delete();
+            new File(extractDir).delete();
+            
+        } catch (IOException e) {
+            System.err.println("测试过程中发生错误: " + e.getMessage());
+        }
+    }
+}

+ 33 - 0
farm-quartz/src/main/resources/application-aws.yml

@@ -0,0 +1,33 @@
+# AWS相关配置
+aws:
+  s3:
+    # AWS访问密钥ID (LocalStack使用任意值)
+    access-key: ${AWS_ACCESS_KEY_ID:test}
+    # AWS秘密访问密钥 (LocalStack使用任意值)
+    secret-key: ${AWS_SECRET_ACCESS_KEY:test}
+    # AWS区域
+    region: ${AWS_REGION:us-east-1}
+    # 存储桶名称
+    bucket-name: ${AWS_S3_BUCKET:my-unencrypted-bucket}
+    # 文件存储路径前缀
+    path-prefix: ${AWS_S3_PATH_PREFIX:uploads/}
+    # 文件访问URL前缀 (用于生成文件访问链接)
+    url-prefix: ${AWS_S3_URL_PREFIX:http://172.14.3.142:4566/my-unencrypted-bucket/}
+    # LocalStack端点URL
+    endpoint-url: ${AWS_S3_ENDPOINT_URL:http://172.14.3.142:4566}
+  ses:
+    # AWS访问密钥ID
+    access-key: ${AWS_SES_ACCESS_KEY:test}
+    # AWS秘密访问密钥
+    secret-key: ${AWS_SES_SECRET_KEY:test}
+    # AWS区域
+    region: ${AWS_SES_REGION:us-east-1}
+    # 发件人邮箱 (LocalStack中任意邮箱都可用)
+    from-email: ${AWS_SES_FROM_EMAIL:sender@nextosd.com}
+    # 邮件编码
+    charset: ${AWS_SES_CHARSET:UTF-8}
+    # LocalStack端点URL
+    endpoint-url: ${AWS_SES_ENDPOINT_URL:http://172.14.3.142:4566}
+
+    
+