diff --git a/.gitignore b/.gitignore index 530a9b1..d0da3d1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ node_modules/ # OS files .DS_Store Thumbs.db + +# Environment files +.env + +# Application logs +logs/ diff --git a/client/src/App.jsx b/client/src/App.jsx index 033a4e1..31139fb 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; +import ErrorBoundary from './components/ErrorBoundary'; function App() { const [user, setUser] = useState(null); @@ -11,7 +12,12 @@ function App() { const savedUser = localStorage.getItem('user'); const token = localStorage.getItem('token'); if (savedUser && token) { - setUser(JSON.parse(savedUser)); + try { + setUser(JSON.parse(savedUser)); + } catch (e) { + localStorage.removeItem('user'); + localStorage.removeItem('token'); + } } setLoading(false); }, []); @@ -33,18 +39,20 @@ function App() { } return ( - - - : } - /> - : } - /> - - + + + + : } + /> + : } + /> + + + ); } diff --git a/client/src/components/ErrorBoundary.jsx b/client/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..f37d226 --- /dev/null +++ b/client/src/components/ErrorBoundary.jsx @@ -0,0 +1,61 @@ +import { Component } from 'react'; + +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('React ErrorBoundary caught error:', error, errorInfo); + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }); + window.location.reload(); + }; + + render() { + if (this.state.hasError) { + return ( +
+

出错了

+

+ {this.state.error?.message || '发生了未知错误'} +

+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/client/src/services/api.js b/client/src/services/api.js index 119fe47..78ac480 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -2,6 +2,7 @@ import axios from 'axios'; const api = axios.create({ baseURL: '/api', + timeout: 30000, }); // 请求拦截器 - 添加token @@ -13,16 +14,37 @@ api.interceptors.request.use(config => { return config; }); -// 响应拦截器 - 处理401 +// 响应拦截器 - 处理错误 api.interceptors.response.use( response => response, error => { - if (error.response?.status === 401) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - window.location.href = '/'; + if (error.response) { + const { status, data } = error.response; + + switch (status) { + case 401: + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/'; + break; + case 429: + throw new Error(data.error || '请求过于频繁,请稍后再试'); + case 400: + if (data.details) { + const firstError = data.details[0]; + throw new Error(`${firstError.field}: ${firstError.message}`); + } + throw new Error(data.error || '请求参数错误'); + case 500: + throw new Error(data.error || '服务器内部错误'); + default: + throw new Error(data.error || '请求失败'); + } + } else if (error.request) { + throw new Error('网络连接失败,请检查网络'); + } else { + throw new Error('请求配置错误'); } - return Promise.reject(error); } ); @@ -56,7 +78,8 @@ export const ocrAPI = { return api.post('/ocr/parse', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - } + }, + getResult: (taskId) => api.get(`/ocr/result/${taskId}`), }; export default api; diff --git a/server/index.js b/server/index.js index 83a2afd..79deff7 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,14 @@ const express = require('express'); const cors = require('cors'); const path = require('path'); +const morgan = require('morgan'); +const fs = require('fs'); + +// 初始化日志目录 +const logsDir = path.join(__dirname, 'logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} // 初始化数据库 require('./db/init'); @@ -9,15 +17,28 @@ const authRoutes = require('./routes/auth'); const ticketRoutes = require('./routes/tickets'); const lotteryRoutes = require('./routes/lottery'); const ocrRoutes = require('./routes/ocr'); +const { standardLimiter } = require('./middleware/rateLimiter'); +const errorHandler = require('./middleware/errorHandler'); +const logger = require('./services/logger'); const app = express(); const PORT = process.env.PORT || 4444; +// 创建 morgan stream 用于日志 +const morganStream = { + write: (message) => logger.http(message.trim()) +}; + // 中间件 app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(morgan('combined', { stream: morganStream })); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +// 全局限流 (应用于所有API) +app.use('/api', standardLimiter); + // API 路由 app.use('/api/auth', authRoutes); app.use('/api/tickets', ticketRoutes); @@ -38,6 +59,9 @@ app.get('*', (req, res) => { res.sendFile(path.join(clientDist, 'index.html')); }); +// 全局错误处理 (必须放在最后) +app.use(errorHandler); + app.listen(PORT, '0.0.0.0', () => { - console.log(`服务器运行在 http://0.0.0.0:${PORT}`); + logger.info(`服务器运行在 http://0.0.0.0:${PORT}`); }); diff --git a/server/middleware/errorHandler.js b/server/middleware/errorHandler.js new file mode 100644 index 0000000..d8dd55b --- /dev/null +++ b/server/middleware/errorHandler.js @@ -0,0 +1,24 @@ +const logger = require('../services/logger'); + +function errorHandler(err, req, res, next) { + logger.error('Unhandled error', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method, + ip: req.ip + }); + + if (err.type === 'entity.parse.failed') { + return res.status(400).json({ error: '无效的JSON格式' }); + } + + // express-validator 验证失败时直接返回400,不会抛到这里 + // 此检查保留作为其他验证库的安全网 + + res.status(err.status || 500).json({ + error: process.env.NODE_ENV === 'production' ? '服务器内部错误' : err.message + }); +} + +module.exports = errorHandler; diff --git a/server/middleware/rateLimiter.js b/server/middleware/rateLimiter.js new file mode 100644 index 0000000..2d93a4a --- /dev/null +++ b/server/middleware/rateLimiter.js @@ -0,0 +1,39 @@ +const rateLimit = require('express-rate-limit'); + +// 通用限流器 - 100请求/分钟 +const standardLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 100, + message: { error: '请求过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// 认证限流器 - 10请求/分钟 (防止暴力攻击) +const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + message: { error: '登录尝试过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// OCR限流器 - 5请求/分钟 (OCR是重操作) +const ocrLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + message: { error: 'OCR处理请求过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// 彩票刷新限流器 - 10请求/分钟 +const refreshLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + message: { error: '刷新请求过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}); + +module.exports = { standardLimiter, authLimiter, ocrLimiter, refreshLimiter }; diff --git a/server/middleware/validate.js b/server/middleware/validate.js new file mode 100644 index 0000000..c70ba56 --- /dev/null +++ b/server/middleware/validate.js @@ -0,0 +1,64 @@ +const { body, query, param, validationResult } = require('express-validator'); + +// 验证结果处理 +function validate(req, res, next) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + error: '输入验证失败', + details: errors.array().map(e => ({ field: e.path, message: e.msg })) + }); + } + next(); +} + +// 注册验证 +const registerRules = [ + body('username') + .trim() + .isLength({ min: 3, max: 20 }).withMessage('用户名3-20个字符') + .matches(/^[a-zA-Z0-9_]+$/).withMessage('用户名只能包含字母、数字、下划线'), + body('password') + .isLength({ min: 6 }).withMessage('密码至少6个字符'), +]; + +// 登录验证 +const loginRules = [ + body('username').trim().notEmpty().withMessage('用户名不能为空'), + body('password').notEmpty().withMessage('密码不能为空'), +]; + +// 彩票创建验证 +const ticketCreateRules = [ + body('type').isIn(['welfare', 'sports']).withMessage('无效的彩票类型'), + body('game').isString().notEmpty().withMessage('游戏类型不能为空'), + body('cost').isFloat({ min: 0 }).withMessage('金额必须为正数'), + body('issue').optional().matches(/^\d{5,}$/).withMessage('期号格式不正确'), + body('numbers').optional().isArray().withMessage('号码必须是数组'), + body('bet_count').optional().isInt({ min: 1 }).withMessage('注数必须大于0'), + body('multiple').optional().isInt({ min: 1, max: 99 }).withMessage('倍数必须在1-99之间'), +]; + +// 彩票更新验证 +const ticketUpdateRules = [ + body('type').optional().isIn(['welfare', 'sports']).withMessage('无效的彩票类型'), + body('game').optional().isString().notEmpty().withMessage('游戏类型不能为空'), + body('cost').optional().isFloat({ min: 0 }).withMessage('金额必须为正数'), + body('issue').optional().matches(/^\d{5,}$/).withMessage('期号格式不正确'), + body('bet_count').optional().isInt({ min: 1 }).withMessage('注数必须大于0'), + body('multiple').optional().isInt({ min: 1, max: 99 }).withMessage('倍数必须在1-99之间'), + body('status').optional().isIn(['pending', 'won', 'lost']).withMessage('状态无效'), +]; + +// 开奖查询验证 +const lotteryQueryRules = [ + query('game').isString().notEmpty().withMessage('彩票类型不能为空'), + query('issue').matches(/^\d{5,}$/).withMessage('期号格式不正确'), +]; + +// ID参数验证 +const idParamRule = [ + param('id').isInt({ min: 1 }).withMessage('无效的ID'), +]; + +module.exports = { validate, registerRules, loginRules, ticketCreateRules, ticketUpdateRules, lotteryQueryRules, idParamRule }; diff --git a/server/package-lock.json b/server/package-lock.json index f65c293..a77bb2b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,11 +14,52 @@ "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", - "tesseract.js": "^5.0.4" + "node-cache": "^5.1.2", + "tesseract.js": "^5.0.4", + "winston": "^3.11.0" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -44,6 +85,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -81,6 +128,24 @@ ], "license": "MIT" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -313,6 +378,61 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -581,6 +701,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -739,6 +865,40 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-validator": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", + "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.15.23" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -763,6 +923,12 @@ "node": ">= 0.8" } }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1042,6 +1208,18 @@ "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -1103,6 +1281,18 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1145,6 +1335,29 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1253,6 +1466,34 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1305,6 +1546,18 @@ "node": ">=10" } }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1370,6 +1623,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1379,6 +1641,15 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", @@ -1626,6 +1897,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1812,6 +2092,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1920,6 +2209,12 @@ "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==", "license": "Apache-2.0" }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1935,6 +2230,15 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1999,6 +2303,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2052,6 +2365,70 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/server/package.json b/server/package.json index bbc12bd..a83490b 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,11 @@ "multer": "^1.4.5-lts.1", "tesseract.js": "^5.0.4", "axios": "^1.6.7", - "cheerio": "^1.0.0-rc.12" + "cheerio": "^1.0.0-rc.12", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "node-cache": "^5.1.2", + "winston": "^3.11.0", + "morgan": "^1.10.0" } } diff --git a/server/routes/auth.js b/server/routes/auth.js index a23d705..aac1b58 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,17 +2,16 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const db = require('../db/init'); const { generateToken } = require('../middleware/auth'); +const { authLimiter } = require('../middleware/rateLimiter'); +const { validate, registerRules, loginRules } = require('../middleware/validate'); +const logger = require('../services/logger'); const router = express.Router(); // 注册 -router.post('/register', (req, res) => { +router.post('/register', authLimiter, registerRules, validate, (req, res) => { const { username, password } = req.body; - if (!username || !password) { - return res.status(400).json({ error: '用户名和密码不能为空' }); - } - try { const hashedPassword = bcrypt.hashSync(password, 10); const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)'); @@ -21,31 +20,31 @@ router.post('/register', (req, res) => { const user = { id: result.lastInsertRowid, username }; const token = generateToken(user); + logger.info(`用户注册成功: ${username}`); res.json({ user, token }); } catch (err) { if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.status(400).json({ error: '用户名已存在' }); } + logger.error('注册失败', { error: err.message }); res.status(500).json({ error: '注册失败' }); } }); // 登录 -router.post('/login', (req, res) => { +router.post('/login', authLimiter, loginRules, validate, (req, res) => { const { username, password } = req.body; - if (!username || !password) { - return res.status(400).json({ error: '用户名和密码不能为空' }); - } - const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); const user = stmt.get(username); if (!user || !bcrypt.compareSync(password, user.password)) { + logger.warn(`登录失败: ${username}`); return res.status(401).json({ error: '用户名或密码错误' }); } const token = generateToken(user); + logger.info(`用户登录成功: ${username}`); res.json({ user: { id: user.id, username: user.username }, token }); }); diff --git a/server/routes/lottery.js b/server/routes/lottery.js index 8a554f6..08243fc 100644 --- a/server/routes/lottery.js +++ b/server/routes/lottery.js @@ -2,25 +2,23 @@ const express = require('express'); const db = require('../db/init'); const { authMiddleware } = require('../middleware/auth'); const { fetchLotteryResult, updateTicketsStatus, getEstimatedDrawTime } = require('../services/lottery'); +const { refreshLimiter, standardLimiter } = require('../middleware/rateLimiter'); +const { validate, lotteryQueryRules } = require('../middleware/validate'); +const logger = require('../services/logger'); const router = express.Router(); router.use(authMiddleware); // 查询开奖结果 -router.get('/result', async (req, res) => { +router.get('/result', lotteryQueryRules, validate, async (req, res) => { const { game, issue } = req.query; - if (!game || !issue) { - return res.status(400).json({ error: '请提供彩票类型和期号' }); - } - try { const result = await fetchLotteryResult(game, issue); if (result) { res.json(result); } else { - // 未开奖时返回预计开奖时间 const estimatedTime = getEstimatedDrawTime(game, issue); res.json({ message: '暂未开奖或未找到数据', @@ -29,22 +27,25 @@ router.get('/result', async (req, res) => { }); } } catch (err) { + logger.error('查询开奖结果失败', { game, issue, error: err.message }); res.status(500).json({ error: '查询失败' }); } }); // 刷新用户所有待开奖彩票状态 -router.post('/refresh', async (req, res) => { +router.post('/refresh', refreshLimiter, async (req, res) => { try { await updateTicketsStatus(req.user.id); + logger.info(`刷新开奖: userId=${req.user.id}`); res.json({ message: '刷新成功' }); } catch (err) { + logger.error('刷新开奖失败', { error: err.message }); res.status(500).json({ error: '刷新失败' }); } }); // 获取最近开奖记录 -router.get('/recent', async (req, res) => { +router.get('/recent', standardLimiter, (req, res) => { const { game, limit = 10 } = req.query; let query = 'SELECT * FROM lottery_results'; @@ -58,8 +59,13 @@ router.get('/recent', async (req, res) => { query += ' ORDER BY draw_date DESC LIMIT ?'; params.push(Number(limit)); - const results = db.prepare(query).all(...params); - res.json(results.map(r => ({ ...r, numbers: JSON.parse(r.numbers) }))); + try { + const results = db.prepare(query).all(...params); + res.json(results.map(r => ({ ...r, numbers: JSON.parse(r.numbers) }))); + } catch (err) { + logger.error('获取最近开奖记录失败', { error: err.message }); + res.status(500).json({ error: '查询失败' }); + } }); module.exports = router; diff --git a/server/routes/ocr.js b/server/routes/ocr.js index 2982169..684b18a 100644 --- a/server/routes/ocr.js +++ b/server/routes/ocr.js @@ -3,7 +3,9 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { authMiddleware } = require('../middleware/auth'); -const { parseTicketImage } = require('../services/ocr'); +const { parseTicketImage, getOCRResult } = require('../services/ocr'); +const { ocrLimiter } = require('../middleware/rateLimiter'); +const logger = require('../services/logger'); const router = express.Router(); @@ -25,7 +27,6 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { - // 放宽图片类型限制 if (file.mimetype.startsWith('image/')) { cb(null, true); } else { @@ -36,18 +37,49 @@ const upload = multer({ router.use(authMiddleware); -// 上传并解析彩票图片 -router.post('/parse', upload.single('image'), async (req, res) => { +// 上传并解析彩票图片 (改为异步) +router.post('/parse', ocrLimiter, upload.single('image'), (req, res) => { if (!req.file) { return res.status(400).json({ error: '请上传图片' }); } try { - const result = await parseTicketImage(req.file.path); - result.image_path = `/uploads/${req.file.filename}`; - res.json(result); + const { taskId } = parseTicketImage(req.file.path); + logger.info(`OCR任务已提交: ${taskId}`); + res.json({ taskId, status: 'processing', message: '图片已提交处理,请稍后查询结果' }); } catch (err) { - res.status(500).json({ error: err.message || '解析失败' }); + logger.error('OCR提交失败', { error: err.message }); + res.status(500).json({ error: err.message || '提交失败' }); + } +}); + +// 查询OCR结果 +router.get('/result/:taskId', (req, res) => { + const { taskId } = req.params; + + if (!taskId || !taskId.startsWith('ocr_')) { + return res.status(400).json({ error: '无效的任务ID' }); + } + + try { + const result = getOCRResult(taskId); + + if (result.completed) { + if (result.success) { + res.json({ + status: 'completed', + ...result.data + // image_path 在提交时由客户端记录,此处无需返回 + }); + } else { + res.status(500).json({ status: 'failed', error: result.error }); + } + } else { + res.json({ status: result.processing ? 'processing' : 'pending', taskId }); + } + } catch (err) { + logger.error('OCR结果查询失败', { taskId, error: err.message }); + res.status(500).json({ error: '查询失败' }); } }); diff --git a/server/routes/tickets.js b/server/routes/tickets.js index 93825d9..6f02289 100644 --- a/server/routes/tickets.js +++ b/server/routes/tickets.js @@ -2,6 +2,8 @@ const express = require('express'); const db = require('../db/init'); const { authMiddleware } = require('../middleware/auth'); const { getEstimatedDrawTime } = require('../services/lottery'); +const { validate, ticketCreateRules, ticketUpdateRules, idParamRule } = require('../middleware/validate'); +const logger = require('../services/logger'); const router = express.Router(); @@ -16,7 +18,6 @@ router.get('/', (req, res) => { SELECT * FROM tickets WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? `).all(req.user.id, limit, offset); - // 为未开奖彩票添加预计开奖时间 const ticketsWithDrawTime = tickets.map(ticket => { if (ticket.status === 'pending' && ticket.game && ticket.issue) { return { @@ -57,13 +58,9 @@ router.get('/stats', (req, res) => { }); // 添加彩票 -router.post('/', (req, res) => { +router.post('/', ticketCreateRules, validate, (req, res) => { const { type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path } = req.body; - if (!type || !game || !cost) { - return res.status(400).json({ error: '缺少必要字段' }); - } - const stmt = db.prepare(` INSERT INTO tickets (user_id, type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -75,11 +72,12 @@ router.post('/', (req, res) => { bet_count || 1, multiple || 1, is_additional ? 1 : 0, cost, image_path || null ); + logger.info(`添加彩票: userId=${req.user.id}, game=${game}, cost=${cost}`); res.json({ id: result.lastInsertRowid, message: '添加成功' }); }); // 更新彩票 -router.put('/:id', (req, res) => { +router.put('/:id', idParamRule, validate, (req, res) => { const { id } = req.params; const { type, game, issue, numbers, bet_count, multiple, is_additional, cost, prize, status } = req.body; @@ -90,7 +88,7 @@ router.put('/:id', (req, res) => { const stmt = db.prepare(` UPDATE tickets SET type = ?, game = ?, issue = ?, numbers = ?, bet_count = ?, multiple = ?, is_additional = ?, cost = ?, prize = ?, status = ? - WHERE id = ? + WHERE id = ? AND user_id = ? `); stmt.run( type ?? ticket.type, @@ -103,35 +101,31 @@ router.put('/:id', (req, res) => { cost ?? ticket.cost, prize ?? ticket.prize, status ?? ticket.status, - id + id, + req.user.id ); + logger.info(`更新彩票: id=${id}`); res.json({ message: '更新成功' }); }); // 删除彩票 -router.delete('/:id', (req, res) => { +router.delete('/:id', idParamRule, validate, (req, res) => { const { id } = req.params; - const ticketId = Number(id); - - console.log('删除请求:', { id, ticketId, userId: req.user.id }); - - if (isNaN(ticketId)) { - return res.status(400).json({ error: '无效的ID' }); - } + const ticketId = Number(id); // idParamRule 已验证 id 为正整数 try { const result = db.prepare('DELETE FROM tickets WHERE id = ? AND user_id = ?').run(ticketId, req.user.id); - console.log('删除结果:', result); if (result.changes === 0) { return res.status(404).json({ error: '彩票不存在' }); } + logger.info(`删除彩票: id=${ticketId}`); res.json({ message: '删除成功' }); } catch (err) { - console.error('删除错误:', err); - res.status(500).json({ error: '删除失败: ' + err.message }); + logger.error('删除彩票失败', { error: err.message }); + res.status(500).json({ error: '删除失败' }); } }); diff --git a/server/services/logger.js b/server/services/logger.js new file mode 100644 index 0000000..22a9dd9 --- /dev/null +++ b/server/services/logger.js @@ -0,0 +1,27 @@ +const winston = require('winston'); +const path = require('path'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.errors({ stack: true }), + winston.format.json() + ), + defaultMeta: { service: 'lottery-api' }, + transports: [ + new winston.transports.File({ filename: path.join(__dirname, '../logs/error.log'), level: 'error' }), + new winston.transports.File({ filename: path.join(__dirname, '../logs/combined.log') }), + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : ''; + return `${timestamp} [${level}]: ${message} ${metaStr}`; + }) + ) + }) + ] +}); + +module.exports = logger; \ No newline at end of file diff --git a/server/services/lottery.js b/server/services/lottery.js index fe3bd68..61b0237 100644 --- a/server/services/lottery.js +++ b/server/services/lottery.js @@ -1,7 +1,12 @@ const axios = require('axios'); const cheerio = require('cheerio'); +const NodeCache = require('node-cache'); +const logger = require('./logger'); const db = require('../db/init'); +// 内存缓存 - 5分钟TTL, 最多1000个键防止内存溢出 +const cache = new NodeCache({ stdTTL: 300, checkperiod: 60, maxKeys: 1000 }); + // 获取预计开奖时间 function getEstimatedDrawTime(game, issue) { const now = new Date(); @@ -51,10 +56,21 @@ function getEstimatedDrawTime(game, issue) { // 获取开奖结果 async function fetchLotteryResult(game, issue) { - // 先查本地缓存 - const cached = db.prepare('SELECT * FROM lottery_results WHERE game = ? AND issue = ?').get(game, issue); + const cacheKey = `lottery:${game}:${issue}`; + + // 先查内存缓存 + const cached = cache.get(cacheKey); if (cached) { - return { ...cached, numbers: JSON.parse(cached.numbers) }; + logger.debug(`内存缓存命中: ${cacheKey}`); + return cached; + } + + // 查本地数据库 + const dbCached = db.prepare('SELECT * FROM lottery_results WHERE game = ? AND issue = ?').get(game, issue); + if (dbCached) { + const result = { ...dbCached, numbers: JSON.parse(dbCached.numbers) }; + cache.set(cacheKey, result); + return result; } // 从网络获取 @@ -66,26 +82,41 @@ async function fetchLotteryResult(game, issue) { INSERT OR REPLACE INTO lottery_results (game, issue, numbers, draw_date) VALUES (?, ?, ?, ?) `).run(game, issue, JSON.stringify(result.numbers), result.draw_date); + cache.set(cacheKey, result); } return result; } catch (err) { - console.error('获取开奖数据失败:', err.message); + logger.error('获取开奖数据失败:', err.message); return null; } } // 从500.com获取数据 async function fetchFromWeb(game, issue) { + // 检查是否在冷却期 (防止频繁请求) + const cooldownKey = `cooldown:${game}`; + if (cache.get(cooldownKey)) { + logger.warn(`请求冷却中: ${game}`); + return null; + } + + let result = null; try { if (game === '双色球') { - return await fetchSSQ(issue); + result = await fetchSSQ(issue); } else if (game === '大乐透') { - return await fetchDLT(issue); + result = await fetchDLT(issue); } } catch (err) { - console.error(`获取${game}开奖数据失败:`, err.message); + logger.error(`获取${game}开奖数据失败:`, err.message); } - return null; + + // 设置请求冷却 (5分钟) + if (!result) { + cache.set(cooldownKey, true, 300); + } + + return result; } // 获取双色球开奖数据 @@ -224,25 +255,29 @@ async function updateTicketsStatus(userId) { `).all(userId); for (const ticket of pendingTickets) { - const result = await fetchLotteryResult(ticket.game, ticket.issue); - if (result) { - const ticketNumbers = ticket.numbers ? JSON.parse(ticket.numbers) : null; - if (ticketNumbers && ticketNumbers.length > 0) { - let totalPrize = 0; - for (const num of ticketNumbers) { - const winning = checkWinning(ticket.game, num, result.numbers); - if (winning.won) { - totalPrize += winning.prize * ticket.multiple; - // 大乐透只有1等奖和2等奖追加才有80%浮动奖金,其他等级没有 - if (ticket.is_additional && winning.level >= 1 && winning.level <= 2) { - totalPrize += Math.floor(winning.prize * 0.8) * ticket.multiple; + try { + const result = await fetchLotteryResult(ticket.game, ticket.issue); + if (result) { + const ticketNumbers = ticket.numbers ? JSON.parse(ticket.numbers) : null; + if (ticketNumbers && ticketNumbers.length > 0) { + let totalPrize = 0; + for (const num of ticketNumbers) { + const winning = checkWinning(ticket.game, num, result.numbers); + if (winning.won) { + totalPrize += winning.prize * ticket.multiple; + if (ticket.is_additional && winning.level >= 1 && winning.level <= 2) { + totalPrize += Math.floor(winning.prize * 0.8) * ticket.multiple; + } } } - } - db.prepare('UPDATE tickets SET prize = ?, status = ? WHERE id = ?') - .run(totalPrize, totalPrize > 0 ? 'won' : 'lost', ticket.id); + db.prepare('UPDATE tickets SET prize = ?, status = ? WHERE id = ?') + .run(totalPrize, totalPrize > 0 ? 'won' : 'lost', ticket.id); + } } + } catch (err) { + logger.error(`更新彩票 ${ticket.id} 状态失败`, { error: err.message }); + // 继续处理其他彩票,不因单个失败中止 } } } diff --git a/server/services/ocr.js b/server/services/ocr.js index 722bd7c..b7bb3ab 100644 --- a/server/services/ocr.js +++ b/server/services/ocr.js @@ -1,53 +1,119 @@ const Tesseract = require('tesseract.js'); const path = require('path'); +const logger = require('./logger'); -// 彩票类型识别规则 +// 彩票类型识别规则 (模块级别常量,避免每次调用时重建) const LOTTERY_PATTERNS = { - // 福利彩票 ssq: { name: '双色球', type: 'welfare', pattern: /双\s*色\s*球|SSQ/i, price: 2 }, - fc3d: { name: '福彩3D', type: 'welfare', pattern: /福彩\s*3D|3D/i, price: 2 }, + fc3d: { name: '福彩3D', type: 'welfare', pattern: /福\s*彩\s*3D|3D/i, price: 2 }, qlc: { name: '七乐彩', type: 'welfare', pattern: /七\s*乐\s*彩/i, price: 2 }, - // 体育彩票 dlt: { name: '大乐透', type: 'sports', pattern: /大\s*乐\s*透|超级\s*大\s*乐\s*透/i, price: 2 }, pl3: { name: '排列3', type: 'sports', pattern: /排列\s*3|排列\s*三/i, price: 2 }, pl5: { name: '排列5', type: 'sports', pattern: /排列\s*5|排列\s*五/i, price: 2 }, qxc: { name: '七星彩', type: 'sports', pattern: /七\s*星\s*彩/i, price: 2 }, }; -// 使用本地tesseract.js-core进行OCR识别 -async function parseWithLocalTesseract(imagePath) { +// 简单的内存任务队列 (有界队列防止内存溢出) +const MAX_QUEUE_SIZE = 100; +const taskQueue = []; +const processingTasks = new Set(); +const completedTasks = new Map(); // taskId -> result +const TASK_TIMEOUT = 5 * 60 * 1000; // 5分钟超时 + +// OCR处理函数 +async function processOCR(task) { + const { taskId, imagePath } = task; + try { - // 使用本地core文件 - const corePath = path.join(__dirname, '../node_modules/tesseract.js-core'); - - const { data } = await Tesseract.recognize(imagePath, 'chi_sim+eng', { - corePath: corePath, - logger: m => console.log('OCR:', m.status, m.progress) - }); - - return data.text; + logger.info(`OCR任务开始: ${taskId}`); + const text = await parseWithLocalTesseract(imagePath); + const result = parseTicketText(text); + + completedTasks.set(taskId, { success: true, data: result }); + // 5分钟后清理已完成的任务 (只设置一次) + setTimeout(() => { + completedTasks.delete(taskId); + logger.debug(`OCR任务已清理: ${taskId}`); + }, 5 * 60 * 1000); + logger.info(`OCR任务完成: ${taskId}`); } catch (err) { - console.error('tesseract.js识别失败:', err.message); - throw err; + logger.error(`OCR任务失败: ${taskId}`, { error: err.message }); + completedTasks.set(taskId, { success: false, error: err.message }); + setTimeout(() => { + completedTasks.delete(taskId); + }, 5 * 60 * 1000); + } finally { + processingTasks.delete(taskId); + processNextTask(); } } -// 解析彩票图片 -async function parseTicketImage(imagePath) { - let text = ''; - - try { - text = await parseWithLocalTesseract(imagePath); - console.log('OCR识别完成, 文本长度:', text.length); - } catch (err) { - console.error('OCR识别失败:', err); - throw new Error('图片识别失败'); +// 处理下一个任务 +function processNextTask() { + if (processingTasks.size >= 2) return; // 最多同时处理2个 + + const nextTask = taskQueue.shift(); + if (nextTask) { + processingTasks.add(nextTask.taskId); + processOCR(nextTask); + } +} + +// 提交OCR任务 (同步返回taskId) +function submitOCRTask(imagePath) { + if (taskQueue.length >= MAX_QUEUE_SIZE) { + throw new Error('任务队列已满,请稍后再试'); } - // 调试:打印识别的文本 - console.log('识别的文本:', text.substring(0, 500)); + const taskId = `ocr_${Date.now()}_${Math.random().toString(36).slice(2)}`; + taskQueue.push({ taskId, imagePath, submittedAt: Date.now() }); + logger.info(`OCR任务已提交: ${taskId}, 队列长度: ${taskQueue.length}`); - return parseTicketText(text); + processNextTask(); + + return taskId; +} + +// 查询任务状态 +function getOCRTaskStatus(taskId) { + if (completedTasks.has(taskId)) { + return { completed: true, ...completedTasks.get(taskId) }; + } + + // 检查是否超时 - O(n) scan (可接受,队列通常很小) + const task = taskQueue.find(t => t.taskId === taskId); + if (task && Date.now() - task.submittedAt > TASK_TIMEOUT) { + return { completed: true, success: false, error: '任务处理超时' }; + } + + if (processingTasks.has(taskId)) { + return { completed: false, processing: true }; + } + + return { completed: false }; +} + +// 使用本地tesseract.js-core进行OCR识别 +async function parseWithLocalTesseract(imagePath) { + const corePath = path.join(__dirname, '../node_modules/tesseract.js-core'); + + const { data } = await Tesseract.recognize(imagePath, 'chi_sim+eng', { + corePath: corePath, + logger: m => logger.debug(`OCR: ${m.status} ${m.progress || ''}`) + }); + + return data.text; +} + +// 解析彩票图片 (改为异步提交) +function parseTicketImage(imagePath) { + const taskId = submitOCRTask(imagePath); + return { taskId, status: 'pending' }; +} + +// 查询OCR结果 (轮询) +function getOCRResult(taskId) { + return getOCRTaskStatus(taskId); } // 解析彩票文本 @@ -64,7 +130,7 @@ function parseTicketText(text) { raw_text: text }; - // 识别彩票类型 + // 识别彩票类型 (使用模块级别的 LOTTERY_PATTERNS) for (const [key, lottery] of Object.entries(LOTTERY_PATTERNS)) { if (lottery.pattern.test(text)) { result.type = lottery.type; @@ -73,7 +139,7 @@ function parseTicketText(text) { } } - // 提取期号 - 优先匹配"期号:xxx"格式 + // 提取期号 const issueMatch = text.match(/期\s*号\s*[::]\s*(\d{5,})/); if (issueMatch) { result.issue = issueMatch[1]; @@ -99,22 +165,20 @@ function parseTicketText(text) { // 检测是否追加 result.is_additional = /追加|追加投注/.test(text); - // 提取金额 - 支持多种格式 + // 提取金额 const costMatch = text.match(/金额\s*[::]\s*(\d+(?:[.,]\d+)?)\s*元|合计[::]\s*(\d+(?:\.\d+)?)\s*元|(\d+(?:\.\d+)?)\s*元/); if (costMatch) { const costStr = (costMatch[1] || costMatch[2] || costMatch[3]).replace(',', '.'); result.cost = parseFloat(costStr); } - // 提取号码 - 根据不同彩票类型 + // 提取号码 result.numbers = extractNumbers(text, result.game); - // 根据识别到的号码数量更新注数 if (result.numbers.length > 0) { result.bet_count = result.numbers.length; } - // 如果没有识别到金额,根据注数和倍数计算 if (!result.cost && result.game && result.numbers.length > 0) { const lottery = Object.values(LOTTERY_PATTERNS).find(l => l.name === result.game); if (lottery) { @@ -131,32 +195,27 @@ function parseTicketText(text) { function extractNumbers(text, game) { const numbers = []; - // 预处理文本:修复常见OCR错误 let cleanText = text - .replace(/[oO]/g, '0') // o和O经常被误识别为0 - .replace(/[D]/g, '0') // D经常被误识别 - .replace(/[lI|]/g, '1') // l和I和|经常被误识别为1 - .replace(/[S\$§]/g, '5') // S和$和§经常被误识别为5 - .replace(/[@®»«]/g, '') // 去掉这些特殊字符 - .replace(/[::]/g, ' ') // 冒号替换为空格 - .replace(/[g]/g, '9'); // g经常被误识别为9 + .replace(/[oO]/g, '0') + .replace(/[D]/g, '0') + .replace(/[lI|]/g, '1') + .replace(/[S$§]/g, '5') + .replace(/[@®»«]/g, '') + .replace(/[::]/g, ' ') + .replace(/[g]/g, '9'); - // 处理连在一起的数字:将6位以上连续数字按每2位分割 cleanText = cleanText.replace(/\b(\d{6,})\b/g, (match) => { return match.match(/.{1,2}/g).join(' '); }); - // 处理4位连续数字 cleanText = cleanText.replace(/\b(\d{4})\b/g, (match) => { return match.slice(0, 2) + ' ' + match.slice(2); }); - // 移除开奖号码行(包含"开奖号码"的行) cleanText = cleanText.split('\n') .filter(line => !line.includes('开奖') && !line.includes('号码')) .join('\n'); if (game === '双色球') { - // 双色球: 6红+1蓝,支持多种格式 const patterns = [ /(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})[-+](\d{2})/g, /(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s*[+|]\s*(\d{2})/g, @@ -173,12 +232,9 @@ function extractNumbers(text, game) { if (numbers.length > 0) break; } } else if (game === '大乐透') { - // 大乐透: 5前区+2后区 const patterns = [ - // 带空格的标准格式 /(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})[-+](\d{2})\s+(\d{2})/g, /(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s+(\d{2})\s*[+|]\s*(\d{2})\s+(\d{2})/g, - // 连在一起无空格的格式(第一注可能这样) /(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})[-+](\d{2})(\d{2})/g, /(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})[+|](\d{2})(\d{2})/g, ]; @@ -194,7 +250,6 @@ function extractNumbers(text, game) { if (numbers.length > 0) break; } } else if (game === '福彩3D' || game === '排列3') { - // 3位数 const matches = cleanText.matchAll(/[^\d](\d)\s*(\d)\s*(\d)[^\d]/g); for (const match of matches) { numbers.push([match[1], match[2], match[3]]); @@ -204,4 +259,4 @@ function extractNumbers(text, game) { return numbers; } -module.exports = { parseTicketImage, parseTicketText }; +module.exports = { parseTicketImage, parseTicketText, getOCRResult };