This commit is contained in:
parent
b9ec6fbe0b
commit
a5bc8ef1f1
36
scripts/lib/cron-alert.js
Normal file
36
scripts/lib/cron-alert.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// 定时任务失败报警: 向 Alert 表插入一条错误记录
|
||||||
|
// 供 server/cron.js 与 scripts/scheduler/index.js 共用
|
||||||
|
// 首页报警 tab 会展示 Alert 表, 字段按错误语义填充 (indicatorName=任务+错误摘要)
|
||||||
|
import 'dotenv/config'
|
||||||
|
import mysql from 'mysql2/promise'
|
||||||
|
|
||||||
|
function parseUrl(url) {
|
||||||
|
const u = new URL(url)
|
||||||
|
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertCronAlert(taskName, message) {
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.error('[cron-alert] 缺少 DATABASE_URL, 跳过报警写入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let conn = null
|
||||||
|
try {
|
||||||
|
conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||||
|
// 用 Asia/Shanghai 时区的当天日期, 避免 UTC 偏移导致 period 显示成昨天
|
||||||
|
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||||
|
const cleanMsg = String(message || '').replace(/\s+/g, ' ').trim()
|
||||||
|
const name = ('[cron] ' + taskName + ' 失败: ' + cleanMsg).slice(0, 180)
|
||||||
|
await conn.execute(
|
||||||
|
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) ' +
|
||||||
|
'VALUES (?, ?, 0, 0, ?, ?, 0, 0, NOW(3))',
|
||||||
|
[name, today, 'cron', 'error'],
|
||||||
|
)
|
||||||
|
console.error('[cron-alert] 已写入报警:', name)
|
||||||
|
} catch (e) {
|
||||||
|
// 报警写入本身失败 (如 DB 不可达) 不能反噬主流程/再次抛错, 只记录
|
||||||
|
console.error('[cron-alert] 写入报警失败:', e.message)
|
||||||
|
} finally {
|
||||||
|
if (conn) await conn.end().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
|
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
|
||||||
import { trigger as triggerSH300Data } from '../scraper/sh300.scraper.js';
|
import { trigger as triggerSH300Data } from '../scraper/sh300.scraper.js';
|
||||||
|
import { insertCronAlert } from '../lib/cron-alert.js';
|
||||||
|
|
||||||
console.log('='.repeat(70));
|
console.log('='.repeat(70));
|
||||||
console.log('🚀 启动定时任务调度器');
|
console.log('🚀 启动定时任务调度器');
|
||||||
console.log('='.repeat(70));
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
// 注册定时任务 - USD-CNY 汇率数据
|
// 注册定时任务 - USD-CNY 汇率数据 (每天 6:20, 出错生成报警)
|
||||||
const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
|
const jobUSDCNY = cron.schedule('20 6 * * *', async () => {
|
||||||
console.log('\n' + '='.repeat(70));
|
console.log('\n' + '='.repeat(70));
|
||||||
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
|
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
|
||||||
console.log('📅 执行时间:', new Date().toISOString());
|
console.log('📅 执行时间:', new Date().toISOString());
|
||||||
@ -18,6 +19,7 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
|
|||||||
console.log('\n✅ 任务执行成功!');
|
console.log('\n✅ 任务执行成功!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('\n❌ 任务执行失败:', error.message);
|
console.error('\n❌ 任务执行失败:', error.message);
|
||||||
|
await insertCronAlert('USD-CNY 汇率更新', error.message);
|
||||||
} finally {
|
} finally {
|
||||||
console.log('='.repeat(70));
|
console.log('='.repeat(70));
|
||||||
}
|
}
|
||||||
@ -26,8 +28,8 @@ const jobUSDCNY = cron.schedule('0 0 * * *', async () => {
|
|||||||
timezone: 'Asia/Shanghai'
|
timezone: 'Asia/Shanghai'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 注册定时任务 - 沪深300指数数据
|
// 注册定时任务 - 沪深300指数数据 (每天 6:30, 出错生成报警)
|
||||||
const jobSH300 = cron.schedule('0 0 * * *', async () => {
|
const jobSH300 = cron.schedule('30 6 * * *', async () => {
|
||||||
console.log('\n' + '='.repeat(70));
|
console.log('\n' + '='.repeat(70));
|
||||||
console.log('⏰ 执行定时任务: 沪深300指数数据更新');
|
console.log('⏰ 执行定时任务: 沪深300指数数据更新');
|
||||||
console.log('📅 执行时间:', new Date().toISOString());
|
console.log('📅 执行时间:', new Date().toISOString());
|
||||||
@ -38,6 +40,7 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
|
|||||||
console.log('\n✅ 任务执行成功!');
|
console.log('\n✅ 任务执行成功!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('\n❌ 任务执行失败:', error.message);
|
console.error('\n❌ 任务执行失败:', error.message);
|
||||||
|
await insertCronAlert('沪深300指数更新', error.message);
|
||||||
} finally {
|
} finally {
|
||||||
console.log('='.repeat(70));
|
console.log('='.repeat(70));
|
||||||
}
|
}
|
||||||
@ -47,12 +50,12 @@ const jobSH300 = cron.schedule('0 0 * * *', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
|
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
|
||||||
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
|
console.log('📅 执行表达式: 20 6 * * * (每天 6:20)');
|
||||||
console.log('📅 下次执行时间: 明天 00:00');
|
console.log('📅 下次执行时间: 明天 06:20');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('✅ 定时任务已启动: 沪深300指数数据更新');
|
console.log('✅ 定时任务已启动: 沪深300指数数据更新');
|
||||||
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
|
console.log('📅 执行表达式: 30 6 * * * (每天 6:30)');
|
||||||
console.log('📅 下次执行时间: 明天 00:00');
|
console.log('📅 下次执行时间: 明天 06:30');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('按 Ctrl+C 停止...');
|
console.log('按 Ctrl+C 停止...');
|
||||||
console.log('='.repeat(70));
|
console.log('='.repeat(70));
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { prisma } from './db.js'
|
|||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||||
import { checkAfterRefresh } from './alert-engine.js'
|
import { checkAfterRefresh } from './alert-engine.js'
|
||||||
|
import { insertCronAlert } from '../scripts/lib/cron-alert.js'
|
||||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||||
|
|
||||||
function parseUrl(url) {
|
function parseUrl(url) {
|
||||||
@ -14,10 +15,10 @@ async function openConn() {
|
|||||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
|
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
||||||
function scheduleNews() {
|
function scheduleNews() {
|
||||||
cron.schedule('0 3 * * *', async () => {
|
cron.schedule('0 6 * * *', async () => {
|
||||||
console.log('[cron] 3:00 触发')
|
console.log('[cron] 6:00 触发')
|
||||||
try {
|
try {
|
||||||
const list = await fetchNewsList()
|
const list = await fetchNewsList()
|
||||||
const r = await persistNews(list)
|
const r = await persistNews(list)
|
||||||
@ -29,25 +30,26 @@ function scheduleNews() {
|
|||||||
} finally { await conn.end() }
|
} finally { await conn.end() }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[cron] 任务失败:', e.message)
|
console.error('[cron] 任务失败:', e.message)
|
||||||
|
await insertCronAlert('新闻拉取+报警检测', e.message)
|
||||||
}
|
}
|
||||||
})
|
}, { timezone: 'Asia/Shanghai' })
|
||||||
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
|
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每天凌晨 4:00 抓浙江油价历史
|
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
||||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||||
// - 0 命中: 不调 apizero (省配额)
|
// - 0 命中: 不调 apizero (省配额)
|
||||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||||
function scheduleOilHistory() {
|
function scheduleOilHistory() {
|
||||||
cron.schedule('0 4 * * *', async () => {
|
cron.schedule('10 6 * * *', async () => {
|
||||||
console.log('[cron] 4:00 油价历史触发')
|
console.log('[cron] 6:10 油价历史触发')
|
||||||
try {
|
try {
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear()
|
||||||
const sched = await oil.getSchedule(year)
|
const sched = await oil.getSchedule(year)
|
||||||
const list = sched.schedule || []
|
const list = sched.schedule || []
|
||||||
// 待定 且 日期 <= 昨天
|
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
||||||
const today = new Date()
|
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||||
today.setHours(0, 0, 0, 0)
|
const today = new Date(todayStr + 'T00:00:00')
|
||||||
const yesterdayMs = today.getTime() - 86400000
|
const yesterdayMs = today.getTime() - 86400000
|
||||||
const pendingPast = list.filter((it) => {
|
const pendingPast = list.filter((it) => {
|
||||||
if (it.status !== '待定') return false
|
if (it.status !== '待定') return false
|
||||||
@ -67,9 +69,10 @@ function scheduleOilHistory() {
|
|||||||
} else {
|
} else {
|
||||||
console.error('[cron] 油价历史任务失败:', e.message)
|
console.error('[cron] 油价历史任务失败:', e.message)
|
||||||
}
|
}
|
||||||
|
await insertCronAlert('浙江油价历史抓取', e.message)
|
||||||
}
|
}
|
||||||
})
|
}, { timezone: 'Asia/Shanghai' })
|
||||||
console.log('[cron] 已注册: 每天 04:00 浙江油价历史抓取')
|
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleNews()
|
scheduleNews()
|
||||||
|
|||||||
@ -82,6 +82,10 @@ function DataDetail({ indicator, onBack }) {
|
|||||||
const rows = (i.points || [])
|
const rows = (i.points || [])
|
||||||
// 图表 X 轴统一用 period 字段
|
// 图表 X 轴统一用 period 字段
|
||||||
const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) }))
|
const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) }))
|
||||||
|
// 表格按时间反序: 最新在前
|
||||||
|
const tableRows = [...rows].reverse()
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
const visibleRows = showAll ? tableRows : tableRows.slice(0, 10)
|
||||||
const lp = lastPoint({ rows })
|
const lp = lastPoint({ rows })
|
||||||
const { trend } = calcTrend(rows)
|
const { trend } = calcTrend(rows)
|
||||||
const title = i.title || i.name
|
const title = i.title || i.name
|
||||||
@ -106,17 +110,22 @@ function DataDetail({ indicator, onBack }) {
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ maxHeight: 240, overflow: 'auto' }}>
|
<div className={'data-table-wrap' + (showAll ? ' scroll' : '')}>
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
<tr><th>月份</th><th style={{ textAlign: 'right' }}>数值</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{visibleRows.map((r) => (
|
||||||
<tr key={periodOf(r)}><td>{periodOf(r)}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
<tr key={periodOf(r)}><td>{periodOf(r)}</td><td style={{ textAlign: 'right' }}>{r.value}</td></tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{tableRows.length > 10 && !showAll && (
|
||||||
|
<div className="table-more" onClick={() => setShowAll(true)}>
|
||||||
|
展开全部(共 {tableRows.length} 行)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -170,6 +170,9 @@ input, textarea, select { font: inherit; }
|
|||||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
|
.data-table th, .data-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--color-border); }
|
||||||
.data-table th { background: #f8f8f8; font-weight: 600; }
|
.data-table th { background: #f8f8f8; font-weight: 600; }
|
||||||
|
.data-table-wrap { position: relative; }
|
||||||
|
.data-table-wrap.scroll { max-height: 300px; overflow: auto; }
|
||||||
|
.table-more { text-align: center; padding: 10px 0 2px; font-size: 13px; color: var(--color-primary); cursor: pointer; }
|
||||||
|
|
||||||
/* 页面标题 */
|
/* 页面标题 */
|
||||||
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
.page-title { font-size: 20px; font-weight: 700; padding: 14px 16px 4px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user