refactor(ocr): remove subprocess mode — HTTP only

- ocr.js: Removed all subprocess/spawn logic (spawn, fs, path, process
  imports, resolveWorkerCommand, buildPythonPath, buildWorkerError,
  callOcrViaSubprocess, resolveOcrMode, runOcrCommand, etc.)
  HTTP is now the only mode. Added validation when OCR_BASE_URL is not
  configured. ~281 lines → ~80 lines.
- default.cjs: Removed projectRoot from ocr config (kept path import
  for data.root)
- runtime.js: Removed OCR_PROJECT_ROOT env var parsing
- runtime-config.js: Removed projectRoot from ocr type definition

All 5 exports preserved: recognizeTencentCaptcha,
recognizeImageCaptcha, batchRecognizeTencentCaptcha,
warmupLocalOcrWorker, closeLocalOcrWorker
This commit is contained in:
yml
2026-05-19 23:29:42 +08:00
parent 2ecf4691d0
commit e812ea2cc4
4 changed files with 17 additions and 215 deletions
-1
View File
@@ -19,7 +19,6 @@ module.exports = {
},
ocr: {
projectRoot: path.resolve(__dirname, '../subservices/ocr-worker'),
baseUrl: '',
},
-5
View File
@@ -128,11 +128,6 @@ function applyEnvOverrides(baseConfig) {
if (sessionDebug !== null) {
nextConfig.session.debug = sessionDebug;
}
const ocrProjectRoot = String(process.env.OCR_PROJECT_ROOT || "").trim();
if (ocrProjectRoot) {
nextConfig.ocr.projectRoot = ocrProjectRoot;
}
const ocrBaseUrl = String(process.env.OCR_BASE_URL || "").trim();
if (ocrBaseUrl) {
nextConfig.ocr.baseUrl = ocrBaseUrl;
+17 -208
View File
@@ -1,52 +1,38 @@
// @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)
return callOcrViaHttp('recognize', payload)
}
export async function recognizeImageCaptcha(payload) {
return callOcrRequest('recognize', payload)
return callOcrViaHttp('recognize', payload)
}
export async function batchRecognizeTencentCaptcha(payload) {
return callOcrRequest('batch', payload, { timeoutMs: 180_000 })
return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 })
}
export async function warmupLocalOcrWorker() {
const mode = resolveOcrMode()
if (mode === 'http') {
await warmupOcrViaHttp()
return
}
await runOcrCommand('healthcheck', { timeoutMs: 30_000 })
await warmupOcrViaHttp()
}
export async function closeLocalOcrWorker() {
// OCR 改为按次调用 Python 子进程或 HTTP 服务,这里不再维护常驻 worker
// HTTP 模式无持久连接,无需关闭
}
// ── Mode Resolution ──────────────────────────────────────────────────────────
// ── HTTP Call ───────────────────────────────────────────────────────────────
function resolveOcrMode() {
if (String(runtimeConfig.ocr.baseUrl || '').trim()) {
return 'http'
}
return 'subprocess'
}
// ── HTTP Mode ─────────────────────────────────────────────────────────────────
async function callOcrViaHttp(action, payload, { timeoutMs }) {
async function callOcrViaHttp(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
if (!baseUrl) {
throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务')
}
const url = `${baseUrl}/ocr/${action}`
let response
@@ -81,6 +67,11 @@ async function callOcrViaHttp(action, payload, { timeoutMs }) {
async function warmupOcrViaHttp() {
const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '')
if (!baseUrl) {
throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查')
}
let response
try {
response = await fetch(`${baseUrl}/health`, {
@@ -96,185 +87,3 @@ async function warmupOcrViaHttp() {
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 syncDocker 请重建 backend 镜像`
const stderrHint = stderrText ? `\n${stderrText}` : ''
return `本地 OCR 已退出(${reason})。${installHint}${stderrHint}`
}
function tryParseJson(text) {
try {
return JSON.parse(text)
} catch {
return null
}
}
-1
View File
@@ -45,7 +45,6 @@ export {};
* debug: boolean
* }
* ocr: {
* projectRoot: string
* baseUrl: string
* }
* data: {