Ver código fonte

新增阿里邮箱发票同步及宜搭写入

zhangkan 1 mês atrás
pai
commit
13c6d06aa5

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

@@ -4,6 +4,7 @@ import com.muzhi.changguchuan.common.R;
 import com.muzhi.changguchuan.service.AlibabaMailBoxService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.web.bind.annotation.*;
@@ -13,20 +14,64 @@ import org.springframework.web.bind.annotation.*;
 @RestController
 @RequestMapping("/dingservice")
 @CrossOrigin//解决跨域问题
+@Slf4j
 public class AlibabaMailBoxController {
 
     @Autowired
     private AlibabaMailBoxService alibabaMailBoxService;
 
-    /**搜索邮件*/
+    /**
+     * STOP 【废弃】
+     * 搜索邮件
+     * */
     @ApiOperation(value = "搜索邮件")
     @GetMapping("queryMessages")
-    @Scheduled(cron = "0 0 6 * * ?")//每天06:00触发(也就是早上06点00分触发)
-//    @Scheduled(cron = "0 30 21 * * ?")//每天22:30触发(也就是晚上21点30分触发)
-//    @Scheduled(cron = "0 0/30 8-20 * * ?")//每天8点~20点,每30分钟执行一次
     public R queryMessages(){
 
         R queryMessages = alibabaMailBoxService.queryMessages();
         return queryMessages;
     }
-}
+
+    /**
+     * REMARK 【手动测试使用】
+     * 手动触发新版昨日邮箱发票同步,便于部署后验证。
+     * */
+    @ApiOperation(value = "同步昨日阿里邮箱发票至宜搭票夹")
+    @GetMapping("syncYesterdayInvoiceMessages")
+    public R syncYesterdayInvoiceMessages() {
+        return alibabaMailBoxService.syncYesterdayInvoiceMessages();
+    }
+
+    /**
+     * REMARK 【手动测试使用】
+     * TEMP 手动验证接口:不传 date 时自动同步今天,亦可显式传入 yyyy-MM-dd。
+     * 部署验证完成后可单独注释本方法,不影响凌晨 1 点的昨日自动同步。
+     */
+    @ApiOperation(value = "临时按日期同步阿里邮箱发票至宜搭票夹")
+    @GetMapping("syncTodayInvoiceMessages")
+    public R syncTodayInvoiceMessages(@RequestParam(value = "date", required = false) String date) {
+        return alibabaMailBoxService.syncInvoiceMessagesByDate(date);
+    }
+
+    /**
+     * NOTE 正式部署使用的定时同步方法
+     * 每天凌晨 1 点(上海时区)自动处理昨天的发票邮件。
+     * */
+    @Scheduled(
+            cron = "${invoice.sync-cron:0 0 1 * * ?}",
+            zone = "${invoice.time-zone:Asia/Shanghai}")
+    public void scheduledYesterdayInvoiceSync() {
+        R result = alibabaMailBoxService.syncYesterdayInvoiceMessages();
+        if (Boolean.TRUE.equals(result.getSuccess())
+                && !Boolean.FALSE.equals(result.getData().get("complete"))) {
+            log.info("[invoice-mail.schedule] 定时同步执行结束:message={}, data={}",
+                    result.getMessage(), result.getData());
+        } else if (Boolean.TRUE.equals(result.getSuccess())) {
+            log.warn("[invoice-mail.schedule] 定时同步部分完成:message={}, data={}",
+                    result.getMessage(), result.getData());
+        } else {
+            log.error("[invoice-mail.schedule] 定时同步执行失败:message={}, data={}",
+                    result.getMessage(), result.getData());
+        }
+    }
+}

+ 56 - 0
src/main/java/com/muzhi/changguchuan/invoice/config/InvoiceMailProperties.java

@@ -0,0 +1,56 @@
+package com.muzhi.changguchuan.invoice.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/** 邮箱发票同步配置;默认值与当前长谷川宜搭应用及服务器目录保持一致。 */
+@Component
+public class InvoiceMailProperties {
+
+    @Value("${invoice.mailbox.address:invoice_receiver@hasegawa-sh.com}")
+    private String mailboxAddress;
+
+    @Value("${invoice.yida.app-type:${myapp.yida.appType:APP_FS41ZGRW4AXJRZZA0S57}}")
+    private String appType;
+
+    @Value("${invoice.yida.system-token:${myapp.yida.appSecret:}}")
+    private String systemToken;
+
+    @Value("${invoice.yida.user-id:yida_pub_account}")
+    private String yidaUserId;
+
+    @Value("${invoice.yida.target-form-uuid:FORM-036D3F7CB4CC44E7AE7846A789C3DA65EOJN}")
+    private String targetFormUuid;
+
+    @Value("${invoice.yida.employee-form-uuid:FORM-B4771E671F5D453EA7350DFDC5D98646THS7}")
+    private String employeeFormUuid;
+
+    /** 相对路径会解析为 Jar 启动目录下的 invoice 文件夹,兼容 Windows 和 Linux。 */
+    @Value("${invoice.storage-root:invoice}")
+    private String storageRoot;
+
+    /** 该 URL 应由 Nginx/IIS 映射到 storage-root。 */
+    @Value("${invoice.public-base-url:http://139.196.11.100:8101/invoice}")
+    private String publicBaseUrl;
+
+    @Value("${invoice.time-zone:Asia/Shanghai}")
+    private String timeZone;
+
+    @Value("${invoice.http-timeout-seconds:60}")
+    private int httpTimeoutSeconds;
+
+    @Value("${invoice.max-pdf-size-bytes:52428800}")
+    private long maxPdfSizeBytes;
+
+    public String getMailboxAddress() { return mailboxAddress; }
+    public String getAppType() { return appType; }
+    public String getSystemToken() { return systemToken; }
+    public String getYidaUserId() { return yidaUserId; }
+    public String getTargetFormUuid() { return targetFormUuid; }
+    public String getEmployeeFormUuid() { return employeeFormUuid; }
+    public String getStorageRoot() { return storageRoot; }
+    public String getPublicBaseUrl() { return publicBaseUrl; }
+    public String getTimeZone() { return timeZone; }
+    public int getHttpTimeoutSeconds() { return httpTimeoutSeconds; }
+    public long getMaxPdfSizeBytes() { return maxPdfSizeBytes; }
+}

+ 12 - 0
src/main/java/com/muzhi/changguchuan/invoice/service/InvoiceMailSyncService.java

@@ -0,0 +1,12 @@
+package com.muzhi.changguchuan.invoice.service;
+
+import com.muzhi.changguchuan.common.R;
+
+/** 从阿里邮箱提取昨日 PDF 发票并写入宜搭票夹。 */
+public interface InvoiceMailSyncService {
+
+    R syncYesterdayInvoices();
+
+    /** 临时手动验证入口:按 yyyy-MM-dd 指定日期同步;空值时使用上海时区当天。 */
+    R syncInvoicesByDate(String date);
+}

+ 779 - 0
src/main/java/com/muzhi/changguchuan/invoice/service/impl/InvoiceMailSyncServiceImpl.java

@@ -0,0 +1,779 @@
+package com.muzhi.changguchuan.invoice.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.dingtalkyida_1_0.models.CreateOrUpdateFormDataHeaders;
+import com.aliyun.dingtalkyida_1_0.models.CreateOrUpdateFormDataRequest;
+import com.aliyun.dingtalkyida_1_0.models.CreateOrUpdateFormDataResponse;
+import com.aliyun.dingtalkyida_1_0.models.SearchFormDatasHeaders;
+import com.aliyun.dingtalkyida_1_0.models.SearchFormDatasRequest;
+import com.aliyun.dingtalkyida_1_0.models.SearchFormDatasResponse;
+import com.aliyun.teautil.models.RuntimeOptions;
+import com.muzhi.changguchuan.common.R;
+import com.muzhi.changguchuan.invoice.config.InvoiceMailProperties;
+import com.muzhi.changguchuan.invoice.service.InvoiceMailSyncService;
+import com.muzhi.changguchuan.service.AccessTokenService;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 昨日邮箱发票同步实现。
+ *
+ * <p>流程:查询昨日收件箱 -> 按正文/主题筛选发票邮件 -> 匹配收件人钉钉账号 ->
+ * 下载 PDF -> 关联行程单 -> 创建或更新宜搭票夹记录。</p>
+ */
+@Service
+@Slf4j
+public class InvoiceMailSyncServiceImpl implements InvoiceMailSyncService {
+
+    private static final String MAIL_API_BASE = "https://alimail-cn.aliyuncs.com/v2";
+    private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
+    private static final DateTimeFormatter DIRECTORY_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd");
+    private static final DateTimeFormatter FORM_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+    private static final Pattern DIGIT_TOKEN = Pattern.compile("\\d{4,}");
+    private static final List<String> INVOICE_KEYWORDS = Arrays.asList(
+            "发票", "电子发票", "电子票", "数电票", "全电票", "数字化电子发票",
+            "增值税", "开票通知", "电子票据", "invoice");
+    private static final List<String> ITINERARY_KEYWORDS = Arrays.asList(
+            "行程单", "行程报销单", "网约车行程", "出行行程", "itinerary", "trip report");
+    private static final List<String> RIDE_INVOICE_KEYWORDS = Arrays.asList(
+            "滴滴", "网约车", "高德", "曹操", "t3", "享道", "花小猪", "出行");
+
+    private final AccessTokenService accessTokenService;
+    private final InvoiceMailProperties properties;
+    private final OkHttpClient httpClient;
+    private final AtomicBoolean running = new AtomicBoolean(false);
+
+    @Autowired
+    public InvoiceMailSyncServiceImpl(AccessTokenService accessTokenService,
+                                      InvoiceMailProperties properties) {
+        this.accessTokenService = accessTokenService;
+        this.properties = properties;
+        this.httpClient = new OkHttpClient.Builder()
+                .connectTimeout(properties.getHttpTimeoutSeconds(), TimeUnit.SECONDS)
+                .readTimeout(properties.getHttpTimeoutSeconds(), TimeUnit.SECONDS)
+                .writeTimeout(properties.getHttpTimeoutSeconds(), TimeUnit.SECONDS)
+                .retryOnConnectionFailure(true)
+                .build();
+    }
+
+    /** 每次只允许一个发票邮件同步运行,防止定时与手动调用重复下载。 */
+    @Override
+    public R syncYesterdayInvoices() {
+        ZoneId zone = ZoneId.of(properties.getTimeZone());
+        return syncWithLock(LocalDate.now(zone).minusDays(1), zone);
+    }
+
+    /** 临时手动验证使用;空日期自动取上海时区今天,格式错误时直接返回明确提示。 */
+    @Override
+    public R syncInvoicesByDate(String date) {
+        ZoneId zone = ZoneId.of(properties.getTimeZone());
+        LocalDate targetDate;
+        try {
+            targetDate = isBlank(date) ? LocalDate.now(zone) : LocalDate.parse(date, FORM_DATE);
+        } catch (DateTimeParseException ex) {
+            return R.error().message("日期格式错误,必须为 yyyy-MM-dd,例如 2026-09-24");
+        }
+        return syncWithLock(targetDate, zone);
+    }
+
+    private R syncWithLock(LocalDate targetDate, ZoneId zone) {
+        if (!running.compareAndSet(false, true)) {
+            return R.error().code(429).message("邮箱发票同步正在执行,请勿重复提交");
+        }
+        try {
+            return syncDate(targetDate, zone);
+        } 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());
+        try {
+            String mailToken = required(accessTokenService.getAlibabaMailboxAccessToken(), "阿里邮箱授权码为空");
+            String dingToken = required(accessTokenService.getAccessToken(), "钉钉授权码为空");
+            List<JSONObject> messages = queryMessages(targetDate, zone, mailToken);
+            stats.messageCount = messages.size();
+            Map<String, EmployeeOwner> ownerCache = new HashMap<>();
+            for (JSONObject message : messages) {
+                if (!containsInvoiceKeyword(message)) {
+                    stats.filteredMessageCount++;
+                    continue;
+                }
+                try {
+                    processMessage(message, targetDate, zone, mailToken, dingToken, ownerCache, stats);
+                } catch (Exception ex) {
+                    stats.addError(message.getString("id"), safeMessage(ex));
+                    log.error("[invoice-mail] 邮件处理失败:messageId={}, error={}",
+                            message.getString("id"), safeMessage(ex), ex);
+                }
+            }
+            boolean complete = stats.errors.isEmpty();
+            log.info("[invoice-mail] 昨日发票同步结束:targetDate={}, messages={}, matchedMessages={}, invoices={}, itineraries={}, written={}, skipped={}, errors={}",
+                    targetDate, stats.messageCount, stats.matchedMessageCount, stats.invoiceCount,
+                    stats.itineraryCount, stats.writtenCount, stats.skippedCount, stats.errors.size());
+            return R.ok().message(complete ? "昨日邮箱发票同步完成" : "昨日邮箱发票同步部分完成")
+                    .data(stats.toMap(complete));
+        } catch (Exception ex) {
+            log.error("[invoice-mail] 昨日发票同步异常终止:targetDate={}, error={}",
+                    targetDate, safeMessage(ex), ex);
+            stats.addError("公共流程", safeMessage(ex));
+            return R.error().message("昨日邮箱发票同步失败:" + 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";
+        String cursor = "0";
+        List<JSONObject> result = new ArrayList<>();
+        int page = 0;
+        do {
+            page++;
+            JSONObject payload = new JSONObject();
+            payload.put("query", query);
+            payload.put("cursor", cursor);
+            payload.put("size", 100);
+            JSONObject response = queryMessagePage(payload, token, "toRecipients");
+            JSONArray messages = response.getJSONArray("messages");
+
+            // 阿里邮箱部分租户遇到不兼容的 $select 时会返回 total,但不会返回 messages,也不会报 HTTP 错误。
+            if (hasReportedButMissingMessages(response, messages)) {
+                response = queryMessagePage(payload, token, null);
+                messages = response.getJSONArray("messages");
+                log.warn("[invoice-mail.api] 邮件列表 total 有值但 messages 为空,已使用无 $select 请求重试:page={}, total={}",
+                        page, response.getInteger("total"));
+            }
+            if (hasReportedButMissingMessages(response, messages)) {
+                throw new IOException("查询邮件返回数量为 " + response.getInteger("total")
+                        + ",但邮件列表为空;为避免遗漏已终止本次同步");
+            }
+
+            if (messages != null) {
+                for (int i = 0; i < messages.size(); i++) {
+                    JSONObject summary = messages.getJSONObject(i);
+                    String messageId = required(summary == null ? null : summary.getString("id"), "邮件 ID 为空");
+                    JSONObject detail = getMessageDetail(messageId, token);
+                    result.add(mergeMessageDetail(summary, detail));
+                }
+            }
+            cursor = response.getString("nextCursor");
+            log.info("[invoice-mail.api] 邮件分页完成:page={}, pageCount={}, total={}, nextCursor={}",
+                    page, messages == null ? 0 : messages.size(), response.getInteger("total"), cursor);
+            if (page >= 1000) throw new IllegalStateException("邮件分页超过安全上限 1000 页");
+        } while (cursor != null && !cursor.isEmpty() && !"$".equals(cursor));
+        return result;
+    }
+
+    static boolean hasReportedButMissingMessages(JSONObject response, JSONArray messages) {
+        Integer total = response == null ? null : response.getInteger("total");
+        return total != null && total > 0 && (messages == null || messages.isEmpty());
+    }
+
+    /** 合并列表和详情;详情缺少收件人或 ID 时保留列表接口返回值。 */
+    static JSONObject mergeMessageDetail(JSONObject summary, JSONObject detailResponse) {
+        JSONObject merged = new JSONObject();
+        if (summary != null) merged.putAll(summary);
+        if (detailResponse == null) return merged;
+        JSONObject detail = detailResponse.getJSONObject("message");
+        if (detail == null) detail = detailResponse;
+        merged.putAll(detail);
+        return merged;
+    }
+
+    private JSONObject queryMessagePage(JSONObject payload, String token, String select) throws IOException {
+        HttpUrl.Builder urlBuilder = baseUrl().newBuilder()
+                .addPathSegment("users")
+                .addPathSegment(properties.getMailboxAddress())
+                .addPathSegment("messages")
+                .addPathSegment("query");
+        if (!isBlank(select)) urlBuilder.addQueryParameter("$select", select);
+        Request request = new Request.Builder().url(urlBuilder.build())
+                .header("Authorization", "Bearer " + token)
+                .post(RequestBody.create(MediaType.parse("application/json"), payload.toJSONString()))
+                .build();
+        return executeJson(request, "查询邮件");
+    }
+
+    /** 按邮件 ID 读取完整详情,确保正文包含发票关键词时也能被识别。 */
+    private JSONObject getMessageDetail(String messageId, String token) throws IOException {
+        HttpUrl url = baseUrl().newBuilder()
+                .addPathSegment("users")
+                .addPathSegment(properties.getMailboxAddress())
+                .addPathSegment("messages")
+                .addPathSegment(messageId)
+                .build();
+        Request request = new Request.Builder().url(url)
+                .header("Authorization", "Bearer " + token).get().build();
+        return executeJson(request, "获取邮件详情");
+    }
+
+    private void processMessage(JSONObject message, LocalDate targetDate, ZoneId zone,
+                                String mailToken, String dingToken,
+                                Map<String, EmployeeOwner> ownerCache, SyncStats stats) throws Exception {
+        String messageId = required(message.getString("id"), "邮件 ID 为空");
+        stats.matchedMessageCount++;
+        EmployeeOwner owner = resolveOwner(message.getJSONArray("toRecipients"), dingToken, ownerCache);
+        if (owner == null) {
+            stats.skippedCount++;
+            throw new IllegalStateException("邮件收件人未匹配到人员邮箱档案");
+        }
+
+        List<PdfDescriptor> invoices = new ArrayList<>();
+        List<PdfDescriptor> itineraries = new ArrayList<>();
+        JSONArray attachments = listAttachments(messageId, mailToken);
+        for (int i = 0; i < attachments.size(); i++) {
+            JSONObject attachment = attachments.getJSONObject(i);
+            String name = attachment.getString("name");
+            if (name == null || !name.toLowerCase(Locale.ROOT).endsWith(".pdf")) continue;
+            PdfDescriptor descriptor = new PdfDescriptor(
+                    required(attachment.getString("id"), "附件 ID 为空"), name, i);
+            if (isItineraryPdfName(name)) itineraries.add(descriptor);
+            else invoices.add(descriptor);
+        }
+        if (invoices.isEmpty()) {
+            stats.skippedCount++;
+            log.warn("[invoice-mail] 发票邮件没有可写入的 PDF 发票:messageId={}, pdfItineraries={}",
+                    messageId, itineraries.size());
+            return;
+        }
+
+        ZonedDateTime messageTime = parseMessageTime(message, targetDate, zone);
+        List<StoredPdf> storedInvoices = downloadAll(messageId, invoices, messageTime, targetDate, mailToken, stats);
+        List<StoredPdf> storedItineraries = downloadAll(messageId, itineraries, messageTime, targetDate, mailToken, stats);
+        stats.invoiceCount += storedInvoices.size();
+        stats.itineraryCount += storedItineraries.size();
+        Map<StoredPdf, List<StoredPdf>> associations = associateItineraries(storedInvoices, storedItineraries);
+        // 宜搭日期组件必须传 13 位毫秒时间戳;yyyy-MM-dd 文本会导致整条表单数据校验失败。
+        long createTimestamp = toYidaDateTimestamp(Instant.now());
+        for (StoredPdf invoice : storedInvoices) {
+            try {
+                writeInvoiceRecord(invoice, associations.get(invoice), owner, createTimestamp, dingToken);
+                stats.writtenCount++;
+            } catch (Exception ex) {
+                stats.addError(invoice.name, safeMessage(ex));
+                log.error("[invoice-mail.yida] 发票记录写入失败:file={}, userId={}, error={}",
+                        invoice.name, owner.userId, safeMessage(ex), ex);
+            }
+        }
+    }
+
+    private JSONArray listAttachments(String messageId, String token) throws IOException {
+        HttpUrl url = baseUrl().newBuilder()
+                .addPathSegment("users")
+                .addPathSegment(properties.getMailboxAddress())
+                .addPathSegment("messages")
+                .addPathSegment(messageId)
+                .addPathSegment("attachments")
+                .build();
+        Request request = new Request.Builder().url(url)
+                .header("Authorization", "Bearer " + token).get().build();
+        JSONArray attachments = executeJson(request, "获取邮件附件").getJSONArray("attachments");
+        return attachments == null ? new JSONArray() : attachments;
+    }
+
+    private List<StoredPdf> downloadAll(String messageId, List<PdfDescriptor> descriptors,
+                                        ZonedDateTime messageTime, LocalDate targetDate,
+                                        String token, SyncStats stats) {
+        List<StoredPdf> result = new ArrayList<>();
+        for (PdfDescriptor descriptor : descriptors) {
+            try {
+                result.add(downloadPdf(messageId, descriptor, messageTime, targetDate, token));
+            } catch (Exception ex) {
+                stats.addError(descriptor.originalName, safeMessage(ex));
+                log.error("[invoice-mail.file] PDF 下载失败:messageId={}, attachmentId={}, file={}, error={}",
+                        messageId, descriptor.id, descriptor.originalName, safeMessage(ex), ex);
+            }
+        }
+        return result;
+    }
+
+    /** 文件名使用邮件时间戳和稳定哈希,既避免重名,也保证同一附件重跑时保持幂等。 */
+    private StoredPdf downloadPdf(String messageId, PdfDescriptor descriptor,
+                                  ZonedDateTime messageTime, LocalDate targetDate,
+                                  String token) throws Exception {
+        String storedName = buildStoredFileName(descriptor.originalName, messageTime,
+                messageId + "|" + descriptor.id + "|" + descriptor.index);
+        Path root = resolveStorageRoot();
+        Path directory = root.resolve(targetDate.format(DIRECTORY_DATE));
+        Path destination = directory.resolve(storedName).normalize();
+        if (!destination.startsWith(root.normalize())) throw new IllegalStateException("发票文件路径不合法");
+        Files.createDirectories(directory);
+        if (!Files.exists(destination)) {
+            HttpUrl url = baseUrl().newBuilder()
+                    .addPathSegment("users")
+                    .addPathSegment(properties.getMailboxAddress())
+                    .addPathSegment("messages")
+                    .addPathSegment(messageId)
+                    .addPathSegment("attachments")
+                    .addPathSegment(descriptor.id)
+                    .addPathSegment("$value")
+                    .build();
+            Request request = new Request.Builder().url(url)
+                    .header("Authorization", "Bearer " + token).get().build();
+            Path temporary = directory.resolve(storedName + ".part-" + UUID.randomUUID()).normalize();
+            try (Response response = httpClient.newCall(request).execute()) {
+                if (!response.isSuccessful()) {
+                    throw new IOException("附件下载失败,HTTP " + response.code());
+                }
+                ResponseBody body = response.body();
+                if (body == null) throw new IOException("附件下载响应体为空");
+                if (body.contentLength() > properties.getMaxPdfSizeBytes()) {
+                    throw new IOException("附件超过允许大小:" + body.contentLength());
+                }
+                copyWithLimit(body.byteStream(), temporary, properties.getMaxPdfSizeBytes());
+                try {
+                    Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
+                } catch (AtomicMoveNotSupportedException ex) {
+                    Files.move(temporary, destination, StandardCopyOption.REPLACE_EXISTING);
+                }
+            } finally {
+                Files.deleteIfExists(temporary);
+            }
+        }
+        long size = Files.size(destination);
+        String relative = targetDate.format(DIRECTORY_DATE) + "/" + storedName;
+        String url = trimTrailingSlash(properties.getPublicBaseUrl()) + "/" + encodeUrlPath(relative);
+        log.info("[invoice-mail.file] PDF 已转存:original={}, stored={}, size={}, path={}, url={}",
+                descriptor.originalName, storedName, size, destination, url);
+        return new StoredPdf(descriptor.originalName, storedName, url, size);
+    }
+
+    private void copyWithLimit(InputStream input, Path target, long maxBytes) throws IOException {
+        long total = 0;
+        byte[] buffer = new byte[8192];
+        try (InputStream in = input;
+             OutputStream out = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
+            int count;
+            while ((count = in.read(buffer)) >= 0) {
+                total += count;
+                if (total > maxBytes) throw new IOException("附件超过允许大小:" + total);
+                out.write(buffer, 0, count);
+            }
+        }
+    }
+
+    /** 将每张行程单关联给最匹配的网约车发票;单发票邮件直接关联全部行程单。 */
+    private Map<StoredPdf, List<StoredPdf>> associateItineraries(List<StoredPdf> invoices,
+                                                                 List<StoredPdf> itineraries) {
+        Map<StoredPdf, List<StoredPdf>> result = new LinkedHashMap<>();
+        for (StoredPdf invoice : invoices) result.put(invoice, new ArrayList<StoredPdf>());
+        if (invoices.isEmpty()) return result;
+        if (invoices.size() == 1) {
+            result.get(invoices.get(0)).addAll(itineraries);
+            return result;
+        }
+        for (int i = 0; i < itineraries.size(); i++) {
+            StoredPdf itinerary = itineraries.get(i);
+            int bestIndex = -1;
+            int bestScore = 0;
+            for (int j = 0; j < invoices.size(); j++) {
+                int score = associationScore(invoices.get(j).originalName, itinerary.originalName);
+                if (score > bestScore) {
+                    bestScore = score;
+                    bestIndex = j;
+                }
+            }
+            if (bestIndex < 0) {
+                bestIndex = itineraries.size() == invoices.size() ? i : findRideInvoice(invoices);
+                if (bestIndex < 0) bestIndex = Math.min(i, invoices.size() - 1);
+                log.warn("[invoice-mail] 行程单缺少明确匹配标识,已按附件顺序关联:itinerary={}, invoice={}",
+                        itinerary.originalName, invoices.get(bestIndex).originalName);
+            }
+            result.get(invoices.get(bestIndex)).add(itinerary);
+        }
+        return result;
+    }
+
+    private int associationScore(String invoiceName, String itineraryName) {
+        int score = containsAny(invoiceName, RIDE_INVOICE_KEYWORDS) ? 1 : 0;
+        Matcher matcher = DIGIT_TOKEN.matcher(invoiceName);
+        while (matcher.find()) {
+            String token = matcher.group();
+            if (itineraryName.contains(token)) score += token.length();
+        }
+        return score;
+    }
+
+    private int findRideInvoice(List<StoredPdf> invoices) {
+        for (int i = 0; i < invoices.size(); i++) {
+            if (containsAny(invoices.get(i).originalName, RIDE_INVOICE_KEYWORDS)) return i;
+        }
+        return -1;
+    }
+
+    private EmployeeOwner resolveOwner(JSONArray recipients, String dingToken,
+                                       Map<String, EmployeeOwner> cache) throws Exception {
+        if (recipients == null) return null;
+        for (int i = 0; i < recipients.size(); i++) {
+            String email = recipientEmail(recipients.getJSONObject(i));
+            if (email == null || email.equalsIgnoreCase(properties.getMailboxAddress())) continue;
+            String key = email.toLowerCase(Locale.ROOT);
+            if (!cache.containsKey(key)) cache.put(key, queryEmployeeOwner(key, dingToken));
+            if (cache.get(key) != null) return cache.get(key);
+        }
+        return null;
+    }
+
+    private EmployeeOwner queryEmployeeOwner(String email, String token) throws Exception {
+        Map<String, Object> query = new HashMap<>();
+        query.put("textField_moclf2te", email);
+        com.aliyun.dingtalkyida_1_0.Client client = createYidaClient();
+        SearchFormDatasHeaders headers = new SearchFormDatasHeaders();
+        headers.xAcsDingtalkAccessToken = token;
+        SearchFormDatasRequest request = new SearchFormDatasRequest()
+                .setSystemToken(required(properties.getSystemToken(), "宜搭 systemToken 未配置"))
+                .setSearchFieldJson(JSON.toJSONString(query))
+                .setCurrentPage(1)
+                .setPageSize(10)
+                .setFormUuid(properties.getEmployeeFormUuid())
+                .setUserId(properties.getYidaUserId())
+                .setAppType(properties.getAppType());
+        SearchFormDatasResponse response = client.searchFormDatasWithOptions(request, headers, new RuntimeOptions());
+        validateYidaResponse(response == null ? null : response.getStatusCode(), "查询人员邮箱档案");
+        JSONObject body = JSON.parseObject(JSON.toJSONString(response.getBody()));
+        JSONArray data = body.getJSONArray("data");
+        if (data == null || data.isEmpty()) {
+            log.warn("[invoice-mail.yida] 邮箱未匹配到人员档案:email={}", email);
+            return null;
+        }
+        JSONObject formData = data.getJSONObject(0).getJSONObject("formData");
+        String userId = formData == null ? null : formData.getString("textField_moclf2tf");
+        if (isBlank(userId) && formData != null) {
+            JSONArray ids = formData.getJSONArray("employeeField_moclf2t8_id");
+            if (ids != null && !ids.isEmpty()) userId = ids.getString(0);
+        }
+        if (isBlank(userId)) throw new IllegalStateException("邮箱档案缺少钉钉 userId:" + email);
+        log.info("[invoice-mail.yida] 收件人匹配完成:email={}, userId={}", email, userId);
+        return new EmployeeOwner(email, userId);
+    }
+
+    private void writeInvoiceRecord(StoredPdf invoice, List<StoredPdf> itineraries,
+                                    EmployeeOwner owner, long createTimestamp, String token) throws Exception {
+        Map<String, Object> form = new LinkedHashMap<>();
+        form.put("employeeField_ml7hkag2", owner.userId);
+        form.put("textField_mlaco9e7", owner.userId);
+        form.put("textField_ml7hkag3", owner.email);
+        form.put("selectField_mmohfc66", "邮箱提取");
+        form.put("selectField_ml9154me", "提取完成");
+        form.put("selectField_mlaco9du", "未使用");
+        form.put("dateField_ml7hkag9", createTimestamp);
+        form.put("textField_ml9154m5", invoice.name);
+        form.put("textField_ml9154ma", invoice.url);
+        form.put("attachmentField_ml9154mb", Collections.singletonList(attachmentValue(invoice)));
+        if (itineraries != null && !itineraries.isEmpty()) {
+            List<Map<String, Object>> itineraryValues = new ArrayList<>();
+            for (StoredPdf itinerary : itineraries) itineraryValues.add(attachmentValue(itinerary));
+            form.put("attachmentField_mn8j0xer", itineraryValues);
+        }
+
+        Map<String, Object> condition = new LinkedHashMap<>();
+        condition.put("key", "textField_ml9154m5");
+        condition.put("value", invoice.name);
+        condition.put("type", "TEXT");
+        condition.put("operator", "eq");
+        condition.put("componentName", "TextField");
+
+        com.aliyun.dingtalkyida_1_0.Client client = createYidaClient();
+        CreateOrUpdateFormDataHeaders headers = new CreateOrUpdateFormDataHeaders();
+        headers.xAcsDingtalkAccessToken = token;
+        CreateOrUpdateFormDataRequest request = new CreateOrUpdateFormDataRequest()
+                .setSystemToken(required(properties.getSystemToken(), "宜搭 systemToken 未配置"))
+                .setFormUuid(properties.getTargetFormUuid())
+                .setUserId(properties.getYidaUserId())
+                .setSearchCondition(JSON.toJSONString(Collections.singletonList(condition)))
+                .setAppType(properties.getAppType())
+                .setFormDataJson(JSON.toJSONString(form));
+        CreateOrUpdateFormDataResponse response = client.createOrUpdateFormDataWithOptions(
+                request, headers, new RuntimeOptions());
+        validateYidaResponse(response == null ? null : response.getStatusCode(), "创建或更新票夹记录");
+        log.info("[invoice-mail.yida] 票夹记录写入完成:file={}, email={}, userId={}, itineraryCount={}",
+                invoice.name, owner.email, owner.userId, itineraries == null ? 0 : itineraries.size());
+    }
+
+    private Map<String, Object> attachmentValue(StoredPdf file) {
+        Map<String, Object> value = new LinkedHashMap<>();
+        value.put("name", file.name);
+        value.put("size", file.size);
+        value.put("fileUuid", file.url);
+        value.put("url", file.url);
+        value.put("downloadUrl", file.url);
+        value.put("previewUrl", file.url);
+        return value;
+    }
+
+    private JSONObject executeJson(Request request, String operation) throws IOException {
+        try (Response response = httpClient.newCall(request).execute()) {
+            ResponseBody body = response.body();
+            String text = body == null ? "" : body.string();
+            if (!response.isSuccessful()) {
+                throw new IOException(operation + "失败,HTTP " + response.code() + ":" + abbreviate(text));
+            }
+            JSONObject json = JSON.parseObject(text);
+            if (json == null) throw new IOException(operation + "返回空 JSON");
+            return json;
+        }
+    }
+
+    private com.aliyun.dingtalkyida_1_0.Client createYidaClient() throws Exception {
+        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
+        config.protocol = "https";
+        config.regionId = "central";
+        return new com.aliyun.dingtalkyida_1_0.Client(config);
+    }
+
+    private void validateYidaResponse(Integer statusCode, String operation) {
+        if (statusCode == null || statusCode < 200 || statusCode >= 300) {
+            throw new IllegalStateException(operation + "失败,HTTP " + statusCode);
+        }
+    }
+
+    private Path resolveStorageRoot() {
+        Path root = Paths.get(properties.getStorageRoot());
+        if (!root.isAbsolute()) root = Paths.get(System.getProperty("user.dir")).resolve(root);
+        return root.toAbsolutePath().normalize();
+    }
+
+    private HttpUrl baseUrl() {
+        HttpUrl url = HttpUrl.parse(MAIL_API_BASE);
+        if (url == null) throw new IllegalStateException("阿里邮箱 API 地址无效");
+        return url;
+    }
+
+    static boolean containsInvoiceKeyword(JSONObject message) {
+        if (message != null && message.getBooleanValue("_keywordMatched")) return true;
+        StringBuilder content = new StringBuilder();
+        append(content, message.get("subject"));
+        append(content, message.get("bodyPreview"));
+        append(content, message.get("textBody"));
+        append(content, message.get("htmlBody"));
+        append(content, message.get("body"));
+        append(content, message.get("snippet"));
+        return containsAny(content.toString(), INVOICE_KEYWORDS);
+    }
+
+    /** 宜搭 DateField 写入值统一使用毫秒级 Unix 时间戳。 */
+    static long toYidaDateTimestamp(Instant instant) {
+        return instant.toEpochMilli();
+    }
+
+    private static void append(StringBuilder target, Object value) {
+        if (value != null) target.append(' ').append(String.valueOf(value));
+    }
+
+    private static boolean containsAny(String value, List<String> keywords) {
+        if (value == null) return false;
+        String lower = value.toLowerCase(Locale.ROOT);
+        for (String keyword : keywords) {
+            if (lower.contains(keyword.toLowerCase(Locale.ROOT))) return true;
+        }
+        return false;
+    }
+
+    static boolean isItineraryPdfName(String name) {
+        return name != null
+                && name.toLowerCase(Locale.ROOT).endsWith(".pdf")
+                && containsAny(name, ITINERARY_KEYWORDS);
+    }
+
+    private String recipientEmail(JSONObject recipient) {
+        if (recipient == null) return null;
+        String email = firstNonBlank(recipient.getString("email"), recipient.getString("address"));
+        JSONObject emailAddress = recipient.getJSONObject("emailAddress");
+        return firstNonBlank(email, emailAddress == null ? null : emailAddress.getString("address"));
+    }
+
+    private ZonedDateTime parseMessageTime(JSONObject message, LocalDate fallback, ZoneId zone) {
+        String value = firstNonBlank(message.getString("date"), message.getString("sentDateTime"),
+                message.getString("receivedDateTime"));
+        if (!isBlank(value)) {
+            try { return Instant.parse(value).atZone(zone); } catch (DateTimeParseException ignored) {}
+            try { return ZonedDateTime.parse(value).withZoneSameInstant(zone); } catch (DateTimeParseException ignored) {}
+        }
+        return fallback.atStartOfDay(zone);
+    }
+
+    static String buildStoredFileName(String originalName, ZonedDateTime messageTime, String stableKey) {
+        String clean = originalName == null ? "invoice.pdf" : originalName.replace('\\', '/');
+        clean = clean.substring(clean.lastIndexOf('/') + 1).replaceAll("[<>:\"/\\\\|?*\\x00-\\x1F]", "_");
+        clean = clean.replaceAll("[. ]+$", "");
+        String base = clean.toLowerCase(Locale.ROOT).endsWith(".pdf")
+                ? clean.substring(0, clean.length() - 4) : clean;
+        if (base.isEmpty()) base = "invoice";
+        if (base.length() > 120) base = base.substring(0, 120);
+        return base + "_" + messageTime.format(FILE_TIMESTAMP) + "_" + shortHash(stableKey) + ".pdf";
+    }
+
+    private static String shortHash(String value) {
+        try {
+            byte[] digest = MessageDigest.getInstance("SHA-256")
+                    .digest(value.getBytes(StandardCharsets.UTF_8));
+            StringBuilder text = new StringBuilder();
+            for (int i = 0; i < 5; i++) text.append(String.format("%02x", digest[i]));
+            return text.toString();
+        } catch (Exception ex) {
+            throw new IllegalStateException("生成附件唯一标识失败", ex);
+        }
+    }
+
+    private String encodeUrlPath(String path) throws Exception {
+        String[] parts = path.replace('\\', '/').split("/");
+        StringBuilder encoded = new StringBuilder();
+        for (String part : parts) {
+            if (encoded.length() > 0) encoded.append('/');
+            encoded.append(URLEncoder.encode(part, "UTF-8").replace("+", "%20"));
+        }
+        return encoded.toString();
+    }
+
+    private String required(String value, String message) {
+        if (isBlank(value)) throw new IllegalStateException(message);
+        return value.trim();
+    }
+
+    private boolean isBlank(String value) { return value == null || value.trim().isEmpty(); }
+
+    private String firstNonBlank(String... values) {
+        for (String value : values) if (!isBlank(value)) return value.trim();
+        return null;
+    }
+
+    private String trimTrailingSlash(String value) {
+        String result = required(value, "发票公网 URL 前缀未配置");
+        while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
+        return result;
+    }
+
+    private String abbreviate(String value) {
+        if (value == null) return "";
+        return value.length() <= 300 ? value : value.substring(0, 300) + "...";
+    }
+
+    private String safeMessage(Throwable throwable) {
+        if (throwable == null) return "未知错误";
+        String message = throwable.getMessage();
+        return isBlank(message) ? throwable.getClass().getSimpleName() : message;
+    }
+
+    private static final class PdfDescriptor {
+        private final String id;
+        private final String originalName;
+        private final int index;
+
+        private PdfDescriptor(String id, String originalName, int index) {
+            this.id = id;
+            this.originalName = originalName;
+            this.index = index;
+        }
+    }
+
+    private static final class StoredPdf {
+        private final String originalName;
+        private final String name;
+        private final String url;
+        private final long size;
+
+        private StoredPdf(String originalName, String name, String url, long size) {
+            this.originalName = originalName;
+            this.name = name;
+            this.url = url;
+            this.size = size;
+        }
+    }
+
+    private static final class EmployeeOwner {
+        private final String email;
+        private final String userId;
+
+        private EmployeeOwner(String email, String userId) {
+            this.email = email;
+            this.userId = userId;
+        }
+    }
+
+    private static final class SyncStats {
+        private final LocalDate targetDate;
+        private int messageCount;
+        private int filteredMessageCount;
+        private int matchedMessageCount;
+        private int invoiceCount;
+        private int itineraryCount;
+        private int writtenCount;
+        private int skippedCount;
+        private final JSONArray errors = new JSONArray();
+
+        private SyncStats(LocalDate targetDate) { this.targetDate = targetDate; }
+
+        private void addError(String item, String reason) {
+            JSONObject error = new JSONObject();
+            error.put("item", item);
+            error.put("reason", reason);
+            errors.add(error);
+        }
+
+        private Map<String, Object> toMap(boolean complete) {
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("date", targetDate.toString());
+            result.put("complete", complete);
+            result.put("messages", messageCount);
+            result.put("filteredMessages", filteredMessageCount);
+            result.put("matchedMessages", matchedMessageCount);
+            result.put("invoices", invoiceCount);
+            result.put("itineraries", itineraryCount);
+            result.put("written", writtenCount);
+            result.put("skipped", skippedCount);
+            result.put("errors", errors);
+            return result;
+        }
+    }
+}

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

@@ -10,4 +10,10 @@ public interface AlibabaMailBoxService {
 
     /**搜索邮件*/
     R queryMessages();
+
+    /** 获取昨日发票邮件、转存 PDF,并写入宜搭票夹。 */
+    R syncYesterdayInvoiceMessages();
+
+    /** 临时手动验证:按指定日期获取发票邮件,日期为空时使用当天。 */
+    R syncInvoiceMessagesByDate(String date);
 }

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

@@ -9,6 +9,7 @@ import com.muzhi.changguchuan.common.alibaba.AlibabaMailBox;
 import com.muzhi.changguchuan.common.dingtalkyida.YiDaFormData;
 import com.muzhi.changguchuan.common.dingtalkyida.YiDaFormDataV2;
 import com.muzhi.changguchuan.common.time.BeijingToUtcConverter;
+import com.muzhi.changguchuan.invoice.service.InvoiceMailSyncService;
 import com.muzhi.changguchuan.service.AccessTokenService;
 import com.muzhi.changguchuan.service.AlibabaMailBoxService;
 import lombok.extern.slf4j.Slf4j;
@@ -33,6 +34,23 @@ public class AlibabaMailBoxServiceImpl implements AlibabaMailBoxService {
     @Autowired
     private AccessTokenService accessTokenService;
 
+    @Autowired
+    private InvoiceMailSyncService invoiceMailSyncService;
+
+    /**
+     * 新版昨日发票同步入口。独立实现用于保持原 queryMessages 手动接口逻辑不变。
+     */
+    @Override
+    public R syncYesterdayInvoiceMessages() {
+        return invoiceMailSyncService.syncYesterdayInvoices();
+    }
+
+    /** 临时手动验证入口,部署验证结束后可连同 Controller 临时接口一起注释。 */
+    @Override
+    public R syncInvoiceMessagesByDate(String date) {
+        return invoiceMailSyncService.syncInvoicesByDate(date);
+    }
+
     /**
      * 搜索邮件,并解析发票信息
      * NOTE 邮件标题匹配各类发票邮件标识:【发票、电子发票、电子票】

+ 26 - 0
src/test/java/com/muzhi/changguchuan/controller/AlibabaMailBoxControllerScheduleTest.java

@@ -0,0 +1,26 @@
+package com.muzhi.changguchuan.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class AlibabaMailBoxControllerScheduleTest {
+
+    @Test
+    void schedulesOnlyTheNewYesterdayInvoiceMethodAtOneAm() throws Exception {
+        Method legacy = AlibabaMailBoxController.class.getMethod("queryMessages");
+        Method temporaryManual = AlibabaMailBoxController.class
+                .getMethod("syncTodayInvoiceMessages", String.class);
+        Method scheduled = AlibabaMailBoxController.class.getMethod("scheduledYesterdayInvoiceSync");
+
+        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.time-zone:Asia/Shanghai}", annotation.zone());
+    }
+}

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

@@ -0,0 +1,132 @@
+package com.muzhi.changguchuan.invoice.service.impl;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.muzhi.changguchuan.common.R;
+import com.muzhi.changguchuan.invoice.config.InvoiceMailProperties;
+import com.muzhi.changguchuan.service.AccessTokenService;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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 InvoiceMailSyncServiceImplTest {
+
+    @Test
+    void recognizesInvoiceKeywordsInSubjectAndBody() {
+        JSONObject subjectMessage = new JSONObject();
+        subjectMessage.put("subject", "您的数电票已开具");
+        JSONObject bodyMessage = new JSONObject();
+        bodyMessage.put("bodyPreview", "Please download your invoice attachment.");
+        JSONObject unrelatedMessage = new JSONObject();
+        unrelatedMessage.put("subject", "会议通知");
+        JSONObject fallbackMessage = new JSONObject();
+        fallbackMessage.put("_keywordMatched", true);
+
+        assertTrue(InvoiceMailSyncServiceImpl.containsInvoiceKeyword(subjectMessage));
+        assertTrue(InvoiceMailSyncServiceImpl.containsInvoiceKeyword(bodyMessage));
+        assertFalse(InvoiceMailSyncServiceImpl.containsInvoiceKeyword(unrelatedMessage));
+        assertTrue(InvoiceMailSyncServiceImpl.containsInvoiceKeyword(fallbackMessage));
+    }
+
+    @Test
+    void recognizesOnlyPdfItineraryAttachments() {
+        assertTrue(InvoiceMailSyncServiceImpl.isItineraryPdfName("滴滴出行行程单.PDF"));
+        assertTrue(InvoiceMailSyncServiceImpl.isItineraryPdfName("trip report.pdf"));
+        assertFalse(InvoiceMailSyncServiceImpl.isItineraryPdfName("行程单.xlsx"));
+        assertFalse(InvoiceMailSyncServiceImpl.isItineraryPdfName("电子发票.pdf"));
+    }
+
+    @Test
+    void detectsReportedMailCountWithoutReturnedMessages() {
+        JSONObject response = new JSONObject();
+        response.put("total", 2);
+
+        assertTrue(InvoiceMailSyncServiceImpl.hasReportedButMissingMessages(response, null));
+        assertTrue(InvoiceMailSyncServiceImpl.hasReportedButMissingMessages(response, new JSONArray()));
+
+        JSONArray messages = new JSONArray();
+        messages.add(new JSONObject());
+        assertFalse(InvoiceMailSyncServiceImpl.hasReportedButMissingMessages(response, messages));
+    }
+
+    @Test
+    void mergesMessageSummaryAndNestedDetail() {
+        JSONObject recipient = new JSONObject();
+        recipient.put("email", "employee@hasegawa-sh.com");
+        JSONArray recipients = new JSONArray();
+        recipients.add(recipient);
+        JSONObject summary = new JSONObject();
+        summary.put("id", "message-1");
+        summary.put("toRecipients", recipients);
+
+        JSONObject detail = new JSONObject();
+        detail.put("subject", "普通标题");
+        detail.put("htmlBody", "您的电子发票已开具");
+        JSONObject detailResponse = new JSONObject();
+        detailResponse.put("message", detail);
+
+        JSONObject merged = InvoiceMailSyncServiceImpl.mergeMessageDetail(summary, detailResponse);
+
+        assertEquals("message-1", merged.getString("id"));
+        assertEquals("employee@hasegawa-sh.com",
+                merged.getJSONArray("toRecipients").getJSONObject(0).getString("email"));
+        assertTrue(InvoiceMailSyncServiceImpl.containsInvoiceKeyword(merged));
+    }
+
+    @Test
+    void buildsStableTimestampedWindowsSafeFileNames() {
+        ZonedDateTime time = ZonedDateTime.of(
+                2026, 9, 23, 10, 11, 12, 345000000, ZoneId.of("Asia/Shanghai"));
+
+        String first = InvoiceMailSyncServiceImpl.buildStoredFileName(
+                "电子发票:订单/001?.PDF", time, "message-1|attachment-1");
+        String repeated = InvoiceMailSyncServiceImpl.buildStoredFileName(
+                "电子发票:订单/001?.PDF", time, "message-1|attachment-1");
+        String another = InvoiceMailSyncServiceImpl.buildStoredFileName(
+                "电子发票:订单/001?.PDF", time, "message-1|attachment-2");
+
+        assertEquals(first, repeated);
+        assertNotEquals(first, another);
+        assertTrue(first.contains("_20260923101112345_"));
+        assertTrue(first.endsWith(".pdf"));
+        assertFalse(first.contains(":"));
+        assertFalse(first.contains("/"));
+        assertFalse(first.contains("?"));
+    }
+
+    @Test
+    void convertsYidaDateFieldToMillisecondTimestamp() {
+        long timestamp = InvoiceMailSyncServiceImpl.toYidaDateTimestamp(
+                Instant.parse("2026-09-24T08:33:27.324Z"));
+
+        assertEquals(1790238807324L, timestamp);
+        assertEquals(13, String.valueOf(timestamp).length());
+    }
+
+    @Test
+    void rejectsInvalidManualDateBeforeCallingExternalServices() {
+        AccessTokenService accessTokenService = mock(AccessTokenService.class);
+        InvoiceMailProperties properties = mock(InvoiceMailProperties.class);
+        when(properties.getHttpTimeoutSeconds()).thenReturn(60);
+        when(properties.getTimeZone()).thenReturn("Asia/Shanghai");
+        InvoiceMailSyncServiceImpl service = new InvoiceMailSyncServiceImpl(accessTokenService, properties);
+
+        R result = service.syncInvoicesByDate("2026-13-40");
+
+        assertFalse(result.getSuccess());
+        assertTrue(result.getMessage().contains("yyyy-MM-dd"));
+        verify(accessTokenService, never()).getAlibabaMailboxAccessToken();
+        verify(accessTokenService, never()).getAccessToken();
+    }
+}