Files
guoju0808/ry-ocr/app/api/routes.py
T
郭庆泰 c3eb8ed9c3 feat: OCR 服务 + 会议材料模块
ry-ocr/ (新)
  本地发票识别微服务 (PaddleOCR 3.x + FastAPI, 8801)
  - QR 优先: 扫到二维码即取开票时间/发票号/金额; 没扫到/格式不合法直接判非发票, 不跑 OCR
  - 配置 QR_FULL_OCR 控制快路径(false, 0.2s)还是全字段(true, 4.5s)
  - /recognize/invoice (multipart) + /recognize/invoice/by-path (本地路径, 白名单) + /recognize/text
  - is_invoice / from_qr / qr_raw / qr_error / error_code 字段
  - 12 字段发票抽取 (regex + 启发式, 左右主体识别)
  - 超时保护 (15s 单页 / 60s 总流程) + PaddleOCR 单例 + ThreadPoolExecutor

ry-api/ruoyi-business/
  - pom.xml: 加 hutool-http/json/core 5.8.27, lombok 1.18.30 (OcrClient @Slf4j 所需)
  - ocr/: OcrClient + InvoiceResult/Fields/Line + ZipExtractor + InvoiceOcrScheduler
  - oss/: OssUploader + OssConfMeta (OCR 识别后重传 OSS)
  - config/: OcrConfig + OcrExecutorConfig (后台线程池)
  - service/impl/InvoiceOcrService: 后台提交 OCR, ZIP 路径解压识别, 替换场景先清旧
  - 会议材料 CRUD 全套 (BizMeetingAuditLog/Executor/Invoice/Material/Supervisor):
    controller + service + mapper + domain + xml

ry-vue3/
  - MeetingDetail.vue (新建): 会议详情页 (含评分维度章节, 改只读)
  - Meetings.vue / OssFileUploader.vue / router / Login.vue: 适配新字段

ry-api/ruoyi-admin/
  - RuoYiApplication.java + application.yml: 启用 @Async 异步支持

_self/
  - manager_meetings.md / manager_meeting_detail.md: 文档
2026-08-22 00:23:22 +08:00

125 lines
3.9 KiB
Python
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.
"""FastAPI 路由"""
from __future__ import annotations
import platform
from pathlib import Path
from typing import List
from fastapi import APIRouter, File, HTTPException, UploadFile
from app import __version__
from app.config import settings
from app.core import get_engine
from app.models import HealthResponse, InvoiceResult, PathRecognizeRequest
from app.services.recognize_service import recognize_file, recognize_path
router = APIRouter()
# ---------- 路径白名单 ----------
def _parse_allowed_dirs() -> List[Path]:
"""解析 ALLOWED_DIRS 配置为绝对路径列表"""
if not settings.allowed_dirs.strip():
return []
sep = ";" if platform.system() == "Windows" else ":"
roots: List[Path] = []
for raw in settings.allowed_dirs.split(sep):
raw = raw.strip().strip('"').strip("'")
if not raw:
continue
try:
p = Path(raw).resolve()
if p.is_dir():
roots.append(p)
except Exception:
pass
return roots
def _check_path_allowed(file_path: Path) -> None:
"""校验路径在白名单内(路径遍历攻击防护)
resolve 后必须是某个 allowed_dir 的子路径。
"""
roots = _parse_allowed_dirs()
if not roots:
raise HTTPException(
status_code=403,
detail="路径接口未启用:在 .env 配置 ALLOWED_DIRS 后重启服务",
)
try:
abs_path = file_path.resolve()
except Exception as e:
raise HTTPException(status_code=400, detail=f"路径无效: {e}")
for root in roots:
try:
abs_path.relative_to(root)
return
except ValueError:
continue
raise HTTPException(
status_code=403,
detail=f"路径不在白名单内(允许: {', '.join(str(r) for r in roots)}",
)
# ---------- 路由 ----------
@router.get("/health", response_model=HealthResponse, summary="健康检查")
def health():
engine_ok = True
try:
get_engine()
except Exception:
engine_ok = False
return HealthResponse(
status="ok" if engine_ok else "degraded",
version=__version__,
engine_ready=engine_ok,
)
@router.post("/recognize/invoice", response_model=InvoiceResult, summary="识别发票(上传文件)")
async def recognize_invoice(file: UploadFile = File(..., description="发票图片或 PDF")):
"""识别发票并返回结构化字段
支持:PNG/JPG/JPEG/BMP/WEBP/TIFF/PDF
"""
content = await file.read()
max_bytes = settings.max_upload_mb * 1024 * 1024
if len(content) > max_bytes:
raise HTTPException(status_code=413, detail=f"文件超过 {settings.max_upload_mb}MB 限制")
if not content:
raise HTTPException(status_code=400, detail="文件为空")
return recognize_file(file.filename or "unknown", content)
@router.post("/recognize/invoice/by-path", response_model=InvoiceResult, summary="识别发票(服务器本地路径)")
def recognize_invoice_by_path(req: PathRecognizeRequest):
"""传入服务器本地路径识别发票(避免重复上传大文件)
**安全**:路径必须在 .env 的 ALLOWED_DIRS 白名单内才会被执行。
防止任意文件读取 / 路径遍历攻击。
"""
p = Path(req.file_path)
_check_path_allowed(p)
if not p.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {p}")
if not p.is_file():
raise HTTPException(status_code=400, detail=f"不是文件: {p}")
return recognize_path(p, delete_after=False)
@router.post("/recognize/text", summary="仅做字段抽取(不上传文件)")
def recognize_text(raw_text: str):
"""对已有的 OCR 文本做字段抽取(便于接入其他 OCR 引擎)"""
from app.services import extract_invoice
fields = extract_invoice(raw_text)
return {"fields": fields}