|
|
@@ -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;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|