添加汇率数据

This commit is contained in:
cheney 2026-04-25 19:22:57 +08:00
parent b61cf9628e
commit d232591a38
10 changed files with 480 additions and 70 deletions

2
.env
View File

@ -1 +1 @@
DATABASE_URL="mysql://root:fullstack@192.168.111.111:3306/iboard"
DATABASE_URL="mysql://root:fullstack@192.168.111.111:3306/iboard"

View File

@ -32,6 +32,10 @@ COPY --from=builder /app/public* ./public/
# 设置淘宝源并安装生产依赖
RUN npm config set registry https://registry.npmmirror.com && npm install --only=production
ENV DATABASE_URL="mysql://root:fullstack@baishe.vps.honor3.com:6033/iboard"
# 暴露 3000 端口
EXPOSE 3000

10
package-lock.json generated
View File

@ -15,6 +15,7 @@
"mariadb": "^3.5.2",
"marked": "^17.0.6",
"next": "^14.2.3",
"node-cron": "^4.2.1",
"prisma": "^7.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -2117,6 +2118,15 @@
}
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",

View File

@ -1,6 +1,7 @@
{
"name": "iboard",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
@ -14,6 +15,7 @@
"mariadb": "^3.5.2",
"marked": "^17.0.6",
"next": "^14.2.3",
"node-cron": "^4.2.1",
"prisma": "^7.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

105
pages/api/exchange-rate.js Normal file
View File

@ -0,0 +1,105 @@
import { createPool } from 'mariadb';
import fs from 'fs';
import path from 'path';
function loadDatabaseConfig() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('DATABASE_URL=')) {
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
// 解析URL
const parts = url.split('://')[1].split('@');
const auth = parts[0].split(':');
const hostAndDb = parts[1].split('/');
const hostAndPort = hostAndDb[0].split(':');
return {
user: auth[0],
password: auth[1],
host: hostAndPort[0],
port: parseInt(hostAndPort[1]),
database: hostAndDb[1]
};
}
}
}
return {
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard'
};
}
let pool;
async function initPool() {
if (!pool) {
const config = loadDatabaseConfig();
pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: 5
});
}
return pool;
}
export default async function handler(req, res) {
try {
await initPool();
// 获取查询参数
const { startDate, endDate, limit = 30 } = req.query;
let query = 'SELECT date, centerPrice FROM econ_ExchangeRate WHERE currency = ? ORDER BY date DESC';
const params = ['USD/CNY'];
if (startDate && endDate) {
query = 'SELECT date, centerPrice FROM econ_ExchangeRate WHERE currency = ? AND date BETWEEN ? AND ? ORDER BY date DESC';
params.push(startDate, endDate);
}
const result = await pool.query(query, params);
// 限制返回数量
const limitedResult = result.slice(0, parseInt(limit));
// 反转顺序,使日期从早到晚
const sortedResult = limitedResult.reverse();
// 格式化数据
const formattedData = sortedResult.map(row => ({
date: row.date instanceof Date ? row.date.toISOString().split('T')[0] : row.date,
rate: parseFloat(row.centerPrice)
}));
res.status(200).json({
success: true,
data: formattedData,
total: result.length
});
} catch (error) {
console.error('获取汇率数据失败:', error.message);
res.status(500).json({
success: false,
message: '获取汇率数据失败',
error: error.message
});
} finally {
if (pool) {
await pool.end();
}
}
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Head from 'next/head'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
@ -159,6 +159,34 @@ const sowInventoryData = [
export default function Data() {
const [selectedIndicator, setSelectedIndicator] = useState('bond-china-treasury')
const [exchangeRateData, setExchangeRateData] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (selectedIndicator === 'central-bank-usd-cny-rate') {
fetchExchangeRateData()
}
}, [selectedIndicator])
const fetchExchangeRateData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch('/api/exchange-rate?limit=60')
const data = await response.json()
if (data.success) {
setExchangeRateData(data.data)
} else {
setError(data.message)
}
} catch (err) {
setError('获取汇率数据失败')
console.error('获取汇率数据失败:', err)
} finally {
setLoading(false)
}
}
const indicators = [
{
@ -180,7 +208,8 @@ export default function Data() {
{ id: 'central-bank-china-gold-monthly', name: '中国黄金储备(月)' },
{ id: 'central-bank-us-gold-monthly', name: '美国黄金储备(月)' },
{ id: 'central-bank-china-gold-yearly', name: '中国黄金储备(年)' },
{ id: 'central-bank-us-gold-yearly', name: '美国黄金储备(年)' }
{ id: 'central-bank-us-gold-yearly', name: '美国黄金储备(年)' },
{ id: 'central-bank-usd-cny-rate', name: '人民币兑美元汇率' }
]
},
{
@ -526,6 +555,54 @@ export default function Data() {
</div>
)}
{selectedIndicator === 'central-bank-usd-cny-rate' && (
<div className="indicator-detail">
<h2 className="indicator-title">人民币兑美元汇率</h2>
<p className="indicator-description">展示人民币兑美元汇率的变化趋势</p>
{loading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : (
<>
<div className="chart-container">
<ResponsiveContainer width="100%" height={500}>
<LineChart data={exchangeRateData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={['dataMin - 0.1', 'dataMax + 0.1']} tickFormatter={(value) => value.toFixed(4)} />
<Tooltip formatter={(value) => [value.toFixed(4), '汇率']} />
<Legend />
<Line type="monotone" dataKey="rate" name="汇率" stroke="#1e88e5" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="data-table-container">
<h3 className="table-title">详细数据</h3>
<table className="data-table">
<thead>
<tr>
<th>日期</th>
<th>汇率</th>
</tr>
</thead>
<tbody>
{exchangeRateData.map((row, index) => (
<tr key={index}>
<td>{row.date}</td>
<td>{row.rate.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{selectedIndicator === 'commodity-pork-price' && (
<div className="indicator-detail">
<h2 className="indicator-title">猪肉价格</h2>

View File

@ -0,0 +1,49 @@
import cron from 'node-cron';
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
console.log('='.repeat(70));
console.log('🚀 启动定时任务调度器');
console.log('='.repeat(70));
// 注册定时任务
const job = cron.schedule('0 0 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
console.log('📅 执行时间:', new Date().toISOString());
console.log('='.repeat(70));
try {
await triggerUSDCNYRate();
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
} finally {
console.log('='.repeat(70));
}
}, {
scheduled: true,
timezone: 'Asia/Shanghai'
});
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('');
console.log('按 Ctrl+C 停止...');
console.log('='.repeat(70));
// 优雅处理退出
process.on('SIGINT', () => {
console.log('\n' + '='.repeat(70));
console.log('🛑 停止定时任务调度器');
console.log('='.repeat(70));
job.stop();
console.log('✅ 定时任务已停止');
console.log('\n' + '='.repeat(70));
console.log('📊 定时任务调度器已停止');
console.log('='.repeat(70));
process.exit(0);
});

49
scripts/scheduler/test.js Normal file
View File

@ -0,0 +1,49 @@
import cron from 'node-cron';
import { trigger as triggerUSDCNYRate } from '../scraper/usd-cny-rate.scraper.js';
console.log('='.repeat(70));
console.log('🚀 启动定时任务调度器');
console.log('='.repeat(70));
// 注册定时任务
const job = cron.schedule('0 0 * * *', async () => {
console.log('\n' + '='.repeat(70));
console.log('⏰ 执行定时任务: USD-CNY 汇率数据更新');
console.log('📅 执行时间:', new Date().toISOString());
console.log('='.repeat(70));
try {
await triggerUSDCNYRate();
console.log('\n✅ 任务执行成功!');
} catch (error) {
console.error('\n❌ 任务执行失败:', error.message);
} finally {
console.log('='.repeat(70));
}
}, {
scheduled: true,
timezone: 'Asia/Shanghai'
});
console.log('✅ 定时任务已启动: USD-CNY 汇率数据更新');
console.log('📅 执行表达式: 0 0 * * * (每天 0 点)');
console.log('📅 下次执行时间: 明天 00:00');
console.log('');
console.log('按 Ctrl+C 停止...');
console.log('='.repeat(70));
// 优雅处理退出
process.on('SIGINT', () => {
console.log('\n' + '='.repeat(70));
console.log('🛑 停止定时任务调度器');
console.log('='.repeat(70));
job.stop();
console.log('✅ 定时任务已停止');
console.log('\n' + '='.repeat(70));
console.log('📊 定时任务调度器已停止');
console.log('='.repeat(70));
process.exit(0);
});

View File

@ -1,23 +1,82 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { PrismaClient } from '../../generated/prisma/client.js';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { createPool } from 'mariadb';
import fs from 'fs';
import path from 'path';
function loadDatabaseConfig() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
console.log('读取 .env 文件...');
const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('DATABASE_URL=')) {
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
console.log('从 .env 文件获取数据库配置成功!');
console.log(`DATABASE_URL: ${url}`);
// 解析URL
const parts = url.split('://')[1].split('@');
const auth = parts[0].split(':');
const hostAndDb = parts[1].split('/');
const hostAndPort = hostAndDb[0].split(':');
return {
user: auth[0],
password: auth[1],
host: hostAndPort[0],
port: parseInt(hostAndPort[1]),
database: hostAndDb[1]
};
}
}
}
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
return {
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard'
};
}
// 创建数据库连接池
const pool = createPool({
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard',
connectionLimit: 5
});
const config = loadDatabaseConfig();
console.log('数据库配置:');
console.log(` 主机: ${config.host}`);
console.log(` 端口: ${config.port}`);
console.log(` 用户: ${config.user}`);
console.log(` 数据库: ${config.database}`);
// 构造 PrismaClient提供 adapter
const prisma = new PrismaClient({
adapter: new PrismaMariaDb(pool)
});
let pool;
async function initPool() {
if (!pool) {
pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: 5
});
try {
const conn = await pool.getConnection();
console.log('数据库连接成功!');
conn.release();
} catch (error) {
console.error('数据库连接失败:', error.message);
throw error;
}
}
return pool;
}
// 浙江数据开放网站URL示例实际需要根据真实网站修改
const ZHEJIANG_OPEN_DATA_URL = 'https://data.zj.gov.cn/';
@ -31,10 +90,9 @@ const MIN_UPDATE_INTERVAL = 30 * 60 * 1000;
// 从数据库读取缓存
async function readCache() {
try {
const cache = await prisma.scraperCache.findUnique({
where: { scraperName: SCRAPER_NAME }
});
return cache || { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
await initPool();
const result = await pool.query('SELECT * FROM ScraperCache WHERE scraperName = ?', [SCRAPER_NAME]);
return result.length > 0 ? result[0] : { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
} catch (error) {
console.error('读取数据库缓存失败:', error.message);
return { scraperName: SCRAPER_NAME, lastDataHash: null, lastUpdateAt: null };
@ -44,18 +102,11 @@ async function readCache() {
// 写入数据库缓存
async function writeCache(lastDataHash) {
try {
await prisma.scraperCache.upsert({
where: { scraperName: SCRAPER_NAME },
update: {
lastDataHash,
lastUpdateAt: new Date()
},
create: {
scraperName: SCRAPER_NAME,
lastDataHash,
lastUpdateAt: new Date()
}
});
await initPool();
await pool.query(
'INSERT INTO ScraperCache (scraperName, lastDataHash, lastUpdateAt) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE lastDataHash = VALUES(lastDataHash), lastUpdateAt = VALUES(lastUpdateAt)',
[SCRAPER_NAME, lastDataHash, new Date()]
);
} catch (error) {
console.error('写入数据库缓存失败:', error.message);
}
@ -76,12 +127,9 @@ function computeDataHash(data) {
// 获取数据库中最新的月份
async function getLatestMonth() {
try {
const latest = await prisma.econ_SowInventory.findFirst({
orderBy: {
month: 'desc'
}
});
return latest ? latest.month : null;
await initPool();
const result = await pool.query('SELECT MAX(month) as latestMonth FROM Econ_SowInventory');
return result[0].latestMonth || null;
} catch (error) {
console.error('查询最新月份失败:', error.message);
return null;
@ -108,6 +156,7 @@ function shouldSkipRequest(cache) {
// 能繁母猪数量数据爬取函数(增量更新版本)
async function scrapeSowInventory(incremental = true) {
try {
await initPool();
console.log('='.repeat(50));
console.log('开始爬取浙江省能繁母猪数量数据...');
console.log(`增量更新模式: ${incremental ? '开启' : '关闭'}`);
@ -224,26 +273,25 @@ async function scrapeSowInventory(incremental = true) {
for (const item of dataToUpdate) {
try {
// 检查数据是否已存在
const existing = await prisma.econ_SowInventory.findUnique({
where: { month: item.month }
});
if (existing) {
const existing = await pool.query('SELECT * FROM Econ_SowInventory WHERE month = ?', [item.month]);
if (existing.length > 0) {
// 检查数据是否真的需要更新(值变化了才更新)
if (existing.inventory !== item.inventory) {
await prisma.econ_SowInventory.update({
where: { month: item.month },
data: { inventory: item.inventory }
});
if (existing[0].inventory !== item.inventory) {
await pool.query(
'UPDATE Econ_SowInventory SET inventory = ? WHERE month = ?',
[item.inventory, item.month]
);
updateCount++;
console.log(`更新数据: ${item.month} - ${item.inventory} (原值: ${existing.inventory})`);
console.log(`更新数据: ${item.month} - ${item.inventory} (原值: ${existing[0].inventory})`);
} else {
console.log(`数据未变化,跳过: ${item.month}`);
}
} else {
await prisma.econ_SowInventory.create({
data: item
});
await pool.query(
'INSERT INTO Econ_SowInventory (month, inventory) VALUES (?, ?)',
[item.month, item.inventory]
);
newCount++;
console.log(`新增数据: ${item.month} - ${item.inventory}`);
}
@ -272,10 +320,11 @@ async function scrapeSowInventory(incremental = true) {
console.error('爬取数据时出错:', error.message);
throw error;
} finally {
// 关闭Prisma连接
await prisma.$disconnect();
// 关闭数据库连接池
await pool.end();
if (pool) {
await pool.end();
console.log('数据库连接池已关闭');
}
}
}

View File

@ -1,17 +1,68 @@
import { createPool } from 'mariadb';
import { fetch as fetchUSDToCNYRate } from '../fetchers/usd-cny-rate.fetcher.js';
import fs from 'fs';
import path from 'path';
let pool;
function loadDatabaseConfig() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
console.log('读取 .env 文件...');
const envContent = fs.readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('DATABASE_URL=')) {
const url = trimmedLine.substring('DATABASE_URL='.length).replace(/^"|"$/g, '');
console.log('从 .env 文件获取数据库配置成功!');
console.log(`DATABASE_URL: ${url}`);
// 解析URL
const parts = url.split('://')[1].split('@');
const auth = parts[0].split(':');
const hostAndDb = parts[1].split('/');
const hostAndPort = hostAndDb[0].split(':');
return {
user: auth[0],
password: auth[1],
host: hostAndPort[0],
port: parseInt(hostAndPort[1]),
database: hostAndDb[1]
};
}
}
}
console.log('未找到 .env 文件或 DATABASE_URL 配置,使用默认配置');
return {
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard'
};
}
async function initPool() {
if (!pool) {
console.log('初始化数据库连接池...');
const config = loadDatabaseConfig();
console.log('数据库配置:');
console.log(` 主机: ${config.host}`);
console.log(` 端口: ${config.port}`);
console.log(` 用户: ${config.user}`);
console.log(` 数据库: ${config.database}`);
pool = createPool({
host: '192.168.111.111',
port: 3306,
user: 'root',
password: 'fullstack',
database: 'iboard',
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: 5
});
@ -212,12 +263,23 @@ export async function trigger(startDate = null, endDate = null) {
}
// 直接运行测试
console.log('='.repeat(60));
console.log('测试 usd-cny-rate.scraper.js');
console.log('='.repeat(60));
trigger()
.then(result => {
async function runTest() {
console.log('='.repeat(60));
console.log('测试 usd-cny-rate.scraper.js');
console.log('='.repeat(60));
// 测试配置读取
console.log('\n测试数据库配置读取...');
const config = loadDatabaseConfig();
console.log('配置读取结果:');
console.log(` 主机: ${config.host}`);
console.log(` 端口: ${config.port}`);
console.log(` 用户: ${config.user}`);
console.log(` 数据库: ${config.database}`);
console.log('配置读取测试完成!');
try {
const result = await trigger();
console.log('\n' + '='.repeat(60));
console.log('测试结果:');
console.log(` 开始日期: ${result.startDate}`);
@ -227,8 +289,11 @@ trigger()
console.log(` 最终数据: ${result.finalCount}`);
console.log(` 操作成功: ${result.success}`);
console.log('='.repeat(60));
})
.catch(error => {
} catch (error) {
console.error('测试失败:', error);
process.exit(1);
});
}
}
// 运行测试
runTest();