feat(宏观): ifinance 分类动态挂载为宏观 tab, 报警迁入监控, 下线油价/本地经济数据/策略研究
Docker Build and Push / build-image (push) Successful in 2m37s
Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https) - 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源) - 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据 - 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client - CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// apizero 接口的文件缓存
|
||||
// - 按 key 存 JSON 到磁盘: <APIZERO_CACHE_DIR>/<key>.json
|
||||
// - 存在性检查: 文件在即视为有效 (用户要求: "同一个年份只要存在就不再调用接口")
|
||||
// - 不做 TTL: 用户没要求, 也避免误命中过期数据
|
||||
//
|
||||
// 环境变量:
|
||||
// APIZERO_CACHE_DIR 缓存目录 (默认 <cwd>/cache/apizero)
|
||||
//
|
||||
// 未来若要加 TTL / DB 后端, 替换本文件即可, 上层调用方不变.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function dir() {
|
||||
return process.env.APIZERO_CACHE_DIR
|
||||
? path.resolve(process.env.APIZERO_CACHE_DIR)
|
||||
: path.resolve(process.cwd(), 'cache', 'apizero')
|
||||
}
|
||||
|
||||
// 读缓存, 不存在返回 null
|
||||
export function read(key) {
|
||||
const p = path.join(dir(), key + '.json')
|
||||
if (!fs.existsSync(p)) return null
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (e) {
|
||||
console.warn(`[apizero-cache] 读 ${key} 失败, 忽略:`, e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 写缓存 (原子: 先写 .tmp 再 rename)
|
||||
export function write(key, data) {
|
||||
const d = dir()
|
||||
fs.mkdirSync(d, { recursive: true })
|
||||
const final = path.join(d, key + '.json')
|
||||
const tmp = final + '.tmp.' + process.pid
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
|
||||
fs.renameSync(tmp, final)
|
||||
return final
|
||||
}
|
||||
|
||||
// 存在性检查
|
||||
export function has(key) {
|
||||
return fs.existsSync(path.join(dir(), key + '.json'))
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// apizero (https://apizero.cn) 通用 HTTP 客户端
|
||||
// - 统一从 env APIZERO_KEY 读取 API key (test key 也能用)
|
||||
// - 统一 QPS 节流 (apizero 限制 3 req/s)
|
||||
// - 统一错误格式: 抛出 ApizeroError(code, message, httpStatus)
|
||||
// - 所有 apizero 接口都进这一层, 后续新增模块复用
|
||||
//
|
||||
// 依赖: 原生 fetch (Node 18+)
|
||||
|
||||
// 简易令牌桶, 限速 3 req/s
|
||||
let lastCallAt = 0
|
||||
const MIN_INTERVAL_MS = 1000 / 3 // ~333ms
|
||||
|
||||
async function throttle() {
|
||||
const now = Date.now()
|
||||
const wait = lastCallAt + MIN_INTERVAL_MS - now
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
||||
lastCallAt = Date.now()
|
||||
}
|
||||
|
||||
// apizero 标准错误
|
||||
export class ApizeroError extends Error {
|
||||
constructor(code, message, httpStatus) {
|
||||
super(message || `apizero error code=${code}`)
|
||||
this.name = 'ApizeroError'
|
||||
this.code = code
|
||||
this.httpStatus = httpStatus
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://v1.apizero.cn/api'
|
||||
|
||||
// 读 key: 优先 env APIZERO_KEY, 否则用空 (匿名调用, 配额更小)
|
||||
function getKey() {
|
||||
return process.env.APIZERO_KEY || ''
|
||||
}
|
||||
|
||||
// 通用 GET 入口
|
||||
// - endpoint: 'oil-price-forecast' (不带 /api 前缀)
|
||||
// - params: 普通对象
|
||||
// 返回: apizero 的 data 字段 (已解一层)
|
||||
export async function get(endpoint, params = {}) {
|
||||
await throttle()
|
||||
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||
}
|
||||
const key = getKey()
|
||||
if (key) qs.set('key', key)
|
||||
|
||||
const url = `${BASE_URL}/${endpoint}?${qs.toString()}`
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } })
|
||||
} catch (e) {
|
||||
throw new ApizeroError('NETWORK', `apizero 网络错误: ${e.message}`, 0)
|
||||
}
|
||||
|
||||
// HTTP 层错误
|
||||
if (!res.ok) {
|
||||
let body = ''
|
||||
try { body = await res.text() } catch { /* ignore */ }
|
||||
throw new ApizeroError('HTTP', `apizero HTTP ${res.status}: ${body.substring(0, 200)}`, res.status)
|
||||
}
|
||||
|
||||
// 解析 body
|
||||
let json
|
||||
try { json = await res.json() }
|
||||
catch (e) { throw new ApizeroError('PARSE', 'apizero 响应非 JSON: ' + e.message, res.status) }
|
||||
|
||||
// apizero 业务层错误: code !== 0
|
||||
if (json && typeof json.code === 'number' && json.code !== 0) {
|
||||
throw new ApizeroError(json.code, json.msg || 'apizero 业务错误', res.status)
|
||||
}
|
||||
|
||||
return json && json.data !== undefined ? json.data : json
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// apizero 模块统一出口
|
||||
// 后续新增 apizero 接口就在这里再 export 一行
|
||||
export * as oil from './oil-price-forecast.js'
|
||||
export * as history from './oil-history.js'
|
||||
export { ApizeroError } from './client.js'
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// 浙江省油价历史: 走 Prisma 落库 + apizero price 接口写入
|
||||
// 设计:
|
||||
// - 每天 04:00 cron 检查 schedule, 待定且 <= 昨天的调价日 -> 抓 price?province=浙江 存本表
|
||||
// - unique (date, province, fuel) 防重复
|
||||
// - 查询 API: 取某省某油品最近 N 天 (默认 180)
|
||||
import { prisma } from '../db.js'
|
||||
import * as client from './client.js'
|
||||
|
||||
const PROVINCE = '浙江'
|
||||
// 油品 -> apizero prices 对象里的 key (price action 返回 "92号汽油" 这种带后缀的 key)
|
||||
const FUEL_KEYS = {
|
||||
'92': '92号汽油',
|
||||
'95': '95号汽油',
|
||||
'0': '0号柴油',
|
||||
}
|
||||
|
||||
// 解析 apizero price action 返回的 prices (中文 key) -> 标准 fuel code
|
||||
function parsePrice(data) {
|
||||
if (!data || !data.prices) throw new Error('apizero 响应无 prices 字段')
|
||||
const out = {}
|
||||
for (const [fuel, label] of Object.entries(FUEL_KEYS)) {
|
||||
const raw = data.prices[label]
|
||||
if (typeof raw === 'string') {
|
||||
// "7.64 元/升" -> 7.64
|
||||
const m = raw.match(/^([\d.]+)/)
|
||||
if (m) out[fuel] = parseFloat(m[1])
|
||||
} else if (typeof raw === 'number') {
|
||||
out[fuel] = raw
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 抓一次浙江当前价并入库 (date = 今日)
|
||||
// 返回 { inserted, skipped } (按 unique 约束: 已存在则 skip)
|
||||
export async function fetchAndStore() {
|
||||
const data = await client.get('oil-price-forecast', { action: 'price', province: PROVINCE })
|
||||
const prices = parsePrice(data)
|
||||
if (Object.keys(prices).length === 0) {
|
||||
throw new Error('apizero 返回价格为空')
|
||||
}
|
||||
const date = new Date()
|
||||
// 抹掉时分秒, 便于按天比较
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
let inserted = 0
|
||||
let skipped = 0
|
||||
for (const [fuel, price] of Object.entries(prices)) {
|
||||
try {
|
||||
await prisma.oilPriceHistory.create({
|
||||
data: { date, province: PROVINCE, fuel, price, source: 'apizero' },
|
||||
})
|
||||
inserted++
|
||||
} catch (e) {
|
||||
// P2002: unique 冲突 -> 跳过
|
||||
if (e?.code === 'P2002') skipped++
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
return { inserted, skipped, date: date.toISOString().slice(0, 10) }
|
||||
}
|
||||
|
||||
// 查询某省某油品最近 N 天
|
||||
// province 必填, 默认 浙江
|
||||
// fuel 可选, 留空返回 4 个油品
|
||||
// days 默认 180
|
||||
export async function query({ province = PROVINCE, fuel, days = 180 }) {
|
||||
const since = new Date()
|
||||
since.setDate(since.getDate() - days)
|
||||
since.setHours(0, 0, 0, 0)
|
||||
|
||||
const where = { province, date: { gte: since } }
|
||||
if (fuel) where.fuel = fuel
|
||||
|
||||
const rows = await prisma.oilPriceHistory.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'asc' }],
|
||||
})
|
||||
|
||||
// 整形: Decimal -> number
|
||||
return rows.map((r) => ({
|
||||
date: r.date.toISOString().slice(0, 10),
|
||||
province: r.province,
|
||||
fuel: r.fuel,
|
||||
price: Number(r.price),
|
||||
}))
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// apizero: 今日油价 (oil-price-forecast)
|
||||
// 4 个 action:
|
||||
// forecast - 油价 + 国际原油 + 下次调价预测 (每次都查实时, 不缓存)
|
||||
// price - 单省油价
|
||||
// price-all - 32 省全部油价
|
||||
// schedule - 某年调价日历 (按 year 缓存到文件, 同年份不重复调)
|
||||
//
|
||||
// 文档: https://apizero.cn/aidocs/oil-price-forecast/raw.md
|
||||
import * as client from './client.js'
|
||||
import * as cache from './cache.js'
|
||||
|
||||
// 调价日历: 按年份缓存, 文件存在即视为有效, 不重复调用
|
||||
// cache key 形如 "oil-schedule-2026"
|
||||
export async function getSchedule(year) {
|
||||
const y = Number(year)
|
||||
if (!Number.isInteger(y) || y < 2025 || y > 2026) {
|
||||
throw new client.ApizeroError(4000, `year 不在 2025-2026 范围: ${year}`, 400)
|
||||
}
|
||||
const cacheKey = `oil-schedule-${y}`
|
||||
const hit = cache.read(cacheKey)
|
||||
if (hit) return { ...hit, _cached: true }
|
||||
const data = await client.get('oil-price-forecast', { action: 'schedule', year: y })
|
||||
cache.write(cacheKey, data)
|
||||
return { ...data, _cached: false }
|
||||
}
|
||||
|
||||
// 实时: 国际原油 + 下次调价预测
|
||||
export async function getForecast() {
|
||||
return client.get('oil-price-forecast', { action: 'forecast' })
|
||||
}
|
||||
|
||||
// 单省油价
|
||||
export async function getPrice(province) {
|
||||
if (!province) throw new client.ApizeroError(4000, 'province 必填', 400)
|
||||
return client.get('oil-price-forecast', { action: 'price', province })
|
||||
}
|
||||
|
||||
// 32 省全部油价
|
||||
export async function getPriceAll() {
|
||||
return client.get('oil-price-forecast', { action: 'price-all' })
|
||||
}
|
||||
+52
-110
@@ -1,110 +1,52 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
async function importNewsFetcher() {
|
||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||
}
|
||||
|
||||
async function alertError(taskName, message) {
|
||||
try {
|
||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||
await insertCronAlert(taskName, message)
|
||||
} catch (e) {
|
||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
try {
|
||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||
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)
|
||||
await alertError('新闻拉取+报警检测', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||
// - 0 命中: 不调 apizero (省配额)
|
||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||
function scheduleOilHistory() {
|
||||
cron.schedule('10 6 * * *', async () => {
|
||||
console.log('[cron] 6:10 油价历史触发')
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const sched = await oil.getSchedule(year)
|
||||
const list = sched.schedule || []
|
||||
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
||||
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||
const today = new Date(todayStr + 'T00:00:00+08:00')
|
||||
const yesterdayMs = today.getTime() - 86400000
|
||||
const pendingPast = list.filter((it) => {
|
||||
if (it.status !== '待定') return false
|
||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
||||
return Number.isFinite(t) && t <= yesterdayMs
|
||||
})
|
||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
||||
if (pendingPast.length === 0) {
|
||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
||||
return
|
||||
}
|
||||
const r = await history.fetchAndStore()
|
||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
||||
} catch (e) {
|
||||
if (e instanceof ApizeroError) {
|
||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
await alertError('浙江油价历史抓取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||
}
|
||||
|
||||
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
||||
function scheduleAutoArchive() {
|
||||
cron.schedule('30 * * * *', async () => {
|
||||
try {
|
||||
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
|
||||
const n = await runAutoArchiveSweep()
|
||||
if (n > 0) console.log('[cron] 自动归档:', n, '条')
|
||||
} catch (e) {
|
||||
console.error('[cron] 自动归档失败:', e.message)
|
||||
await alertError('任务自动归档', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleOilHistory()
|
||||
scheduleAutoArchive()
|
||||
import cron from 'node-cron'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
async function importNewsFetcher() {
|
||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||
}
|
||||
|
||||
async function alertError(taskName, message) {
|
||||
try {
|
||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||
await insertCronAlert(taskName, message)
|
||||
} catch (e) {
|
||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻, 出错生成报警
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
try {
|
||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
await alertError('新闻拉取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻')
|
||||
}
|
||||
|
||||
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
||||
function scheduleAutoArchive() {
|
||||
cron.schedule('30 * * * *', async () => {
|
||||
try {
|
||||
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
|
||||
const n = await runAutoArchiveSweep()
|
||||
if (n > 0) console.log('[cron] 自动归档:', n, '条')
|
||||
} catch (e) {
|
||||
console.error('[cron] 自动归档失败:', e.message)
|
||||
await alertError('任务自动归档', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleAutoArchive()
|
||||
|
||||
@@ -9,7 +9,6 @@ import newsRouter from './routes/news.js'
|
||||
import alertsRouter from './routes/alerts.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
import otaRouter from './routes/ota.js'
|
||||
import apizeroRouter from './routes/apizero.js'
|
||||
import monitorsRouter from './routes/monitors.js'
|
||||
import mihomeRouter from './routes/mihome.js'
|
||||
import checkinRouter from './routes/checkin.js'
|
||||
@@ -43,7 +42,6 @@ app.use('/api', dataRouter)
|
||||
app.use('/api', newsRouter)
|
||||
app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
app.use('/api', apizeroRouter)
|
||||
app.use('/api', monitorsRouter)
|
||||
app.use('/api/mihome', mihomeRouter)
|
||||
// V0.2 打卡 / 日历 / 清单
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// apizero 代理路由 (前端不直接调 apizero, 一律走后端, key 不外泄)
|
||||
//
|
||||
// GET /api/apizero/oil/forecast - 油价 + 预测 (实时)
|
||||
// GET /api/apizero/oil/price-all - 32 省油价
|
||||
// GET /api/apizero/oil/price?province= - 单省油价
|
||||
// GET /api/apizero/oil/schedule?year= - 某年调价日历 (按年缓存)
|
||||
//
|
||||
// 错误: apizero 错误统一包成 502 (上游错误) 或 400 (参数错误)
|
||||
import { Router } from 'express'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import { ApizeroError, oil, history } from '../apizero/index.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
function handleApizeroError(e, res, next) {
|
||||
if (e instanceof ApizeroError) {
|
||||
// 参数错 -> 400, 上游错 -> 502, QPS 限流 / 额度用完 -> 503
|
||||
if (e.httpStatus === 400) return res.status(400).json({ error: e.message, apizero_code: e.code })
|
||||
if (e.code === 4029 || e.code === 4030) return res.status(503).json({ error: e.message, apizero_code: e.code })
|
||||
return res.status(502).json({ error: e.message, apizero_code: e.code })
|
||||
}
|
||||
next(e)
|
||||
}
|
||||
|
||||
// 实时: 油价 + 国际原油 + 下次调价预测
|
||||
router.get('/apizero/oil/forecast', async (req, res, next) => {
|
||||
try { res.json(await oil.getForecast()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 32 省油价
|
||||
router.get('/apizero/oil/price-all', async (req, res, next) => {
|
||||
try { res.json(await oil.getPriceAll()) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 单省油价
|
||||
router.get('/apizero/oil/price', async (req, res, next) => {
|
||||
try { res.json(await oil.getPrice(req.query.province)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
// 调价日历 (按年缓存)
|
||||
router.get('/apizero/oil/schedule', async (req, res, next) => {
|
||||
try { res.json(await oil.getSchedule(req.query.year)) }
|
||||
catch (e) { handleApizeroError(e, res, next) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// 浙江省油价历史 (DB)
|
||||
// GET /api/apizero/oil/history?fuel=92&days=180
|
||||
// - fuel: 92 / 95 / 0 (留空 = 3 个油品都返回)
|
||||
// - days: 默认 180 (半年)
|
||||
router.get('/apizero/oil/history', async (req, res, next) => {
|
||||
try {
|
||||
const fuel = req.query.fuel ? String(req.query.fuel) : undefined
|
||||
const days = Math.max(1, Math.min(Number(req.query.days) || 180, 730))
|
||||
const items = await history.query({ fuel, days })
|
||||
res.json({ items, fuel: fuel || 'all', days })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
+2
-31
@@ -6,36 +6,7 @@ const router = Router()
|
||||
|
||||
router.use(authRequired)
|
||||
|
||||
// 指标列表: 元数据 + 明细点, 过滤 hidden (数据与脚本保留, 仅不展示)
|
||||
router.get('/indicators', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.indicator.findMany({
|
||||
where: { hidden: false },
|
||||
include: { points: { orderBy: { period: 'asc' } } },
|
||||
orderBy: [{ groupName: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
// 组装前端友好结构: points 保持从老到新
|
||||
const indicators = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
groupName: r.groupName,
|
||||
title: r.title,
|
||||
unit: r.unit,
|
||||
source: r.source,
|
||||
frequency: r.frequency,
|
||||
points: r.points.map((p) => ({ period: p.period, value: p.value })),
|
||||
}))
|
||||
res.json({ indicators })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
router.get('/strategies', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.strategy.findMany({ orderBy: { name: 'asc' } })
|
||||
res.json({ strategies: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// 能繁母猪存栏 (独立抓取域, 不属于宏观「经济数据」外接模块)
|
||||
router.get('/sow', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await prisma.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
|
||||
@@ -43,4 +14,4 @@ router.get('/sow', async (_req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user