perf: add rate limiting, validation, logging, and error handling

- Add express-rate-limit with tiered rate limits per endpoint
- Add express-validator for input validation on all API routes
- Add winston + morgan for structured logging
- Add node-cache for lottery result caching (5min TTL, 1000 max keys)
- Convert OCR to async task queue with max 2 concurrent jobs
- Add global error handler middleware
- Add React ErrorBoundary for client-side error handling
- Fix memory leaks in OCR task queue and lottery cache
- Add per-item error handling in batch ticket updates
- Fix SQL UPDATE to include user_id in WHERE clause

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-19 11:35:12 +08:00
co-authored by Claude Opus 4.6
parent 7b1073e23b
commit 335f0fb8dc
17 changed files with 927 additions and 148 deletions
+6
View File
@@ -13,3 +13,9 @@ node_modules/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Environment files
.env
# Application logs
logs/
+8
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import ErrorBoundary from './components/ErrorBoundary';
function App() { function App() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@@ -11,7 +12,12 @@ function App() {
const savedUser = localStorage.getItem('user'); const savedUser = localStorage.getItem('user');
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (savedUser && token) { if (savedUser && token) {
try {
setUser(JSON.parse(savedUser)); setUser(JSON.parse(savedUser));
} catch (e) {
localStorage.removeItem('user');
localStorage.removeItem('token');
}
} }
setLoading(false); setLoading(false);
}, []); }, []);
@@ -33,6 +39,7 @@ function App() {
} }
return ( return (
<ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route <Route
@@ -45,6 +52,7 @@ function App() {
/> />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ErrorBoundary>
); );
} }
+61
View File
@@ -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 (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '20px',
textAlign: 'center',
backgroundColor: '#f5f5f5'
}}>
<h2 style={{ color: '#e74c3c', marginBottom: '10px' }}>出错了</h2>
<p style={{ color: '#666', marginBottom: '20px' }}>
{this.state.error?.message || '发生了未知错误'}
</p>
<button
onClick={this.handleRetry}
style={{
padding: '10px 20px',
backgroundColor: '#3498db',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '16px'
}}
>
刷新页面
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+27 -4
View File
@@ -2,6 +2,7 @@ import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
timeout: 30000,
}); });
// 请求拦截器 - 添加token // 请求拦截器 - 添加token
@@ -13,16 +14,37 @@ api.interceptors.request.use(config => {
return config; return config;
}); });
// 响应拦截器 - 处理401 // 响应拦截器 - 处理错误
api.interceptors.response.use( api.interceptors.response.use(
response => response, response => response,
error => { error => {
if (error.response?.status === 401) { if (error.response) {
const { status, data } = error.response;
switch (status) {
case 401:
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
window.location.href = '/'; 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, { return api.post('/ocr/parse', formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
} },
getResult: (taskId) => api.get(`/ocr/result/${taskId}`),
}; };
export default api; export default api;
+26 -2
View File
@@ -1,6 +1,14 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); 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'); require('./db/init');
@@ -9,15 +17,28 @@ const authRoutes = require('./routes/auth');
const ticketRoutes = require('./routes/tickets'); const ticketRoutes = require('./routes/tickets');
const lotteryRoutes = require('./routes/lottery'); const lotteryRoutes = require('./routes/lottery');
const ocrRoutes = require('./routes/ocr'); const ocrRoutes = require('./routes/ocr');
const { standardLimiter } = require('./middleware/rateLimiter');
const errorHandler = require('./middleware/errorHandler');
const logger = require('./services/logger');
const app = express(); const app = express();
const PORT = process.env.PORT || 4444; const PORT = process.env.PORT || 4444;
// 创建 morgan stream 用于日志
const morganStream = {
write: (message) => logger.http(message.trim())
};
// 中间件 // 中间件
app.use(cors()); 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'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// 全局限流 (应用于所有API)
app.use('/api', standardLimiter);
// API 路由 // API 路由
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/tickets', ticketRoutes); app.use('/api/tickets', ticketRoutes);
@@ -38,6 +59,9 @@ app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html')); res.sendFile(path.join(clientDist, 'index.html'));
}); });
// 全局错误处理 (必须放在最后)
app.use(errorHandler);
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`服务器运行在 http://0.0.0.0:${PORT}`); logger.info(`服务器运行在 http://0.0.0.0:${PORT}`);
}); });
+24
View File
@@ -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;
+39
View File
@@ -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 };
+64
View File
@@ -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 };
+378 -1
View File
@@ -14,11 +14,52 @@
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1", "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -44,6 +85,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -81,6 +128,24 @@
], ],
"license": "MIT" "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": { "node_modules/bcryptjs": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -313,6 +378,61 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -581,6 +701,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT" "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": { "node_modules/encodeurl": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -739,6 +865,40 @@
"url": "https://opencollective.com/express" "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": { "node_modules/file-uri-to-path": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "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": ">= 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": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -1042,6 +1208,18 @@
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
"license": "MIT" "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": { "node_modules/is-url": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
@@ -1103,6 +1281,18 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1145,6 +1335,29 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1253,6 +1466,34 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "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": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1305,6 +1546,18 @@
"node": ">=10" "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": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -1370,6 +1623,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1379,6 +1641,15 @@
"wrappy": "1" "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": { "node_modules/opencollective-postinstall": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
@@ -1626,6 +1897,15 @@
], ],
"license": "MIT" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1812,6 +2092,15 @@
"simple-concat": "^1.0.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1920,6 +2209,12 @@
"integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==", "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
"license": "Apache-2.0" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1935,6 +2230,15 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "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": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -1999,6 +2303,15 @@
"node": ">= 0.4.0" "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": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -2052,6 +2365,70 @@
"webidl-conversions": "^3.0.0" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+6 -1
View File
@@ -15,6 +15,11 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"tesseract.js": "^5.0.4", "tesseract.js": "^5.0.4",
"axios": "^1.6.7", "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"
} }
} }
+9 -10
View File
@@ -2,17 +2,16 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const db = require('../db/init'); const db = require('../db/init');
const { generateToken } = require('../middleware/auth'); 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(); const router = express.Router();
// 注册 // 注册
router.post('/register', (req, res) => { router.post('/register', authLimiter, registerRules, validate, (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
try { try {
const hashedPassword = bcrypt.hashSync(password, 10); const hashedPassword = bcrypt.hashSync(password, 10);
const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)'); 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 user = { id: result.lastInsertRowid, username };
const token = generateToken(user); const token = generateToken(user);
logger.info(`用户注册成功: ${username}`);
res.json({ user, token }); res.json({ user, token });
} catch (err) { } catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(400).json({ error: '用户名已存在' }); return res.status(400).json({ error: '用户名已存在' });
} }
logger.error('注册失败', { error: err.message });
res.status(500).json({ error: '注册失败' }); res.status(500).json({ error: '注册失败' });
} }
}); });
// 登录 // 登录
router.post('/login', (req, res) => { router.post('/login', authLimiter, loginRules, validate, (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码不能为空' });
}
const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
const user = stmt.get(username); const user = stmt.get(username);
if (!user || !bcrypt.compareSync(password, user.password)) { if (!user || !bcrypt.compareSync(password, user.password)) {
logger.warn(`登录失败: ${username}`);
return res.status(401).json({ error: '用户名或密码错误' }); return res.status(401).json({ error: '用户名或密码错误' });
} }
const token = generateToken(user); const token = generateToken(user);
logger.info(`用户登录成功: ${username}`);
res.json({ user: { id: user.id, username: user.username }, token }); res.json({ user: { id: user.id, username: user.username }, token });
}); });
+14 -8
View File
@@ -2,25 +2,23 @@ const express = require('express');
const db = require('../db/init'); const db = require('../db/init');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
const { fetchLotteryResult, updateTicketsStatus, getEstimatedDrawTime } = require('../services/lottery'); 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(); const router = express.Router();
router.use(authMiddleware); router.use(authMiddleware);
// 查询开奖结果 // 查询开奖结果
router.get('/result', async (req, res) => { router.get('/result', lotteryQueryRules, validate, async (req, res) => {
const { game, issue } = req.query; const { game, issue } = req.query;
if (!game || !issue) {
return res.status(400).json({ error: '请提供彩票类型和期号' });
}
try { try {
const result = await fetchLotteryResult(game, issue); const result = await fetchLotteryResult(game, issue);
if (result) { if (result) {
res.json(result); res.json(result);
} else { } else {
// 未开奖时返回预计开奖时间
const estimatedTime = getEstimatedDrawTime(game, issue); const estimatedTime = getEstimatedDrawTime(game, issue);
res.json({ res.json({
message: '暂未开奖或未找到数据', message: '暂未开奖或未找到数据',
@@ -29,22 +27,25 @@ router.get('/result', async (req, res) => {
}); });
} }
} catch (err) { } catch (err) {
logger.error('查询开奖结果失败', { game, issue, error: err.message });
res.status(500).json({ error: '查询失败' }); res.status(500).json({ error: '查询失败' });
} }
}); });
// 刷新用户所有待开奖彩票状态 // 刷新用户所有待开奖彩票状态
router.post('/refresh', async (req, res) => { router.post('/refresh', refreshLimiter, async (req, res) => {
try { try {
await updateTicketsStatus(req.user.id); await updateTicketsStatus(req.user.id);
logger.info(`刷新开奖: userId=${req.user.id}`);
res.json({ message: '刷新成功' }); res.json({ message: '刷新成功' });
} catch (err) { } catch (err) {
logger.error('刷新开奖失败', { error: err.message });
res.status(500).json({ error: '刷新失败' }); res.status(500).json({ error: '刷新失败' });
} }
}); });
// 获取最近开奖记录 // 获取最近开奖记录
router.get('/recent', async (req, res) => { router.get('/recent', standardLimiter, (req, res) => {
const { game, limit = 10 } = req.query; const { game, limit = 10 } = req.query;
let query = 'SELECT * FROM lottery_results'; let query = 'SELECT * FROM lottery_results';
@@ -58,8 +59,13 @@ router.get('/recent', async (req, res) => {
query += ' ORDER BY draw_date DESC LIMIT ?'; query += ' ORDER BY draw_date DESC LIMIT ?';
params.push(Number(limit)); params.push(Number(limit));
try {
const results = db.prepare(query).all(...params); const results = db.prepare(query).all(...params);
res.json(results.map(r => ({ ...r, numbers: JSON.parse(r.numbers) }))); 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; module.exports = router;
+40 -8
View File
@@ -3,7 +3,9 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { authMiddleware } = require('../middleware/auth'); 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(); const router = express.Router();
@@ -25,7 +27,6 @@ const upload = multer({
storage, storage,
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// 放宽图片类型限制
if (file.mimetype.startsWith('image/')) { if (file.mimetype.startsWith('image/')) {
cb(null, true); cb(null, true);
} else { } else {
@@ -36,18 +37,49 @@ const upload = multer({
router.use(authMiddleware); router.use(authMiddleware);
// 上传并解析彩票图片 // 上传并解析彩票图片 (改为异步)
router.post('/parse', upload.single('image'), async (req, res) => { router.post('/parse', ocrLimiter, upload.single('image'), (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: '请上传图片' }); return res.status(400).json({ error: '请上传图片' });
} }
try { try {
const result = await parseTicketImage(req.file.path); const { taskId } = parseTicketImage(req.file.path);
result.image_path = `/uploads/${req.file.filename}`; logger.info(`OCR任务已提交: ${taskId}`);
res.json(result); res.json({ taskId, status: 'processing', message: '图片已提交处理,请稍后查询结果' });
} catch (err) { } 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: '查询失败' });
} }
}); });
+14 -20
View File
@@ -2,6 +2,8 @@ const express = require('express');
const db = require('../db/init'); const db = require('../db/init');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
const { getEstimatedDrawTime } = require('../services/lottery'); const { getEstimatedDrawTime } = require('../services/lottery');
const { validate, ticketCreateRules, ticketUpdateRules, idParamRule } = require('../middleware/validate');
const logger = require('../services/logger');
const router = express.Router(); 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 ? SELECT * FROM tickets WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset); `).all(req.user.id, limit, offset);
// 为未开奖彩票添加预计开奖时间
const ticketsWithDrawTime = tickets.map(ticket => { const ticketsWithDrawTime = tickets.map(ticket => {
if (ticket.status === 'pending' && ticket.game && ticket.issue) { if (ticket.status === 'pending' && ticket.game && ticket.issue) {
return { 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; 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(` const stmt = db.prepare(`
INSERT INTO tickets (user_id, type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path) INSERT INTO tickets (user_id, type, game, issue, numbers, bet_count, multiple, is_additional, cost, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -75,11 +72,12 @@ router.post('/', (req, res) => {
bet_count || 1, multiple || 1, is_additional ? 1 : 0, cost, image_path || null 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: '添加成功' }); res.json({ id: result.lastInsertRowid, message: '添加成功' });
}); });
// 更新彩票 // 更新彩票
router.put('/:id', (req, res) => { router.put('/:id', idParamRule, validate, (req, res) => {
const { id } = req.params; const { id } = req.params;
const { type, game, issue, numbers, bet_count, multiple, is_additional, cost, prize, status } = req.body; 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(` const stmt = db.prepare(`
UPDATE tickets SET type = ?, game = ?, issue = ?, numbers = ?, bet_count = ?, multiple = ?, is_additional = ?, cost = ?, prize = ?, status = ? UPDATE tickets SET type = ?, game = ?, issue = ?, numbers = ?, bet_count = ?, multiple = ?, is_additional = ?, cost = ?, prize = ?, status = ?
WHERE id = ? WHERE id = ? AND user_id = ?
`); `);
stmt.run( stmt.run(
type ?? ticket.type, type ?? ticket.type,
@@ -103,35 +101,31 @@ router.put('/:id', (req, res) => {
cost ?? ticket.cost, cost ?? ticket.cost,
prize ?? ticket.prize, prize ?? ticket.prize,
status ?? ticket.status, status ?? ticket.status,
id id,
req.user.id
); );
logger.info(`更新彩票: id=${id}`);
res.json({ message: '更新成功' }); res.json({ message: '更新成功' });
}); });
// 删除彩票 // 删除彩票
router.delete('/:id', (req, res) => { router.delete('/:id', idParamRule, validate, (req, res) => {
const { id } = req.params; const { id } = req.params;
const ticketId = Number(id); const ticketId = Number(id); // idParamRule 已验证 id 为正整数
console.log('删除请求:', { id, ticketId, userId: req.user.id });
if (isNaN(ticketId)) {
return res.status(400).json({ error: '无效的ID' });
}
try { try {
const result = db.prepare('DELETE FROM tickets WHERE id = ? AND user_id = ?').run(ticketId, req.user.id); const result = db.prepare('DELETE FROM tickets WHERE id = ? AND user_id = ?').run(ticketId, req.user.id);
console.log('删除结果:', result);
if (result.changes === 0) { if (result.changes === 0) {
return res.status(404).json({ error: '彩票不存在' }); return res.status(404).json({ error: '彩票不存在' });
} }
logger.info(`删除彩票: id=${ticketId}`);
res.json({ message: '删除成功' }); res.json({ message: '删除成功' });
} catch (err) { } catch (err) {
console.error('删除错误:', err); logger.error('删除彩票失败', { error: err.message });
res.status(500).json({ error: '删除失败: ' + err.message }); res.status(500).json({ error: '删除失败' });
} }
}); });
+27
View File
@@ -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;
+44 -9
View File
@@ -1,7 +1,12 @@
const axios = require('axios'); const axios = require('axios');
const cheerio = require('cheerio'); const cheerio = require('cheerio');
const NodeCache = require('node-cache');
const logger = require('./logger');
const db = require('../db/init'); const db = require('../db/init');
// 内存缓存 - 5分钟TTL, 最多1000个键防止内存溢出
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60, maxKeys: 1000 });
// 获取预计开奖时间 // 获取预计开奖时间
function getEstimatedDrawTime(game, issue) { function getEstimatedDrawTime(game, issue) {
const now = new Date(); const now = new Date();
@@ -51,10 +56,21 @@ function getEstimatedDrawTime(game, issue) {
// 获取开奖结果 // 获取开奖结果
async function fetchLotteryResult(game, issue) { async function fetchLotteryResult(game, issue) {
// 先查本地缓存 const cacheKey = `lottery:${game}:${issue}`;
const cached = db.prepare('SELECT * FROM lottery_results WHERE game = ? AND issue = ?').get(game, issue);
// 先查内存缓存
const cached = cache.get(cacheKey);
if (cached) { 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) INSERT OR REPLACE INTO lottery_results (game, issue, numbers, draw_date)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(game, issue, JSON.stringify(result.numbers), result.draw_date); `).run(game, issue, JSON.stringify(result.numbers), result.draw_date);
cache.set(cacheKey, result);
} }
return result; return result;
} catch (err) { } catch (err) {
console.error('获取开奖数据失败:', err.message); logger.error('获取开奖数据失败:', err.message);
return null; return null;
} }
} }
// 从500.com获取数据 // 从500.com获取数据
async function fetchFromWeb(game, issue) { async function fetchFromWeb(game, issue) {
// 检查是否在冷却期 (防止频繁请求)
const cooldownKey = `cooldown:${game}`;
if (cache.get(cooldownKey)) {
logger.warn(`请求冷却中: ${game}`);
return null;
}
let result = null;
try { try {
if (game === '双色球') { if (game === '双色球') {
return await fetchSSQ(issue); result = await fetchSSQ(issue);
} else if (game === '大乐透') { } else if (game === '大乐透') {
return await fetchDLT(issue); result = await fetchDLT(issue);
} }
} catch (err) { } 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,6 +255,7 @@ async function updateTicketsStatus(userId) {
`).all(userId); `).all(userId);
for (const ticket of pendingTickets) { for (const ticket of pendingTickets) {
try {
const result = await fetchLotteryResult(ticket.game, ticket.issue); const result = await fetchLotteryResult(ticket.game, ticket.issue);
if (result) { if (result) {
const ticketNumbers = ticket.numbers ? JSON.parse(ticket.numbers) : null; const ticketNumbers = ticket.numbers ? JSON.parse(ticket.numbers) : null;
@@ -233,7 +265,6 @@ async function updateTicketsStatus(userId) {
const winning = checkWinning(ticket.game, num, result.numbers); const winning = checkWinning(ticket.game, num, result.numbers);
if (winning.won) { if (winning.won) {
totalPrize += winning.prize * ticket.multiple; totalPrize += winning.prize * ticket.multiple;
// 大乐透只有1等奖和2等奖追加才有80%浮动奖金,其他等级没有
if (ticket.is_additional && winning.level >= 1 && winning.level <= 2) { if (ticket.is_additional && winning.level >= 1 && winning.level <= 2) {
totalPrize += Math.floor(winning.prize * 0.8) * ticket.multiple; totalPrize += Math.floor(winning.prize * 0.8) * ticket.multiple;
} }
@@ -244,6 +275,10 @@ async function updateTicketsStatus(userId) {
.run(totalPrize, totalPrize > 0 ? 'won' : 'lost', ticket.id); .run(totalPrize, totalPrize > 0 ? 'won' : 'lost', ticket.id);
} }
} }
} catch (err) {
logger.error(`更新彩票 ${ticket.id} 状态失败`, { error: err.message });
// 继续处理其他彩票,不因单个失败中止
}
} }
} }
+103 -48
View File
@@ -1,53 +1,119 @@
const Tesseract = require('tesseract.js'); const Tesseract = require('tesseract.js');
const path = require('path'); const path = require('path');
const logger = require('./logger');
// 彩票类型识别规则 // 彩票类型识别规则 (模块级别常量,避免每次调用时重建)
const LOTTERY_PATTERNS = { const LOTTERY_PATTERNS = {
// 福利彩票
ssq: { name: '双色球', type: 'welfare', pattern: /双\s*色\s*球|SSQ/i, price: 2 }, 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 }, qlc: { name: '七乐彩', type: 'welfare', pattern: /七\s*乐\s*彩/i, price: 2 },
// 体育彩票
dlt: { name: '大乐透', type: 'sports', pattern: /大\s*乐\s*透|超级\s*大\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 }, pl3: { name: '排列3', type: 'sports', pattern: /排列\s*3|排列\s*三/i, price: 2 },
pl5: { name: '排列5', type: 'sports', pattern: /排列\s*5|排列\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 }, qxc: { name: '七星彩', type: 'sports', pattern: /七\s*星\s*彩/i, price: 2 },
}; };
// 简单的内存任务队列 (有界队列防止内存溢出)
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 {
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) {
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();
}
}
// 处理下一个任务
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('任务队列已满,请稍后再试');
}
const taskId = `ocr_${Date.now()}_${Math.random().toString(36).slice(2)}`;
taskQueue.push({ taskId, imagePath, submittedAt: Date.now() });
logger.info(`OCR任务已提交: ${taskId}, 队列长度: ${taskQueue.length}`);
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识别 // 使用本地tesseract.js-core进行OCR识别
async function parseWithLocalTesseract(imagePath) { async function parseWithLocalTesseract(imagePath) {
try {
// 使用本地core文件
const corePath = path.join(__dirname, '../node_modules/tesseract.js-core'); const corePath = path.join(__dirname, '../node_modules/tesseract.js-core');
const { data } = await Tesseract.recognize(imagePath, 'chi_sim+eng', { const { data } = await Tesseract.recognize(imagePath, 'chi_sim+eng', {
corePath: corePath, corePath: corePath,
logger: m => console.log('OCR:', m.status, m.progress) logger: m => logger.debug(`OCR: ${m.status} ${m.progress || ''}`)
}); });
return data.text; return data.text;
} catch (err) {
console.error('tesseract.js识别失败:', err.message);
throw err;
}
} }
// 解析彩票图片 // 解析彩票图片 (改为异步提交)
async function parseTicketImage(imagePath) { function parseTicketImage(imagePath) {
let text = ''; const taskId = submitOCRTask(imagePath);
return { taskId, status: 'pending' };
try {
text = await parseWithLocalTesseract(imagePath);
console.log('OCR识别完成, 文本长度:', text.length);
} catch (err) {
console.error('OCR识别失败:', err);
throw new Error('图片识别失败');
} }
// 调试:打印识别的文本 // 查询OCR结果 (轮询)
console.log('识别的文本:', text.substring(0, 500)); function getOCRResult(taskId) {
return getOCRTaskStatus(taskId);
return parseTicketText(text);
} }
// 解析彩票文本 // 解析彩票文本
@@ -64,7 +130,7 @@ function parseTicketText(text) {
raw_text: text raw_text: text
}; };
// 识别彩票类型 // 识别彩票类型 (使用模块级别的 LOTTERY_PATTERNS)
for (const [key, lottery] of Object.entries(LOTTERY_PATTERNS)) { for (const [key, lottery] of Object.entries(LOTTERY_PATTERNS)) {
if (lottery.pattern.test(text)) { if (lottery.pattern.test(text)) {
result.type = lottery.type; result.type = lottery.type;
@@ -73,7 +139,7 @@ function parseTicketText(text) {
} }
} }
// 提取期号 - 优先匹配"期号:xxx"格式 // 提取期号
const issueMatch = text.match(/期\s*号\s*[:]\s*(\d{5,})/); const issueMatch = text.match(/期\s*号\s*[:]\s*(\d{5,})/);
if (issueMatch) { if (issueMatch) {
result.issue = issueMatch[1]; result.issue = issueMatch[1];
@@ -99,22 +165,20 @@ function parseTicketText(text) {
// 检测是否追加 // 检测是否追加
result.is_additional = /追加|追加投注/.test(text); result.is_additional = /追加|追加投注/.test(text);
// 提取金额 - 支持多种格式 // 提取金额
const costMatch = text.match(/金额\s*[:]\s*(\d+(?:[.,]\d+)?)\s*元|合计[:]\s*(\d+(?:\.\d+)?)\s*元|(\d+(?:\.\d+)?)\s*元/); const costMatch = text.match(/金额\s*[:]\s*(\d+(?:[.,]\d+)?)\s*元|合计[:]\s*(\d+(?:\.\d+)?)\s*元|(\d+(?:\.\d+)?)\s*元/);
if (costMatch) { if (costMatch) {
const costStr = (costMatch[1] || costMatch[2] || costMatch[3]).replace(',', '.'); const costStr = (costMatch[1] || costMatch[2] || costMatch[3]).replace(',', '.');
result.cost = parseFloat(costStr); result.cost = parseFloat(costStr);
} }
// 提取号码 - 根据不同彩票类型 // 提取号码
result.numbers = extractNumbers(text, result.game); result.numbers = extractNumbers(text, result.game);
// 根据识别到的号码数量更新注数
if (result.numbers.length > 0) { if (result.numbers.length > 0) {
result.bet_count = result.numbers.length; result.bet_count = result.numbers.length;
} }
// 如果没有识别到金额,根据注数和倍数计算
if (!result.cost && result.game && result.numbers.length > 0) { if (!result.cost && result.game && result.numbers.length > 0) {
const lottery = Object.values(LOTTERY_PATTERNS).find(l => l.name === result.game); const lottery = Object.values(LOTTERY_PATTERNS).find(l => l.name === result.game);
if (lottery) { if (lottery) {
@@ -131,32 +195,27 @@ function parseTicketText(text) {
function extractNumbers(text, game) { function extractNumbers(text, game) {
const numbers = []; const numbers = [];
// 预处理文本:修复常见OCR错误
let cleanText = text let cleanText = text
.replace(/[oO]/g, '0') // o和O经常被误识别为0 .replace(/[oO]/g, '0')
.replace(/[D]/g, '0') // D经常被误识别 .replace(/[D]/g, '0')
.replace(/[lI|]/g, '1') // l和I和|经常被误识别为1 .replace(/[lI|]/g, '1')
.replace(/[S\$§]/g, '5') // S和$和§经常被误识别为5 .replace(/[S$§]/g, '5')
.replace(/[@®»«]/g, '') // 去掉这些特殊字符 .replace(/[@®»«]/g, '')
.replace(/[:]/g, ' ') // 冒号替换为空格 .replace(/[:]/g, ' ')
.replace(/[g]/g, '9'); // g经常被误识别为9 .replace(/[g]/g, '9');
// 处理连在一起的数字:将6位以上连续数字按每2位分割
cleanText = cleanText.replace(/\b(\d{6,})\b/g, (match) => { cleanText = cleanText.replace(/\b(\d{6,})\b/g, (match) => {
return match.match(/.{1,2}/g).join(' '); return match.match(/.{1,2}/g).join(' ');
}); });
// 处理4位连续数字
cleanText = cleanText.replace(/\b(\d{4})\b/g, (match) => { cleanText = cleanText.replace(/\b(\d{4})\b/g, (match) => {
return match.slice(0, 2) + ' ' + match.slice(2); return match.slice(0, 2) + ' ' + match.slice(2);
}); });
// 移除开奖号码行(包含"开奖号码"的行)
cleanText = cleanText.split('\n') cleanText = cleanText.split('\n')
.filter(line => !line.includes('开奖') && !line.includes('号码')) .filter(line => !line.includes('开奖') && !line.includes('号码'))
.join('\n'); .join('\n');
if (game === '双色球') { if (game === '双色球') {
// 双色球: 6红+1蓝,支持多种格式
const patterns = [ 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})[-+](\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, /(\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; if (numbers.length > 0) break;
} }
} else if (game === '大乐透') { } else if (game === '大乐透') {
// 大乐透: 5前区+2后区
const patterns = [ 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})[-+](\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})\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,
/(\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; if (numbers.length > 0) break;
} }
} else if (game === '福彩3D' || game === '排列3') { } else if (game === '福彩3D' || game === '排列3') {
// 3位数
const matches = cleanText.matchAll(/[^\d](\d)\s*(\d)\s*(\d)[^\d]/g); const matches = cleanText.matchAll(/[^\d](\d)\s*(\d)\s*(\d)[^\d]/g);
for (const match of matches) { for (const match of matches) {
numbers.push([match[1], match[2], match[3]]); numbers.push([match[1], match[2], match[3]]);
@@ -204,4 +259,4 @@ function extractNumbers(text, game) {
return numbers; return numbers;
} }
module.exports = { parseTicketImage, parseTicketText }; module.exports = { parseTicketImage, parseTicketText, getOCRResult };