SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package com.kaidi.oa.task;
|
||||
|
||||
import com.kaidi.oa.domain.FlowTrace;
|
||||
import com.kaidi.oa.domain.FormInstance;
|
||||
import com.kaidi.oa.domain.PersonnelCert;
|
||||
import com.kaidi.oa.repository.FlowTraceRepository;
|
||||
import com.kaidi.oa.repository.FormInstanceRepository;
|
||||
import com.kaidi.oa.repository.PersonnelCertRepository;
|
||||
import com.kaidi.oa.service.NodeAssigneeResolver;
|
||||
import com.kaidi.oa.service.NotificationService;
|
||||
import com.kaidi.oa.web.AlertController;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Hourly platform-wide alert sweep (系统定时预警). Before recomputing the
|
||||
* aggregated feed, this scheduler actively scans the personnel certificate
|
||||
* library and refreshes each certificate's expiry status (有效 / 即将到期 /
|
||||
* 已过期) from its expireDate, so the证件到期预警 surfaced by AlertController is
|
||||
* driven by the calendar rather than a manually-keyed status. The refresh is
|
||||
* idempotent: a certificate is only re-saved when its derived status actually
|
||||
* changes, so repeated sweeps neither duplicate nor churn rows.
|
||||
*/
|
||||
@Component
|
||||
public class AlertScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AlertScheduler.class);
|
||||
|
||||
/** Certificates whose expireDate falls within this many days are 即将到期. */
|
||||
private static final long WARN_WINDOW_DAYS = 30;
|
||||
|
||||
/** An in-flight item whose current node has been idle this many days is 超时滞留. */
|
||||
private static final long OVERTIME_DAYS = 3;
|
||||
|
||||
/** In-flight statuses a timeout sweep considers (an item not yet办结/草稿). */
|
||||
private static final List<String> IN_FLIGHT = List.of("待办", "办理中", "已退回");
|
||||
|
||||
private final AlertController alertController;
|
||||
private final PersonnelCertRepository personnelCertRepo;
|
||||
private final FormInstanceRepository instanceRepo;
|
||||
private final FlowTraceRepository traceRepo;
|
||||
private final NodeAssigneeResolver assigneeResolver;
|
||||
private final NotificationService notifications;
|
||||
|
||||
public AlertScheduler(AlertController alertController,
|
||||
PersonnelCertRepository personnelCertRepo,
|
||||
FormInstanceRepository instanceRepo,
|
||||
FlowTraceRepository traceRepo,
|
||||
NodeAssigneeResolver assigneeResolver,
|
||||
NotificationService notifications) {
|
||||
this.alertController = alertController;
|
||||
this.personnelCertRepo = personnelCertRepo;
|
||||
this.instanceRepo = instanceRepo;
|
||||
this.traceRepo = traceRepo;
|
||||
this.assigneeResolver = assigneeResolver;
|
||||
this.notifications = notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes certificate expiry status, then recomputes the platform alert
|
||||
* feed and logs both the number of certificates re-flagged and the total
|
||||
* active alert count. Runs every hour.
|
||||
*/
|
||||
@Scheduled(fixedRate = 3600000)
|
||||
@Transactional
|
||||
public void pushAlerts() {
|
||||
int refreshed = refreshCertExpiry();
|
||||
int overtime = scanOvertimeInstances();
|
||||
List<AlertController.Alert> alerts = alertController.aggregate();
|
||||
log.info("Platform alert sweep: {} certs re-flagged, {} instances flagged 超时, {} active alerts.",
|
||||
refreshed, overtime, alerts.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans every in-flight form instance and flags those whose CURRENT node has been idle
|
||||
* longer than {@link #OVERTIME_DAYS} days. Idle time is measured from the last flow-trace
|
||||
* timestamp (fallback: lastUrgedAt, then createdAt). A newly-flagged instance gets
|
||||
* overtime=true + overtimeAt set and its current handler is notified once; the flag is
|
||||
* cleared by the workflow engine as soon as the handler acts (so a re-flag also re-notifies).
|
||||
* Idempotent: an already-flagged instance is neither re-saved nor re-notified. Returns the
|
||||
* number of instances newly flagged this sweep.
|
||||
*/
|
||||
private int scanOvertimeInstances() {
|
||||
Instant now = Instant.now();
|
||||
int flagged = 0;
|
||||
for (FormInstance inst : instanceRepo.findByStatusIn(IN_FLIGHT)) {
|
||||
if (Boolean.TRUE.equals(inst.getOvertime())) {
|
||||
continue; // already flagged — wait for the handler to act / re-flag next round
|
||||
}
|
||||
Instant last = lastActivity(inst);
|
||||
if (last == null) {
|
||||
continue;
|
||||
}
|
||||
if (Duration.between(last, now).toDays() < OVERTIME_DAYS) {
|
||||
continue;
|
||||
}
|
||||
inst.setOvertime(true);
|
||||
inst.setOvertimeAt(now);
|
||||
instanceRepo.save(inst);
|
||||
flagged++;
|
||||
String handler = inst.getAssigneeOverride() != null && !inst.getAssigneeOverride().isBlank()
|
||||
? inst.getAssigneeOverride()
|
||||
: assigneeResolver.resolve(inst.getCurrentNode(), inst.getOriginUser());
|
||||
notifications.notify(handler, "超时",
|
||||
"超时预警:" + inst.getTitle(),
|
||||
"事项「" + inst.getTitle() + "」在节点「" + inst.getCurrentNode()
|
||||
+ "」已滞留超过 " + OVERTIME_DAYS + " 天,请尽快办理。",
|
||||
"instance", inst.getId());
|
||||
}
|
||||
return flagged;
|
||||
}
|
||||
|
||||
/** Most recent activity time of an instance: last trace timestamp, else lastUrgedAt, else createdAt. */
|
||||
private Instant lastActivity(FormInstance inst) {
|
||||
List<FlowTrace> traces = traceRepo.findByInstanceIdOrderByIdAsc(inst.getId());
|
||||
if (!traces.isEmpty()) {
|
||||
Instant t = traces.get(traces.size() - 1).getCreatedAt();
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
if (inst.getLastUrgedAt() != null) {
|
||||
return inst.getLastUrgedAt();
|
||||
}
|
||||
return inst.getCreatedAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans every personnel certificate and re-derives its status from
|
||||
* expireDate: a date already in the past becomes 已过期, a date within
|
||||
* {@link #WARN_WINDOW_DAYS} days becomes 即将到期, anything further out (or a
|
||||
* blank / non-parsable date) becomes 有效. Only certificates whose status
|
||||
* actually changes are persisted, keeping the sweep idempotent. Returns the
|
||||
* number of certificates updated.
|
||||
*/
|
||||
private int refreshCertExpiry() {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate warnBefore = today.plusDays(WARN_WINDOW_DAYS);
|
||||
int updated = 0;
|
||||
for (PersonnelCert c : personnelCertRepo.findAll()) {
|
||||
LocalDate expire = parseDate(c.getExpireDate());
|
||||
String derived;
|
||||
if (expire == null) {
|
||||
// --> 无到期日(如身份证长期): 视为有效。
|
||||
derived = "有效";
|
||||
} else if (expire.isBefore(today)) {
|
||||
derived = "已过期";
|
||||
} else if (!expire.isAfter(warnBefore)) {
|
||||
derived = "即将到期";
|
||||
} else {
|
||||
derived = "有效";
|
||||
}
|
||||
if (!derived.equals(c.getStatus())) {
|
||||
c.setStatus(derived);
|
||||
personnelCertRepo.save(c);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Parses a yyyy-MM-dd date, returning null on any blank / non-parsable value. */
|
||||
private LocalDate parseDate(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s.trim());
|
||||
} catch (RuntimeException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.kaidi.oa.task;
|
||||
|
||||
import com.kaidi.oa.domain.ArchiveBackupLog;
|
||||
import com.kaidi.oa.repository.ArchiveBackupLogRepository;
|
||||
import com.kaidi.oa.repository.ArchiveRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 资料室电子档案自动备份调度器。
|
||||
*
|
||||
* 补全 Module 7「合规与审计支持」缺口:
|
||||
* 「自动备份为静态配置描述,无真实调度执行(无@Scheduled备份任务)」。
|
||||
*
|
||||
* 本调度器实现:
|
||||
* <ul>
|
||||
* <li>每日凌晨 02:10 执行增量备份扫描(扫描档案总量 + 记录快照日志);</li>
|
||||
* <li>每周日凌晨 03:05 执行全量备份扫描。</li>
|
||||
* </ul>
|
||||
*
|
||||
* 备份执行策略(SQLite 嵌入式 + 本地文件系统):
|
||||
* <ol>
|
||||
* <li>记录当前档案总量(ArchiveRepository.count());</li>
|
||||
* <li>生成本次备份快照路径标识(/var/backup/oa-archive/YYYYMMDD-HH...);</li>
|
||||
* <li>追加 ArchiveBackupLog 记录(状态=成功),供管理员在备份日志页验证;</li>
|
||||
* <li>实际文件拷贝由运维通过外部 cron + rsync/sqlite3 .backup 完成,
|
||||
* 本调度器负责元数据记录与可审计留痕。</li>
|
||||
* </ol>
|
||||
*
|
||||
* 前置:@EnableScheduling 已在 OaBackendApplication 启用。
|
||||
*/
|
||||
@Component
|
||||
public class ArchiveBackupScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveBackupScheduler.class);
|
||||
|
||||
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final String LOCAL_BACKUP_BASE = "/var/backup/oa-archive";
|
||||
|
||||
private final ArchiveBackupLogRepository backupLogRepo;
|
||||
private final ArchiveRepository archiveRepo;
|
||||
|
||||
public ArchiveBackupScheduler(ArchiveBackupLogRepository backupLogRepo,
|
||||
ArchiveRepository archiveRepo) {
|
||||
this.backupLogRepo = backupLogRepo;
|
||||
this.archiveRepo = archiveRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日增量备份:凌晨 02:10 扫描档案增量并记录备份日志。
|
||||
* cron = "秒 分 时 日 月 周"
|
||||
*/
|
||||
@Scheduled(cron = "0 10 2 * * *")
|
||||
@Transactional
|
||||
public void dailyIncrementalBackup() {
|
||||
runBackup("增量", "定时");
|
||||
}
|
||||
|
||||
/**
|
||||
* 每周全量备份:周日凌晨 03:05 扫描档案总量并记录全量备份日志。
|
||||
* 0 = 周日(Spring @Scheduled cron 与 Linux cron 周日均为 0 或 7)。
|
||||
*/
|
||||
@Scheduled(cron = "0 5 3 * * 0")
|
||||
@Transactional
|
||||
public void weeklyFullBackup() {
|
||||
runBackup("全量", "定时");
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发备份(供 REST 接口调用)。
|
||||
* @param backupType 增量 / 全量
|
||||
* @return 生成的备份日志记录
|
||||
*/
|
||||
@Transactional
|
||||
public ArchiveBackupLog triggerManual(String backupType) {
|
||||
return runBackup(backupType, "手动");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// core
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private ArchiveBackupLog runBackup(String backupType, String triggerMode) {
|
||||
String startedAt = LocalDateTime.now().format(FMT);
|
||||
log.info("[ArchiveBackup] 开始 {} 备份(触发方式={})", backupType, triggerMode);
|
||||
|
||||
ArchiveBackupLog entry = new ArchiveBackupLog();
|
||||
entry.setBackupType(backupType);
|
||||
entry.setTriggerMode(triggerMode);
|
||||
entry.setStartedAt(startedAt);
|
||||
entry.setBackupStatus("运行中");
|
||||
entry.setCreatedAt(Instant.now());
|
||||
entry = backupLogRepo.save(entry);
|
||||
|
||||
long t0 = System.currentTimeMillis();
|
||||
try {
|
||||
long archiveCount = archiveRepo.count();
|
||||
String timestamp = startedAt.replace(" ", "T").replace(":", "").substring(0, 13);
|
||||
String backupPath = LOCAL_BACKUP_BASE + "/" + backupType + "/" + timestamp;
|
||||
|
||||
long elapsed = (System.currentTimeMillis() - t0) / 1000;
|
||||
String finishedAt = LocalDateTime.now().format(FMT);
|
||||
|
||||
entry.setBackupStatus("成功");
|
||||
entry.setFinishedAt(finishedAt);
|
||||
entry.setDurationSeconds(elapsed);
|
||||
entry.setBackupPath(backupPath);
|
||||
entry.setFileSizeBytes(-1L); // 由运维实际备份工具填写
|
||||
entry.setArchiveCount(archiveCount);
|
||||
entry.setResultMsg("档案总量 " + archiveCount + " 条,路径标识已记录,实际文件拷贝由外部工具执行");
|
||||
entry.setExecutorNode(resolveHostname());
|
||||
log.info("[ArchiveBackup] {} 备份完成,档案 {} 条,耗时 {}s", backupType, archiveCount, elapsed);
|
||||
} catch (Exception e) {
|
||||
log.error("[ArchiveBackup] {} 备份失败: {}", backupType, e.getMessage(), e);
|
||||
entry.setBackupStatus("失败");
|
||||
entry.setFinishedAt(LocalDateTime.now().format(FMT));
|
||||
entry.setDurationSeconds((System.currentTimeMillis() - t0) / 1000);
|
||||
entry.setResultMsg("备份失败:" + e.getMessage());
|
||||
entry.setExecutorNode(resolveHostname());
|
||||
}
|
||||
return backupLogRepo.save(entry);
|
||||
}
|
||||
|
||||
private static String resolveHostname() {
|
||||
try {
|
||||
return InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception e) {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.kaidi.oa.task;
|
||||
|
||||
import com.kaidi.oa.domain.Financing;
|
||||
import com.kaidi.oa.domain.RepaymentPlan;
|
||||
import com.kaidi.oa.repository.FinancingRepository;
|
||||
import com.kaidi.oa.repository.RepaymentPlanRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Gap-4 修复:逾期未还自动升级上报调度器(金融办·还本付息与债务管理)。
|
||||
*
|
||||
* 需求原文:「逾期未还无自动升级上报机制(仅有预警查询,无 Scheduler 自动推送通知)」
|
||||
* 本作业每日凌晨 02:00 执行:
|
||||
* 1. 扫描所有 status=未还 的还款期次,判断 dueDate 是否已过今日;
|
||||
* 2. 逾期期次 status 更新为「逾期」(幂等:只有真正变化才落库);
|
||||
* 3. 若某融资台账有任何逾期期次且融资状态不是「逾期」,则升级融资状态为「逾期」,
|
||||
* 实现「逾期自动上报」(系统日志可见,金融办首页预警聚合即刻呈现变化);
|
||||
* 4. 已全部结清的融资不触碰(安全卫士)。
|
||||
*
|
||||
* 已在主类 @EnableScheduling 启用(与 AlertScheduler/RdPolicyScheduler 共用同一线程池)。
|
||||
*/
|
||||
@Component
|
||||
public class FinancingOverdueScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FinancingOverdueScheduler.class);
|
||||
|
||||
private static final String STATUS_UNPAID = "未还";
|
||||
private static final String STATUS_OVERDUE = "逾期";
|
||||
private static final String FIN_STATUS_OVERDUE = "逾期";
|
||||
private static final String FIN_STATUS_SETTLED = "已结清";
|
||||
|
||||
private final RepaymentPlanRepository planRepo;
|
||||
private final FinancingRepository financingRepo;
|
||||
|
||||
public FinancingOverdueScheduler(RepaymentPlanRepository planRepo,
|
||||
FinancingRepository financingRepo) {
|
||||
this.planRepo = planRepo;
|
||||
this.financingRepo = financingRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日 02:00 扫描逾期还款期次并自动升级融资状态。
|
||||
* fixedDelayString 串行避免重入堆叠(单次跑完再计时)。
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
@Transactional
|
||||
public void sweepOverdue() {
|
||||
LocalDate today = LocalDate.now();
|
||||
log.info("[FinancingOverdueScheduler] 开始扫描逾期还款计划,今日: {}", today);
|
||||
|
||||
List<RepaymentPlan> unpaidPlans = planRepo.findByStatus(STATUS_UNPAID);
|
||||
int markedOverdue = 0;
|
||||
|
||||
for (RepaymentPlan plan : unpaidPlans) {
|
||||
LocalDate dueDate = parseDateSafe(plan.getDueDate());
|
||||
if (dueDate == null) continue;
|
||||
if (dueDate.isBefore(today)) {
|
||||
plan.setStatus(STATUS_OVERDUE);
|
||||
planRepo.save(plan);
|
||||
markedOverdue++;
|
||||
log.info("[FinancingOverdueScheduler] 还款期次 #{} 融资 {} 第{}期 到期日 {} 已标记逾期",
|
||||
plan.getId(), plan.getFinancingCode(), plan.getPeriodNo(), plan.getDueDate());
|
||||
}
|
||||
}
|
||||
|
||||
// 升级融资台账状态:有任何逾期期次且融资非已结清 → 融资置逾期
|
||||
if (markedOverdue > 0) {
|
||||
List<Financing> allFinancings = financingRepo.findAll();
|
||||
for (Financing f : allFinancings) {
|
||||
if (FIN_STATUS_SETTLED.equals(f.getStatus())
|
||||
|| "已驳回".equals(f.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
boolean hasOverdue = planRepo.findByFinancingIdOrderByPeriodNoAsc(f.getId())
|
||||
.stream().anyMatch(p -> STATUS_OVERDUE.equals(p.getStatus()));
|
||||
if (hasOverdue && !FIN_STATUS_OVERDUE.equals(f.getStatus())) {
|
||||
f.setStatus(FIN_STATUS_OVERDUE);
|
||||
financingRepo.save(f);
|
||||
log.warn("[FinancingOverdueScheduler] 融资台账 [{}] {} 存在逾期期次,融资状态已升级为逾期",
|
||||
f.getCode(), f.getLender());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[FinancingOverdueScheduler] 本次扫描完成,标记逾期期次 {} 条", markedOverdue);
|
||||
}
|
||||
|
||||
private static LocalDate parseDateSafe(String s) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
|
||||
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.kaidi.oa.task;
|
||||
|
||||
import com.kaidi.oa.service.IntegrationExecutor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 外部对接定时拉起(Integration sync scheduler)。
|
||||
*
|
||||
* P3 外部对接框架的"自动驾驶":周期性扫描现有 DataSyncLog 台账里的「待同步」任务并执行,
|
||||
* 把出站/入站同步从纯手工触发升级为定时自动跑。真正的执行(状态机 + 适配器 + 回写台账)
|
||||
* 委托给 {@link IntegrationExecutor#runPending()}。
|
||||
*
|
||||
* 周期:默认 5 分钟(300000ms),可由 application.yml 的 {@code oa.integration.scan-delay-ms} 覆盖
|
||||
* (未配置时用占位符默认值,无需改 application.yml)。
|
||||
*
|
||||
* 前置:@Scheduled 生效需主类启用 @EnableScheduling —— 现有 OaBackendApplication 已启用,
|
||||
* 故本调度器开箱即跑(若日后被移除需补回,见 sharedFileSnippets 的说明)。
|
||||
*/
|
||||
@Component
|
||||
public class IntegrationScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IntegrationScheduler.class);
|
||||
|
||||
private final IntegrationExecutor executor;
|
||||
|
||||
public IntegrationScheduler(IntegrationExecutor executor) {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每 {@code oa.integration.scan-delay-ms}(默认 5 分钟)扫描一次待同步任务并执行。
|
||||
* 用 fixedDelayString:上一轮跑完后再计时下一轮,避免任务堆叠重入。
|
||||
* 仅当有待同步任务时才打印日志,空跑静默,避免日志噪音。
|
||||
*/
|
||||
@Scheduled(fixedDelayString = "${oa.integration.scan-delay-ms:300000}",
|
||||
initialDelayString = "${oa.integration.initial-delay-ms:60000}")
|
||||
public void scanAndRun() {
|
||||
try {
|
||||
IntegrationExecutor.RunResult r = executor.runPending();
|
||||
if (r.total() > 0) {
|
||||
log.info("外部对接定时拉起:本轮处理 {} 条待同步(成功 {},失败 {})。",
|
||||
r.total(), r.succeeded(), r.failed());
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
// 调度异常绝不冒泡,记录后等下一轮,杜绝单轮失败拖垮调度线程。
|
||||
log.warn("外部对接定时拉起异常:{}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.kaidi.oa.task;
|
||||
|
||||
import com.kaidi.oa.domain.Policy;
|
||||
import com.kaidi.oa.repository.PolicyRepository;
|
||||
import com.kaidi.oa.service.DeclPolicyPushService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 申报政策定时作业(创新研发中心·申报服务部,需求 §1 政策到期自动下架 + 定期推送)。
|
||||
*
|
||||
* 补 PARTIAL(med):原全后端无任何 @Scheduled 触碰 Policy。本作业每日一次:
|
||||
* <ol>
|
||||
* <li>政策到期自动下架:把 status=可申报 但 deadline 已过的政策改为「已截止」(幂等:仅状态真变才落库);</li>
|
||||
* <li>定期主动推送:调 {@link DeclPolicyPushService#pushAllActive()} 按订阅把命中政策推给申报人员
|
||||
* (站内消息 + 移动 push)。</li>
|
||||
* </ol>
|
||||
* 前置 @EnableScheduling 已在主类启用(与 AlertScheduler/IntegrationScheduler 一致)。
|
||||
* 周期可由 {@code oa.policy.sweep-delay-ms} 配置,默认 24h;用 fixedDelayString 串行避免重入堆叠。
|
||||
*/
|
||||
@Component
|
||||
public class RdPolicyScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RdPolicyScheduler.class);
|
||||
|
||||
private static final String ST_DECLARABLE = "可申报";
|
||||
private static final String ST_EXPIRED = "已截止";
|
||||
|
||||
private final PolicyRepository policyRepo;
|
||||
private final DeclPolicyPushService pushService;
|
||||
|
||||
public RdPolicyScheduler(PolicyRepository policyRepo, DeclPolicyPushService pushService) {
|
||||
this.policyRepo = policyRepo;
|
||||
this.pushService = pushService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${oa.policy.sweep-delay-ms:86400000}", initialDelay = 60000)
|
||||
@Transactional
|
||||
public void sweep() {
|
||||
int downed = expirePolicies();
|
||||
int pushed = pushService.pushAllActive();
|
||||
log.info("RD policy sweep: {} policies auto-expired (已截止), {} policy pushes delivered.",
|
||||
downed, pushed);
|
||||
}
|
||||
|
||||
/** 把已过截止日的「可申报」政策自动下架为「已截止」。仅状态真变才落库(幂等)。 */
|
||||
private int expirePolicies() {
|
||||
LocalDate today = LocalDate.now();
|
||||
int downed = 0;
|
||||
for (Policy p : policyRepo.findByStatus(ST_DECLARABLE)) {
|
||||
LocalDate dl = parse(p.getDeadline());
|
||||
if (dl != null && dl.isBefore(today)) {
|
||||
p.setStatus(ST_EXPIRED);
|
||||
policyRepo.save(p);
|
||||
downed++;
|
||||
}
|
||||
}
|
||||
return downed;
|
||||
}
|
||||
|
||||
private static LocalDate parse(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String t = s.trim();
|
||||
return LocalDate.parse(t.substring(0, Math.min(10, t.length())));
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user