Files
DeveloperandClaude Opus 4.6 335f0fb8dc 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>
2026-03-19 11:35:12 +08:00

263 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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*彩\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 },
};
// 简单的内存任务队列 (有界队列防止内存溢出)
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识别
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);
}
// 解析彩票文本
function parseTicketText(text) {
const result = {
type: null,
game: null,
issue: null,
numbers: [],
bet_count: 1,
multiple: 1,
is_additional: false,
cost: 0,
raw_text: text
};
// 识别彩票类型 (使用模块级别的 LOTTERY_PATTERNS)
for (const [key, lottery] of Object.entries(LOTTERY_PATTERNS)) {
if (lottery.pattern.test(text)) {
result.type = lottery.type;
result.game = lottery.name;
break;
}
}
// 提取期号
const issueMatch = text.match(/期\s*号\s*[:]\s*(\d{5,})/);
if (issueMatch) {
result.issue = issueMatch[1];
} else {
const issueMatch2 = text.match(/第?\s*(\d{5,})\s*期/);
if (issueMatch2) {
result.issue = issueMatch2[1];
}
}
// 提取注数
const betMatch = text.match(/(\d+)\s*注/);
if (betMatch) {
result.bet_count = parseInt(betMatch[1]);
}
// 提取倍数
const multipleMatch = text.match(/(\d+)\s*倍/);
if (multipleMatch) {
result.multiple = parseInt(multipleMatch[1]);
}
// 检测是否追加
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) {
let basePrice = lottery.price * result.bet_count * result.multiple;
if (result.is_additional) basePrice += result.bet_count * result.multiple;
result.cost = basePrice;
}
}
return result;
}
// 提取号码
function extractNumbers(text, game) {
const numbers = [];
let cleanText = text
.replace(/[oO]/g, '0')
.replace(/[D]/g, '0')
.replace(/[lI|]/g, '1')
.replace(/[S$§]/g, '5')
.replace(/[@®»«]/g, '')
.replace(/[:]/g, ' ')
.replace(/[g]/g, '9');
cleanText = cleanText.replace(/\b(\d{6,})\b/g, (match) => {
return match.match(/.{1,2}/g).join(' ');
});
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 === '双色球') {
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,
];
for (const pattern of patterns) {
const matches = cleanText.matchAll(pattern);
for (const match of matches) {
numbers.push({
red: [match[1], match[2], match[3], match[4], match[5], match[6]],
blue: match[7]
});
}
if (numbers.length > 0) break;
}
} else if (game === '大乐透') {
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,
];
for (const pattern of patterns) {
const matches = cleanText.matchAll(pattern);
for (const match of matches) {
numbers.push({
front: [match[1], match[2], match[3], match[4], match[5]],
back: [match[6], match[7]]
});
}
if (numbers.length > 0) break;
}
} else if (game === '福彩3D' || game === '排列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]]);
}
}
return numbers;
}
module.exports = { parseTicketImage, parseTicketText, getOCRResult };