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: 文档
125 lines
3.9 KiB
Python
125 lines
3.9 KiB
Python
"""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} |