Files
order_site/apps/backend/src/services/session/ocr.js
T
yml bc5fd4212d feat(backend): refactor OCR client to support HTTP + subprocess dual mode
- runtime-config.js: add baseUrl field to ocr type definition
- default.cjs: add ocr.baseUrl default (empty string = subprocess mode)
- runtime.js: add OCR_BASE_URL env var parsing
- ocr.js: add HTTP mode (when OCR_BASE_URL set) alongside existing subprocess mode
  - resolveOcrMode() detects mode from runtimeConfig.ocr.baseUrl
  - callOcrViaHttp() calls FastAPI endpoints directly with payload
  - warmupOcrViaHttp() calls /health endpoint for HTTP mode warmup
  - callOcrRequest() routes between HTTP and subprocess based on mode
  - All 5 original exports preserved (recognizeTencentCaptcha, etc.)
  - Subprocess mode remains backward compatible (default when no OCR_BASE_URL)
2026-05-19 23:09:30 +08:00

281 lines
7.9 KiB
JavaScript
Raw 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.
// @ts-nocheck
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { runtimeConfig } from '../../config/runtime.js'
const DEFAULT_TIMEOUT_MS = 60_000
export async function recognizeTencentCaptcha(payload) {
return callOcrRequest('recognize', payload)
}
export async function recognizeImageCaptcha(payload) {
return callOcrRequest('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload) {
return callOcrRequest('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupLocalOcrWorker() {
const mode = resolveOcrMode()
if (mode === 'http') {
await warmupOcrViaHttp()
return
}
await runOcrCommand('healthcheck', { timeoutMs: 30_000 })
}
export async function closeLocalOcrWorker() {
// OCR 改为按次调用 Python 子进程或 HTTP 服务,这里不再维护常驻 worker。
}
// ── Mode Resolution ──────────────────────────────────────────────────────────
function resolveOcrMode() {
if (String(runtimeConfig.ocr.baseUrl || '').trim()) {
return 'http'
}
return 'subprocess'
}
// ── HTTP Mode ─────────────────────────────────────────────────────────────────
async function callOcrViaHttp(action, payload, { timeoutMs }) {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
const url = `${baseUrl}/ocr/${action}`
let response
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
})
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`)
}
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 ocr-worker 容器是否正常运行`)
}
if (!response.ok) {
throw new Error(`OCR 服务返回错误:HTTP ${response.status}`)
}
const result = await response.json()
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new Error('OCR 服务返回了无效响应')
}
if (result.code !== 0) {
throw new Error(`OCR 服务返回错误:${result.msg || '未知错误'}`)
}
return result
}
async function warmupOcrViaHttp() {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
let response
try {
response = await fetch(`${baseUrl}/health`, {
signal: AbortSignal.timeout(30_000),
})
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`)
}
throw new Error(`OCR 服务不可用(${baseUrl}),请检查 ocr-worker 容器是否正常运行`)
}
if (!response.ok) {
throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`)
}
}
// ── Request Router ────────────────────────────────────────────────────────────
async function callOcrRequest(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const mode = resolveOcrMode()
if (mode === 'http') {
return await callOcrViaHttp(action, payload, { timeoutMs })
}
// subprocess mode
const response = await runOcrCommand('request', {
timeoutMs,
input: JSON.stringify({
action,
payload: payload || {},
}),
})
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('本地 OCR 返回了无效响应')
}
return response
}
// ── Subprocess Mode ───────────────────────────────────────────────────────────
async function runOcrCommand(command, { timeoutMs = DEFAULT_TIMEOUT_MS, input = '' } = {}) {
const projectRoot = resolveOcrProjectRoot()
const workerCommand = resolveWorkerCommand(projectRoot)
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
PYTHONPATH: buildPythonPath(path.join(projectRoot, 'src')),
}
return await new Promise((resolve, reject) => {
const child = spawn(workerCommand.command, [...workerCommand.args, command], {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
env,
})
let stdout = ''
let stderr = ''
let settled = false
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill()
}, timeoutMs)
child.stdout.on('data', (chunk) => {
stdout += String(chunk || '')
})
child.stderr.on('data', (chunk) => {
stderr += String(chunk || '')
})
child.on('error', (error) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
reject(buildWorkerError(error))
})
child.on('close', (code, signal) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
if (timedOut) {
reject(new Error(`本地 OCR 执行超时,请检查 ${projectRoot} 的 Python 依赖是否已经准备完成`))
return
}
if (signal || code !== 0) {
reject(
new Error(
buildWorkerExitMessage({
code,
signal,
projectRoot,
stderrText: stderr.trim(),
}),
),
)
return
}
if (command === 'healthcheck') {
resolve(null)
return
}
const parsed = tryParseJson(stdout.trim())
if (!parsed || typeof parsed !== 'object') {
reject(new Error(`本地 OCR 返回无法解析的结果${stderr.trim() ? `\n${stderr.trim()}` : ''}`))
return
}
resolve(parsed)
})
if (input) {
child.stdin.end(`${input}\n`, 'utf8')
return
}
child.stdin.end()
})
}
function resolveOcrProjectRoot() {
return String(runtimeConfig.ocr.projectRoot || '').trim()
}
function resolveWorkerCommand(projectRoot) {
const venvPython = path.join(projectRoot, '.venv', 'bin', 'python')
if (fs.existsSync(venvPython)) {
return {
command: venvPython,
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
return {
command: 'python3',
args: ['-u', '-m', 'ocr_worker.cli'],
}
}
function buildPythonPath(srcPath) {
const current = String(process.env.PYTHONPATH || '').trim()
return current ? `${srcPath}${path.delimiter}${current}` : srcPath
}
function buildWorkerError(error) {
if (
error instanceof Error &&
(error.message.includes('spawn python3 ENOENT') ||
error.message.includes('spawn ') && error.message.includes('python'))
) {
const projectRoot = resolveOcrProjectRoot()
return new Error(
`无法启动本地 OCR。请检查 Python 是否可用,并确认 OCR_PROJECT_ROOT 指向的目录存在:${projectRoot}`,
)
}
return new Error(
`本地 OCR 启动失败: ${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 = `请检查 ${projectRoot} 的 Python 依赖是否已安装;本地源码运行请先执行 uv sync,Docker 请重建 backend 镜像`
const stderrHint = stderrText ? `\n${stderrText}` : ''
return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}`
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}