更新首页

This commit is contained in:
cheney
2026-08-06 15:39:59 +08:00
parent ec15f40323
commit 547181d333
23 changed files with 3248 additions and 19 deletions
+46
View File
@@ -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 }
}
+36
View File
@@ -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()
+7
View File
@@ -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)'}`)
+24
View File
@@ -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
+25
View File
@@ -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