#!/usr/bin/env node /** * 定时自动任务(Scheduled Task Automation)本地验证 * * 默认:DB + mock worker 全链路(无需 Portal 进程) * 可选: * VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker 的 Portal 到点执行(需 H5_SCHEDULED_TASK_WORKER_ENABLED=1) * --due-seconds=90 创建 N 秒后执行的一次性任务(live 模式用) */ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs'; import mysql from 'mysql2/promise'; import { migrateSchema } from '../db.mjs'; import { parseScheduledTaskIntent, shouldUseScheduledTaskAutomation } from '../scheduled-task-intent.mjs'; import { createScheduledTaskService } from '../scheduled-task-service.mjs'; import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs'; import { getLocalParts, normalizeTimezone } from '../schedule-time.mjs'; const tz = 'Asia/Shanghai'; const TEST_TITLE_PREFIX = 'VERIFY_SCHEDULED_TASK_'; let passed = 0; let failed = 0; function pass(label, detail = '') { passed += 1; console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`); } function fail(label, detail = '') { failed += 1; console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`); } function loadDatabaseUrl() { if (process.env.DATABASE_URL) return process.env.DATABASE_URL; const envPath = new URL('../.env', import.meta.url); if (!fs.existsSync(envPath)) return null; const envText = fs.readFileSync(envPath, 'utf8'); const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, ''); return dbLine || null; } function parseArgs(argv) { const dueSecondsArg = argv.find((arg) => arg.startsWith('--due-seconds=')); return { dueSeconds: dueSecondsArg ? Number(dueSecondsArg.split('=')[1]) : 120, }; } function formatRunAtLocal(epochMs, timezone = tz) { const parts = getLocalParts(epochMs, timezone); return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')} ${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`; } function testIntentLayer() { assert.equal(shouldUseScheduledTaskAutomation('每天6点帮我做今日新闻页面'), true); assert.equal(parseScheduledTaskIntent('每天6点帮我做今日新闻页面').action, 'create_scheduled_task'); assert.equal(shouldUseScheduledTaskAutomation('每天早上7点把当天待办发给我'), false); pass('意图识别', '自动化 vs 待办 digest 边界正确'); } async function cleanupTestRows(pool, userId) { await pool.query( `DELETE FROM h5_scheduled_tasks WHERE user_id = ? AND title LIKE ?`, [userId, `${TEST_TITLE_PREFIX}%`], ); } async function testDatabaseAndMockWorker(pool, userId, { dueSeconds }) { await migrateSchema(pool); pass('数据库 schema', 'h5_scheduled_tasks 已就绪'); const scheduledTaskService = createScheduledTaskService(pool, { defaultTimezone: normalizeTimezone(tz), }); await cleanupTestRows(pool, userId); const runAtMs = Date.now() + Math.max(30, dueSeconds) * 1000; const runAtLocal = formatRunAtLocal(runAtMs, tz); const created = await scheduledTaskService.createTask({ userId, title: `${TEST_TITLE_PREFIX}mock_pipeline`, taskSpec: '生成一段今日新闻摘要(验证脚本,无需写页面)', recurrence: 'once', runAtLocal, notifyChannel: 'web', sourceChannel: 'api', sourceText: 'verify-scheduled-task-automation', }); if (created.recurrence === 'once' && created.nextRunAt > Date.now()) { pass('创建一次性任务', `next_run_at=${new Date(created.nextRunAt).toLocaleString('zh-CN', { timeZone: tz })}`); } else { fail('创建一次性任务', `status=${created.status}, nextRunAt=${created.nextRunAt}`); return; } const notDue = await scheduledTaskService.listDueTasks({ now: Date.now() - 1000 }); if (!notDue.some((row) => row.id === created.id)) { pass('到期扫描(未到点)', '未来任务不会进入 due 列表'); } else { fail('到期扫描(未到点)', '未来任务不应被扫描为 due'); } await pool.query( `UPDATE h5_scheduled_tasks SET next_run_at = ?, status = 'active', locked_until = NULL WHERE id = ?`, [Date.now() - 1000, created.id], ); const notifications = []; const worker = startScheduledTaskWorker({ scheduledTaskService, scheduleService: { async createUserNotification(input) { notifications.push(input); }, }, userAuth: { id: 'mock-user-auth' }, tkmindProxy: { id: 'mock-proxy' }, executeTask: async (task) => ({ sessionId: 'verify-session', requestId: crypto.randomUUID(), deliveryText: `验证交付:${task.title} 已在 ${new Date().toLocaleString('zh-CN', { timeZone: tz })} 完成。`, }), logger: { warn() {}, info() {} }, runOnStart: false, }); await worker.runOnce(); worker.stop(); const [rows] = await pool.query( `SELECT status, last_result_json, last_error FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`, [created.id], ); const row = rows[0]; if (row?.status === 'completed') { pass('mock worker 执行', '一次性任务 status=completed'); } else { fail('mock worker 执行', `status=${row?.status ?? 'missing'}, error=${row?.last_error ?? ''}`); } if (notifications.some((item) => item.notificationType === 'scheduled_task_result')) { pass('结果推送(mock)', 'scheduled_task_result 通知已写入'); } else { fail('结果推送(mock)', `notifications=${notifications.length}`); } let lastResult = row?.last_result_json; if (typeof lastResult === 'string') { try { lastResult = JSON.parse(lastResult); } catch { lastResult = null; } } if (lastResult?.deliveryText) { pass('执行结果落盘', String(lastResult.deliveryText).slice(0, 80)); } else { fail('执行结果落盘', 'last_result_json 缺少 deliveryText'); } await cleanupTestRows(pool, userId); } async function testLivePortalWorker(pool, userId, { dueSeconds }) { const enabled = ['1', 'true', 'yes', 'on'].includes( String(process.env.VERIFY_SCHEDULED_TASK_LIVE ?? '').toLowerCase(), ); if (!enabled) { pass('Live Portal worker(跳过)', '设置 VERIFY_SCHEDULED_TASK_LIVE=1 可启用'); return; } const baseUrl = process.env.H5_PORT ? `http://127.0.0.1:${process.env.H5_PORT}` : 'http://127.0.0.1:8081'; let portalOk = false; try { const res = await fetch(`${baseUrl}/api/status`, { signal: AbortSignal.timeout(3000) }); portalOk = res.ok; } catch { portalOk = false; } if (!portalOk) { pass('Live Portal worker(跳过)', `Portal 未运行:${baseUrl}/api/status 不可达`); return; } pass('Live Portal 探活', baseUrl); const scheduledTaskService = createScheduledTaskService(pool, { defaultTimezone: normalizeTimezone(tz), }); await cleanupTestRows(pool, userId); const waitSeconds = Math.max(30, Math.min(180, dueSeconds)); const runAtMs = Date.now() + waitSeconds * 1000; const created = await scheduledTaskService.createTask({ userId, title: `${TEST_TITLE_PREFIX}live_portal`, taskSpec: '用三句话总结今天的热点新闻(验证脚本,回复文本即可,不要生成页面)', recurrence: 'once', runAtLocal: formatRunAtLocal(runAtMs, tz), notifyChannel: 'web', sourceChannel: 'api', }); pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`); console.log(`\n等待 Portal worker 执行(最多 ${waitSeconds + 120}s)…`); console.log('请确认 Portal 进程已设置 H5_SCHEDULED_TASK_WORKER_ENABLED=1\n'); const deadline = Date.now() + (waitSeconds + 120) * 1000; let terminal = null; while (Date.now() < deadline) { const [rows] = await pool.query( `SELECT status, last_error, last_result_json, updated_at FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`, [created.id], ); terminal = rows[0]; if (terminal?.status === 'completed' || terminal?.status === 'failed') break; await new Promise((resolve) => setTimeout(resolve, 5000)); } if (terminal?.status === 'completed') { pass('Live Portal worker', `任务 completed,updated_at=${terminal.updated_at}`); } else if (terminal?.status === 'failed') { fail('Live Portal worker', terminal.last_error ?? 'failed'); } else { fail('Live Portal worker', `超时,最后 status=${terminal?.status ?? 'unknown'}`); } await cleanupTestRows(pool, userId); } async function main() { const { dueSeconds } = parseArgs(process.argv.slice(2)); console.log('=== 定时自动任务本地验证 ===\n'); testIntentLayer(); const dbUrl = loadDatabaseUrl(); if (!dbUrl) { fail('数据库场景', '未配置 DATABASE_URL'); } else { const pool = mysql.createPool(dbUrl); try { const username = process.env.VERIFY_SCHEDULED_TASK_USER ?? 'john8'; const [users] = await pool.query( 'SELECT id, username FROM h5_users WHERE username = ? LIMIT 1', [username], ); if (!users[0]) { const [fallback] = await pool.query('SELECT id, username FROM h5_users ORDER BY created_at ASC LIMIT 1'); if (!fallback[0]) { fail('数据库场景', '无可用测试用户'); } else { pass('测试用户', `${fallback[0].username} (${fallback[0].id})`); await testDatabaseAndMockWorker(pool, fallback[0].id, { dueSeconds }); await testLivePortalWorker(pool, fallback[0].id, { dueSeconds }); } } else { pass('测试用户', `${users[0].username} (${users[0].id})`); await testDatabaseAndMockWorker(pool, users[0].id, { dueSeconds }); await testLivePortalWorker(pool, users[0].id, { dueSeconds }); } } finally { await pool.end(); } } console.log('\n=== 汇总 ==='); console.log(`通过: ${passed}`); console.log(`失败: ${failed}`); process.exit(failed > 0 ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(1); });