Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ uv sync
# 基础配置
APP_NAME=OCR Task System
DEBUG=False
HOST=0.0.0.0
HOST=127.0.0.1
PORT=8000
# 浏览器可访问的 API 地址,用于 OCR 结果中的图片链接
PUBLIC_API_BASE_URL=http://localhost:8000/api/v1

# 数据库
DATABASE_URL=sqlite+aiosqlite:///./tasks.db
Expand All @@ -132,14 +134,47 @@ TASK_TIMEOUT=3600

```bash
# 方式1:使用 uv
uv run uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000

# 方式2:直接运行
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
```

访问 API 文档:http://localhost:8000/docs

### 文件访问与部署边界

图片预览接口为 `GET /api/v1/tasks/{task_id}/images/{image_id}`,仅返回已完成
任务的 PNG 裁剪图片。图片必须位于 `OUTPUT_DIR/<task_id>/`,并且登记在该任务
`ocr_result.json` 的 `ref_image_paths` 中。文件名、真实目录和 PNG 内容均会校验;
符号链接、目录穿越、未登记图片、上传原件、配置、数据库和任意文本文件不可通过此接口读取。
无效请求统一返回 `404 {"detail":"Image not found"}`,不返回服务器文件路径。

旧结果中的 `GET /api/v1/tasks/file?path=...` 图片链接仍可使用,但执行完全相同的
任务和产物校验,不再提供通用文件读取功能。保留旧任务时,需要一起保留任务数据库记录、
裁剪图片和 `ocr_result.json`。新生成的 Markdown 使用图片 ID,不包含服务器绝对文件路径。

本应用仍是共享工作区,没有内置用户登录和任务归属隔离。默认开发服务和 Docker 宿主端口
仅绑定本机;容器内部仍监听 `0.0.0.0` 以供端口转发。若要对外服务,请通过统一鉴权的
网关或反向代理开放前端及全部 API,并阻止用户绕过代理直连后端。多用户数据隔离还需要
任务归属权限校验,不能将 UUID 当作授权凭证。

反向代理部署时,将 `PUBLIC_API_BASE_URL` 配置为浏览器能够访问、且受到相同访问控制的
API 地址(例如 `https://ocr.example.com/api/v1`)。图片使用 `<img src>` 加载,代理的
认证方式需要支持图片请求,例如同源会话 Cookie。更改该配置只影响新生成的结果链接。

### 安全回归测试

在 `apps/backend` 目录、安装后端依赖的环境中运行:

```bash
python -m pytest tests
```

测试使用真实 FastAPI 路由、临时 SQLite 数据库及临时文件,不读取已有任务数据库。
图片和 PDF 的上传、转换、裁剪、结果合并、新旧预览链接均参与测试;仅外部 OCR 模型
响应使用固定样本,不需要 GPU 或在线模型服务。

## 📖 API 使用指南

### 统一响应格式
Expand Down Expand Up @@ -235,7 +270,7 @@ docker build -t glm-ocr:latest .
```bash
docker run -d \
--name glm-ocr \
-p 8000:8000 \
-p 127.0.0.1:8000:8000 \
-v $(pwd)/data:/backend/data \
-e WORKER_COUNT=5 \
glm-ocr:latest
Expand Down Expand Up @@ -282,8 +317,9 @@ uv add --dev pytest
| 配置项 | 说明 | 默认值 |
|-------|------|--------|
| `APP_NAME` | 应用名称 | OCR Task System |
| `HOST` | 监听地址 | 0.0.0.0 |
| `HOST` | 直接启动时的监听地址 | 127.0.0.1 |
| `PORT` | 监听端口 | 8000 |
| `PUBLIC_API_BASE_URL` | 新结果图片链接使用的浏览器可访问 API 地址 | http://localhost:8000/api/v1 |
| `DATABASE_URL` | 数据库连接 | sqlite+aiosqlite:///./tasks.db |
| `OUTPUT_DIR` | 输出目录 | ./data |
| `WORKER_COUNT` | Worker 数量 | 5 |
Expand Down
99 changes: 40 additions & 59 deletions apps/backend/app/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@
from datetime import datetime, UTC

from fastapi import APIRouter, HTTPException, status, UploadFile, File, Form, Response
from mimetypes import guess_type
from starlette.concurrency import run_in_threadpool

from app.schemas.response import ApiResponse, TaskData, TaskResultData
from app.core.task_manager import get_task_manager
from app.utils.logger import logger
from app.utils.upload_file_manager import file_upload_handler
from app.utils.config import settings
from app.utils.task_images import (
TaskImageNotFound,
image_id_from_path,
read_task_image,
validate_image_id,
)


router = APIRouter(prefix="/tasks", tags=["tasks"])
Expand Down Expand Up @@ -102,76 +108,51 @@ async def submit_task(

@router.get("/file")
async def read_file(path: str):
"""
读取指定路径的文件内容

对于图片文件,直接返回图片数据
对于其他文件,返回JSON格式的文件信息

- **path**: 文件路径
"""
"""兼容旧结果中的图片链接;仅允许访问已完成任务的裁剪图片。"""
try:
file_path = Path(path)

# 检查文件是否存在
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File not found: {path}",
)
task_id, image_id = image_id_from_path(path)
except TaskImageNotFound:
raise HTTPException(status_code=404, detail="Image not found") from None
return await _task_image_response(task_id, image_id)

# 检查是否为文件
if not file_path.is_file():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Path is not a file: {path}",
)

# 获取文件MIME类型
mime_type, _ = guess_type(file_path.name)
if mime_type is None:
mime_type = "application/octet-stream"

# 读取文件内容
with open(file_path, "rb") as f:
content = f.read()

# 如果是图片文件,直接返回二进制数据
if mime_type.startswith("image/"):
return Response(
content=content,
media_type=mime_type,
headers={
"Content-Disposition": f"inline; filename=\"{file_path.name}\""
}
)
@router.get("/{task_id}/images/{image_id}")
async def get_task_image(task_id: str, image_id: str):
"""获取任务生成并登记的裁剪图片,不接受服务器文件路径。"""
return await _task_image_response(task_id, image_id)

# 其他文件类型,返回JSON格式
try:
text_content = content.decode("utf-8")
except UnicodeDecodeError:
text_content = "(binary file)"

return ApiResponse(
success=True,
data={
"path": str(file_path.absolute()),
"filename": file_path.name,
"size": file_path.stat().st_size,
"mime_type": mime_type,
"content": text_content,
async def _task_image_response(task_id: str, image_id: str):
try:
validate_image_id(task_id, image_id)
task = await get_task_manager().get_task_status(task_id)
# Uploaded files cannot supply their own manifest before OCR completes.
if (
not task
or task.get("status") != "completed"
or not task.get("result_file_path")
):
raise TaskImageNotFound
content = await run_in_threadpool(read_task_image, task_id, image_id)
return Response(
content=content,
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="{image_id}"',
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, no-store",
},
message="File read successfully",
)

except TaskImageNotFound:
raise HTTPException(status_code=404, detail="Image not found") from None
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to read file: {e}")
logger.error(f"Failed to read task image: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to read file: {str(e)}",
)
detail="Failed to read task image",
) from None


@router.get("/{task_id}", response_model=ApiResponse[dict])
Expand Down
34 changes: 24 additions & 10 deletions apps/backend/app/core/steps/merge_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
from pathlib import Path
import json
import os
from html import escape

from app.core.flows.base import ProcessingContext
from app.utils.logger import logger
from app.utils.config import settings
from app.utils.task_images import TaskImageNotFound, image_id_from_path


class MergeResultsStepInput:
Expand Down Expand Up @@ -99,19 +102,30 @@ async def _merge_to_markdown(
result["metadata"] = context.metadata
merge_res_layout = []
total_pages = len(pages)
for i, page in enumerate(pages):
# page_num = page.get("page_number", i + 1)
for page in pages:
layout = page.get("layout", {}).get("blocks", [])
for i, block in enumerate(layout):
for block in layout:
text = block.get("content", "")
layout_type = block.get("layout_type", "")
if layout_type == "image":
img_name = block.get("image_path")
# 将相对路径转换为绝对路径
if img_name and not os.path.isabs(img_name):
img_name = os.path.abspath(img_name)

text = f'<div style="text-align: center;"><img src="http://localhost:8000/api/v1/tasks/file?path={img_name}" alt="Image"/></div>\n'
try:
image_path = block.get("image_path")
if isinstance(image_path, str):
image_path = os.path.abspath(image_path)
task_id, image_id = image_id_from_path(image_path)
if task_id != context.task_id:
raise TaskImageNotFound
image_url = (
f"{settings.PUBLIC_API_BASE_URL.rstrip('/')}/tasks/"
f"{task_id}/images/{image_id}"
)
text = (
'<div style="text-align: center;">'
f'<img src="{escape(image_url, quote=True)}" alt="Image"/></div>\n'
)
except TaskImageNotFound:
# Failed crops have no image_path; do not emit a broken URL.
text = text or ""
markdown_lines.append(f"{text}\n")
merge_res_layout.append(
{
Expand All @@ -123,7 +137,7 @@ async def _merge_to_markdown(
)
if progress_callback:
progress = 100.0
await progress_callback(progress, f"Merging page {i + 1}/{total_pages}")
await progress_callback(progress, f"Merged {total_pages} pages")
result["full_markdown"] = "".join(markdown_lines)
result["layout"] = merge_res_layout
# 写入文件
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/app/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ class Settings(BaseSettings):
APP_NAME: str = "OCR Task System"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
HOST: str = "0.0.0.0"
HOST: str = "127.0.0.1"
PORT: int = 8000
# Browser-reachable API base used in generated Markdown image links.
PUBLIC_API_BASE_URL: str = "http://localhost:8000/api/v1"

# 数据库配置
DATABASE_URL: str = "sqlite+aiosqlite:///./tasks.db"
Expand Down
Loading
Loading