harden deploy reports and admin alerts
This commit is contained in:
142
app/api/ocr.py
142
app/api/ocr.py
@@ -1,14 +1,16 @@
|
||||
import re
|
||||
import time
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_telegram_user
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.services.admin_notifications import create_admin_notification
|
||||
from app.services.ocr_provider import get_ocr_provider
|
||||
from app.services.rate_limit import check_rate_limit
|
||||
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
|
||||
@@ -40,6 +42,72 @@ class OCRResultRead(BaseModel):
|
||||
provider: str = "heuristic"
|
||||
|
||||
|
||||
async def validate_ocr_upload(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
current_user: User,
|
||||
content: bytes,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
) -> str:
|
||||
try:
|
||||
return validate_upload(
|
||||
content=content,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
max_bytes=MAX_OCR_FILE_BYTES,
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="upload_blocked",
|
||||
title="Upload blocked",
|
||||
body=f"OCR upload blocked: {filename or '-'}\nReason: {exc.detail}",
|
||||
entity_type="user",
|
||||
entity_id=current_user.id,
|
||||
severity="warning",
|
||||
idempotency_key=(
|
||||
f"upload_blocked:{current_user.id}:{filename or 'upload'}:{exc.status_code}:"
|
||||
f"{int(time.time() // 60)}"
|
||||
),
|
||||
metadata={
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
raise
|
||||
|
||||
|
||||
async def recognize_with_alert(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
current_user: User,
|
||||
content: bytes,
|
||||
filename: str | None,
|
||||
scope: str,
|
||||
):
|
||||
try:
|
||||
return await get_ocr_provider().recognize(content, filename)
|
||||
except Exception as exc: # noqa: BLE001 - OCR must fail gracefully and alert admins
|
||||
await create_admin_notification(
|
||||
session,
|
||||
event_type="ocr_failed",
|
||||
title="OCR provider failed",
|
||||
body=f"Scope: {scope}\nFile: {filename or '-'}\nError: {type(exc).__name__}",
|
||||
entity_type="user",
|
||||
entity_id=current_user.id,
|
||||
severity="error",
|
||||
idempotency_key=f"ocr_failed:{scope}:{current_user.id}:{int(time.time() // 60)}",
|
||||
metadata={"scope": scope, "filename": filename, "error_type": type(exc).__name__},
|
||||
)
|
||||
await session.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
|
||||
async def parse_text_receipt(
|
||||
request: Request,
|
||||
@@ -49,17 +117,23 @@ async def parse_text_receipt(
|
||||
) -> ReceiptSuggestion:
|
||||
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(
|
||||
content=content,
|
||||
await validate_ocr_upload(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
max_bytes=MAX_OCR_FILE_BYTES,
|
||||
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
|
||||
content=content,
|
||||
)
|
||||
content_type = (file.content_type or "").lower()
|
||||
if content_type.startswith("image/") or content_type == "application/pdf":
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
if not result.recognized_text:
|
||||
result = await recognize_with_alert(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
scope="parse_text_receipt",
|
||||
)
|
||||
if not result or not result.recognized_text:
|
||||
return ReceiptSuggestion(
|
||||
confidence=0,
|
||||
message="Не удалось уверенно распознать чек. Открылся ручной ввод: проверьте дату, сумму, литры и цену.",
|
||||
@@ -133,8 +207,22 @@ async def recognize_license_plate(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
result = await recognize_with_alert(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
scope="license_plate",
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
|
||||
@@ -151,8 +239,22 @@ async def recognize_vin(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
result = await recognize_with_alert(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
scope="vin",
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
|
||||
@@ -169,8 +271,22 @@ async def recognize_service_document(
|
||||
) -> OCRResultRead:
|
||||
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
|
||||
content = await file.read()
|
||||
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
|
||||
result = await get_ocr_provider().recognize(content, file.filename)
|
||||
await validate_ocr_upload(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
result = await recognize_with_alert(
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
content=content,
|
||||
filename=file.filename,
|
||||
scope="service_document",
|
||||
)
|
||||
if result is None:
|
||||
return OCRResultRead(recognized_text="", candidates=[], provider="error")
|
||||
return OCRResultRead(
|
||||
recognized_text=result.recognized_text,
|
||||
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
|
||||
|
||||
Reference in New Issue
Block a user