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
+6
View File
@@ -0,0 +1,6 @@
"""核心能力:OCR / PDF / 图像"""
from .ocr_engine import get_engine, recognize, warmup, OCRTimeout
from .pdf_processor import pdf_to_images
from .image_processor import auto_rotate, enhance
__all__ = ["get_engine", "recognize", "warmup", "OCRTimeout", "pdf_to_images", "auto_rotate", "enhance"]
+47
View File
@@ -0,0 +1,47 @@
"""图像预处理:自动旋转、放大、去噪"""
from __future__ import annotations
from pathlib import Path
import cv2
import numpy as np
def auto_rotate(img_path: str | Path) -> Path:
"""基于方向检测的简易旋转(> 阈值倾斜就转 90°)
说明:发票多为横向,此处只处理 0/90/180/270 四方向,
复杂倾斜交给 PaddleOCR 自带的 textline orientation。
"""
p = Path(img_path)
img = cv2.imread(str(p))
if img is None:
return p
h, w = img.shape[:2]
# 横图宽 > 高 * 1.2,认为方向正确;否则旋转
if h > w * 1.2:
rotated = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
out = p.with_name(f"{p.stem}_rot.png")
cv2.imwrite(str(out), rotated)
return out
return p
def enhance(img_path: str | Path) -> Path:
"""轻度增强:灰度 + 自适应二值化(对手机拍的发票有帮助)"""
p = Path(img_path)
img = cv2.imread(str(p))
if img is None:
return p
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 弱增强:只对低对比度图做二值化
if gray.std() < 50:
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10
)
out = p.with_name(f"{p.stem}_enh.png")
cv2.imwrite(str(out), binary)
return out
return p
+146
View File
@@ -0,0 +1,146 @@
"""PaddleOCR 引擎封装(单例 + 超时)
适配 paddleocr 3.0+ 的 predict() 接口。
降级到 2.x 时也能跑(参数兼容)。
"""
from __future__ import annotations
import threading
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
from pathlib import Path
from typing import List, Optional
from loguru import logger
from app.config import settings
from app.models import OCRLine
_lock = threading.Lock()
_engine = None
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ocr")
# ---------- 引擎构造 ----------
def _build_engine():
try:
from paddleocr import PaddleOCR
# paddleocr >= 3.0: device="cpu"/"gpu", 没有 use_gpu / show_log
# 模型选择:
# v5_server - 精度高但 CPU 慢 (5+ 秒/页)
# v5_mobile - CPU 友好 (1~2 秒/页), 精度略低, 推荐生产环境
mobile = settings.ocr_lang.endswith("mobile") or settings.ocr_engine == "mobile"
if settings.use_gpu or settings.ocr_engine == "server":
# GPU 或用户显式指定 server 模型
det_name = "PP-OCRv5_server_det"
rec_name = "PP-OCRv5_server_rec"
else:
# 默认 mobile (CPU 友好)
det_name = "PP-OCRv5_mobile_det"
rec_name = "PP-OCRv5_mobile_rec"
return PaddleOCR(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
lang=settings.ocr_lang,
device="gpu" if settings.use_gpu else "cpu",
text_detection_model_name=det_name,
text_recognition_model_name=rec_name,
)
except TypeError:
# paddleocr 2.x 老参数(向后兼容)
from paddleocr import PaddleOCR
return PaddleOCR(
use_angle_cls=True,
lang=settings.ocr_lang,
use_gpu=settings.use_gpu,
show_log=False,
)
def get_engine():
global _engine
if _engine is None:
with _lock:
if _engine is None:
logger.info("正在初始化 PaddleOCR (lang={}, gpu={})...", settings.ocr_lang, settings.use_gpu)
_engine = _build_engine()
logger.info("PaddleOCR 初始化完成")
return _engine
def _normalize(result) -> List[OCRLine]:
lines: List[OCRLine] = []
if not result:
return lines
if isinstance(result, list) and result and isinstance(result[0], dict):
for page in result:
texts = page.get("rec_texts") or []
scores = page.get("rec_scores") or []
polys = page.get("rec_polys") or []
for i, txt in enumerate(texts):
conf = float(scores[i]) if i < len(scores) else 0.0
box = polys[i].tolist() if i < len(polys) and hasattr(polys[i], "tolist") else []
lines.append(OCRLine(text=str(txt).strip(), confidence=conf, box=box))
return lines
try:
for page in result:
for det in page:
box = det[0]
txt, conf = det[1]
lines.append(OCRLine(text=str(txt).strip(), confidence=float(conf), box=box))
except Exception:
logger.exception("无法解析 PaddleOCR 输出: {}", result)
return lines
class OCRTimeout(Exception):
"""OCR 识别超时"""
pass
def recognize(image_path: str | Path, timeout_s: Optional[float] = None) -> List[OCRLine]:
"""对单张图片做 OCR,带超时控制
timeout_s 默认为 settings.ocr_page_timeout_s(默认 5 秒)。
超时立即抛 OCRTimeout,不等模型跑完。
"""
timeout = timeout_s or settings.ocr_page_timeout_s
engine = get_engine()
future = _executor.submit(engine.predict, str(image_path))
try:
raw = future.result(timeout=timeout)
except FutTimeout:
future.cancel()
logger.error("OCR 超时 ({:.1f}s): {}", timeout, image_path)
raise OCRTimeout(f"OCR 识别超时 ({timeout}秒): {image_path}")
except Exception as e:
logger.exception("OCR 执行异常: {}", e)
raise
return _normalize(raw)
def warmup():
"""预热:构造引擎 + 真跑一次空白图预测(避免首次请求超时)
跳过预测失败(比如没装 paddle)但不报错。
"""
try:
engine = get_engine()
except Exception as e:
logger.warning("OCR 引擎初始化失败: {}", e)
return
try:
import numpy as np
# 1x1 灰度图,足以触发模型完整链路
dummy = np.zeros((64, 64, 3), dtype=np.uint8)
engine.predict(dummy)
logger.info("OCR 预热完成(首次预测已跑)")
except Exception as e:
logger.warning("OCR 预测预热失败(不影响主流程): {}", e)
+35
View File
@@ -0,0 +1,35 @@
"""PDF → 图片 (使用 PyMuPDF,无需 poppler 系统依赖)"""
from __future__ import annotations
from pathlib import Path
from typing import List
import fitz # PyMuPDF
from loguru import logger
from app.config import settings
def pdf_to_images(pdf_path: str | Path, dpi: int | None = None) -> List[Path]:
"""把 PDF 每页渲染成 PNG,返回文件路径列表"""
dpi = dpi or settings.pdf_dpi
pdf_path = Path(pdf_path)
out_dir = pdf_path.parent / f".{pdf_path.stem}_pages"
out_dir.mkdir(exist_ok=True)
doc = fitz.open(str(pdf_path))
saved: List[Path] = []
try:
# 1.0 = 72 DPI 的 zoom 系数
zoom = dpi / 72.0
mat = fitz.Matrix(zoom, zoom)
for idx, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=mat, alpha=False)
out_path = out_dir / f"page_{idx:03d}.png"
pix.save(str(out_path))
saved.append(out_path)
finally:
doc.close()
logger.info("PDF 转图片: {}{} 页 (dpi={})", pdf_path.name, len(saved), dpi)
return saved