|
|
@@ -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);
|