Files
order_site/apps/backend/src/services/ocr.js
T
2026-04-08 16:30:42 +08:00

214 lines
5.1 KiB
JavaScript

import { spawn } from 'node:child_process'
import readline from 'node:readline'
import process from 'node:process'
import { runtimeConfig } from '../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
let workerPromise = null
let requestSequence = 0
const pendingRequests = new Map()
let stderrBuffer = []
export async function recognizeTencentCaptcha(payload) {
return callOcrWorker('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload) {
return callOcrWorker('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupLocalOcrWorker() {
await ensureOcrWorker()
}
export async function closeLocalOcrWorker() {
const worker = await workerPromise?.catch(() => null)
if (!worker) {
workerPromise = null
return
}
worker.child.kill()
workerPromise = null
}
async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const worker = await ensureOcrWorker()
const requestId = `ocr-${Date.now()}-${++requestSequence}`
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingRequests.delete(requestId)
worker.child.kill()
reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常'))
}, timeoutMs)
pendingRequests.set(requestId, {
resolve: (value) => {
clearTimeout(timer)
resolve(value)
},
reject: (error) => {
clearTimeout(timer)
reject(error)
},
})
worker.child.stdin.write(
`${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`,
'utf8',
)
})
}
async function ensureOcrWorker() {
if (!workerPromise) {
workerPromise = createOcrWorker().catch((error) => {
workerPromise = null
throw error
})
}
return workerPromise
}
function createOcrWorker() {
return new Promise((resolve, reject) => {
const projectRoot = resolveOcrProjectRoot()
stderrBuffer = []
const child = spawn('uv', ['run', 'ocr-worker', 'worker'], {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
})
child.stdin.setDefaultEncoding('utf8')
const stdoutReader = readline.createInterface({ input: child.stdout })
const worker = { child, stdoutReader }
const readyTimer = setTimeout(() => {
reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`))
child.kill()
}, 15_000)
let ready = false
stdoutReader.on('line', (line) => {
const message = tryParseJson(line)
if (!message || typeof message !== 'object') {
return
}
if (message.type === 'ready') {
if (!ready) {
ready = true
clearTimeout(readyTimer)
resolve(worker)
}
return
}
if (message.type !== 'response') {
return
}
const requestId = String(message.id || '')
const pending = pendingRequests.get(requestId)
if (!pending) {
return
}
pendingRequests.delete(requestId)
pending.resolve(message.payload)
})
child.stderr.on('data', (chunk) => {
const text = String(chunk || '').trim()
if (!text) {
return
}
stderrBuffer.push(text)
if (stderrBuffer.length > 20) {
stderrBuffer = stderrBuffer.slice(-20)
}
})
child.on('error', (error) => {
clearTimeout(readyTimer)
const workerError = buildWorkerError(error)
failAllPendingRequests(workerError)
if (!ready) {
reject(workerError)
}
workerPromise = null
})
child.on('exit', (code, signal) => {
clearTimeout(readyTimer)
const workerError = new Error(
buildWorkerExitMessage({
code,
signal,
projectRoot,
stderrText: stderrBuffer.join('\n'),
}),
)
failAllPendingRequests(workerError)
if (!ready) {
reject(workerError)
}
workerPromise = null
})
})
}
function failAllPendingRequests(error) {
for (const [requestId, pending] of pendingRequests.entries()) {
pendingRequests.delete(requestId)
pending.reject(error)
}
}
function resolveOcrProjectRoot() {
return String(runtimeConfig.ocr.projectRoot || '').trim()
}
function buildWorkerError(error) {
if (error instanceof Error && error.message.includes('spawn uv ENOENT')) {
return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里')
}
return new Error(
`本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`,
)
}
function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) {
const reason = signal
? `signal ${signal}`
: typeof code === 'number'
? `exit code ${code}`
: 'unknown reason'
const installHint = `请先执行: cd ${projectRoot} && uv sync`
const stderrHint = stderrText ? `\n${stderrText}` : ''
return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}`
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}