first commit

This commit is contained in:
2026-03-11 16:28:04 +08:00
commit c0f9ddabbf
101 changed files with 11601 additions and 0 deletions

6
backend/.env.example Normal file
View 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
View 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
View 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()

View 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
View File

View File

View File

View 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}",
)

View 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)

View 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

View 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)

View 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)

View 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=["报告导出"])

View 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)

View File

View 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()

View 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

View 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
View 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"}

View 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",
]

View 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")

View 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")

View 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")

View 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())

View 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")

View 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")

View 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])

View 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",
)

View File

View 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

View 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()

View 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

View 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

View 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())

View File

View 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置信度不足可能存在识别误差。",
)

View 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

View 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

View File

View 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

View 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]

View 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

View 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

View 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

View 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]

View File

View 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 []

View 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())

View 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

View 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, "未知账户")

View 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

View 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 "时间和金额近似"

View 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 = (
"请分析这张手机截图判断它来自哪个APPwechat/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,
}

View 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

View 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

View File

View 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

View File

@@ -0,0 +1,5 @@
import hashlib
def sha256_file(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()

View File

View 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

View 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"])

View 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

View 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
View 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
View 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())

View File

19
backend/tests/conftest.py Normal file
View 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
View 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"

View 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"