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();