diff --git a/.env b/.env
index 3d8e86a..e1e83b5 100644
--- a/.env
+++ b/.env
@@ -1 +1 @@
-DATABASE_URL="mysql://root:fullstack@192.168.111.111:3306/iboard"
+DATABASE_URL="mysql://root:fullstack@192.168.111.111:3306/iboard"
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 5ca70ad..3318e36 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 264873a..26e8eac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 4d02ab0..7337904 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pages/api/exchange-rate.js b/pages/api/exchange-rate.js
new file mode 100644
index 0000000..3ecb5e1
--- /dev/null
+++ b/pages/api/exchange-rate.js
@@ -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();
+ }
+ }
+}
diff --git a/pages/data.js b/pages/data.js
index 828bfc8..c169462 100644
--- a/pages/data.js
+++ b/pages/data.js
@@ -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() {
)}
+ {selectedIndicator === 'central-bank-usd-cny-rate' && (
+
+
人民币兑美元汇率
+
展示人民币兑美元汇率的变化趋势
+
+ {loading ? (
+
加载中...
+ ) : error ? (
+
{error}
+ ) : (
+ <>
+
+
+
+
+
+ value.toFixed(4)} />
+ [value.toFixed(4), '汇率']} />
+
+
+
+
+
+
+
+
详细数据
+
+
+
+ | 日期 |
+ 汇率 |
+
+
+
+ {exchangeRateData.map((row, index) => (
+
+ | {row.date} |
+ {row.rate.toFixed(4)} |
+
+ ))}
+
+
+
+ >
+ )}
+
+ )}
+
{selectedIndicator === 'commodity-pork-price' && (
猪肉价格
diff --git a/scripts/scheduler/index.js b/scripts/scheduler/index.js
new file mode 100644
index 0000000..fe969d4
--- /dev/null
+++ b/scripts/scheduler/index.js
@@ -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);
+});
diff --git a/scripts/scheduler/test.js b/scripts/scheduler/test.js
new file mode 100644
index 0000000..fe969d4
--- /dev/null
+++ b/scripts/scheduler/test.js
@@ -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);
+});
diff --git a/scripts/scraper/sow-inventory-scraper.js b/scripts/scraper/sow-inventory-scraper.js
index 2184a08..a5e0cb2 100644
--- a/scripts/scraper/sow-inventory-scraper.js
+++ b/scripts/scraper/sow-inventory-scraper.js
@@ -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('数据库连接池已关闭');
+ }
}
}
diff --git a/scripts/scraper/usd-cny-rate.scraper.js b/scripts/scraper/usd-cny-rate.scraper.js
index eec7765..43ed9a0 100644
--- a/scripts/scraper/usd-cny-rate.scraper.js
+++ b/scripts/scraper/usd-cny-rate.scraper.js
@@ -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();