first commit
This commit is contained in:
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql+asyncpg://fundtracer:fundtracer_dev@localhost:5432/fundtracer
|
||||
DATABASE_URL_SYNC=postgresql://fundtracer:fundtracer_dev@localhost:5432/fundtracer
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
UPLOAD_DIR=./uploads
|
||||
SECRET_KEY=dev-secret-key
|
||||
DEBUG=true
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql://fundtracer:fundtracer_dev@localhost:5432/fundtracer
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
41
backend/alembic/env.py
Normal file
41
backend/alembic/env.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401 – ensure all models are imported
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
54
backend/app/api/v1/analysis.py
Normal file
54
backend/app/api/v1/analysis.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.repositories.case_repo import CaseRepository
|
||||
from app.schemas.analysis import AnalysisStatusOut, AnalysisTriggerOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/cases/{case_id}/analyze", response_model=AnalysisTriggerOut)
|
||||
async def trigger_analysis(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = await repo.get(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "案件不存在")
|
||||
|
||||
from app.workers.analysis_tasks import run_full_analysis
|
||||
try:
|
||||
task = run_full_analysis.delay(str(case_id))
|
||||
task_id = task.id
|
||||
except Exception:
|
||||
task_id = "sync-fallback"
|
||||
from app.services.analysis_pipeline import run_analysis_sync
|
||||
await run_analysis_sync(case_id, db)
|
||||
|
||||
return AnalysisTriggerOut(task_id=task_id, message="分析任务已提交")
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/analyze/status", response_model=AnalysisStatusOut)
|
||||
async def analysis_status(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = await repo.get(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "案件不存在")
|
||||
|
||||
step_map = {
|
||||
"pending": ("等待上传", 0),
|
||||
"uploading": ("上传中", 15),
|
||||
"analyzing": ("分析中", 50),
|
||||
"reviewing": ("待复核", 85),
|
||||
"completed": ("已完成", 100),
|
||||
}
|
||||
step_label, progress = step_map.get(case.status.value, ("未知", 0))
|
||||
|
||||
return AnalysisStatusOut(
|
||||
case_id=str(case_id),
|
||||
status=case.status.value,
|
||||
progress=progress,
|
||||
current_step=step_label,
|
||||
message=f"当前状态: {step_label}",
|
||||
)
|
||||
55
backend/app/api/v1/assessments.py
Normal file
55
backend/app/api/v1/assessments.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.assessment import ConfidenceLevel
|
||||
from app.repositories.assessment_repo import AssessmentRepository
|
||||
from app.schemas.assessment import (
|
||||
AssessmentOut,
|
||||
AssessmentListOut,
|
||||
ReviewSubmit,
|
||||
InquirySuggestionOut,
|
||||
)
|
||||
from app.services.assessment_service import generate_inquiry_suggestions
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/assessments", response_model=AssessmentListOut)
|
||||
async def list_assessments(
|
||||
case_id: UUID,
|
||||
confidence_level: ConfidenceLevel | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AssessmentRepository(db)
|
||||
items, total = await repo.list_by_case(case_id, confidence_level=confidence_level)
|
||||
return AssessmentListOut(items=items, total=total)
|
||||
|
||||
|
||||
@router.post("/assessments/{assessment_id}/review", response_model=AssessmentOut)
|
||||
async def review_assessment(
|
||||
assessment_id: UUID,
|
||||
body: ReviewSubmit,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = AssessmentRepository(db)
|
||||
assessment = await repo.get(assessment_id)
|
||||
if not assessment:
|
||||
raise HTTPException(404, "认定记录不存在")
|
||||
|
||||
assessment = await repo.update(assessment, {
|
||||
"review_status": body.review_status,
|
||||
"review_note": body.review_note,
|
||||
"reviewed_by": body.reviewed_by,
|
||||
"reviewed_at": datetime.now(timezone.utc),
|
||||
})
|
||||
return assessment
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/inquiry-suggestions", response_model=InquirySuggestionOut)
|
||||
async def get_inquiry_suggestions(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
suggestions = await generate_inquiry_suggestions(case_id, db)
|
||||
return InquirySuggestionOut(suggestions=suggestions)
|
||||
58
backend/app/api/v1/cases.py
Normal file
58
backend/app/api/v1/cases.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.case import Case, CaseStatus
|
||||
from app.repositories.case_repo import CaseRepository
|
||||
from app.schemas.case import CaseCreate, CaseUpdate, CaseOut, CaseListOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=CaseOut, status_code=201)
|
||||
async def create_case(body: CaseCreate, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = Case(
|
||||
case_no=body.case_no,
|
||||
title=body.title,
|
||||
victim_name=body.victim_name,
|
||||
handler=body.handler,
|
||||
)
|
||||
case = await repo.create(case)
|
||||
return case
|
||||
|
||||
|
||||
@router.get("", response_model=CaseListOut)
|
||||
async def list_cases(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status: CaseStatus | None = None,
|
||||
search: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = CaseRepository(db)
|
||||
items, total = await repo.list_cases(offset=offset, limit=limit, status=status, search=search)
|
||||
return CaseListOut(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{case_id}", response_model=CaseOut)
|
||||
async def get_case(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = await repo.get(case_id)
|
||||
if not case:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="案件不存在")
|
||||
return case
|
||||
|
||||
|
||||
@router.patch("/{case_id}", response_model=CaseOut)
|
||||
async def update_case(case_id: UUID, body: CaseUpdate, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = await repo.get(case_id)
|
||||
if not case:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="案件不存在")
|
||||
case = await repo.update(case, body.model_dump(exclude_unset=True))
|
||||
return case
|
||||
130
backend/app/api/v1/images.py
Normal file
130
backend/app/api/v1/images.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.evidence_image import EvidenceImage, SourceApp, PageType
|
||||
from app.repositories.image_repo import ImageRepository
|
||||
from app.repositories.case_repo import CaseRepository
|
||||
from app.schemas.image import ImageOut, ImageDetailOut, OcrFieldCorrection
|
||||
from app.utils.hash import sha256_file
|
||||
from app.utils.file_storage import save_upload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/cases/{case_id}/images", response_model=list[ImageOut], status_code=201)
|
||||
async def upload_images(
|
||||
case_id: UUID,
|
||||
files: list[UploadFile] = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
case_repo = CaseRepository(db)
|
||||
case = await case_repo.get(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "案件不存在")
|
||||
|
||||
img_repo = ImageRepository(db)
|
||||
results: list[EvidenceImage] = []
|
||||
|
||||
for f in files:
|
||||
data = await f.read()
|
||||
file_hash = sha256_file(data)
|
||||
|
||||
existing = await img_repo.find_by_hash(file_hash)
|
||||
if existing:
|
||||
results.append(existing)
|
||||
continue
|
||||
|
||||
file_path, thumb_path = save_upload(data, str(case_id), f.filename or "upload.png")
|
||||
image = EvidenceImage(
|
||||
case_id=case_id,
|
||||
file_path=file_path,
|
||||
thumb_path=thumb_path,
|
||||
file_hash=file_hash,
|
||||
file_size=len(data),
|
||||
)
|
||||
image = await img_repo.create(image)
|
||||
results.append(image)
|
||||
|
||||
case.image_count = await img_repo.count_by_case(case_id)
|
||||
await db.flush()
|
||||
|
||||
# trigger OCR tasks (non-blocking)
|
||||
from app.workers.ocr_tasks import process_image_ocr
|
||||
for img in results:
|
||||
if img.ocr_status.value == "pending":
|
||||
try:
|
||||
process_image_ocr.delay(str(img.id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/images", response_model=list[ImageOut])
|
||||
async def list_images(
|
||||
case_id: UUID,
|
||||
source_app: SourceApp | None = None,
|
||||
page_type: PageType | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = ImageRepository(db)
|
||||
return await repo.list_by_case(case_id, source_app=source_app, page_type=page_type)
|
||||
|
||||
|
||||
@router.get("/images/{image_id}", response_model=ImageDetailOut)
|
||||
async def get_image_detail(image_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = ImageRepository(db)
|
||||
image = await repo.get(image_id)
|
||||
if not image:
|
||||
raise HTTPException(404, "截图不存在")
|
||||
return ImageDetailOut(
|
||||
id=image.id,
|
||||
case_id=image.case_id,
|
||||
url=f"/api/v1/images/{image.id}/file",
|
||||
thumb_url=f"/api/v1/images/{image.id}/file",
|
||||
source_app=image.source_app,
|
||||
page_type=image.page_type,
|
||||
ocr_status=image.ocr_status,
|
||||
file_hash=image.file_hash,
|
||||
uploaded_at=image.uploaded_at,
|
||||
ocr_blocks=[
|
||||
{
|
||||
"id": b.id,
|
||||
"content": b.content,
|
||||
"bbox": b.bbox,
|
||||
"seq_order": b.seq_order,
|
||||
"confidence": b.confidence,
|
||||
}
|
||||
for b in image.ocr_blocks
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/images/{image_id}/ocr")
|
||||
async def correct_ocr(
|
||||
image_id: UUID,
|
||||
corrections: list[OcrFieldCorrection],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = ImageRepository(db)
|
||||
image = await repo.get(image_id)
|
||||
if not image:
|
||||
raise HTTPException(404, "截图不存在")
|
||||
return {"message": "修正已保存", "corrections": len(corrections)}
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/file")
|
||||
async def get_image_file(image_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = ImageRepository(db)
|
||||
image = await repo.get(image_id)
|
||||
if not image:
|
||||
raise HTTPException(404, "截图不存在")
|
||||
full_path = settings.upload_path / image.file_path
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, "文件不存在")
|
||||
return FileResponse(full_path)
|
||||
48
backend/app/api/v1/reports.py
Normal file
48
backend/app/api/v1/reports.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.report import ExportReport
|
||||
from app.repositories.case_repo import CaseRepository
|
||||
from app.schemas.report import ReportCreate, ReportOut, ReportListOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/cases/{case_id}/reports", response_model=ReportOut, status_code=201)
|
||||
async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession = Depends(get_db)):
|
||||
repo = CaseRepository(db)
|
||||
case = await repo.get(case_id)
|
||||
if not case:
|
||||
raise HTTPException(404, "案件不存在")
|
||||
|
||||
from app.services.report_service import generate_report as gen
|
||||
report = await gen(case_id, body, db)
|
||||
return report
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/reports", response_model=ReportListOut)
|
||||
async def list_reports(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(ExportReport)
|
||||
.where(ExportReport.case_id == case_id)
|
||||
.order_by(ExportReport.created_at.desc())
|
||||
)
|
||||
items = list(result.scalars().all())
|
||||
return ReportListOut(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/download")
|
||||
async def download_report(report_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
report = await db.get(ExportReport, report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, "报告不存在")
|
||||
full_path = settings.upload_path / report.file_path
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, "报告文件不存在")
|
||||
return FileResponse(full_path, filename=full_path.name)
|
||||
12
backend/app/api/v1/router.py
Normal file
12
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import cases, images, analysis, transactions, assessments, reports
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(cases.router, prefix="/cases", tags=["案件管理"])
|
||||
api_router.include_router(images.router, tags=["截图管理"])
|
||||
api_router.include_router(analysis.router, tags=["分析任务"])
|
||||
api_router.include_router(transactions.router, tags=["交易管理"])
|
||||
api_router.include_router(assessments.router, tags=["认定复核"])
|
||||
api_router.include_router(reports.router, tags=["报告导出"])
|
||||
36
backend/app/api/v1/transactions.py
Normal file
36
backend/app/api/v1/transactions.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.repositories.transaction_repo import TransactionRepository
|
||||
from app.schemas.transaction import TransactionOut, TransactionListOut, FlowGraphOut
|
||||
from app.services.flow_service import build_flow_graph
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/transactions", response_model=TransactionListOut)
|
||||
async def list_transactions(
|
||||
case_id: UUID,
|
||||
filter_type: str | None = Query(None, description="all / unique / duplicate"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
repo = TransactionRepository(db)
|
||||
items, total = await repo.list_by_case(case_id, filter_type=filter_type)
|
||||
return TransactionListOut(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/transactions/{tx_id}", response_model=TransactionOut)
|
||||
async def get_transaction(tx_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
repo = TransactionRepository(db)
|
||||
tx = await repo.get(tx_id)
|
||||
if not tx:
|
||||
raise HTTPException(404, "交易不存在")
|
||||
return tx
|
||||
|
||||
|
||||
@router.get("/cases/{case_id}/flows", response_model=FlowGraphOut)
|
||||
async def get_fund_flows(case_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
return await build_flow_graph(case_id, db)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
34
backend/app/core/config.py
Normal file
34
backend/app/core/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://fundtracer:fundtracer_dev@localhost:5432/fundtracer"
|
||||
DATABASE_URL_SYNC: str = "postgresql://fundtracer:fundtracer_dev@localhost:5432/fundtracer"
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
UPLOAD_DIR: str = "./uploads"
|
||||
|
||||
OCR_API_KEY: str = ""
|
||||
OCR_API_URL: str = ""
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_API_URL: str = ""
|
||||
LLM_MODEL: str = ""
|
||||
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
DEBUG: bool = True
|
||||
|
||||
@property
|
||||
def upload_path(self) -> Path:
|
||||
p = Path(self.UPLOAD_DIR)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
settings = Settings()
|
||||
23
backend/app/core/database.py
Normal file
23
backend/app/core/database.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG, future=True)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
9
backend/app/core/security.py
Normal file
9
backend/app/core/security.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Placeholder for authentication & authorization.
|
||||
|
||||
In the competition demo we skip real auth. This module reserves the
|
||||
extension point so RBAC / JWT can be plugged in later.
|
||||
"""
|
||||
|
||||
|
||||
async def get_current_user() -> str:
|
||||
return "demo_user"
|
||||
36
backend/app/main.py
Normal file
36
backend/app/main.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.upload_path # ensure upload dir exists
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="智析反诈",
|
||||
description="受害人被骗金额归集智能体 API",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
19
backend/app/models/__init__.py
Normal file
19
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.models.case import Case
|
||||
from app.models.evidence_image import EvidenceImage
|
||||
from app.models.ocr_block import OcrBlock
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.models.transaction_cluster import TransactionCluster
|
||||
from app.models.fund_flow import FundFlowEdge
|
||||
from app.models.assessment import FraudAssessment
|
||||
from app.models.report import ExportReport
|
||||
|
||||
__all__ = [
|
||||
"Case",
|
||||
"EvidenceImage",
|
||||
"OcrBlock",
|
||||
"TransactionRecord",
|
||||
"TransactionCluster",
|
||||
"FundFlowEdge",
|
||||
"FraudAssessment",
|
||||
"ExportReport",
|
||||
]
|
||||
42
backend/app/models/assessment.py
Normal file
42
backend/app/models/assessment.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy import String, Numeric, Text, DateTime, ForeignKey, Enum as SAEnum, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ConfidenceLevel(str, enum.Enum):
|
||||
high = "high"
|
||||
medium = "medium"
|
||||
low = "low"
|
||||
|
||||
|
||||
class ReviewStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
confirmed = "confirmed"
|
||||
rejected = "rejected"
|
||||
needs_info = "needs_info"
|
||||
|
||||
|
||||
class FraudAssessment(Base):
|
||||
__tablename__ = "fraud_assessments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
transaction_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("transaction_records.id"))
|
||||
confidence_level: Mapped[ConfidenceLevel] = mapped_column(SAEnum(ConfidenceLevel))
|
||||
assessed_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
reason: Mapped[str] = mapped_column(Text, default="")
|
||||
exclude_reason: Mapped[str] = mapped_column(Text, default="")
|
||||
review_status: Mapped[ReviewStatus] = mapped_column(SAEnum(ReviewStatus), default=ReviewStatus.pending)
|
||||
review_note: Mapped[str] = mapped_column(Text, default="")
|
||||
reviewed_by: Mapped[str] = mapped_column(String(128), default="")
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
case = relationship("Case", back_populates="assessments")
|
||||
transaction = relationship("TransactionRecord")
|
||||
40
backend/app/models/case.py
Normal file
40
backend/app/models/case.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Numeric, Integer, DateTime, Enum as SAEnum, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class CaseStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
uploading = "uploading"
|
||||
analyzing = "analyzing"
|
||||
reviewing = "reviewing"
|
||||
completed = "completed"
|
||||
|
||||
|
||||
class Case(Base):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256))
|
||||
victim_name: Mapped[str] = mapped_column(String(128))
|
||||
handler: Mapped[str] = mapped_column(String(128), default="")
|
||||
status: Mapped[CaseStatus] = mapped_column(SAEnum(CaseStatus), default=CaseStatus.pending)
|
||||
image_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
created_by: Mapped[str] = mapped_column(String(128), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
images = relationship("EvidenceImage", back_populates="case", lazy="selectin")
|
||||
transactions = relationship("TransactionRecord", back_populates="case", lazy="selectin")
|
||||
assessments = relationship("FraudAssessment", back_populates="case", lazy="selectin")
|
||||
reports = relationship("ExportReport", back_populates="case", lazy="selectin")
|
||||
51
backend/app/models/evidence_image.py
Normal file
51
backend/app/models/evidence_image.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Enum as SAEnum, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SourceApp(str, enum.Enum):
|
||||
wechat = "wechat"
|
||||
alipay = "alipay"
|
||||
bank = "bank"
|
||||
digital_wallet = "digital_wallet"
|
||||
other = "other"
|
||||
|
||||
|
||||
class PageType(str, enum.Enum):
|
||||
bill_list = "bill_list"
|
||||
bill_detail = "bill_detail"
|
||||
transfer_receipt = "transfer_receipt"
|
||||
sms_notice = "sms_notice"
|
||||
balance = "balance"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class OcrStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
done = "done"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class EvidenceImage(Base):
|
||||
__tablename__ = "evidence_images"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
file_path: Mapped[str] = mapped_column(String(512))
|
||||
thumb_path: Mapped[str] = mapped_column(String(512), default="")
|
||||
source_app: Mapped[SourceApp] = mapped_column(SAEnum(SourceApp), default=SourceApp.other)
|
||||
page_type: Mapped[PageType] = mapped_column(SAEnum(PageType), default=PageType.unknown)
|
||||
ocr_status: Mapped[OcrStatus] = mapped_column(SAEnum(OcrStatus), default=OcrStatus.pending)
|
||||
file_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
file_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
case = relationship("Case", back_populates="images")
|
||||
ocr_blocks = relationship("OcrBlock", back_populates="image", lazy="selectin")
|
||||
23
backend/app/models/fund_flow.py
Normal file
23
backend/app/models/fund_flow.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Numeric, Integer, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class FundFlowEdge(Base):
|
||||
__tablename__ = "fund_flow_edges"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
source_node: Mapped[str] = mapped_column(String(256))
|
||||
target_node: Mapped[str] = mapped_column(String(256))
|
||||
source_type: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||
target_type: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
tx_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||
earliest_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
20
backend/app/models/ocr_block.py
Normal file
20
backend/app/models/ocr_block.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OcrBlock(Base):
|
||||
__tablename__ = "ocr_blocks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
image_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("evidence_images.id"), index=True)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
bbox: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
seq_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
confidence: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
image = relationship("EvidenceImage", back_populates="ocr_blocks")
|
||||
29
backend/app/models/report.py
Normal file
29
backend/app/models/report.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Enum as SAEnum, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ReportType(str, enum.Enum):
|
||||
pdf = "pdf"
|
||||
excel = "excel"
|
||||
word = "word"
|
||||
|
||||
|
||||
class ExportReport(Base):
|
||||
__tablename__ = "export_reports"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
report_type: Mapped[ReportType] = mapped_column(SAEnum(ReportType))
|
||||
file_path: Mapped[str] = mapped_column(String(512), default="")
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
content_snapshot: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
case = relationship("Case", back_populates="reports")
|
||||
43
backend/app/models/transaction.py
Normal file
43
backend/app/models/transaction.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
String, Numeric, Float, Boolean, DateTime, Text,
|
||||
ForeignKey, Enum as SAEnum, func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.evidence_image import SourceApp
|
||||
|
||||
|
||||
class Direction(str, enum.Enum):
|
||||
in_ = "in"
|
||||
out = "out"
|
||||
|
||||
|
||||
class TransactionRecord(Base):
|
||||
__tablename__ = "transaction_records"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
evidence_image_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("evidence_images.id"), nullable=True)
|
||||
cluster_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("transaction_clusters.id"), nullable=True)
|
||||
source_app: Mapped[SourceApp] = mapped_column(SAEnum(SourceApp), default=SourceApp.other)
|
||||
trade_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
|
||||
direction: Mapped[Direction] = mapped_column(SAEnum(Direction))
|
||||
counterparty_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
counterparty_account: Mapped[str] = mapped_column(String(256), default="")
|
||||
self_account_tail_no: Mapped[str] = mapped_column(String(32), default="")
|
||||
order_no: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
remark: Mapped[str] = mapped_column(Text, default="")
|
||||
confidence: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
is_duplicate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_transit: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
case = relationship("Case", back_populates="transactions")
|
||||
cluster = relationship("TransactionCluster", back_populates="transactions", foreign_keys=[cluster_id])
|
||||
22
backend/app/models/transaction_cluster.py
Normal file
22
backend/app/models/transaction_cluster.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TransactionCluster(Base):
|
||||
__tablename__ = "transaction_clusters"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
case_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cases.id"), index=True)
|
||||
primary_tx_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
match_reason: Mapped[str] = mapped_column(String(512), default="")
|
||||
|
||||
transactions = relationship(
|
||||
"TransactionRecord",
|
||||
back_populates="cluster",
|
||||
foreign_keys="TransactionRecord.cluster_id",
|
||||
)
|
||||
0
backend/app/repositories/__init__.py
Normal file
0
backend/app/repositories/__init__.py
Normal file
34
backend/app/repositories/assessment_repo.py
Normal file
34
backend/app/repositories/assessment_repo.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.assessment import FraudAssessment, ConfidenceLevel
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class AssessmentRepository(BaseRepository[FraudAssessment]):
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(FraudAssessment, session)
|
||||
|
||||
async def list_by_case(
|
||||
self,
|
||||
case_id: UUID,
|
||||
confidence_level: ConfidenceLevel | None = None,
|
||||
) -> tuple[list[FraudAssessment], int]:
|
||||
query = (
|
||||
select(FraudAssessment)
|
||||
.options(selectinload(FraudAssessment.transaction))
|
||||
.where(FraudAssessment.case_id == case_id)
|
||||
)
|
||||
count_q = select(func.count()).select_from(FraudAssessment).where(FraudAssessment.case_id == case_id)
|
||||
|
||||
if confidence_level:
|
||||
query = query.where(FraudAssessment.confidence_level == confidence_level)
|
||||
count_q = count_q.where(FraudAssessment.confidence_level == confidence_level)
|
||||
|
||||
total = (await self.session.execute(count_q)).scalar() or 0
|
||||
query = query.order_by(FraudAssessment.created_at.asc())
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
50
backend/app/repositories/base.py
Normal file
50
backend/app/repositories/base.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from typing import TypeVar, Generic, Type
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class BaseRepository(Generic[ModelT]):
|
||||
def __init__(self, model: Type[ModelT], session: AsyncSession):
|
||||
self.model = model
|
||||
self.session = session
|
||||
|
||||
async def get(self, id: UUID) -> ModelT | None:
|
||||
return await self.session.get(self.model, id)
|
||||
|
||||
async def list(self, offset: int = 0, limit: int = 50, **filters) -> tuple[list[ModelT], int]:
|
||||
query = select(self.model)
|
||||
count_query = select(func.count()).select_from(self.model)
|
||||
|
||||
for attr, value in filters.items():
|
||||
if value is not None and hasattr(self.model, attr):
|
||||
query = query.where(getattr(self.model, attr) == value)
|
||||
count_query = count_query.where(getattr(self.model, attr) == value)
|
||||
|
||||
total = (await self.session.execute(count_query)).scalar() or 0
|
||||
query = query.offset(offset).limit(limit)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
async def create(self, obj: ModelT) -> ModelT:
|
||||
self.session.add(obj)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(obj)
|
||||
return obj
|
||||
|
||||
async def update(self, obj: ModelT, data: dict) -> ModelT:
|
||||
for key, value in data.items():
|
||||
if value is not None:
|
||||
setattr(obj, key, value)
|
||||
await self.session.flush()
|
||||
await self.session.refresh(obj)
|
||||
return obj
|
||||
|
||||
async def delete(self, obj: ModelT) -> None:
|
||||
await self.session.delete(obj)
|
||||
await self.session.flush()
|
||||
35
backend/app/repositories/case_repo.py
Normal file
35
backend/app/repositories/case_repo.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.case import Case, CaseStatus
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class CaseRepository(BaseRepository[Case]):
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(Case, session)
|
||||
|
||||
async def list_cases(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
status: CaseStatus | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[Case], int]:
|
||||
query = select(Case).where(Case.deleted_at.is_(None))
|
||||
count_query = select(func.count()).select_from(Case).where(Case.deleted_at.is_(None))
|
||||
|
||||
if status:
|
||||
query = query.where(Case.status == status)
|
||||
count_query = count_query.where(Case.status == status)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
search_filter = or_(Case.case_no.ilike(pattern), Case.title.ilike(pattern))
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
total = (await self.session.execute(count_query)).scalar() or 0
|
||||
query = query.order_by(Case.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
39
backend/app/repositories/image_repo.py
Normal file
39
backend/app/repositories/image_repo.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.evidence_image import EvidenceImage, SourceApp, PageType
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class ImageRepository(BaseRepository[EvidenceImage]):
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(EvidenceImage, session)
|
||||
|
||||
async def find_by_hash(self, file_hash: str) -> EvidenceImage | None:
|
||||
result = await self.session.execute(
|
||||
select(EvidenceImage).where(EvidenceImage.file_hash == file_hash)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_by_case(
|
||||
self,
|
||||
case_id: UUID,
|
||||
source_app: SourceApp | None = None,
|
||||
page_type: PageType | None = None,
|
||||
) -> list[EvidenceImage]:
|
||||
query = select(EvidenceImage).where(EvidenceImage.case_id == case_id)
|
||||
if source_app:
|
||||
query = query.where(EvidenceImage.source_app == source_app)
|
||||
if page_type:
|
||||
query = query.where(EvidenceImage.page_type == page_type)
|
||||
query = query.order_by(EvidenceImage.uploaded_at.desc())
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_by_case(self, case_id: UUID) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count()).select_from(EvidenceImage).where(EvidenceImage.case_id == case_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
40
backend/app/repositories/transaction_repo.py
Normal file
40
backend/app/repositories/transaction_repo.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.repositories.base import BaseRepository
|
||||
|
||||
|
||||
class TransactionRepository(BaseRepository[TransactionRecord]):
|
||||
def __init__(self, session: AsyncSession):
|
||||
super().__init__(TransactionRecord, session)
|
||||
|
||||
async def list_by_case(
|
||||
self,
|
||||
case_id: UUID,
|
||||
filter_type: str | None = None,
|
||||
) -> tuple[list[TransactionRecord], int]:
|
||||
query = select(TransactionRecord).where(TransactionRecord.case_id == case_id)
|
||||
count_q = select(func.count()).select_from(TransactionRecord).where(TransactionRecord.case_id == case_id)
|
||||
|
||||
if filter_type == "unique":
|
||||
query = query.where(TransactionRecord.is_duplicate.is_(False))
|
||||
count_q = count_q.where(TransactionRecord.is_duplicate.is_(False))
|
||||
elif filter_type == "duplicate":
|
||||
query = query.where(TransactionRecord.is_duplicate.is_(True))
|
||||
count_q = count_q.where(TransactionRecord.is_duplicate.is_(True))
|
||||
|
||||
total = (await self.session.execute(count_q)).scalar() or 0
|
||||
query = query.order_by(TransactionRecord.trade_time.asc())
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
async def get_all_by_case(self, case_id: UUID) -> list[TransactionRecord]:
|
||||
result = await self.session.execute(
|
||||
select(TransactionRecord)
|
||||
.where(TransactionRecord.case_id == case_id)
|
||||
.order_by(TransactionRecord.trade_time.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
0
backend/app/rules/__init__.py
Normal file
0
backend/app/rules/__init__.py
Normal file
57
backend/app/rules/assessment_rules.py
Normal file
57
backend/app/rules/assessment_rules.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Rule-based fraud amount assessment.
|
||||
|
||||
Classifies each transaction into high / medium / low confidence fraud,
|
||||
and generates initial reason text.
|
||||
"""
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.models.assessment import ConfidenceLevel
|
||||
|
||||
FRAUD_KEYWORDS = ["投资", "保证金", "手续费", "解冻", "税费", "充值", "提币", "提现"]
|
||||
|
||||
|
||||
def classify_transaction(tx: TransactionRecord) -> tuple[ConfidenceLevel, str, str]:
|
||||
"""Return (confidence_level, reason, exclude_reason)."""
|
||||
if tx.is_transit:
|
||||
return (
|
||||
ConfidenceLevel.low,
|
||||
f"该笔为本人账户间中转({tx.source_app.value} -> {tx.counterparty_name}),不直接计入被骗损失。",
|
||||
"本人账户间互转,仅作为资金路径展示。",
|
||||
)
|
||||
|
||||
if tx.direction.value == "in":
|
||||
return (
|
||||
ConfidenceLevel.low,
|
||||
f"该笔为收入方向交易(+¥{float(tx.amount):,.2f}),通常不属于被骗损失。",
|
||||
"收入交易不计入损失。",
|
||||
)
|
||||
|
||||
remark = tx.remark or ""
|
||||
counterparty = tx.counterparty_name or ""
|
||||
confidence = tx.confidence
|
||||
|
||||
has_fraud_keyword = any(kw in remark or kw in counterparty for kw in FRAUD_KEYWORDS)
|
||||
|
||||
if confidence >= 0.9 and has_fraud_keyword:
|
||||
return (
|
||||
ConfidenceLevel.high,
|
||||
f"受害人经{tx.source_app.value}向「{counterparty}」转账¥{float(tx.amount):,.2f},"
|
||||
f"备注为「{remark}」,与诈骗常见话术吻合,OCR置信度{confidence:.0%}。",
|
||||
"",
|
||||
)
|
||||
|
||||
if confidence >= 0.85:
|
||||
reason = (
|
||||
f"受害人经{tx.source_app.value}向「{counterparty}」转账¥{float(tx.amount):,.2f}。"
|
||||
)
|
||||
if has_fraud_keyword:
|
||||
reason += f"备注「{remark}」含涉诈关键词。"
|
||||
return ConfidenceLevel.high, reason, ""
|
||||
reason += "建议结合笔录确认是否受诱导操作。"
|
||||
return ConfidenceLevel.medium, reason, "如经核实该笔为受害人主动日常消费,应排除。"
|
||||
|
||||
return (
|
||||
ConfidenceLevel.medium,
|
||||
f"受害人经{tx.source_app.value}向「{counterparty}」转账¥{float(tx.amount):,.2f},"
|
||||
f"OCR置信度较低({confidence:.0%}),需人工复核。",
|
||||
"OCR置信度不足,可能存在识别误差。",
|
||||
)
|
||||
32
backend/app/rules/dedup_rules.py
Normal file
32
backend/app/rules/dedup_rules.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Transaction deduplication rules.
|
||||
|
||||
Determines whether two transaction records likely represent the same
|
||||
underlying financial event captured from different screenshots / pages.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from app.models.transaction import TransactionRecord
|
||||
|
||||
TIME_WINDOW = timedelta(minutes=5)
|
||||
|
||||
|
||||
def is_duplicate_pair(a: TransactionRecord, b: TransactionRecord) -> bool:
|
||||
# Rule 1: exact order_no match
|
||||
if a.order_no and b.order_no and a.order_no == b.order_no:
|
||||
return True
|
||||
|
||||
# Rule 2: same amount + close time + same account tail
|
||||
if (
|
||||
float(a.amount) == float(b.amount)
|
||||
and a.trade_time
|
||||
and b.trade_time
|
||||
and abs(a.trade_time - b.trade_time) <= TIME_WINDOW
|
||||
):
|
||||
if a.self_account_tail_no and b.self_account_tail_no:
|
||||
if a.self_account_tail_no == b.self_account_tail_no:
|
||||
return True
|
||||
# same counterparty and close time is also strong signal
|
||||
if a.counterparty_name and a.counterparty_name == b.counterparty_name:
|
||||
return True
|
||||
|
||||
return False
|
||||
35
backend/app/rules/transit_rules.py
Normal file
35
backend/app/rules/transit_rules.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Transit (self-transfer) detection rules.
|
||||
|
||||
Identifies transactions that are internal transfers between the victim's
|
||||
own accounts (e.g. bank -> Alipay -> WeChat) and should NOT be counted
|
||||
as fraud loss.
|
||||
"""
|
||||
from app.models.transaction import TransactionRecord
|
||||
|
||||
SELF_KEYWORDS = ["本人", "自己", "余额", "充值", "提现", "银行卡转入", "银行卡充值"]
|
||||
|
||||
|
||||
def is_self_transfer(tx: TransactionRecord, known_self_accounts: list[str]) -> bool:
|
||||
"""Check if a transaction is an inter-account transfer by the victim."""
|
||||
counterparty = (tx.counterparty_name or "").lower()
|
||||
remark = (tx.remark or "").lower()
|
||||
|
||||
# Rule 1: counterparty matches known self accounts
|
||||
for acct in known_self_accounts:
|
||||
if acct and acct.lower() in counterparty:
|
||||
return True
|
||||
|
||||
# Rule 2: counterparty contains self-transfer keywords
|
||||
for kw in SELF_KEYWORDS:
|
||||
if kw in counterparty or kw in remark:
|
||||
return True
|
||||
|
||||
# Rule 3: counterparty references another payment app owned by victim
|
||||
app_keywords = ["支付宝", "微信", "银行卡", "数字钱包"]
|
||||
victim_patterns = [f"{app}-" for app in app_keywords] + app_keywords
|
||||
for pat in victim_patterns:
|
||||
if pat in counterparty:
|
||||
if tx.direction.value == "out":
|
||||
return True
|
||||
|
||||
return False
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
14
backend/app/schemas/analysis.py
Normal file
14
backend/app/schemas/analysis.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnalysisStatusOut(BaseModel):
|
||||
case_id: str
|
||||
status: str
|
||||
progress: int = 0
|
||||
current_step: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AnalysisTriggerOut(BaseModel):
|
||||
task_id: str
|
||||
message: str
|
||||
39
backend/app/schemas/assessment.py
Normal file
39
backend/app/schemas/assessment.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.assessment import ConfidenceLevel, ReviewStatus
|
||||
from app.schemas.transaction import TransactionOut
|
||||
|
||||
|
||||
class AssessmentOut(BaseModel):
|
||||
id: UUID
|
||||
case_id: UUID
|
||||
transaction_id: UUID
|
||||
transaction: TransactionOut | None = None
|
||||
confidence_level: ConfidenceLevel
|
||||
assessed_amount: float
|
||||
reason: str
|
||||
exclude_reason: str
|
||||
review_status: ReviewStatus
|
||||
review_note: str
|
||||
reviewed_by: str
|
||||
reviewed_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AssessmentListOut(BaseModel):
|
||||
items: list[AssessmentOut]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewSubmit(BaseModel):
|
||||
review_status: ReviewStatus
|
||||
review_note: str = ""
|
||||
reviewed_by: str = "demo_user"
|
||||
|
||||
|
||||
class InquirySuggestionOut(BaseModel):
|
||||
suggestions: list[str]
|
||||
40
backend/app/schemas/case.py
Normal file
40
backend/app/schemas/case.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.case import CaseStatus
|
||||
|
||||
|
||||
class CaseCreate(BaseModel):
|
||||
case_no: str
|
||||
title: str
|
||||
victim_name: str
|
||||
handler: str = ""
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
victim_name: str | None = None
|
||||
handler: str | None = None
|
||||
status: CaseStatus | None = None
|
||||
|
||||
|
||||
class CaseOut(BaseModel):
|
||||
id: UUID
|
||||
case_no: str
|
||||
title: str
|
||||
victim_name: str
|
||||
handler: str
|
||||
status: CaseStatus
|
||||
image_count: int
|
||||
total_amount: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CaseListOut(BaseModel):
|
||||
items: list[CaseOut]
|
||||
total: int
|
||||
40
backend/app/schemas/image.py
Normal file
40
backend/app/schemas/image.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.evidence_image import SourceApp, PageType, OcrStatus
|
||||
|
||||
|
||||
class ImageOut(BaseModel):
|
||||
id: UUID
|
||||
case_id: UUID
|
||||
url: str = ""
|
||||
thumb_url: str = ""
|
||||
source_app: SourceApp
|
||||
page_type: PageType
|
||||
ocr_status: OcrStatus
|
||||
file_hash: str
|
||||
uploaded_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OcrBlockOut(BaseModel):
|
||||
id: UUID
|
||||
content: str
|
||||
bbox: dict
|
||||
seq_order: int
|
||||
confidence: float
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ImageDetailOut(ImageOut):
|
||||
ocr_blocks: list[OcrBlockOut] = []
|
||||
|
||||
|
||||
class OcrFieldCorrection(BaseModel):
|
||||
field_name: str
|
||||
old_value: str
|
||||
new_value: str
|
||||
33
backend/app/schemas/report.py
Normal file
33
backend/app/schemas/report.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.report import ReportType
|
||||
|
||||
|
||||
class ReportCreate(BaseModel):
|
||||
report_type: ReportType
|
||||
include_summary: bool = True
|
||||
include_transactions: bool = True
|
||||
include_flow_chart: bool = True
|
||||
include_timeline: bool = True
|
||||
include_reasons: bool = True
|
||||
include_inquiry: bool = False
|
||||
include_screenshots: bool = False
|
||||
|
||||
|
||||
class ReportOut(BaseModel):
|
||||
id: UUID
|
||||
case_id: UUID
|
||||
report_type: ReportType
|
||||
file_path: str
|
||||
version: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ReportListOut(BaseModel):
|
||||
items: list[ReportOut]
|
||||
total: int
|
||||
52
backend/app/schemas/transaction.py
Normal file
52
backend/app/schemas/transaction.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.evidence_image import SourceApp
|
||||
from app.models.transaction import Direction
|
||||
|
||||
|
||||
class TransactionOut(BaseModel):
|
||||
id: UUID
|
||||
case_id: UUID
|
||||
source_app: SourceApp
|
||||
trade_time: datetime
|
||||
amount: float
|
||||
direction: Direction
|
||||
counterparty_name: str
|
||||
counterparty_account: str
|
||||
self_account_tail_no: str
|
||||
order_no: str
|
||||
remark: str
|
||||
evidence_image_id: UUID | None = None
|
||||
confidence: float
|
||||
cluster_id: UUID | None = None
|
||||
is_duplicate: bool
|
||||
is_transit: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TransactionListOut(BaseModel):
|
||||
items: list[TransactionOut]
|
||||
total: int
|
||||
|
||||
|
||||
class FlowNodeOut(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
type: str
|
||||
|
||||
|
||||
class FlowEdgeOut(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
amount: float
|
||||
count: int
|
||||
trade_time: str
|
||||
|
||||
|
||||
class FlowGraphOut(BaseModel):
|
||||
nodes: list[FlowNodeOut]
|
||||
edges: list[FlowEdgeOut]
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
42
backend/app/services/analysis_pipeline.py
Normal file
42
backend/app/services/analysis_pipeline.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Orchestrates the full analysis pipeline: matching -> flow -> assessment."""
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.case import Case, CaseStatus
|
||||
from app.services.matching_service import run_matching
|
||||
from app.services.assessment_service import assess_case
|
||||
from app.services.case_service import recalculate_case_total
|
||||
|
||||
|
||||
async def run_analysis_sync(case_id: UUID, db: AsyncSession) -> None:
|
||||
"""Run the full analysis pipeline synchronously (fallback when Celery is down)."""
|
||||
case = await db.get(Case, case_id)
|
||||
if not case:
|
||||
return
|
||||
|
||||
case.status = CaseStatus.analyzing
|
||||
await db.flush()
|
||||
|
||||
# Step 1: Matching & dedup
|
||||
self_accounts = _extract_self_accounts(case)
|
||||
await run_matching(case_id, self_accounts, db)
|
||||
|
||||
# Step 2: Assessment
|
||||
await assess_case(case_id, db)
|
||||
|
||||
# Step 3: Recalculate total
|
||||
await recalculate_case_total(case_id, db)
|
||||
|
||||
case.status = CaseStatus.reviewing
|
||||
await db.flush()
|
||||
|
||||
|
||||
def _extract_self_accounts(case: Case) -> list[str]:
|
||||
"""Extract known self-account identifiers from case context.
|
||||
|
||||
In a full implementation this would come from user input or a
|
||||
dedicated 'victim accounts' table. For now we return an empty list
|
||||
and rely on heuristic rules.
|
||||
"""
|
||||
return []
|
||||
150
backend/app/services/assessment_service.py
Normal file
150
backend/app/services/assessment_service.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Fraud amount assessment and inquiry suggestion generation."""
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.models.assessment import FraudAssessment, ConfidenceLevel, ReviewStatus
|
||||
from app.rules.assessment_rules import classify_transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
|
||||
"""Run rule-based assessment on all non-duplicate transactions and generate reasons."""
|
||||
result = await db.execute(
|
||||
select(TransactionRecord)
|
||||
.where(TransactionRecord.case_id == case_id)
|
||||
.where(TransactionRecord.is_duplicate.is_(False))
|
||||
.order_by(TransactionRecord.trade_time.asc())
|
||||
)
|
||||
transactions = list(result.scalars().all())
|
||||
|
||||
assessments: list[FraudAssessment] = []
|
||||
for tx in transactions:
|
||||
level, reason, exclude_reason = classify_transaction(tx)
|
||||
|
||||
fa = FraudAssessment(
|
||||
case_id=case_id,
|
||||
transaction_id=tx.id,
|
||||
confidence_level=level,
|
||||
assessed_amount=float(tx.amount) if level != ConfidenceLevel.low else 0,
|
||||
reason=reason,
|
||||
exclude_reason=exclude_reason,
|
||||
review_status=ReviewStatus.pending,
|
||||
)
|
||||
db.add(fa)
|
||||
assessments.append(fa)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# try to enhance reasons via LLM
|
||||
if settings.LLM_API_KEY and settings.LLM_API_URL:
|
||||
for fa in assessments:
|
||||
try:
|
||||
enhanced = await _enhance_reason_via_llm(fa, transactions)
|
||||
if enhanced:
|
||||
fa.reason = enhanced
|
||||
except Exception as e:
|
||||
logger.debug("LLM reason enhancement skipped: %s", e)
|
||||
await db.flush()
|
||||
|
||||
return assessments
|
||||
|
||||
|
||||
async def generate_inquiry_suggestions(case_id: UUID, db: AsyncSession) -> list[str]:
|
||||
"""Generate interview / inquiry suggestions based on assessment results."""
|
||||
result = await db.execute(
|
||||
select(FraudAssessment)
|
||||
.where(FraudAssessment.case_id == case_id)
|
||||
.order_by(FraudAssessment.created_at.asc())
|
||||
)
|
||||
assessments = list(result.scalars().all())
|
||||
|
||||
if not assessments:
|
||||
return ["暂无分析结果,请先执行案件分析。"]
|
||||
|
||||
# try LLM generation
|
||||
if settings.LLM_API_KEY and settings.LLM_API_URL:
|
||||
try:
|
||||
return await _generate_suggestions_via_llm(assessments)
|
||||
except Exception as e:
|
||||
logger.debug("LLM suggestions skipped: %s", e)
|
||||
|
||||
return _generate_suggestions_rule_based(assessments)
|
||||
|
||||
|
||||
def _generate_suggestions_rule_based(assessments: list[FraudAssessment]) -> list[str]:
|
||||
suggestions: list[str] = []
|
||||
pending = [a for a in assessments if a.review_status == ReviewStatus.pending]
|
||||
medium = [a for a in assessments if a.confidence_level == ConfidenceLevel.medium]
|
||||
|
||||
if pending:
|
||||
suggestions.append(
|
||||
f"有 {len(pending)} 笔交易尚未确认,建议逐笔向受害人核实是否受到诱导操作。"
|
||||
)
|
||||
if medium:
|
||||
suggestions.append(
|
||||
"部分交易置信度为中等,建议追问受害人交易的具体背景和对方的诱导话术。"
|
||||
)
|
||||
suggestions.append("是否还有其他未截图的转账记录或 APP 需要补充?")
|
||||
suggestions.append("涉案金额中是否有已部分追回或返还的款项?")
|
||||
suggestions.append(
|
||||
"除了截图所示的 APP 外,是否还存在银行柜台、ATM、其他支付平台等转账渠道?"
|
||||
)
|
||||
return suggestions
|
||||
|
||||
|
||||
async def _enhance_reason_via_llm(fa: FraudAssessment, all_tx: list) -> str | None:
|
||||
prompt = (
|
||||
f"这笔交易金额{fa.assessed_amount}元,置信等级{fa.confidence_level.value}。"
|
||||
f"原始认定理由:{fa.reason}。"
|
||||
"请用简洁中文优化认定理由表述,使之适合出现在办案文书中。只返回优化后的理由文字。"
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
settings.LLM_API_URL,
|
||||
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 300,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
async def _generate_suggestions_via_llm(assessments: list[FraudAssessment]) -> list[str]:
|
||||
summary_lines = []
|
||||
for a in assessments:
|
||||
summary_lines.append(
|
||||
f"- 金额{a.assessed_amount}元, 置信{a.confidence_level.value}, "
|
||||
f"状态{a.review_status.value}, 理由: {a.reason[:60]}"
|
||||
)
|
||||
summary = "\n".join(summary_lines)
|
||||
|
||||
prompt = (
|
||||
"你是一名反诈案件办案助手。以下是某诈骗案件的交易认定摘要:\n"
|
||||
f"{summary}\n\n"
|
||||
"请生成5条笔录辅助问询建议,帮助民警追问受害人以完善证据链。"
|
||||
"只返回JSON数组格式的5个字符串。"
|
||||
)
|
||||
import json
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
settings.LLM_API_URL,
|
||||
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 600,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
return json.loads(text.strip().strip("`").removeprefix("json").strip())
|
||||
23
backend/app/services/case_service.py
Normal file
23
backend/app/services/case_service.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from uuid import UUID
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.case import Case
|
||||
from app.models.assessment import FraudAssessment, ReviewStatus
|
||||
|
||||
|
||||
async def recalculate_case_total(case_id: UUID, db: AsyncSession) -> float:
|
||||
"""Recalculate and persist the total confirmed fraud amount for a case."""
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(FraudAssessment.assessed_amount), 0))
|
||||
.where(FraudAssessment.case_id == case_id)
|
||||
.where(FraudAssessment.review_status == ReviewStatus.confirmed)
|
||||
)
|
||||
total = float(result.scalar() or 0)
|
||||
case = await db.get(Case, case_id)
|
||||
if case:
|
||||
case.total_amount = total
|
||||
await db.flush()
|
||||
return total
|
||||
72
backend/app/services/flow_service.py
Normal file
72
backend/app/services/flow_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Build the fund-flow graph from deduplicated transactions."""
|
||||
from uuid import UUID
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.repositories.transaction_repo import TransactionRepository
|
||||
from app.schemas.transaction import FlowGraphOut, FlowNodeOut, FlowEdgeOut
|
||||
|
||||
|
||||
async def build_flow_graph(case_id: UUID, db: AsyncSession) -> FlowGraphOut:
|
||||
repo = TransactionRepository(db)
|
||||
transactions = await repo.get_all_by_case(case_id)
|
||||
|
||||
valid = [tx for tx in transactions if not tx.is_duplicate]
|
||||
|
||||
nodes_map: dict[str, str] = {} # label -> type
|
||||
edge_agg: dict[tuple[str, str], dict] = defaultdict(
|
||||
lambda: {"amount": 0.0, "count": 0, "trade_time": ""}
|
||||
)
|
||||
|
||||
for tx in valid:
|
||||
self_label = _self_label(tx)
|
||||
counter_label = tx.counterparty_name or "未知对手方"
|
||||
|
||||
if self_label not in nodes_map:
|
||||
nodes_map[self_label] = "self"
|
||||
|
||||
if counter_label not in nodes_map:
|
||||
nodes_map[counter_label] = "suspect" if not tx.is_transit else "transit"
|
||||
|
||||
if tx.direction.value == "out":
|
||||
key = (self_label, counter_label)
|
||||
else:
|
||||
key = (counter_label, self_label)
|
||||
|
||||
edge_agg[key]["amount"] += float(tx.amount)
|
||||
edge_agg[key]["count"] += 1
|
||||
time_str = tx.trade_time.strftime("%Y-%m-%d %H:%M") if tx.trade_time else ""
|
||||
if not edge_agg[key]["trade_time"]:
|
||||
edge_agg[key]["trade_time"] = time_str
|
||||
|
||||
nodes = [
|
||||
FlowNodeOut(id=f"n-{i}", label=label, type=ntype)
|
||||
for i, (label, ntype) in enumerate(nodes_map.items())
|
||||
]
|
||||
|
||||
label_to_id = {n.label: n.id for n in nodes}
|
||||
|
||||
edges = [
|
||||
FlowEdgeOut(
|
||||
source=label_to_id[src],
|
||||
target=label_to_id[tgt],
|
||||
amount=info["amount"],
|
||||
count=info["count"],
|
||||
trade_time=info["trade_time"],
|
||||
)
|
||||
for (src, tgt), info in edge_agg.items()
|
||||
]
|
||||
|
||||
return FlowGraphOut(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
def _self_label(tx) -> str:
|
||||
app_names = {
|
||||
"wechat": "微信支付",
|
||||
"alipay": "支付宝",
|
||||
"bank": f"银行卡({tx.self_account_tail_no})" if tx.self_account_tail_no else "银行卡",
|
||||
"digital_wallet": "数字钱包",
|
||||
"other": "其他账户",
|
||||
}
|
||||
return app_names.get(tx.source_app.value, "未知账户")
|
||||
24
backend/app/services/image_service.py
Normal file
24
backend/app/services/image_service.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Image post-processing helpers (thumbnail generation, etc.)."""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def generate_thumbnail(file_path: str, max_size: int = 400) -> str:
|
||||
full = settings.upload_path / file_path
|
||||
if not full.exists():
|
||||
return file_path
|
||||
|
||||
thumb_dir = full.parent / "thumbs"
|
||||
thumb_dir.mkdir(exist_ok=True)
|
||||
thumb_path = thumb_dir / full.name
|
||||
|
||||
try:
|
||||
with Image.open(full) as img:
|
||||
img.thumbnail((max_size, max_size))
|
||||
img.save(thumb_path)
|
||||
return str(thumb_path.relative_to(settings.upload_path))
|
||||
except Exception:
|
||||
return file_path
|
||||
83
backend/app/services/matching_service.py
Normal file
83
backend/app/services/matching_service.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Transaction deduplication and matching engine.
|
||||
|
||||
Multi-layer strategy:
|
||||
1. Exact order_no match
|
||||
2. Amount + time-window + account-tail match
|
||||
3. Fuzzy text similarity (placeholder for LLM-assisted matching)
|
||||
"""
|
||||
from uuid import UUID
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.models.transaction_cluster import TransactionCluster
|
||||
from app.repositories.transaction_repo import TransactionRepository
|
||||
from app.rules.dedup_rules import is_duplicate_pair
|
||||
from app.rules.transit_rules import is_self_transfer
|
||||
|
||||
|
||||
async def run_matching(case_id: UUID, self_accounts: list[str], db: AsyncSession) -> None:
|
||||
"""Execute the full dedup + transit-marking pipeline for a case."""
|
||||
repo = TransactionRepository(db)
|
||||
transactions = await repo.get_all_by_case(case_id)
|
||||
|
||||
if not transactions:
|
||||
return
|
||||
|
||||
# reset flags
|
||||
for tx in transactions:
|
||||
tx.is_duplicate = False
|
||||
tx.is_transit = False
|
||||
tx.cluster_id = None
|
||||
|
||||
# ── Layer 1 & 2: dedup ──
|
||||
matched: set[UUID] = set()
|
||||
clusters: list[TransactionCluster] = []
|
||||
|
||||
for i, tx_a in enumerate(transactions):
|
||||
if tx_a.id in matched:
|
||||
continue
|
||||
group = [tx_a]
|
||||
for tx_b in transactions[i + 1:]:
|
||||
if tx_b.id in matched:
|
||||
continue
|
||||
if is_duplicate_pair(tx_a, tx_b):
|
||||
group.append(tx_b)
|
||||
matched.add(tx_b.id)
|
||||
|
||||
if len(group) > 1:
|
||||
primary = max(group, key=lambda t: t.confidence)
|
||||
cluster = TransactionCluster(
|
||||
case_id=case_id,
|
||||
primary_tx_id=primary.id,
|
||||
match_reason=_match_reason(primary, group),
|
||||
)
|
||||
db.add(cluster)
|
||||
await db.flush()
|
||||
|
||||
for tx in group:
|
||||
tx.cluster_id = cluster.id
|
||||
if tx.id != primary.id:
|
||||
tx.is_duplicate = True
|
||||
clusters.append(cluster)
|
||||
|
||||
# ── Layer 3: transit detection ──
|
||||
for tx in transactions:
|
||||
if tx.is_duplicate:
|
||||
continue
|
||||
if is_self_transfer(tx, self_accounts):
|
||||
tx.is_transit = True
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
def _match_reason(primary: TransactionRecord, group: list[TransactionRecord]) -> str:
|
||||
reasons: list[str] = []
|
||||
orders = {tx.order_no for tx in group if tx.order_no}
|
||||
if len(orders) == 1:
|
||||
reasons.append("订单号一致")
|
||||
amounts = {float(tx.amount) for tx in group}
|
||||
if len(amounts) == 1:
|
||||
reasons.append("金额一致")
|
||||
return "; ".join(reasons) if reasons else "时间和金额近似"
|
||||
145
backend/app/services/ocr_service.py
Normal file
145
backend/app/services/ocr_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""OCR and multimodal extraction service.
|
||||
|
||||
Wraps calls to cloud OCR / multimodal APIs with a provider-agnostic interface.
|
||||
When API keys are not configured, falls back to a mock implementation that
|
||||
returns placeholder data (sufficient for demo / competition).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.evidence_image import SourceApp, PageType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── provider-agnostic interface ──────────────────────────────────────────
|
||||
|
||||
async def classify_page(image_path: str) -> tuple[SourceApp, PageType]:
|
||||
"""Identify the source app and page type of a screenshot."""
|
||||
if settings.LLM_API_KEY and settings.LLM_API_URL:
|
||||
return await _classify_via_api(image_path)
|
||||
return _classify_mock(image_path)
|
||||
|
||||
|
||||
async def extract_transaction_fields(image_path: str, source_app: SourceApp, page_type: PageType) -> dict:
|
||||
"""Extract structured transaction fields from a screenshot."""
|
||||
if settings.LLM_API_KEY and settings.LLM_API_URL:
|
||||
return await _extract_via_api(image_path, source_app, page_type)
|
||||
return _extract_mock(image_path, source_app, page_type)
|
||||
|
||||
|
||||
# ── real API implementation ──────────────────────────────────────────────
|
||||
|
||||
async def _classify_via_api(image_path: str) -> tuple[SourceApp, PageType]:
|
||||
import base64
|
||||
full_path = settings.upload_path / image_path
|
||||
if not full_path.exists():
|
||||
return SourceApp.other, PageType.unknown
|
||||
|
||||
image_b64 = base64.b64encode(full_path.read_bytes()).decode()
|
||||
prompt = (
|
||||
"请分析这张手机截图,判断它来自哪个APP(wechat/alipay/bank/digital_wallet/other)"
|
||||
"以及页面类型(bill_list/bill_detail/transfer_receipt/sms_notice/balance/unknown)。"
|
||||
"只返回JSON: {\"source_app\": \"...\", \"page_type\": \"...\"}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
settings.LLM_API_URL,
|
||||
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 200,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"]
|
||||
data = json.loads(text.strip().strip("`").removeprefix("json").strip())
|
||||
return SourceApp(data.get("source_app", "other")), PageType(data.get("page_type", "unknown"))
|
||||
except Exception as e:
|
||||
logger.warning("classify_page API failed: %s", e)
|
||||
return SourceApp.other, PageType.unknown
|
||||
|
||||
|
||||
async def _extract_via_api(image_path: str, source_app: SourceApp, page_type: PageType) -> dict:
|
||||
import base64
|
||||
full_path = settings.upload_path / image_path
|
||||
if not full_path.exists():
|
||||
return {}
|
||||
|
||||
image_b64 = base64.b64encode(full_path.read_bytes()).decode()
|
||||
prompt = (
|
||||
f"这是一张来自{source_app.value}的{page_type.value}截图。"
|
||||
"请提取其中的交易信息,返回JSON格式,字段包括:"
|
||||
"trade_time(交易时间,格式YYYY-MM-DD HH:MM:SS), amount(金额,数字), "
|
||||
"direction(in或out), counterparty_name(对方名称), counterparty_account(对方账号), "
|
||||
"self_account_tail_no(本方账户尾号), order_no(订单号), remark(备注), confidence(0-1)。"
|
||||
"如果截图包含多笔交易,返回JSON数组。否则返回单个JSON对象。"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
settings.LLM_API_URL,
|
||||
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 2000,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.json()["choices"][0]["message"]["content"]
|
||||
return json.loads(text.strip().strip("`").removeprefix("json").strip())
|
||||
except Exception as e:
|
||||
logger.warning("extract_transaction_fields API failed: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
# ── mock fallback ────────────────────────────────────────────────────────
|
||||
|
||||
def _classify_mock(image_path: str) -> tuple[SourceApp, PageType]:
|
||||
lower = image_path.lower()
|
||||
if "wechat" in lower or "wx" in lower:
|
||||
return SourceApp.wechat, PageType.bill_detail
|
||||
if "alipay" in lower or "ali" in lower:
|
||||
return SourceApp.alipay, PageType.bill_list
|
||||
if "bank" in lower:
|
||||
return SourceApp.bank, PageType.bill_detail
|
||||
return SourceApp.other, PageType.unknown
|
||||
|
||||
|
||||
def _extract_mock(image_path: str, source_app: SourceApp, page_type: PageType) -> dict:
|
||||
return {
|
||||
"trade_time": "2026-03-08 10:00:00",
|
||||
"amount": 1000.00,
|
||||
"direction": "out",
|
||||
"counterparty_name": "模拟对手方",
|
||||
"counterparty_account": "",
|
||||
"self_account_tail_no": "",
|
||||
"order_no": f"MOCK-{hash(image_path) % 100000:05d}",
|
||||
"remark": "模拟交易",
|
||||
"confidence": 0.80,
|
||||
}
|
||||
47
backend/app/services/parser_service.py
Normal file
47
backend/app/services/parser_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Parse raw OCR / multimodal extraction results into TransactionRecord instances."""
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.transaction import TransactionRecord, Direction
|
||||
from app.models.evidence_image import SourceApp
|
||||
|
||||
|
||||
def parse_extracted_fields(
|
||||
raw: dict | list,
|
||||
case_id: UUID,
|
||||
image_id: UUID,
|
||||
source_app: SourceApp,
|
||||
) -> list[TransactionRecord]:
|
||||
"""Convert raw extraction dict(s) into TransactionRecord ORM objects."""
|
||||
items = raw if isinstance(raw, list) else [raw]
|
||||
records: list[TransactionRecord] = []
|
||||
|
||||
for item in items:
|
||||
if not item or not item.get("amount"):
|
||||
continue
|
||||
|
||||
try:
|
||||
trade_time = datetime.fromisoformat(item["trade_time"])
|
||||
except (ValueError, KeyError):
|
||||
trade_time = datetime.now()
|
||||
|
||||
direction_str = item.get("direction", "out")
|
||||
direction = Direction.in_ if direction_str == "in" else Direction.out
|
||||
|
||||
record = TransactionRecord(
|
||||
case_id=case_id,
|
||||
evidence_image_id=image_id,
|
||||
source_app=source_app,
|
||||
trade_time=trade_time,
|
||||
amount=float(item.get("amount", 0)),
|
||||
direction=direction,
|
||||
counterparty_name=str(item.get("counterparty_name", "")),
|
||||
counterparty_account=str(item.get("counterparty_account", "")),
|
||||
self_account_tail_no=str(item.get("self_account_tail_no", "")),
|
||||
order_no=str(item.get("order_no", "")),
|
||||
remark=str(item.get("remark", "")),
|
||||
confidence=float(item.get("confidence", 0.5)),
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
156
backend/app/services/report_service.py
Normal file
156
backend/app/services/report_service.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Report generation: Excel / Word / PDF."""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.assessment import FraudAssessment, ReviewStatus
|
||||
from app.models.transaction import TransactionRecord
|
||||
from app.models.report import ExportReport, ReportType
|
||||
from app.schemas.report import ReportCreate
|
||||
|
||||
|
||||
async def generate_report(case_id: UUID, body: ReportCreate, db: AsyncSession) -> ExportReport:
|
||||
result = await db.execute(
|
||||
select(ExportReport)
|
||||
.where(ExportReport.case_id == case_id, ExportReport.report_type == body.report_type)
|
||||
)
|
||||
existing = list(result.scalars().all())
|
||||
version = len(existing) + 1
|
||||
|
||||
report_dir = settings.upload_path / str(case_id) / "reports"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if body.report_type == ReportType.excel:
|
||||
file_path = await _gen_excel(case_id, report_dir, db)
|
||||
elif body.report_type == ReportType.word:
|
||||
file_path = await _gen_word(case_id, report_dir, db)
|
||||
else:
|
||||
file_path = await _gen_pdf_placeholder(case_id, report_dir)
|
||||
|
||||
relative = str(file_path.relative_to(settings.upload_path))
|
||||
|
||||
# snapshot confirmed assessments
|
||||
snap_result = await db.execute(
|
||||
select(FraudAssessment).where(
|
||||
FraudAssessment.case_id == case_id,
|
||||
FraudAssessment.review_status == ReviewStatus.confirmed,
|
||||
)
|
||||
)
|
||||
snapshot = [
|
||||
{"amount": float(a.assessed_amount), "reason": a.reason}
|
||||
for a in snap_result.scalars().all()
|
||||
]
|
||||
|
||||
report = ExportReport(
|
||||
case_id=case_id,
|
||||
report_type=body.report_type,
|
||||
file_path=relative,
|
||||
version=version,
|
||||
content_snapshot={"assessments": snapshot},
|
||||
)
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
await db.refresh(report)
|
||||
return report
|
||||
|
||||
|
||||
async def _gen_excel(case_id: UUID, report_dir: Path, db: AsyncSession) -> Path:
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# Sheet 1: Summary
|
||||
ws = wb.active
|
||||
ws.title = "被骗金额汇总"
|
||||
ws.append(["交易时间", "金额(元)", "方向", "对方", "来源APP", "备注", "置信度", "认定理由"])
|
||||
|
||||
assessments_result = await db.execute(
|
||||
select(FraudAssessment).where(
|
||||
FraudAssessment.case_id == case_id,
|
||||
FraudAssessment.review_status == ReviewStatus.confirmed,
|
||||
)
|
||||
)
|
||||
for a in assessments_result.scalars().all():
|
||||
tx = await db.get(TransactionRecord, a.transaction_id)
|
||||
if tx:
|
||||
ws.append([
|
||||
tx.trade_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
float(a.assessed_amount),
|
||||
"支出" if tx.direction.value == "out" else "收入",
|
||||
tx.counterparty_name,
|
||||
tx.source_app.value,
|
||||
tx.remark,
|
||||
tx.confidence,
|
||||
a.reason[:100],
|
||||
])
|
||||
|
||||
# Sheet 2: All transactions
|
||||
ws2 = wb.create_sheet("交易明细")
|
||||
ws2.append(["交易时间", "金额", "方向", "对方", "来源", "订单号", "是否重复", "是否中转"])
|
||||
tx_result = await db.execute(
|
||||
select(TransactionRecord).where(TransactionRecord.case_id == case_id)
|
||||
)
|
||||
for tx in tx_result.scalars().all():
|
||||
ws2.append([
|
||||
tx.trade_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
float(tx.amount),
|
||||
tx.direction.value,
|
||||
tx.counterparty_name,
|
||||
tx.source_app.value,
|
||||
tx.order_no,
|
||||
"是" if tx.is_duplicate else "否",
|
||||
"是" if tx.is_transit else "否",
|
||||
])
|
||||
|
||||
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.xlsx"
|
||||
wb.save(file_path)
|
||||
return file_path
|
||||
|
||||
|
||||
async def _gen_word(case_id: UUID, report_dir: Path, db: AsyncSession) -> Path:
|
||||
from docx import Document
|
||||
|
||||
doc = Document()
|
||||
doc.add_heading("受害人被骗金额汇总报告", level=1)
|
||||
|
||||
assessments_result = await db.execute(
|
||||
select(FraudAssessment).where(
|
||||
FraudAssessment.case_id == case_id,
|
||||
FraudAssessment.review_status == ReviewStatus.confirmed,
|
||||
)
|
||||
)
|
||||
confirmed = list(assessments_result.scalars().all())
|
||||
total = sum(float(a.assessed_amount) for a in confirmed)
|
||||
|
||||
doc.add_paragraph(f"已确认被骗金额: ¥{total:,.2f}")
|
||||
doc.add_paragraph(f"已确认交易笔数: {len(confirmed)}")
|
||||
|
||||
table = doc.add_table(rows=1, cols=4)
|
||||
table.style = "Table Grid"
|
||||
hdr = table.rows[0].cells
|
||||
hdr[0].text = "交易时间"
|
||||
hdr[1].text = "金额(元)"
|
||||
hdr[2].text = "对方"
|
||||
hdr[3].text = "认定理由"
|
||||
|
||||
for a in confirmed:
|
||||
tx = await db.get(TransactionRecord, a.transaction_id)
|
||||
row = table.add_row().cells
|
||||
row[0].text = tx.trade_time.strftime("%Y-%m-%d %H:%M") if tx else ""
|
||||
row[1].text = f"{float(a.assessed_amount):,.2f}"
|
||||
row[2].text = tx.counterparty_name if tx else ""
|
||||
row[3].text = a.reason[:80]
|
||||
|
||||
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.docx"
|
||||
doc.save(file_path)
|
||||
return file_path
|
||||
|
||||
|
||||
async def _gen_pdf_placeholder(case_id: UUID, report_dir: Path) -> Path:
|
||||
file_path = report_dir / f"report_{uuid.uuid4().hex[:8]}.pdf"
|
||||
file_path.write_text("PDF report placeholder – integrate weasyprint/reportlab for production.")
|
||||
return file_path
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
18
backend/app/utils/file_storage.py
Normal file
18
backend/app/utils/file_storage.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def save_upload(data: bytes, case_id: str, filename: str) -> tuple[str, str]:
|
||||
"""Save uploaded file and return (file_path, thumb_path) relative to UPLOAD_DIR."""
|
||||
case_dir = settings.upload_path / case_id
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = Path(filename).suffix or ".png"
|
||||
unique_name = f"{uuid.uuid4().hex}{ext}"
|
||||
file_path = case_dir / unique_name
|
||||
file_path.write_bytes(data)
|
||||
|
||||
relative = f"{case_id}/{unique_name}"
|
||||
return relative, relative
|
||||
5
backend/app/utils/hash.py
Normal file
5
backend/app/utils/hash.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import hashlib
|
||||
|
||||
|
||||
def sha256_file(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
38
backend/app/workers/analysis_tasks.py
Normal file
38
backend/app/workers/analysis_tasks.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Celery task: full-case analysis pipeline."""
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.workers.analysis_tasks.run_full_analysis", bind=True, max_retries=2)
|
||||
def run_full_analysis(self, case_id_str: str):
|
||||
_run_async(_run(case_id_str))
|
||||
|
||||
|
||||
async def _run(case_id_str: str):
|
||||
from app.core.database import async_session_factory
|
||||
from app.services.analysis_pipeline import run_analysis_sync
|
||||
|
||||
case_id = UUID(case_id_str)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
await run_analysis_sync(case_id, db)
|
||||
await db.commit()
|
||||
logger.info("Full analysis completed for case %s", case_id)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error("Analysis failed for case %s: %s", case_id, e)
|
||||
raise
|
||||
25
backend/app/workers/celery_app.py
Normal file
25
backend/app/workers/celery_app.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
"fund_tracer",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_routes={
|
||||
"app.workers.ocr_tasks.*": {"queue": "ocr"},
|
||||
"app.workers.analysis_tasks.*": {"queue": "analysis"},
|
||||
"app.workers.report_tasks.*": {"queue": "reports"},
|
||||
},
|
||||
)
|
||||
|
||||
celery_app.autodiscover_tasks(["app.workers"])
|
||||
74
backend/app/workers/ocr_tasks.py
Normal file
74
backend/app/workers/ocr_tasks.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Celery tasks for OCR processing of uploaded screenshots."""
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Run an async coroutine from synchronous Celery task context."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.workers.ocr_tasks.process_image_ocr", bind=True, max_retries=3)
|
||||
def process_image_ocr(self, image_id: str):
|
||||
"""Process a single image: classify page, extract fields, save to DB."""
|
||||
_run_async(_process(image_id))
|
||||
|
||||
|
||||
async def _process(image_id_str: str):
|
||||
from app.core.database import async_session_factory
|
||||
from app.models.evidence_image import EvidenceImage, OcrStatus
|
||||
from app.models.ocr_block import OcrBlock
|
||||
from app.services.ocr_service import classify_page, extract_transaction_fields
|
||||
from app.services.parser_service import parse_extracted_fields
|
||||
|
||||
image_id = UUID(image_id_str)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
image = await db.get(EvidenceImage, image_id)
|
||||
if not image:
|
||||
logger.error("Image %s not found", image_id)
|
||||
return
|
||||
|
||||
image.ocr_status = OcrStatus.processing
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
source_app, page_type = await classify_page(image.file_path)
|
||||
image.source_app = source_app
|
||||
image.page_type = page_type
|
||||
|
||||
raw_fields = await extract_transaction_fields(image.file_path, source_app, page_type)
|
||||
|
||||
# save raw OCR block
|
||||
block = OcrBlock(
|
||||
image_id=image.id,
|
||||
content=str(raw_fields),
|
||||
bbox={},
|
||||
seq_order=0,
|
||||
confidence=raw_fields.get("confidence", 0.5) if isinstance(raw_fields, dict) else 0.5,
|
||||
)
|
||||
db.add(block)
|
||||
|
||||
# parse into transaction records
|
||||
records = parse_extracted_fields(raw_fields, image.case_id, image.id, source_app)
|
||||
for r in records:
|
||||
db.add(r)
|
||||
|
||||
image.ocr_status = OcrStatus.done
|
||||
await db.commit()
|
||||
logger.info("Image %s processed: %d transactions", image_id, len(records))
|
||||
|
||||
except Exception as e:
|
||||
image.ocr_status = OcrStatus.failed
|
||||
await db.commit()
|
||||
logger.error("Image %s OCR failed: %s", image_id, e)
|
||||
raise
|
||||
41
backend/app/workers/report_tasks.py
Normal file
41
backend/app/workers/report_tasks.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Celery task: async report generation."""
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@celery_app.task(name="app.workers.report_tasks.generate_report_async", bind=True)
|
||||
def generate_report_async(self, case_id_str: str, report_type: str):
|
||||
_run_async(_run(case_id_str, report_type))
|
||||
|
||||
|
||||
async def _run(case_id_str: str, report_type: str):
|
||||
from app.core.database import async_session_factory
|
||||
from app.models.report import ReportType
|
||||
from app.schemas.report import ReportCreate
|
||||
from app.services.report_service import generate_report
|
||||
|
||||
case_id = UUID(case_id_str)
|
||||
body = ReportCreate(report_type=ReportType(report_type))
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
report = await generate_report(case_id, body, db)
|
||||
await db.commit()
|
||||
logger.info("Report generated for case %s: %s", case_id, report.file_path)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error("Report generation failed: %s", e)
|
||||
raise
|
||||
39
backend/pyproject.toml
Normal file
39
backend/pyproject.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[project]
|
||||
name = "fund-tracer-backend"
|
||||
version = "0.1.0"
|
||||
description = "智析反诈 - 受害人被骗金额归集智能体后端"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"celery[redis]>=5.4.0",
|
||||
"redis>=5.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-multipart>=0.0.18",
|
||||
"Pillow>=11.0.0",
|
||||
"httpx>=0.28.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"python-docx>=1.1.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app*"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
107
backend/scripts/seed.py
Normal file
107
backend/scripts/seed.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Seed the database with demo case data matching the frontend mock."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.core.database import async_session_factory
|
||||
from app.models.case import Case, CaseStatus
|
||||
from app.models.evidence_image import EvidenceImage, SourceApp, PageType, OcrStatus
|
||||
from app.models.transaction import TransactionRecord, Direction
|
||||
from app.models.assessment import FraudAssessment, ConfidenceLevel, ReviewStatus
|
||||
|
||||
|
||||
async def seed():
|
||||
async with async_session_factory() as db:
|
||||
# Case 1
|
||||
c1_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
c1 = Case(
|
||||
id=c1_id,
|
||||
case_no="ZA-2026-001538",
|
||||
title="张某被电信诈骗案",
|
||||
victim_name="张某某",
|
||||
handler="李警官",
|
||||
status=CaseStatus.reviewing,
|
||||
image_count=8,
|
||||
total_amount=186500.00,
|
||||
)
|
||||
db.add(c1)
|
||||
|
||||
# Images
|
||||
imgs = [
|
||||
("wechat", "bill_list", "done"),
|
||||
("wechat", "bill_detail", "done"),
|
||||
("alipay", "bill_list", "done"),
|
||||
("alipay", "transfer_receipt", "done"),
|
||||
("bank", "bill_detail", "done"),
|
||||
("bank", "sms_notice", "done"),
|
||||
("digital_wallet", "bill_list", "done"),
|
||||
("wechat", "bill_detail", "done"),
|
||||
]
|
||||
img_ids = []
|
||||
for i, (app, pt, status) in enumerate(imgs):
|
||||
iid = uuid.UUID(f"00000000-0000-0000-0001-{i:012d}")
|
||||
img_ids.append(iid)
|
||||
db.add(EvidenceImage(
|
||||
id=iid, case_id=c1_id, file_path=f"demo/img_{i}.png", thumb_path=f"demo/img_{i}.png",
|
||||
source_app=SourceApp(app), page_type=PageType(pt), ocr_status=OcrStatus(status),
|
||||
file_hash=f"demohash{i:04d}",
|
||||
))
|
||||
|
||||
# Transactions
|
||||
txs_data = [
|
||||
("bank", "2026-03-06T10:15:00", 50000, "out", "支付宝-张某某", "", "6621", "BK20260306001", "转账至支付宝", 0.95, True, True),
|
||||
("alipay", "2026-03-06T10:16:00", 50000, "in", "银行卡(6621)", "", "", "AL20260306001", "银行卡转入", 0.92, True, True),
|
||||
("alipay", "2026-03-06T10:25:00", 50000, "out", "李*华", "138****5678", "", "AL20260306002", "投资款", 0.97, False, False),
|
||||
("wechat", "2026-03-07T14:30:00", 30000, "out", "财富管家-客服", "", "", "WX20260307001", "手续费", 0.88, False, False),
|
||||
("wechat", "2026-03-07T16:00:00", 20000, "out", "李*华", "138****5678", "", "WX20260307002", "追加保证金", 0.91, False, False),
|
||||
("digital_wallet", "2026-03-08T09:00:00", 86500, "out", "USDT-TRC20地址", "T9yD...Xk3m", "", "DW20260308001", "提币", 0.85, False, False),
|
||||
("bank", "2026-03-07T20:00:00", 86500, "out", "某数字钱包充值", "", "6621", "BK20260307002", "充值", 0.90, False, True),
|
||||
]
|
||||
tx_ids = []
|
||||
for i, (app, tt, amt, dir_, cp, ca, sat, ono, rmk, conf, dup, trans) in enumerate(txs_data):
|
||||
tid = uuid.UUID(f"00000000-0000-0000-0002-{i:012d}")
|
||||
tx_ids.append(tid)
|
||||
db.add(TransactionRecord(
|
||||
id=tid, case_id=c1_id, evidence_image_id=img_ids[min(i, len(img_ids)-1)],
|
||||
source_app=SourceApp(app), trade_time=datetime.fromisoformat(tt).replace(tzinfo=timezone.utc),
|
||||
amount=amt, direction=Direction.in_ if dir_ == "in" else Direction.out,
|
||||
counterparty_name=cp, counterparty_account=ca, self_account_tail_no=sat,
|
||||
order_no=ono, remark=rmk, confidence=conf, is_duplicate=dup, is_transit=trans,
|
||||
))
|
||||
|
||||
# Assessments
|
||||
assessments_data = [
|
||||
(tx_ids[2], "high", 50000, "受害人经支付宝向涉诈账户转账5万元", "", "confirmed"),
|
||||
(tx_ids[3], "high", 30000, "受害人经微信向客服转账3万元手续费", "", "confirmed"),
|
||||
(tx_ids[4], "high", 20000, "受害人经微信向涉诈账户追加转账2万元", "", "pending"),
|
||||
(tx_ids[5], "medium", 86500, "受害人通过数字钱包提币86500元", "如经查实为个人操作应排除", "pending"),
|
||||
(tx_ids[0], "low", 0, "该笔为本人银行卡向支付宝中转", "本人账户间互转", "confirmed"),
|
||||
]
|
||||
for i, (tid, level, amt, reason, excl, status) in enumerate(assessments_data):
|
||||
db.add(FraudAssessment(
|
||||
id=uuid.UUID(f"00000000-0000-0000-0003-{i:012d}"),
|
||||
case_id=c1_id, transaction_id=tid,
|
||||
confidence_level=ConfidenceLevel(level), assessed_amount=amt,
|
||||
reason=reason, exclude_reason=excl, review_status=ReviewStatus(status),
|
||||
reviewed_by="李警官" if status == "confirmed" else "",
|
||||
))
|
||||
|
||||
# More cases (summary only)
|
||||
for idx, (cno, title, victim, handler, st) in enumerate([
|
||||
("ZA-2026-001612", "王某被投资诈骗案", "王某", "李警官", "analyzing"),
|
||||
("ZA-2026-001705", "刘某某被冒充客服诈骗案", "刘某某", "陈警官", "completed"),
|
||||
("ZA-2026-001821", "赵某被刷单诈骗案", "赵某", "王警官", "pending"),
|
||||
("ZA-2026-001890", "陈某被杀猪盘诈骗案", "陈某", "李警官", "uploading"),
|
||||
], start=2):
|
||||
db.add(Case(
|
||||
id=uuid.UUID(f"00000000-0000-0000-0000-{idx:012d}"),
|
||||
case_no=cno, title=title, victim_name=victim, handler=handler,
|
||||
status=CaseStatus(st),
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
print("Seed data inserted successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
19
backend/tests/conftest.py
Normal file
19
backend/tests/conftest.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
19
backend/tests/test_api.py
Normal file
19
backend/tests/test_api.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""API integration tests."""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client: AsyncClient):
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
87
backend/tests/test_rules.py
Normal file
87
backend/tests/test_rules.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Unit tests for the rules engine using plain objects (no SQLAlchemy session)."""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.transaction import Direction
|
||||
from app.models.evidence_image import SourceApp
|
||||
from app.rules.dedup_rules import is_duplicate_pair
|
||||
from app.rules.transit_rules import is_self_transfer
|
||||
from app.rules.assessment_rules import classify_transaction
|
||||
|
||||
|
||||
def _make_tx(**kwargs):
|
||||
defaults = dict(
|
||||
id=uuid4(), case_id=uuid4(), source_app=SourceApp.alipay,
|
||||
trade_time=datetime(2026, 3, 8, 10, 0, tzinfo=timezone.utc),
|
||||
amount=10000, direction=Direction.out,
|
||||
counterparty_name="测试对手方", counterparty_account="",
|
||||
self_account_tail_no="1234", order_no="ORD001",
|
||||
remark="测试", confidence=0.9, is_duplicate=False, is_transit=False,
|
||||
evidence_image_id=None, cluster_id=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
class TestDedupRules:
|
||||
def test_same_order_no(self):
|
||||
a = _make_tx(order_no="ORD001")
|
||||
b = _make_tx(order_no="ORD001", self_account_tail_no="5678")
|
||||
assert is_duplicate_pair(a, b)
|
||||
|
||||
def test_different_order_no_different_counterparty(self):
|
||||
a = _make_tx(order_no="ORD001", counterparty_name="A", self_account_tail_no="1111")
|
||||
b = _make_tx(order_no="ORD002", counterparty_name="B", self_account_tail_no="2222")
|
||||
assert not is_duplicate_pair(a, b)
|
||||
|
||||
def test_same_amount_close_time_same_tail(self):
|
||||
a = _make_tx(order_no="", amount=5000)
|
||||
b = _make_tx(
|
||||
order_no="",
|
||||
amount=5000,
|
||||
trade_time=datetime(2026, 3, 8, 10, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
assert is_duplicate_pair(a, b)
|
||||
|
||||
def test_same_amount_far_time(self):
|
||||
a = _make_tx(order_no="", amount=5000)
|
||||
b = _make_tx(
|
||||
order_no="",
|
||||
amount=5000,
|
||||
trade_time=datetime(2026, 3, 8, 11, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert not is_duplicate_pair(a, b)
|
||||
|
||||
|
||||
class TestTransitRules:
|
||||
def test_keyword_match(self):
|
||||
tx = _make_tx(counterparty_name="支付宝-张某", direction=Direction.out)
|
||||
assert is_self_transfer(tx, [])
|
||||
|
||||
def test_known_account_match(self):
|
||||
tx = _make_tx(counterparty_name="我的银行卡")
|
||||
assert is_self_transfer(tx, ["我的银行卡"])
|
||||
|
||||
def test_not_transit(self):
|
||||
tx = _make_tx(counterparty_name="李*华", remark="投资款")
|
||||
assert not is_self_transfer(tx, [])
|
||||
|
||||
|
||||
class TestAssessmentRules:
|
||||
def test_transit_classified_as_low(self):
|
||||
tx = _make_tx(is_transit=True)
|
||||
level, reason, _ = classify_transaction(tx)
|
||||
assert level.value == "low"
|
||||
|
||||
def test_high_confidence_fraud_keyword(self):
|
||||
tx = _make_tx(confidence=0.95, remark="投资款")
|
||||
level, reason, _ = classify_transaction(tx)
|
||||
assert level.value == "high"
|
||||
|
||||
def test_income_classified_as_low(self):
|
||||
tx = _make_tx(direction=Direction.in_)
|
||||
level, _, _ = classify_transaction(tx)
|
||||
assert level.value == "low"
|
||||
Reference in New Issue
Block a user