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: 文档
This commit is contained in:
郭庆泰
2026-08-22 00:23:22 +08:00
parent edfaf4e7f5
commit c3eb8ed9c3
88 changed files with 6710 additions and 240 deletions
+1
View File
@@ -0,0 +1 @@
"""API 层"""
+125
View File
@@ -0,0 +1,125 @@
"""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}