168 lines
4.8 KiB
JavaScript
168 lines
4.8 KiB
JavaScript
/**
|
||
* 工单轮询器
|
||
* GH-GMP-005 · M1 · Notion Sync Layer
|
||
*
|
||
* 定时轮询工单数据库,检测新工单和状态变更
|
||
* 维护 lastCheckedTime + processedSet 避免重复处理
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const DEFAULT_INTERVAL_MS = 30000; // 30秒
|
||
const STATE_FILE = 'poller-state.json';
|
||
|
||
class TicketPoller {
|
||
/**
|
||
* @param {object} opts
|
||
* @param {import('./db-reader')} opts.dbReader
|
||
* @param {function} opts.onNewTicket - async (ticket) => void
|
||
* @param {function} [opts.onUpdatedTicket] - async (ticket) => void
|
||
* @param {number} [opts.intervalMs]
|
||
* @param {string} [opts.stateDir] - 持久化状态文件目录
|
||
* @param {object} [opts.logger]
|
||
*/
|
||
constructor(opts) {
|
||
this.dbReader = opts.dbReader;
|
||
this.onNewTicket = opts.onNewTicket;
|
||
this.onUpdatedTicket = opts.onUpdatedTicket || null;
|
||
this.intervalMs = opts.intervalMs || DEFAULT_INTERVAL_MS;
|
||
this.stateDir = opts.stateDir || process.cwd();
|
||
this.logger = opts.logger || console;
|
||
|
||
this._timer = null;
|
||
this._running = false;
|
||
this._pollCount = 0;
|
||
|
||
// 持久化状态
|
||
this._lastCheckedTime = null;
|
||
this._processedSet = new Set(); // pageId集合
|
||
|
||
this._loadState();
|
||
}
|
||
|
||
start() {
|
||
if (this._running) {
|
||
this.logger.warn('[poller] 已在运行中,忽略重复启动');
|
||
return;
|
||
}
|
||
this._running = true;
|
||
this.logger.info(
|
||
`[poller] 启动轮询 · 间隔 ${this.intervalMs}ms · 上次检查 ${this._lastCheckedTime || '无'}`
|
||
);
|
||
// 立即执行一次
|
||
this._poll();
|
||
this._timer = setInterval(() => this._poll(), this.intervalMs);
|
||
}
|
||
|
||
stop() {
|
||
if (this._timer) {
|
||
clearInterval(this._timer);
|
||
this._timer = null;
|
||
}
|
||
this._running = false;
|
||
this._saveState();
|
||
this.logger.info(`[poller] 已停止 · 共轮询 ${this._pollCount} 次`);
|
||
}
|
||
|
||
get isRunning() {
|
||
return this._running;
|
||
}
|
||
|
||
get stats() {
|
||
return {
|
||
running: this._running,
|
||
pollCount: this._pollCount,
|
||
processedCount: this._processedSet.size,
|
||
lastCheckedTime: this._lastCheckedTime,
|
||
};
|
||
}
|
||
|
||
// ─── 内部 ───
|
||
|
||
async _poll() {
|
||
try {
|
||
this._pollCount++;
|
||
|
||
// 策略1:查询待开发且有负责Agent的工单
|
||
const pending = await this.dbReader.queryPendingTickets();
|
||
for (const ticket of pending) {
|
||
if (!this._processedSet.has(ticket.pageId)) {
|
||
this.logger.info(
|
||
`[poller] 🆕 新工单 · ${ticket['编号'] || ticket['任务标题']} · ${ticket['负责Agent']}`
|
||
);
|
||
this._processedSet.add(ticket.pageId);
|
||
try {
|
||
await this.onNewTicket(ticket);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[poller] 处理新工单失败 · ${ticket['编号']} · ${err.message}`
|
||
);
|
||
// 从processed中移除,下轮重试
|
||
this._processedSet.delete(ticket.pageId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 策略2:检测最近变更的工单(如果有回调)
|
||
if (this.onUpdatedTicket && this._lastCheckedTime) {
|
||
const updated = await this.dbReader.queryUpdatedSince(this._lastCheckedTime);
|
||
for (const ticket of updated) {
|
||
// 跳过已在本轮处理过的新工单
|
||
if (pending.some((p) => p.pageId === ticket.pageId)) continue;
|
||
try {
|
||
await this.onUpdatedTicket(ticket);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`[poller] 处理更新工单失败 · ${ticket['编号']} · ${err.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
this._lastCheckedTime = new Date().toISOString();
|
||
|
||
// 定期持久化(每10次)
|
||
if (this._pollCount % 10 === 0) {
|
||
this._saveState();
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(`[poller] 轮询异常 · ${err.message}`);
|
||
}
|
||
}
|
||
|
||
_loadState() {
|
||
try {
|
||
const fp = path.join(this.stateDir, STATE_FILE);
|
||
if (fs.existsSync(fp)) {
|
||
const data = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
||
this._lastCheckedTime = data.lastCheckedTime || null;
|
||
this._processedSet = new Set(data.processedIds || []);
|
||
this.logger.info(
|
||
`[poller] 加载持久化状态 · ${this._processedSet.size} 条已处理记录`
|
||
);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`[poller] 加载状态失败 · ${err.message}`);
|
||
}
|
||
}
|
||
|
||
_saveState() {
|
||
try {
|
||
const fp = path.join(this.stateDir, STATE_FILE);
|
||
const data = {
|
||
lastCheckedTime: this._lastCheckedTime,
|
||
processedIds: Array.from(this._processedSet),
|
||
savedAt: new Date().toISOString(),
|
||
};
|
||
fs.writeFileSync(fp, JSON.stringify(data, null, 2), 'utf-8');
|
||
} catch (err) {
|
||
this.logger.warn(`[poller] 保存状态失败 · ${err.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = TicketPoller;
|