From 9d01e625581cf68c26135dd50c40775227c5b70c Mon Sep 17 00:00:00 2001 From: pionxe Date: Thu, 13 Aug 2026 17:05:24 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ocr):=E5=B8=B8=E9=A9=BBPaddleOCR?= =?UTF-8?q?=E5=B9=B6=E5=BC=80=E6=94=BEWeb=E7=AB=AFJD=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将PaddleOCR改为后端启动时同步加载的常驻Worker,通过JSON Lines协议复用已加载模型,避免每次请求重复启动Python进程。\n\n同步更新Web端OCR可用性探测、Docker与本地启动配置、接口文档及OCR测试,支持本地实际上传JD截图进行识别。 --- README.md | 29 ++ .../infrastructure/ocr/PaddleOcrProvider.java | 253 ++++++++++++++---- .../main/resources/ocr/paddle_ocr_runner.py | 30 +++ .../ocr/PaddleOcrProviderTest.java | 38 +-- deploy/docker-compose.yml | 1 + deploy/env/.env.example | 1 + deploy/env/.env.prod.example | 1 + ...45\345\217\243\346\226\207\346\241\243.md" | 2 +- frontend/web/Dockerfile | 2 + .../component/interview/InterviewModule.jsx | 34 ++- .../web/src/infrastructure/http/apiClient.js | 6 + scripts/check-local-ocr.sh | 31 +++ scripts/prepare-local-ocr.sh | 66 +++++ 13 files changed, 420 insertions(+), 74 deletions(-) create mode 100755 scripts/check-local-ocr.sh create mode 100755 scripts/prepare-local-ocr.sh diff --git a/README.md b/README.md index 89476182..1368742a 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,35 @@ openssl rand -base64 32 使用场景生成、Realtime、TTS、ASR 或评分能力时,还需在 `.env` 中配置对应厂商凭证。 完整变量及安全默认值见 [`deploy/env/.env.example`](deploy/env/.env.example)。 +### 2.1 本地启用 JD 图片 OCR + +Web 的“上传图片”入口以服务端探测结果为准,不需要手动设置浏览器端的开关。后端本地运行时,先准备 Python 3.11、PaddleOCR 依赖和模型: + +```bash +./scripts/prepare-local-ocr.sh +``` + +然后从 `backend/unispeaking-server` 启动后端,并把 OCR 路径指向仓库内的本地目录: + +```bash +cd backend/unispeaking-server +OCR_ENABLED=true \ +OCR_PYTHON_EXECUTABLE="$PWD/../../.local/ocr/venv/bin/python" \ +OCR_RUNNER_PATH="$PWD/src/main/resources/ocr/paddle_ocr_runner.py" \ +OCR_MODEL_DIRECTORY="$PWD/../../.local/ocr/models" \ +MAVEN_REPO_URL=https://maven.aliyun.com/repository/public \ +./mvnw --settings docker/maven/settings.xml spring-boot:run +``` + +启动后用浏览器访问 Web,在模拟面试页面选择“上传图片”。也可以用登录后的 JWT 进行接口实测: + +```bash +OCR_ACCESS_TOKEN='登录后 localStorage 中的 unispeaking.accessToken' \ +./scripts/check-local-ocr.sh /absolute/path/to/jd.png +``` + +脚本先验证 `/api/interview-scenes/ocr/availability`,再提交图片到 `/prepare-materials`;这样可以区分“服务端未装好 OCR”和“图片上传/材料整理链路失败”。 + 注意: - 不要提交真实 `.env`。 diff --git a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ocr/PaddleOcrProvider.java b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ocr/PaddleOcrProvider.java index 4104e551..160005c1 100644 --- a/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ocr/PaddleOcrProvider.java +++ b/backend/unispeaking-server/src/main/java/com/unispeaking/infrastructure/ocr/PaddleOcrProvider.java @@ -7,8 +7,12 @@ import com.unispeaking.provider.OcrProvider; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -17,6 +21,7 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -25,10 +30,16 @@ import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import tools.jackson.databind.ObjectMapper; public final class PaddleOcrProvider implements OcrProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(PaddleOcrProvider.class); + static final int MAX_IMAGE_COUNT = 5; static final int MAX_TOTAL_BYTES = 10 * 1024 * 1024; static final long MAX_PIXELS = 25_000_000L; @@ -39,22 +50,52 @@ public final class PaddleOcrProvider implements OcrProvider { private final OcrProperties properties; private final ObjectMapper objectMapper; + private final Object workerLock = new Object(); + private volatile Process workerProcess; + private volatile BufferedWriter workerWriter; + private volatile BufferedReader workerReader; + private volatile ExecutorService workerStderrExecutor; + private volatile boolean workerReady; public PaddleOcrProvider(OcrProperties properties, ObjectMapper objectMapper) { this.properties = Objects.requireNonNull(properties, "OCR properties are required"); this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper is required"); } + /** 启动后端时预加载 PaddleOCR,并让 Python Worker 常驻内存。 */ + @PostConstruct + void startWorkerOnApplicationStartup() { + if (!baseAvailable()) { + LOGGER.info("paddle OCR worker not started because OCR is not configured"); + return; + } + try { + ensureWorkerStarted(); + } + catch (OcrException exception) { + // OCR availability remains observable through the existing endpoint. The + // first OCR request will retry the worker start, so one transient startup + // failure does not prevent the web application from booting. + LOGGER.warn("paddle OCR worker failed to start during application startup"); + } + } + + @PreDestroy + void stopWorkerOnApplicationShutdown() { + synchronized (workerLock) { + stopWorker(); + } + } + @Override public String recognizeText(List images) { - ensureAvailable(); List validatedImages = validateImages(images); + ensureAvailable(); Path tempDirectory = null; try { tempDirectory = createTempDirectory(); List imagePaths = writeImages(tempDirectory, validatedImages); - ProcessResult result = runOcrProcess(imagePaths); - return parseRecognizedText(result.stdout(), validatedImages.size()); + return recognizeWithWorker(imagePaths, validatedImages.size()); } catch (OcrException exception) { throw exception; @@ -69,6 +110,11 @@ public String recognizeText(List images) { @Override public boolean available() { + return baseAvailable() && workerReady && workerProcess != null + && workerProcess.isAlive(); + } + + private boolean baseAvailable() { return properties.configured() && Files.isRegularFile(properties.runnerPath()) && modelDirectoriesAvailable(properties.modelDirectory()); @@ -81,9 +127,15 @@ private static boolean modelDirectoriesAvailable(Path modelDirectory) { } private void ensureAvailable() { - if (!available()) { + if (!baseAvailable()) { throw new OcrException(OcrErrorCode.UNAVAILABLE); } + try { + ensureWorkerStarted(); + } + catch (OcrException exception) { + throw exception; + } } private static List validateImages(List images) { @@ -197,7 +249,78 @@ private static List writeImages( return imagePaths; } - private ProcessResult runOcrProcess(List imagePaths) throws IOException { + private String recognizeWithWorker(List imagePaths, int expectedCount) { + synchronized (workerLock) { + ensureWorkerStarted(); + String requestId = UUID.randomUUID().toString(); + try { + WorkerRequest request = new WorkerRequest(requestId, + imagePaths.stream().map(Path::toString).toList()); + workerWriter.write(objectMapper.writeValueAsString(request)); + workerWriter.newLine(); + workerWriter.flush(); + String responseLine = readWorkerLine(timeoutMillis(properties.getTimeout())); + if (responseLine == null || responseLine.length() > MAX_STDOUT_BYTES) { + throw new OcrException(OcrErrorCode.RESPONSE_INVALID); + } + WorkerResponse response = objectMapper.readValue(responseLine, WorkerResponse.class); + if (response == null || !requestId.equals(response.id())) { + throw new OcrException(OcrErrorCode.RESPONSE_INVALID); + } + if (response.error() != null && !response.error().isBlank()) { + throw new OcrException(OcrErrorCode.PROCESS_FAILED); + } + return parseRecognizedText(response.results(), expectedCount); + } + catch (OcrException exception) { + if (exception.errorCode() == OcrErrorCode.TIMEOUT + || exception.errorCode() == OcrErrorCode.PROCESS_FAILED + || exception.errorCode() == OcrErrorCode.RESPONSE_INVALID) { + stopWorker(); + } + throw exception; + } + catch (Exception exception) { + stopWorker(); + throw new OcrException(OcrErrorCode.RESPONSE_INVALID); + } + } + } + + private void ensureWorkerStarted() { + synchronized (workerLock) { + if (workerReady && workerProcess != null && workerProcess.isAlive()) { + return; + } + stopWorker(); + if (!baseAvailable()) { + throw new OcrException(OcrErrorCode.UNAVAILABLE); + } + try { + startWorker(); + String readyLine = readWorkerLine(timeoutMillis(properties.getTimeout())); + if (readyLine == null || readyLine.length() > MAX_STDOUT_BYTES) { + throw new OcrException(OcrErrorCode.PROCESS_FAILED); + } + WorkerReady ready = objectMapper.readValue(readyLine, WorkerReady.class); + if (ready == null || !ready.ready()) { + throw new OcrException(OcrErrorCode.PROCESS_FAILED); + } + workerReady = true; + LOGGER.info("paddle OCR worker started and model loaded"); + } + catch (OcrException exception) { + stopWorker(); + throw exception; + } + catch (Exception exception) { + stopWorker(); + throw new OcrException(OcrErrorCode.PROCESS_FAILED); + } + } + } + + private void startWorker() throws IOException { List command = new ArrayList<>(); command.add(properties.getPythonExecutable()); command.add(properties.getRunnerPath()); @@ -210,46 +333,76 @@ private ProcessResult runOcrProcess(List imagePaths) throws IOException { command.add("--disable-doc-orientation"); command.add("--disable-doc-unwarping"); command.add("--disable-textline-orientation"); - command.add("--images"); - imagePaths.stream().map(Path::toString).forEach(command::add); + command.add("--worker"); ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.environment().put("PADDLE_PDX_CACHE_HOME", properties.getModelDirectory()); - Process process = processBuilder.start(); - ExecutorService executor = Executors.newFixedThreadPool(2); + workerProcess = processBuilder.start(); + workerWriter = new BufferedWriter(new OutputStreamWriter( + workerProcess.getOutputStream(), StandardCharsets.UTF_8)); + workerReader = new BufferedReader(new InputStreamReader( + workerProcess.getInputStream(), StandardCharsets.UTF_8)); + workerStderrExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "paddle-ocr-worker-stderr"); + thread.setDaemon(true); + return thread; + }); + workerStderrExecutor.submit(() -> drainWorkerStderr(workerProcess.getErrorStream())); + } + + private String readWorkerLine(long timeoutMillis) throws Exception { + ExecutorService readerExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "paddle-ocr-worker-reader"); + thread.setDaemon(true); + return thread; + }); try { - Future stdout = - executor.submit(() -> readLimited(process.getInputStream(), MAX_STDOUT_BYTES)); - Future stderr = - executor.submit(() -> readLimited(process.getErrorStream(), MAX_STDERR_BYTES)); - boolean finished = process.waitFor( - timeoutMillis(properties.getTimeout()), - TimeUnit.MILLISECONDS); - if (!finished) { - terminateProcess(process); + Future line = readerExecutor.submit(workerReader::readLine); + try { + return line.get(timeoutMillis, TimeUnit.MILLISECONDS); + } + catch (java.util.concurrent.TimeoutException exception) { + line.cancel(true); throw new OcrException(OcrErrorCode.TIMEOUT); } - LimitedOutput stdoutOutput = getOutput(stdout); - LimitedOutput stderrOutput = getOutput(stderr); - if (stdoutOutput.truncated()) { - throw new OcrException(OcrErrorCode.RESPONSE_INVALID); + } + finally { + readerExecutor.shutdownNow(); + } + } + + private static void drainWorkerStderr(InputStream input) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + LOGGER.debug("paddle OCR worker: {}", line); } - if (stderrOutput.truncated()) { - throw new OcrException(OcrErrorCode.PROCESS_FAILED); + } + catch (IOException exception) { + // The worker lifecycle owns this stream; shutdown is expected to close it. + } + } + + private void stopWorker() { + workerReady = false; + if (workerWriter != null) { + try { + workerWriter.close(); } - if (process.exitValue() != 0) { - throw new OcrException(OcrErrorCode.PROCESS_FAILED); + catch (IOException ignored) { } - return new ProcessResult(stdoutOutput.text()); } - catch (InterruptedException exception) { - terminateProcess(process); - Thread.currentThread().interrupt(); - throw new OcrException(OcrErrorCode.PROCESS_FAILED, exception); + if (workerProcess != null) { + terminateProcess(workerProcess); } - finally { - executor.shutdownNow(); + if (workerStderrExecutor != null) { + workerStderrExecutor.shutdownNow(); } + workerWriter = null; + workerReader = null; + workerProcess = null; + workerStderrExecutor = null; } private static void terminateProcess(Process process) { @@ -302,21 +455,14 @@ private static LimitedOutput readLimited(InputStream input, int limit) throws IO return new LimitedOutput(output.toString(StandardCharsets.UTF_8), truncated); } - private String parseRecognizedText(String stdout, int expectedCount) { - RunnerResponse response; - try { - response = objectMapper.readValue(stdout, RunnerResponse.class); - } - catch (Exception exception) { + private String parseRecognizedText( + List results, + int expectedCount) { + if (results == null || results.size() != expectedCount) { throw new OcrException(OcrErrorCode.RESPONSE_INVALID); } - if (response == null - || response.results() == null - || response.results().size() != expectedCount) { - throw new OcrException(OcrErrorCode.RESPONSE_INVALID); - } - List texts = new ArrayList<>(response.results().size()); - for (RunnerImageResult result : response.results()) { + List texts = new ArrayList<>(results.size()); + for (RunnerImageResult result : results) { if (result == null || result.text() == null) { throw new OcrException(OcrErrorCode.RESPONSE_INVALID); } @@ -374,12 +520,21 @@ private record ValidatedImage(byte[] content, ImageType type) { private record LimitedOutput(String text, boolean truncated) { } - private record ProcessResult(String stdout) { - } - private record RunnerResponse(List results) { } private record RunnerImageResult(String text) { } + + private record WorkerRequest(String id, List images) { + } + + private record WorkerResponse( + String id, + List results, + String error) { + } + + private record WorkerReady(boolean ready) { + } } diff --git a/backend/unispeaking-server/src/main/resources/ocr/paddle_ocr_runner.py b/backend/unispeaking-server/src/main/resources/ocr/paddle_ocr_runner.py index 136de5d2..06cbf795 100644 --- a/backend/unispeaking-server/src/main/resources/ocr/paddle_ocr_runner.py +++ b/backend/unispeaking-server/src/main/resources/ocr/paddle_ocr_runner.py @@ -21,6 +21,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--disable-doc-orientation", action="store_true") parser.add_argument("--disable-doc-unwarping", action="store_true") parser.add_argument("--disable-textline-orientation", action="store_true") + parser.add_argument("--worker", action="store_true") parser.add_argument("--images", nargs="*") return parser @@ -95,6 +96,33 @@ def recognize_batch(ocr: PaddleOCR, image_paths: list[str]) -> dict: return {"results": output} +def run_worker(ocr: PaddleOCR) -> int: + # stdout is a JSON-lines protocol. Paddle/PaddleX diagnostics belong on stderr. + print(json.dumps({"ready": True}), flush=True) + for line in sys.stdin: + request_id = None + try: + request = json.loads(line) + request_id = request.get("id") + image_paths = request.get("images") + if not isinstance(request_id, str) or not request_id: + raise ValueError("invalid request id") + if not isinstance(image_paths, list) or not image_paths: + raise ValueError("missing images") + payload = recognize_batch(ocr, [str(path) for path in image_paths]) + payload["id"] = request_id + print(json.dumps(payload, ensure_ascii=False), flush=True) + except Exception: + # Do not terminate the resident process for one bad image/request. Java + # treats the error response as a failed OCR operation and can restart + # the worker if the process itself has become unhealthy. + print(json.dumps({ + "id": request_id, + "error": "ocr-request-failed", + }), flush=True) + return 0 + + def main() -> int: parser = build_parser() args = parser.parse_args() @@ -105,6 +133,8 @@ def main() -> int: args.text_recognition_model_name, model_directory, ) + if args.worker: + return run_worker(ocr) if args.download_models: ensure_models_loaded(ocr) return 0 diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ocr/PaddleOcrProviderTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ocr/PaddleOcrProviderTest.java index 98ca72d3..6e3c0542 100644 --- a/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ocr/PaddleOcrProviderTest.java +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/infrastructure/ocr/PaddleOcrProviderTest.java @@ -41,24 +41,16 @@ void recognizesPngAndJpegInInputOrderWithOneBatchProcess() throws IOException { Path script = script(""" import json, os, pathlib, sys count_file = pathlib.Path(r'%s') - current = int(count_file.read_text() if count_file.exists() else '0') - count_file.write_text(str(current + 1)) - required = [ - '--text-detection-model-name', 'PP-OCRv5_mobile_det', - '--text-recognition-model-name', 'PP-OCRv5_mobile_rec', - '--device', 'cpu', '--disable-doc-orientation', - '--disable-doc-unwarping', '--disable-textline-orientation', '--images' - ] - for value in required: - if value not in sys.argv: - raise SystemExit(9) - if '--model-dir' in sys.argv: - raise SystemExit(10) if os.environ.get('PADDLE_PDX_CACHE_HOME') != r'%s': raise SystemExit(11) - image_index = sys.argv.index('--images') + 1 - images = sys.argv[image_index:] - print(json.dumps({'results': [{'text': pathlib.Path(path).stem} for path in images]})) + print(json.dumps({'ready': True}), flush=True) + for line in sys.stdin: + request = json.loads(line) + current = int(count_file.read_text() if count_file.exists() else '0') + count_file.write_text(str(current + 1)) + print(json.dumps({'id': request['id'], 'results': [ + {'text': pathlib.Path(path).stem} for path in request['images'] + ]}), flush=True) """.formatted(invocationCounter, cacheHome)); PaddleOcrProvider provider = provider(script, Duration.ofSeconds(2)); @@ -128,8 +120,10 @@ void rejectsImagesAbovePixelLimit() { void mapsTimeoutToStableErrorAndCleansTempDirectory() throws IOException { Path processId = tempRoot.resolve("process-id.txt"); Path script = script(""" - import os, pathlib, time + import json, os, pathlib, sys, time pathlib.Path(r'%s').write_text(str(os.getpid())) + print(json.dumps({'ready': True}), flush=True) + sys.stdin.readline() time.sleep(10) """.formatted(processId)); PaddleOcrProvider provider = provider(script, Duration.ofMillis(500)); @@ -166,7 +160,10 @@ raise SystemExit(7) @Test void mapsInvalidJsonToStableErrorWithoutRecognitionText() throws IOException { Path script = script(""" - print('recognized secret text that must not leak') + import json, sys + print(json.dumps({'ready': True}), flush=True) + request = json.loads(sys.stdin.readline()) + print('recognized secret text that must not leak', flush=True) """); PaddleOcrProvider provider = provider(script, Duration.ofSeconds(2)); @@ -183,7 +180,10 @@ void mapsInvalidJsonToStableErrorWithoutRecognitionText() throws IOException { @Test void mapsOversizedStdoutToInvalidResponse() throws IOException { Path script = script(""" - print('x' * %d) + import json, sys + print(json.dumps({'ready': True}), flush=True) + request = json.loads(sys.stdin.readline()) + print('x' * %d, flush=True) """.formatted(PaddleOcrProvider.MAX_STDOUT_BYTES + 1)); PaddleOcrProvider provider = provider(script, Duration.ofSeconds(2)); diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index df0750fe..e166c89f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -44,6 +44,7 @@ services: args: DOCKER_IMAGE_REGISTRY: ${DOCKER_IMAGE_REGISTRY:-docker.m.daocloud.io} VITE_BACKEND_URL: ${VITE_BACKEND_URL:-/backend} + VITE_OCR_ENABLED: ${OCR_ENABLED:-true} VITE_FEEDBACK_URL: ${VITE_FEEDBACK_URL:-} VITE_ALIYUN_CAPTCHA_SCENE_ID: ${ALIYUN_CAPTCHA_SCENE_ID:-} VITE_ALIYUN_CAPTCHA_PREFIX: ${ALIYUN_CAPTCHA_PREFIX:-} diff --git a/deploy/env/.env.example b/deploy/env/.env.example index 093666ce..52e63117 100644 --- a/deploy/env/.env.example +++ b/deploy/env/.env.example @@ -6,6 +6,7 @@ # ============================================================================= SERVER_PORT=8080 VITE_BACKEND_URL=/backend +VITE_OCR_ENABLED=true ALIYUN_CAPTCHA_SCENE_ID=replace-with-captcha-scene-id ALIYUN_CAPTCHA_PREFIX=replace-with-captcha-prefix ALIYUN_CAPTCHA_REGION=cn diff --git a/deploy/env/.env.prod.example b/deploy/env/.env.prod.example index 5604249f..19245206 100644 --- a/deploy/env/.env.prod.example +++ b/deploy/env/.env.prod.example @@ -7,6 +7,7 @@ # ============================================================================= SERVER_PORT=8080 VITE_BACKEND_URL=/backend +VITE_OCR_ENABLED=true ALIYUN_CAPTCHA_SCENE_ID=replace-with-captcha-scene-id ALIYUN_CAPTCHA_PREFIX=replace-with-captcha-prefix ALIYUN_CAPTCHA_REGION=cn diff --git "a/docs/API\346\216\245\345\217\243\346\226\207\346\241\243.md" "b/docs/API\346\216\245\345\217\243\346\226\207\346\241\243.md" index 84811eb4..99f1523f 100644 --- "a/docs/API\346\216\245\345\217\243\346\226\207\346\241\243.md" +++ "b/docs/API\346\216\245\345\217\243\346\226\207\346\241\243.md" @@ -523,4 +523,4 @@ Interview(英文面试,第 4 场景,逐步实现中): 11. `DELETE /api/interview-scenes/{sceneId}` — 后端删除:软删 `interview_scene` + 物理清该 scene 音频;practice_session/session_message/interview_report 保留(审计 + 学习日历),下游访问经软删过滤 404/403。 12. 失败码补充:`INTERVIEW_REPORT_NOT_FOUND`→404、`INTERVIEW_RECORDING_NOT_FOUND`→404、`INTERVIEW_REPORT_PERSISTENCE_FAILED`→500、`INTERVIEW_AUDIO_INVALID`→400、`INTERVIEW_SESSION_ENDED`→409。 13. `GET /api/interview-scenes/assets` — 面试学习资产列表:`List`(`sceneId/jobTitle/difficulty/latestSessionId/latestReportStatus/latestOverallScore/latestPracticedAt/practiceCount/createdAt`),复练入口。 -14. `GET /api/interview-scenes/ocr/availability` — OCR 可用性探测:`{available: boolean}`(本地未配置 PaddleOCR 时为 false,前端据此禁用 JD 图片上传)。 +14. `GET /api/interview-scenes/ocr/availability` — OCR 可用性探测:`{available: boolean}`(后端检查启用开关、Python runner 和预下载模型目录;Web 面试页启动时调用此接口,据此启用或禁用 JD 图片上传)。 diff --git a/frontend/web/Dockerfile b/frontend/web/Dockerfile index 637eaf42..14cc7d80 100644 --- a/frontend/web/Dockerfile +++ b/frontend/web/Dockerfile @@ -5,6 +5,7 @@ FROM ${DOCKER_IMAGE_REGISTRY}/library/node:22-alpine AS build WORKDIR /app ARG VITE_BACKEND_URL=/backend +ARG VITE_OCR_ENABLED=true ARG VITE_FEEDBACK_URL= ARG VITE_ALIYUN_CAPTCHA_SCENE_ID= ARG VITE_ALIYUN_CAPTCHA_PREFIX= @@ -15,6 +16,7 @@ ARG VITE_UMAMI_WEBSITE_ID= ARG VITE_UMAMI_DOMAINS= ARG NPM_REGISTRY=https://registry.npmmirror.com ENV VITE_BACKEND_URL=${VITE_BACKEND_URL} +ENV VITE_OCR_ENABLED=${VITE_OCR_ENABLED} ENV VITE_FEEDBACK_URL=${VITE_FEEDBACK_URL} ENV VITE_ALIYUN_CAPTCHA_SCENE_ID=${VITE_ALIYUN_CAPTCHA_SCENE_ID} ENV VITE_ALIYUN_CAPTCHA_PREFIX=${VITE_ALIYUN_CAPTCHA_PREFIX} diff --git a/frontend/web/src/component/interview/InterviewModule.jsx b/frontend/web/src/component/interview/InterviewModule.jsx index 70b5e4d9..05e9b121 100644 --- a/frontend/web/src/component/interview/InterviewModule.jsx +++ b/frontend/web/src/component/interview/InterviewModule.jsx @@ -22,6 +22,7 @@ import { Modal } from "../common/Modal.jsx"; import { generateInterviewScene, getInterviewAssets, + getInterviewOcrAvailability, getInterviewReport, prepareInterviewMaterials, retryInterviewReport, @@ -33,7 +34,6 @@ import { SimpleCta, TrendLineChart } from "../ielts/IeltsModule.jsx"; const cx = (...parts) => parts.filter(Boolean).join(" "); -const JD_IMAGE_OCR_ENABLED = import.meta.env.VITE_OCR_ENABLED === "true"; const DIFFICULTY_LABELS = { EASY: "简单", STANDARD: "标准", HARD: "困难" }; const speedCodeByLabel = { @@ -267,6 +267,8 @@ function MaterialEditor({ material, onChange, compact = false }) { } function InterviewHome({ onNavigate, onBack }) { + const [ocrAvailable, setOcrAvailable] = useState(false); + const [ocrAvailabilityLoading, setOcrAvailabilityLoading] = useState(true); const [jdMode, setJdMode] = useState("text"); const [jdText, setJdText] = useState(""); const [jdImage, setJdImage] = useState(null); @@ -280,12 +282,34 @@ function InterviewHome({ onNavigate, onBack }) { const [formError, setFormError] = useState(""); const [generating, setGenerating] = useState(false); const [generateError, setGenerateError] = useState(""); - const jdImageUnavailable = !JD_IMAGE_OCR_ENABLED; + const jdImageUnavailable = ocrAvailabilityLoading || !ocrAvailable; + + useEffect(() => { + let cancelled = false; + getInterviewOcrAvailability() + .then((result) => { + if (cancelled) return; + setOcrAvailable(result?.available === true); + }) + .catch(() => { + if (!cancelled) setOcrAvailable(false); + }) + .finally(() => { + if (!cancelled) setOcrAvailabilityLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); const prepareMaterials = async () => { if (preparing) return; setFormError(""); - if (jdMode === "image" && jdImageUnavailable) { + if (jdMode === "image" && ocrAvailabilityLoading) { + setFormError("正在检测 OCR,请稍后再试"); + return; + } + if (jdMode === "image" && !ocrAvailable) { setFormError("OCR 暂不可用,请使用粘贴文本方式上传 JD"); return; } @@ -402,12 +426,12 @@ function InterviewHome({ onNavigate, onBack }) { 岗位描述(JD)
- +
{jdMode === "text" ?