Bläddra i källkod

抓取邮箱发票数据改成1小时执行一次

CRK 3 veckor sedan
förälder
incheckning
d8334a92f0

+ 3 - 2
pom.xml

@@ -25,6 +25,7 @@
         <jodatime.version>2.10.1</jodatime.version>
         <swagger.version>2.7.0</swagger.version>
         <commons-fileupload.version>1.3.1</commons-fileupload.version>
+        <lombok.version>1.18.36</lombok.version>
         <!--        <thymeleaf.version>3.0.2.RELEASE</thymeleaf.version>-->
         <!-- 布局功能的支持程序 thymeleaf3主程序 layout2以上版本-->
         <!--        <thymeleaf-layout-dialect.version>2.1.1</thymeleaf-layout-dialect.version>-->
@@ -267,7 +268,7 @@
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
             <optional>true</optional>
-            <version>1.18.10</version>
+            <version>${lombok.version}</version>
         </dependency>
 
         <!--MD5加密-->
@@ -411,4 +412,4 @@
 <!--        </resources>-->
     </build>
 
-</project>
+</project>

+ 3 - 3
src/main/java/com/muzhi/changguchuan/controller/AlibabaMailBoxController.java

@@ -55,13 +55,13 @@ public class AlibabaMailBoxController {
 
     /**
      * NOTE 正式部署使用的定时同步方法
-     * 每天凌晨 1 点(上海时区)自动处理昨天的发票邮件。
+     * 每个整点(上海时区)自动处理触发时间之前一小时内的发票邮件。
      * */
     @Scheduled(
-            cron = "${invoice.sync-cron:0 0 1 * * ?}",
+            cron = "${invoice.sync-cron:0 0 * * * ?}",
             zone = "${invoice.time-zone:Asia/Shanghai}")
     public void scheduledYesterdayInvoiceSync() {
-        R result = alibabaMailBoxService.syncYesterdayInvoiceMessages();
+        R result = alibabaMailBoxService.syncLatestHourInvoiceMessages();
         if (Boolean.TRUE.equals(result.getSuccess())
                 && !Boolean.FALSE.equals(result.getData().get("complete"))) {
             log.info("[invoice-mail.schedule] 定时同步执行结束:message={}, data={}",

+ 4 - 1
src/main/java/com/muzhi/changguchuan/invoice/service/InvoiceMailSyncService.java

@@ -2,9 +2,12 @@ package com.muzhi.changguchuan.invoice.service;
 
 import com.muzhi.changguchuan.common.R;
 
-/** 从阿里邮箱提取昨日 PDF 发票并写入宜搭票夹。 */
+/** 从阿里邮箱提取 PDF 发票并写入宜搭票夹。 */
 public interface InvoiceMailSyncService {
 
+    /** 同步当前执行时间之前一小时内收到的发票邮件。 */
+    R syncLatestHourInvoices();
+
     R syncYesterdayInvoices();
 
     /** 临时手动验证入口:按 yyyy-MM-dd 指定日期同步;空值时使用上海时区当天。 */

+ 90 - 21
src/main/java/com/muzhi/changguchuan/invoice/service/impl/InvoiceMailSyncServiceImpl.java

@@ -37,6 +37,7 @@ import java.nio.file.Paths;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardOpenOption;
 import java.security.MessageDigest;
+import java.time.Clock;
 import java.time.Instant;
 import java.time.LocalDate;
 import java.time.ZoneId;
@@ -83,13 +84,21 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
     private final AccessTokenService accessTokenService;
     private final InvoiceMailProperties properties;
     private final OkHttpClient httpClient;
+    private final Clock clock;
     private final AtomicBoolean running = new AtomicBoolean(false);
 
     @Autowired
     public InvoiceMailSyncServiceImpl(AccessTokenService accessTokenService,
                                       InvoiceMailProperties properties) {
+        this(accessTokenService, properties, Clock.systemUTC());
+    }
+
+    InvoiceMailSyncServiceImpl(AccessTokenService accessTokenService,
+                               InvoiceMailProperties properties,
+                               Clock clock) {
         this.accessTokenService = accessTokenService;
         this.properties = properties;
+        this.clock = clock;
         this.httpClient = new OkHttpClient.Builder()
                 .connectTimeout(properties.getHttpTimeoutSeconds(), TimeUnit.SECONDS)
                 .readTimeout(properties.getHttpTimeoutSeconds(), TimeUnit.SECONDS)
@@ -98,11 +107,18 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
                 .build();
     }
 
+    /** 每次只允许一个发票邮件同步运行,防止定时与手动调用重复下载。 */
+    @Override
+    public R syncLatestHourInvoices() {
+        ZoneId zone = ZoneId.of(properties.getTimeZone());
+        return syncHourWithLock(zone);
+    }
+
     /** 每次只允许一个发票邮件同步运行,防止定时与手动调用重复下载。 */
     @Override
     public R syncYesterdayInvoices() {
         ZoneId zone = ZoneId.of(properties.getTimeZone());
-        return syncWithLock(LocalDate.now(zone).minusDays(1), zone);
+        return syncDayWithLock(LocalDate.now(zone).minusDays(1), zone);
     }
 
     /** 临时手动验证使用;空日期自动取上海时区今天,格式错误时直接返回明确提示。 */
@@ -115,28 +131,54 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
         } catch (DateTimeParseException ex) {
             return R.error().message("日期格式错误,必须为 yyyy-MM-dd,例如 2026-09-24");
         }
-        return syncWithLock(targetDate, zone);
+        return syncDayWithLock(targetDate, zone);
+    }
+
+    private R syncHourWithLock(ZoneId zone) {
+        if (!running.compareAndSet(false, true)) {
+            return R.error().code(429).message("邮箱发票同步正在执行,请勿重复提交");
+        }
+        try {
+            return syncDate(zone);
+        } finally {
+            running.set(false);
+        }
     }
 
-    private R syncWithLock(LocalDate targetDate, ZoneId zone) {
+    private R syncDayWithLock(LocalDate targetDate, ZoneId zone) {
         if (!running.compareAndSet(false, true)) {
             return R.error().code(429).message("邮箱发票同步正在执行,请勿重复提交");
         }
         try {
-            return syncDate(targetDate, zone);
+            Instant start = targetDate.atStartOfDay(zone).toInstant();
+            Instant end = targetDate.plusDays(1).atStartOfDay(zone).toInstant();
+            return syncRange(targetDate, zone, start, end, "按日");
         } finally {
             running.set(false);
         }
     }
 
-    private R syncDate(LocalDate targetDate, ZoneId zone) {
-        SyncStats stats = new SyncStats(targetDate);
-        log.info("[invoice-mail] 昨日发票同步开始:mailbox={}, targetDate={}, storageRoot={}",
-                properties.getMailboxAddress(), targetDate, resolveStorageRoot());
+    /** 定时任务以实际触发时间为上界,只同步此前一小时内收到的邮件。 */
+    private R syncDate(ZoneId zone) {
+        SyncWindow window = previousHourWindow(clock, zone);
+        return syncRange(window.targetDate, zone, window.start, window.end, "最近一小时");
+    }
+
+    static SyncWindow previousHourWindow(Clock clock, ZoneId zone) {
+        ZonedDateTime triggerTime = ZonedDateTime.now(clock).withZoneSameInstant(zone);
+        return new SyncWindow(triggerTime.toLocalDate(), triggerTime.minusHours(1).toInstant(),
+                triggerTime.toInstant());
+    }
+
+    private R syncRange(LocalDate targetDate, ZoneId zone, Instant windowStart,
+                        Instant windowEnd, String scope) {
+        SyncStats stats = new SyncStats(targetDate, windowStart, windowEnd);
+        log.info("[invoice-mail] 发票同步开始:scope={}, mailbox={}, windowStart={}, windowEnd={}, storageRoot={}",
+                scope, properties.getMailboxAddress(), windowStart, windowEnd, resolveStorageRoot());
         try {
             String mailToken = required(accessTokenService.getAlibabaMailboxAccessToken(), "阿里邮箱授权码为空");
             String dingToken = required(accessTokenService.getAccessToken(), "钉钉授权码为空");
-            List<JSONObject> messages = queryMessages(targetDate, zone, mailToken);
+            List<JSONObject> messages = queryMessages(windowStart, windowEnd, mailToken);
             stats.messageCount = messages.size();
             Map<String, EmployeeOwner> ownerCache = new HashMap<>();
             for (JSONObject message : messages) {
@@ -153,28 +195,26 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
                 }
             }
             boolean complete = stats.errors.isEmpty();
-            log.info("[invoice-mail] 昨日发票同步结束:targetDate={}, messages={}, matchedMessages={}, invoices={}, itineraries={}, written={}, skipped={}, errors={}",
-                    targetDate, stats.messageCount, stats.matchedMessageCount, stats.invoiceCount,
+            log.info("[invoice-mail] 发票同步结束:scope={}, windowStart={}, windowEnd={}, messages={}, matchedMessages={}, invoices={}, itineraries={}, written={}, skipped={}, errors={}",
+                    scope, windowStart, windowEnd, stats.messageCount, stats.matchedMessageCount, stats.invoiceCount,
                     stats.itineraryCount, stats.writtenCount, stats.skippedCount, stats.errors.size());
-            return R.ok().message(complete ? "昨日邮箱发票同步完成" : "昨日邮箱发票同步部分完成")
+            return R.ok().message(complete ? scope + "邮箱发票同步完成" : scope + "邮箱发票同步部分完成")
                     .data(stats.toMap(complete));
         } catch (Exception ex) {
-            log.error("[invoice-mail] 昨日发票同步异常终止:targetDate={}, error={}",
-                    targetDate, safeMessage(ex), ex);
+            log.error("[invoice-mail] 发票同步异常终止:scope={}, windowStart={}, windowEnd={}, error={}",
+                    scope, windowStart, windowEnd, safeMessage(ex), ex);
             stats.addError("公共流程", safeMessage(ex));
-            return R.error().message("昨日邮箱发票同步失败:" + safeMessage(ex))
+            return R.error().message(scope + "邮箱发票同步失败:" + safeMessage(ex))
                     .data(stats.toMap(false));
         }
     }
 
     /**
-     * 查询目标日期收件箱全部邮件,再逐封读取详情用于主题、正文关键词判断。
+     * 查询指定时间窗口内的收件箱邮件,再逐封读取详情用于主题、正文关键词判断。
      * 列表接口仅请求租户已确认支持的 toRecipients,避免扩展 $select 导致 total 有值但 messages 为空。
      */
-    private List<JSONObject> queryMessages(LocalDate date, ZoneId zone, String token) throws IOException {
-        Instant start = date.atStartOfDay(zone).toInstant().minusMillis(1);
-        Instant end = date.plusDays(1).atStartOfDay(zone).toInstant();
-        String query = "date>" + start + " AND date<" + end + " AND folderId:2";
+    private List<JSONObject> queryMessages(Instant start, Instant end, String token) throws IOException {
+        String query = buildMailQuery(start, end);
         String cursor = "0";
         List<JSONObject> result = new ArrayList<>();
         int page = 0;
@@ -215,6 +255,15 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
         return result;
     }
 
+    /** 邮箱 API 仅支持严格大于,因此将包含式起点前移 1 毫秒;终点保持排除式,避免跨窗口重复。 */
+    static String buildMailQuery(Instant startInclusive, Instant endExclusive) {
+        if (startInclusive == null || endExclusive == null || !startInclusive.isBefore(endExclusive)) {
+            throw new IllegalArgumentException("邮件查询时间窗口无效");
+        }
+        return "date>" + startInclusive.minusMillis(1)
+                + " AND date<" + endExclusive + " AND folderId:2";
+    }
+
     static boolean hasReportedButMissingMessages(JSONObject response, JSONArray messages) {
         Integer total = response == null ? null : response.getInteger("total");
         return total != null && total > 0 && (messages == null || messages.isEmpty());
@@ -741,8 +790,22 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
         }
     }
 
+    static final class SyncWindow {
+        final LocalDate targetDate;
+        final Instant start;
+        final Instant end;
+
+        private SyncWindow(LocalDate targetDate, Instant start, Instant end) {
+            this.targetDate = targetDate;
+            this.start = start;
+            this.end = end;
+        }
+    }
+
     private static final class SyncStats {
         private final LocalDate targetDate;
+        private final Instant windowStart;
+        private final Instant windowEnd;
         private int messageCount;
         private int filteredMessageCount;
         private int matchedMessageCount;
@@ -752,7 +815,11 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
         private int skippedCount;
         private final JSONArray errors = new JSONArray();
 
-        private SyncStats(LocalDate targetDate) { this.targetDate = targetDate; }
+        private SyncStats(LocalDate targetDate, Instant windowStart, Instant windowEnd) {
+            this.targetDate = targetDate;
+            this.windowStart = windowStart;
+            this.windowEnd = windowEnd;
+        }
 
         private void addError(String item, String reason) {
             JSONObject error = new JSONObject();
@@ -764,6 +831,8 @@ public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
         private Map<String, Object> toMap(boolean complete) {
             Map<String, Object> result = new LinkedHashMap<>();
             result.put("date", targetDate.toString());
+            result.put("windowStart", windowStart.toString());
+            result.put("windowEnd", windowEnd.toString());
             result.put("complete", complete);
             result.put("messages", messageCount);
             result.put("filteredMessages", filteredMessageCount);

+ 3 - 0
src/main/java/com/muzhi/changguchuan/service/AlibabaMailBoxService.java

@@ -14,6 +14,9 @@ public interface AlibabaMailBoxService {
     /** 获取昨日发票邮件、转存 PDF,并写入宜搭票夹。 */
     R syncYesterdayInvoiceMessages();
 
+    /** 获取当前执行时间之前一小时内的发票邮件、转存 PDF,并写入宜搭票夹。 */
+    R syncLatestHourInvoiceMessages();
+
     /** 临时手动验证:按指定日期获取发票邮件,日期为空时使用当天。 */
     R syncInvoiceMessagesByDate(String date);
 }

+ 6 - 0
src/main/java/com/muzhi/changguchuan/service/impl/AlibabaMailBoxServiceImpl.java

@@ -45,6 +45,12 @@ public class AlibabaMailBoxServiceImpl implements AlibabaMailBoxService {
         return invoiceMailSyncService.syncYesterdayInvoices();
     }
 
+    /** 每小时定时同步入口。 */
+    @Override
+    public R syncLatestHourInvoiceMessages() {
+        return invoiceMailSyncService.syncLatestHourInvoices();
+    }
+
     /** 临时手动验证入口,部署验证结束后可连同 Controller 临时接口一起注释。 */
     @Override
     public R syncInvoiceMessagesByDate(String date) {

+ 22 - 2
src/test/java/com/muzhi/changguchuan/controller/AlibabaMailBoxControllerScheduleTest.java

@@ -1,17 +1,24 @@
 package com.muzhi.changguchuan.controller;
 
+import com.muzhi.changguchuan.common.R;
+import com.muzhi.changguchuan.service.AlibabaMailBoxService;
 import org.junit.jupiter.api.Test;
 import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 class AlibabaMailBoxControllerScheduleTest {
 
     @Test
-    void schedulesOnlyTheNewYesterdayInvoiceMethodAtOneAm() throws Exception {
+    void schedulesInvoiceSyncEveryHour() throws Exception {
         Method legacy = AlibabaMailBoxController.class.getMethod("queryMessages");
         Method temporaryManual = AlibabaMailBoxController.class
                 .getMethod("syncTodayInvoiceMessages", String.class);
@@ -20,7 +27,20 @@ class AlibabaMailBoxControllerScheduleTest {
         assertNull(legacy.getAnnotation(Scheduled.class));
         assertNull(temporaryManual.getAnnotation(Scheduled.class));
         Scheduled annotation = scheduled.getAnnotation(Scheduled.class);
-        assertEquals("${invoice.sync-cron:0 0 1 * * ?}", annotation.cron());
+        assertEquals("${invoice.sync-cron:0 0 * * * ?}", annotation.cron());
         assertEquals("${invoice.time-zone:Asia/Shanghai}", annotation.zone());
     }
+
+    @Test
+    void scheduledMethodSyncsOnlyTheLatestHour() {
+        AlibabaMailBoxService service = mock(AlibabaMailBoxService.class);
+        when(service.syncLatestHourInvoiceMessages()).thenReturn(R.ok().data("complete", true));
+        AlibabaMailBoxController controller = new AlibabaMailBoxController();
+        ReflectionTestUtils.setField(controller, "alibabaMailBoxService", service);
+
+        controller.scheduledYesterdayInvoiceSync();
+
+        verify(service).syncLatestHourInvoiceMessages();
+        verify(service, never()).syncYesterdayInvoiceMessages();
+    }
 }

+ 22 - 0
src/test/java/com/muzhi/changguchuan/invoice/service/impl/InvoiceMailSyncServiceImplTest.java

@@ -7,6 +7,7 @@ import com.muzhi.changguchuan.invoice.config.InvoiceMailProperties;
 import com.muzhi.changguchuan.service.AccessTokenService;
 import org.junit.jupiter.api.Test;
 
+import java.time.Clock;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -114,6 +115,27 @@ class InvoiceMailSyncServiceImplTest {
         assertEquals(13, String.valueOf(timestamp).length());
     }
 
+    @Test
+    void buildsInclusiveStartAndExclusiveEndMailQuery() {
+        String query = InvoiceMailSyncServiceImpl.buildMailQuery(
+                Instant.parse("2026-10-09T01:00:00Z"),
+                Instant.parse("2026-10-09T02:00:00Z"));
+
+        assertEquals("date>2026-10-09T00:59:59.999Z AND date<2026-10-09T02:00:00Z AND folderId:2", query);
+    }
+
+    @Test
+    void latestHourSyncUsesTheHourBeforeItsTriggerTime() {
+        Clock clock = Clock.fixed(Instant.parse("2026-10-09T02:00:00Z"), ZoneId.of("UTC"));
+
+        InvoiceMailSyncServiceImpl.SyncWindow window = InvoiceMailSyncServiceImpl.previousHourWindow(
+                clock, ZoneId.of("Asia/Shanghai"));
+
+        assertEquals(Instant.parse("2026-10-09T01:00:00Z"), window.start);
+        assertEquals(Instant.parse("2026-10-09T02:00:00Z"), window.end);
+        assertEquals("2026-10-09", window.targetDate.toString());
+    }
+
     @Test
     void rejectsInvalidManualDateBeforeCallingExternalServices() {
         AccessTokenService accessTokenService = mock(AccessTokenService.class);