更新首页
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { prisma } from './db.js'
|
||||
|
||||
// 跨源对比: 同 (indicatorName, period) 多源数据差异超阈值则插 Alert
|
||||
// conn: mysql2 连接
|
||||
// 阈值: 绝对差 >= 0.01 且 相对差 >= 1%
|
||||
// 返回 { inserted, scanned }
|
||||
const ABS_TOL = 0.01
|
||||
const REL_TOL = 0.01
|
||||
|
||||
export async function checkAfterRefresh(conn) {
|
||||
// conn 是 mysql2 连接, 走原始 SQL
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT ia.name AS indicatorName, a.period, ia.source, a.value
|
||||
FROM IndicatorPoint a
|
||||
JOIN Indicator ia ON a.indicatorId = ia.id
|
||||
ORDER BY ia.name, a.period
|
||||
`)
|
||||
const groups = new Map()
|
||||
for (const r of rows) {
|
||||
const k = r.indicatorName + '|' + r.period
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k).push({ source: r.source, value: Number(r.value) })
|
||||
}
|
||||
let inserted = 0
|
||||
for (const [k, arr] of groups) {
|
||||
if (arr.length < 2) continue
|
||||
const sources = new Set(arr.map((x) => x.source))
|
||||
if (sources.size < 2) continue
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const A = arr[i], B = arr[j]
|
||||
const diff = Math.abs(A.value - B.value)
|
||||
const base = Math.max(Math.abs(A.value), Math.abs(B.value), 1e-9)
|
||||
const ratio = diff / base
|
||||
if (diff < ABS_TOL || ratio < REL_TOL) continue
|
||||
const [name, period] = k.split('|')
|
||||
const [r] = await conn.execute(
|
||||
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))',
|
||||
[name, period, A.value, B.value, A.source, B.source, diff, ratio],
|
||||
)
|
||||
if (r.affectedRows === 1) inserted++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { inserted, scanned: rows.length }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
|
||||
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(/^\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
console.log('[cron] 3:00 触发')
|
||||
try {
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
}
|
||||
})
|
||||
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
@@ -5,6 +5,8 @@ import express from 'express'
|
||||
import cors from 'cors'
|
||||
import authRouter from './routes/auth.js'
|
||||
import dataRouter from './routes/data.js'
|
||||
import newsRouter from './routes/news.js'
|
||||
import alertsRouter from './routes/alerts.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
@@ -21,6 +23,8 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
// 业务路由
|
||||
app.use('/api/auth', authRouter)
|
||||
app.use('/api', dataRouter)
|
||||
app.use('/api', newsRouter)
|
||||
app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
@@ -41,6 +45,9 @@ app.use((err, _req, res, _next) => {
|
||||
res.status(500).json({ error: err.message || '服务器内部错误' })
|
||||
})
|
||||
|
||||
// 启动定时任务: 每天凌晨 3 点拉取新闻
|
||||
import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message))
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 报警列表: 按时间最新优先, cursor 分页 (cursor = id)
|
||||
router.get('/alerts', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : null
|
||||
const where = cursor ? { id: { lt: cursor } } : {}
|
||||
const items = await prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
const nextCursor = items.length === limit ? String(items[items.length - 1].id) : null
|
||||
res.json({ items, nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 新闻列表: 按时间最新优先, cursor 分页 (cursor = publishedAt ISO 字符串)
|
||||
// 用 prisma cursor 配合 id 兜底 (同一秒发布的新闻)
|
||||
router.get('/news', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor || null
|
||||
const where = cursor ? { publishedAt: { lt: new Date(cursor) } } : {}
|
||||
const items = await prisma.news.findMany({
|
||||
where,
|
||||
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit,
|
||||
})
|
||||
const nextCursor = items.length === limit ? items[items.length - 1].publishedAt.toISOString() : null
|
||||
res.json({ items, nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user