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

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
*.egg
dist/
build/
.eggs/
*.whl
# Virtual environments
.venv/
venv/
env/
# Node
node_modules/
frontend/node_modules/
# Build output
frontend/dist/
# Environment & secrets
.env
.env.*
!.env.example
backend/.env
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Uploads & generated files
backend/uploads/
uploads/
# Database
*.db
*.sqlite3
# Pytest
.pytest_cache/
htmlcov/
.coverage
# Celery
celerybeat-schedule
celerybeat.pid
# Alembic
backend/alembic/versions/*.pyc
# Logs
*.log
# OS
Thumbs.db

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# 智析反诈 — 受害人被骗金额归集智能体
面向电信诈骗案件受害人资金梳理的多APP账单智能归集与被骗金额核验系统。
## 功能概览
- **案件管理** — 创建、查看、管理诈骗案件
- **截图上传** — 批量上传微信/支付宝/银行/数字钱包等多APP账单截图
- **OCR识别** — 云端多模态自动识别页面类型、提取交易字段,支持人工修正
- **交易归并** — 自动去重(订单号/金额+时间窗口)、识别本人账户中转
- **资金分析** — 生成资金流转关系图、交易时间轴、收款方聚合
- **认定复核** — 高/中/低置信分层,人工复核确认,自动生成认定理由
- **笔录辅助** — 基于分析结果自动生成笔录问询建议
- **报告导出** — Excel汇总表 / PDF报告 / Word文书含证据索引和审计快照
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | React 18 + TypeScript + Ant Design + ECharts + TanStack Query + Zustand |
| 后端 | Python + FastAPI + SQLAlchemy 2.x (async) + Pydantic v2 |
| 数据库 | PostgreSQL 16 |
| 队列 | Celery + Redis 7 |
| AI能力 | 云OCR / 多模态大模型API可配置 |
| 报告 | openpyxl + python-docx |
## 项目结构
```
fund-tracer/
├── frontend/ # React 前端
│ ├── src/
│ │ ├── pages/ # 7 个核心页面
│ │ ├── services/ # API 服务层
│ │ ├── store/ # Zustand 状态管理
│ │ ├── mock/ # Mock 数据(后端不可用时自动降级)
│ │ └── types/ # TypeScript 类型定义
│ └── package.json
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/v1/ # REST API 路由
│ │ ├── models/ # SQLAlchemy ORM 模型
│ │ ├── schemas/ # Pydantic 请求/响应模型
│ │ ├── services/ # 业务逻辑层
│ │ ├── rules/ # 规则引擎(去重/中转/认定)
│ │ ├── workers/ # Celery 异步任务
│ │ └── repositories/ # 数据访问层
│ ├── alembic/ # 数据库迁移
│ ├── scripts/ # 种子数据等脚本
│ └── tests/ # pytest 测试
├── infra/
│ ├── docker/ # docker-compose (PG + Redis)
│ ├── env/ # 环境变量模板
│ └── scripts/ # 一键启动脚本
└── docs/ # 文档
```
## 快速开始
### 前提条件
- Node.js >= 18
- Python >= 3.11
- Docker & Docker Compose
### 一键启动
```bash
bash infra/scripts/start-dev.sh
```
### 手动启动
```bash
# 1. 启动基础设施
cd infra/docker && docker compose up -d
# 2. 安装并启动后端
cd backend
pip install -e ".[dev]"
cp ../infra/env/.env.example .env # 按需编辑
alembic revision --autogenerate -m "init"
alembic upgrade head
python -m scripts.seed # 插入演示数据
uvicorn app.main:app --reload --port 8000
# 3. 安装并启动前端
cd frontend
npm install
npm run dev
```
### 访问
- 前端: http://localhost:5173
- 后端 API: http://localhost:8000
- API 文档: http://localhost:8000/docs
## 纯前端演示模式
即使没有后端服务,前端也能正常运行。系统会自动检测后端可用性,不可用时降级为内置 mock 数据驱动。
```bash
cd frontend && npm run dev
```
## 配置 AI 能力
`backend/.env` 中配置:
```
OCR_API_KEY=your_key
OCR_API_URL=https://api.example.com/v1/chat/completions
LLM_API_KEY=your_key
LLM_API_URL=https://api.example.com/v1/chat/completions
LLM_MODEL=model_name
```
支持 OpenAI 兼容格式的多模态 API。未配置时自动使用 mock 数据。
## 测试
```bash
cd backend
pytest tests/ -v
```
## API 端点一览
| 端点 | 方法 | 功能 |
|------|------|------|
| `/api/v1/cases` | GET/POST | 案件列表/创建 |
| `/api/v1/cases/{id}` | GET/PATCH | 案件详情/更新 |
| `/api/v1/cases/{id}/images` | GET/POST | 截图列表/上传 |
| `/api/v1/images/{id}` | GET | 截图详情+OCR结果 |
| `/api/v1/cases/{id}/analyze` | POST | 触发分析 |
| `/api/v1/cases/{id}/transactions` | GET | 交易列表 |
| `/api/v1/cases/{id}/flows` | GET | 资金流图 |
| `/api/v1/cases/{id}/assessments` | GET | 认定列表 |
| `/api/v1/assessments/{id}/review` | POST | 提交复核 |
| `/api/v1/cases/{id}/inquiry-suggestions` | GET | 问询建议 |
| `/api/v1/cases/{id}/reports` | GET/POST | 报告列表/生成 |
| `/api/v1/reports/{id}/download` | GET | 下载报告 |

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"

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智析反诈 - 受害人被骗金额归集智能体</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5397
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@ant-design/pro-components": "^2.8.10",
"@tanstack/react-query": "^5.90.21",
"antd": "^5.29.3",
"dayjs": "^1.11.19",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { useRoutes } from 'react-router-dom';
import { routes } from './routes';
export default function App() {
return useRoutes(routes);
}

26
frontend/src/global.css Normal file
View File

@@ -0,0 +1,26 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans SC', sans-serif;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Typography, theme, Space } from 'antd';
import {
FolderOpenOutlined,
DashboardOutlined,
FileImageOutlined,
SwapOutlined,
ApartmentOutlined,
AuditOutlined,
FileTextOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
const menuItems = [
{ key: '/cases', icon: <FolderOpenOutlined />, label: '案件管理' },
{ key: '/cases/1/workspace', icon: <DashboardOutlined />, label: '工作台' },
{ key: '/cases/1/screenshots', icon: <FileImageOutlined />, label: '截图管理' },
{ key: '/cases/1/transactions', icon: <SwapOutlined />, label: '交易归并' },
{ key: '/cases/1/analysis', icon: <ApartmentOutlined />, label: '资金分析' },
{ key: '/cases/1/review', icon: <AuditOutlined />, label: '认定复核' },
{ key: '/cases/1/reports', icon: <FileTextOutlined />, label: '报告导出' },
];
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const selectedKey = menuItems
.map((m) => m.key)
.filter((k) => location.pathname.startsWith(k))
.sort((a, b) => b.length - a.length)[0] || '/cases';
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
theme="light"
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
overflow: 'auto',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 100,
}}
>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Space>
<SafetyCertificateOutlined
style={{ fontSize: 24, color: token.colorPrimary }}
/>
{!collapsed && (
<Typography.Title
level={5}
style={{ margin: 0, whiteSpace: 'nowrap' }}
>
</Typography.Title>
)}
</Space>
</div>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ border: 'none' }}
/>
</Sider>
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 0.2s' }}>
<Header
style={{
padding: '0 24px',
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'sticky',
top: 0,
zIndex: 99,
}}
>
<Typography.Text strong style={{ fontSize: 15 }}>
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
· v0.1.0
</Typography.Text>
</Header>
<Content
style={{
margin: 24,
minHeight: 'calc(100vh - 64px - 48px)',
}}
>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

35
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider, App as AntApp } from 'antd';
import { QueryClientProvider } from '@tanstack/react-query';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import App from './App';
import { queryClient } from './services/queryClient';
import './global.css';
dayjs.locale('zh-cn');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#1677ff',
borderRadius: 8,
},
}}
>
<AntApp>
<App />
</AntApp>
</ConfigProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

198
frontend/src/mock/data.ts Normal file
View File

@@ -0,0 +1,198 @@
import type {
CaseRecord,
EvidenceImage,
TransactionRecord,
FraudAssessment,
FundFlowNode,
FundFlowEdge,
ExportReport,
} from '../types';
export const mockCases: CaseRecord[] = [
{
id: '1',
caseNo: 'ZA-2026-001538',
title: '张某被电信诈骗案',
victimName: '张某某',
handler: '李警官',
status: 'reviewing',
imageCount: 24,
totalAmount: 186500.0,
createdAt: '2026-03-08 09:30:00',
updatedAt: '2026-03-10 14:20:00',
},
{
id: '2',
caseNo: 'ZA-2026-001612',
title: '王某被投资诈骗案',
victimName: '王某',
handler: '李警官',
status: 'analyzing',
imageCount: 18,
totalAmount: 0,
createdAt: '2026-03-09 11:00:00',
updatedAt: '2026-03-10 16:45:00',
},
{
id: '3',
caseNo: 'ZA-2026-001705',
title: '刘某某被冒充客服诈骗案',
victimName: '刘某某',
handler: '陈警官',
status: 'completed',
imageCount: 12,
totalAmount: 53200.0,
createdAt: '2026-03-05 15:20:00',
updatedAt: '2026-03-07 10:30:00',
},
{
id: '4',
caseNo: 'ZA-2026-001821',
title: '赵某被刷单诈骗案',
victimName: '赵某',
handler: '王警官',
status: 'pending',
imageCount: 0,
totalAmount: 0,
createdAt: '2026-03-10 08:15:00',
updatedAt: '2026-03-10 08:15:00',
},
{
id: '5',
caseNo: 'ZA-2026-001890',
title: '陈某被杀猪盘诈骗案',
victimName: '陈某',
handler: '李警官',
status: 'uploading',
imageCount: 8,
totalAmount: 0,
createdAt: '2026-03-11 07:45:00',
updatedAt: '2026-03-11 08:00:00',
},
];
export const mockImages: EvidenceImage[] = [
{ id: 'img-1', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_list', ocrStatus: 'done', hash: 'a1b2', uploadedAt: '2026-03-08 09:35:00' },
{ id: 'img-2', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'done', hash: 'c3d4', uploadedAt: '2026-03-08 09:35:00' },
{ id: 'img-3', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'bill_list', ocrStatus: 'done', hash: 'e5f6', uploadedAt: '2026-03-08 09:36:00' },
{ id: 'img-4', caseId: '1', url: '', thumbUrl: '', sourceApp: 'alipay', pageType: 'transfer_receipt', ocrStatus: 'done', hash: 'g7h8', uploadedAt: '2026-03-08 09:36:00' },
{ id: 'img-5', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'bill_detail', ocrStatus: 'done', hash: 'i9j0', uploadedAt: '2026-03-08 09:37:00' },
{ id: 'img-6', caseId: '1', url: '', thumbUrl: '', sourceApp: 'bank', pageType: 'sms_notice', ocrStatus: 'processing', hash: 'k1l2', uploadedAt: '2026-03-08 09:37:00' },
{ id: 'img-7', caseId: '1', url: '', thumbUrl: '', sourceApp: 'digital_wallet', pageType: 'bill_list', ocrStatus: 'done', hash: 'm3n4', uploadedAt: '2026-03-08 09:38:00' },
{ id: 'img-8', caseId: '1', url: '', thumbUrl: '', sourceApp: 'wechat', pageType: 'bill_detail', ocrStatus: 'failed', hash: 'o5p6', uploadedAt: '2026-03-08 09:38:00' },
];
export const mockTransactions: TransactionRecord[] = [
{
id: 'tx-1', caseId: '1', sourceApp: 'bank', tradeTime: '2026-03-06 10:15:00',
amount: 50000, direction: 'out', counterpartyName: '支付宝-张某某',
counterpartyAccount: '', selfAccountTailNo: '6621', orderNo: 'BK20260306001',
remark: '转账至支付宝', evidenceImageId: 'img-5', confidence: 0.95,
clusterId: 'cl-1', isDuplicate: false, isTransit: true,
},
{
id: 'tx-2', caseId: '1', sourceApp: 'alipay', tradeTime: '2026-03-06 10:16:00',
amount: 50000, direction: 'in', counterpartyName: '银行卡(6621)',
counterpartyAccount: '', selfAccountTailNo: '', orderNo: 'AL20260306001',
remark: '银行卡转入', evidenceImageId: 'img-3', confidence: 0.92,
clusterId: 'cl-1', isDuplicate: true, isTransit: true,
},
{
id: 'tx-3', caseId: '1', sourceApp: 'alipay', tradeTime: '2026-03-06 10:25:00',
amount: 50000, direction: 'out', counterpartyName: '李*华',
counterpartyAccount: '138****5678', selfAccountTailNo: '', orderNo: 'AL20260306002',
remark: '投资款', evidenceImageId: 'img-4', confidence: 0.97,
isDuplicate: false, isTransit: false,
},
{
id: 'tx-4', caseId: '1', sourceApp: 'wechat', tradeTime: '2026-03-07 14:30:00',
amount: 30000, direction: 'out', counterpartyName: '财富管家-客服',
counterpartyAccount: '', selfAccountTailNo: '', orderNo: 'WX20260307001',
remark: '手续费', evidenceImageId: 'img-2', confidence: 0.88,
isDuplicate: false, isTransit: false,
},
{
id: 'tx-5', caseId: '1', sourceApp: 'wechat', tradeTime: '2026-03-07 16:00:00',
amount: 20000, direction: 'out', counterpartyName: '李*华',
counterpartyAccount: '138****5678', selfAccountTailNo: '', orderNo: 'WX20260307002',
remark: '追加保证金', evidenceImageId: 'img-1', confidence: 0.91,
isDuplicate: false, isTransit: false,
},
{
id: 'tx-6', caseId: '1', sourceApp: 'digital_wallet', tradeTime: '2026-03-08 09:00:00',
amount: 86500, direction: 'out', counterpartyName: 'USDT-TRC20地址',
counterpartyAccount: 'T9yD...Xk3m', selfAccountTailNo: '', orderNo: 'DW20260308001',
remark: '提币', evidenceImageId: 'img-7', confidence: 0.85,
isDuplicate: false, isTransit: false,
},
{
id: 'tx-7', caseId: '1', sourceApp: 'bank', tradeTime: '2026-03-07 20:00:00',
amount: 86500, direction: 'out', counterpartyName: '某数字钱包充值',
counterpartyAccount: '', selfAccountTailNo: '6621', orderNo: 'BK20260307002',
remark: '充值', evidenceImageId: 'img-5', confidence: 0.90,
clusterId: 'cl-2', isDuplicate: false, isTransit: true,
},
];
export const mockAssessments: FraudAssessment[] = [
{
id: 'fa-1', caseId: '1', transactionId: 'tx-3',
transaction: mockTransactions[2],
confidenceLevel: 'high', assessedAmount: 50000,
reason: '受害人经支付宝向涉诈账户"李*华(138****5678)"转账5万元标注为"投资款",与笔录中描述的对方诱导投资场景吻合。',
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:00:00',
},
{
id: 'fa-2', caseId: '1', transactionId: 'tx-4',
transaction: mockTransactions[3],
confidenceLevel: 'high', assessedAmount: 30000,
reason: '受害人经微信向"财富管家-客服"转账3万元标注为"手续费",属于诈骗常见话术诱导收取的费用。',
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:05:00',
},
{
id: 'fa-3', caseId: '1', transactionId: 'tx-5',
transaction: mockTransactions[4],
confidenceLevel: 'high', assessedAmount: 20000,
reason: '受害人经微信向同一涉诈账户"李*华"追加转账2万元标注为"追加保证金",与笔录描述一致。',
reviewStatus: 'pending',
},
{
id: 'fa-4', caseId: '1', transactionId: 'tx-6',
transaction: mockTransactions[5],
confidenceLevel: 'medium', assessedAmount: 86500,
reason: '受害人通过数字钱包向链上地址提币86500元该操作发生在诈骗持续期间。但链上地址与其他已知涉诈线索暂无直接关联建议结合链上追踪结果确认。',
excludeReason: '如经查实该提币为受害人个人操作(如套现自用),应排除。',
reviewStatus: 'pending',
},
{
id: 'fa-5', caseId: '1', transactionId: 'tx-1',
transaction: mockTransactions[0],
confidenceLevel: 'low', assessedAmount: 0,
reason: '该笔为受害人本人银行卡向支付宝的转账,属于本人账户间资金中转,不直接计入被骗损失。已在资金路径中标记为中转节点。',
excludeReason: '本人账户间互转,仅作为资金路径展示。',
reviewStatus: 'confirmed', reviewedBy: '李警官', reviewedAt: '2026-03-10 14:10:00',
},
];
export const mockFlowNodes: FundFlowNode[] = [
{ id: 'n-bank', label: '银行卡(6621)', type: 'self' },
{ id: 'n-alipay', label: '支付宝', type: 'self' },
{ id: 'n-wechat', label: '微信支付', type: 'self' },
{ id: 'n-wallet', label: '数字钱包', type: 'self' },
{ id: 'n-suspect1', label: '李*华\n138****5678', type: 'suspect' },
{ id: 'n-suspect2', label: '财富管家-客服', type: 'suspect' },
{ id: 'n-chain', label: 'USDT地址\nT9yD...Xk3m', type: 'suspect' },
];
export const mockFlowEdges: FundFlowEdge[] = [
{ source: 'n-bank', target: 'n-alipay', amount: 50000, count: 1, tradeTime: '2026-03-06 10:15' },
{ source: 'n-alipay', target: 'n-suspect1', amount: 50000, count: 1, tradeTime: '2026-03-06 10:25' },
{ source: 'n-wechat', target: 'n-suspect2', amount: 30000, count: 1, tradeTime: '2026-03-07 14:30' },
{ source: 'n-wechat', target: 'n-suspect1', amount: 20000, count: 1, tradeTime: '2026-03-07 16:00' },
{ source: 'n-bank', target: 'n-wallet', amount: 86500, count: 1, tradeTime: '2026-03-07 20:00' },
{ source: 'n-wallet', target: 'n-chain', amount: 86500, count: 1, tradeTime: '2026-03-08 09:00' },
];
export const mockReports: ExportReport[] = [
{ id: 'rpt-1', caseId: '1', type: 'excel', url: '#', createdAt: '2026-03-10 15:00:00', version: 1 },
];

View File

@@ -0,0 +1,327 @@
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Card, Row, Col, Tag, Typography, Space, Timeline, Statistic, Divider } from 'antd';
import {
ApartmentOutlined,
ClockCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import { fetchTransactions, fetchFlows } from '../../services/api';
const nodeColorMap: Record<string, string> = {
self: '#1677ff',
suspect: '#cf1322',
transit: '#fa8c16',
unknown: '#8c8c8c',
};
const Analysis: React.FC = () => {
const { id = '1' } = useParams();
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const { data: flowData } = useQuery({
queryKey: ['flows', id],
queryFn: () => fetchFlows(id),
});
const mockTransactions = txData?.items ?? [];
const mockFlowNodes = flowData?.nodes ?? [];
const mockFlowEdges = flowData?.edges ?? [];
const flowChartOption = useMemo(() => {
const nodes = mockFlowNodes.map((n: any) => ({
name: n.label,
symbolSize: n.type === 'suspect' ? 60 : 50,
itemStyle: { color: nodeColorMap[n.type] },
label: { show: true, fontSize: 11 },
category: n.type === 'self' ? 0 : n.type === 'suspect' ? 1 : 2,
}));
const edges = mockFlowEdges.map((e: any) => {
const src = mockFlowNodes.find((n: any) => n.id === e.source);
const tgt = mockFlowNodes.find((n: any) => n.id === e.target);
return {
source: src?.label || '',
target: tgt?.label || '',
value: e.amount,
lineStyle: {
width: Math.max(2, Math.min(8, e.amount / 20000)),
curveness: 0.2,
},
label: {
show: true,
formatter: `¥${e.amount.toLocaleString()}`,
fontSize: 11,
},
};
});
return {
tooltip: { trigger: 'item' },
legend: {
data: ['本人账户', '涉诈账户', '中转账户'],
bottom: 10,
},
series: [
{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
force: {
repulsion: 400,
edgeLength: [120, 200],
},
categories: [
{ name: '本人账户' },
{ name: '涉诈账户' },
{ name: '中转账户' },
],
data: nodes,
links: edges,
edgeSymbol: ['none', 'arrow'],
edgeSymbolSize: 10,
emphasis: { focus: 'adjacency' },
},
],
};
}, [mockFlowNodes, mockFlowEdges]);
const timelineChartOption = useMemo(() => {
const sorted = [...mockTransactions]
.filter((t) => !t.isDuplicate)
.sort((a, b) => a.tradeTime.localeCompare(b.tradeTime));
return {
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0];
return `${p.axisValue}<br/>金额: ¥${Math.abs(p.value).toLocaleString()}`;
},
},
grid: { left: 80, right: 40, top: 40, bottom: 60 },
xAxis: {
type: 'category',
data: sorted.map((t) => t.tradeTime.slice(5, 16)),
axisLabel: { rotate: 30, fontSize: 11 },
},
yAxis: {
type: 'value',
name: '金额(元)',
axisLabel: {
formatter: (v: number) => `¥${(v / 1000).toFixed(0)}K`,
},
},
series: [
{
type: 'bar',
data: sorted.map((t) => ({
value: t.direction === 'out' ? -t.amount : t.amount,
itemStyle: {
color: t.direction === 'out' ? '#cf1322' : '#52c41a',
},
})),
barWidth: 30,
},
],
};
}, [mockTransactions]);
const validTx = mockTransactions.filter((t) => !t.isDuplicate);
const totalFraud = validTx
.filter((t) => t.direction === 'out' && !t.isTransit)
.reduce((s, t) => s + t.amount, 0);
const sortedTx = [...validTx].sort((a, b) =>
a.tradeTime.localeCompare(b.tradeTime),
);
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="疑似被骗总额(去重去中转)"
value={totalFraud}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 28 }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="涉诈对手方"
value={3}
suffix="个"
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="有效交易"
value={validTx.length}
suffix={`/ ${mockTransactions.length}`}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={14}>
<Card
title={
<Space>
<ApartmentOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
extra={
<Space>
<Tag color="#1677ff"></Tag>
<Tag color="#cf1322"></Tag>
<Tag color="#fa8c16"></Tag>
</Space>
}
>
<ReactECharts
option={flowChartOption}
style={{ height: 420 }}
/>
</Card>
<Card
title={
<Space>
<ClockCircleOutlined />
<span></span>
</Space>
}
>
<ReactECharts
option={timelineChartOption}
style={{ height: 300 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
绿
</Typography.Text>
</Card>
</Col>
<Col span={10}>
<Card
title="交易时间线"
style={{ marginBottom: 24 }}
>
<Timeline
items={sortedTx.map((tx) => ({
color: tx.direction === 'out'
? tx.isTransit
? 'orange'
: 'red'
: 'green',
children: (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{tx.tradeTime}
</Typography.Text>
<br />
<Space>
<Tag
color={
tx.sourceApp === 'wechat'
? 'green'
: tx.sourceApp === 'alipay'
? 'blue'
: tx.sourceApp === 'bank'
? 'purple'
: 'orange'
}
style={{ fontSize: 11 }}
>
{tx.sourceApp === 'wechat'
? '微信'
: tx.sourceApp === 'alipay'
? '支付宝'
: tx.sourceApp === 'bank'
? '银行'
: '数字钱包'}
</Tag>
{tx.isTransit && <Tag color="orange"></Tag>}
</Space>
<br />
<Typography.Text
strong
style={{
color: tx.direction === 'out' ? '#cf1322' : '#389e0d',
}}
>
{tx.direction === 'out' ? '-' : '+'}¥
{tx.amount.toLocaleString()}
</Typography.Text>
<Typography.Text style={{ marginLeft: 8, fontSize: 13 }}>
{tx.counterpartyName}
</Typography.Text>
{tx.remark && (
<>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{tx.remark}
</Typography.Text>
</>
)}
</div>
),
}))}
/>
</Card>
<Card title="收款方聚合">
<Space direction="vertical" style={{ width: '100%' }}>
{[
{ name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' },
{ name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' },
{ name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' },
].map((item, idx) => (
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
<Row justify="space-between" align="middle">
<Col>
<Typography.Text strong>{item.name}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.count}
</Typography.Text>
</Col>
<Col>
<Space>
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{item.amount.toLocaleString()}
</Typography.Text>
<Tag color={item.risk === 'high' ? 'red' : 'orange'}>
{item.risk === 'high' ? '高风险' : '中风险'}
</Tag>
</Space>
</Col>
</Row>
</Card>
))}
</Space>
</Card>
</Col>
</Row>
</div>
);
};
export default Analysis;

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Table,
Button,
Tag,
Space,
Input,
Typography,
Modal,
Form,
Row,
Col,
Statistic,
message,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
FolderOpenOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { CaseRecord, CaseStatus } from '../../types';
import { fetchCases, createCase } from '../../services/api';
const statusConfig: Record<CaseStatus, { color: string; label: string }> = {
pending: { color: 'default', label: '待处理' },
uploading: { color: 'processing', label: '上传中' },
analyzing: { color: 'blue', label: '分析中' },
reviewing: { color: 'orange', label: '待复核' },
completed: { color: 'green', label: '已完成' },
};
const CaseList: React.FC = () => {
const navigate = useNavigate();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [search, setSearch] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['cases', search],
queryFn: () => fetchCases({ search: search || undefined }),
});
const cases = data?.items ?? [];
const createMutation = useMutation({
mutationFn: createCase,
onSuccess: () => {
message.success('案件创建成功');
qc.invalidateQueries({ queryKey: ['cases'] });
setCreateOpen(false);
form.resetFields();
},
});
const totalCases = cases.length;
const pendingReview = cases.filter((c) => c.status === 'reviewing').length;
const completedCount = cases.filter((c) => c.status === 'completed').length;
const analyzingCount = cases.filter(
(c) => c.status === 'analyzing' || c.status === 'uploading',
).length;
const columns: ColumnsType<CaseRecord> = [
{
title: '案件编号',
dataIndex: 'caseNo',
width: 180,
render: (text, record) => (
<a onClick={() => navigate(`/cases/${record.id}/workspace`)}>{text}</a>
),
},
{ title: '案件名称', dataIndex: 'title', ellipsis: true },
{ title: '受害人', dataIndex: 'victimName', width: 100 },
{ title: '承办人', dataIndex: 'handler', width: 100 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: CaseStatus) => (
<Tag color={statusConfig[s].color}>{statusConfig[s].label}</Tag>
),
},
{
title: '截图数',
dataIndex: 'imageCount',
width: 80,
align: 'center',
},
{
title: '识别金额(元)',
dataIndex: 'totalAmount',
width: 140,
align: 'right',
render: (v: number) =>
v > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button
type="link"
size="small"
onClick={() => navigate(`/cases/${record.id}/workspace`)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="全部案件"
value={totalCases}
prefix={<FolderOpenOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="处理中"
value={analyzingCount}
prefix={<ClockCircleOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待复核"
value={pendingReview}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已完成"
value={completedCount}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card
title="案件列表"
extra={
<Space>
<Input
placeholder="搜索案件编号、名称"
prefix={<SearchOutlined />}
style={{ width: 240 }}
allowClear
onPressEnter={(e) => setSearch((e.target as HTMLInputElement).value)}
onChange={(e) => !e.target.value && setSearch('')}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
>
</Button>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={cases}
loading={isLoading}
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (t) => `${t}` }}
/>
</Card>
<Modal
title="新建案件"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => {
form.validateFields().then((values) => createMutation.mutate(values));
}}
confirmLoading={createMutation.isPending}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="案件编号"
name="caseNo"
rules={[{ required: true, message: '请输入案件编号' }]}
>
<Input placeholder="如ZA-2026-001XXX" />
</Form.Item>
<Form.Item
label="案件名称"
name="title"
rules={[{ required: true, message: '请输入案件名称' }]}
>
<Input placeholder="如:张某被电信诈骗案" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="受害人姓名"
name="victimName"
rules={[{ required: true, message: '请输入受害人姓名' }]}
>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="承办人" name="handler">
<Input />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
);
};
export default CaseList;

View File

@@ -0,0 +1,329 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Button,
Space,
Typography,
Row,
Col,
Statistic,
Table,
Tag,
Divider,
Descriptions,
Select,
Checkbox,
message,
Steps,
Result,
} from 'antd';
import {
FileTextOutlined,
FileExcelOutlined,
FilePdfOutlined,
FileWordOutlined,
DownloadOutlined,
PrinterOutlined,
HistoryOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { fetchCase, fetchAssessments, fetchReports, generateReport, getReportDownloadUrl } from '../../services/api';
const Reports: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [generated, setGenerated] = useState(false);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
const { data: reportsData } = useQuery({ queryKey: ['reports', id], queryFn: () => fetchReports(id) });
const allAssessments = assessData?.items ?? [];
const reportsList = reportsData?.items ?? [];
const confirmedAssessments = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0,
);
const totalConfirmed = confirmedAssessments.reduce(
(s, a) => s + a.assessedAmount,
0,
);
const genMutation = useMutation({
mutationFn: (reportType: string) => generateReport(id, { report_type: reportType }),
onSuccess: () => {
setGenerated(true);
qc.invalidateQueries({ queryKey: ['reports', id] });
message.success('报告生成成功');
},
});
if (!currentCase) return null;
const historyColumns: ColumnsType<(typeof mockReports)[0]> = [
{
title: '类型',
dataIndex: 'type',
width: 100,
render: (t: string) => {
const map: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
pdf: { icon: <FilePdfOutlined />, label: 'PDF', color: 'red' },
excel: { icon: <FileExcelOutlined />, label: 'Excel', color: 'green' },
word: { icon: <FileWordOutlined />, label: 'Word', color: 'blue' },
};
const cfg = map[t] || map.pdf;
return <Tag icon={cfg.icon} color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '版本',
dataIndex: 'version',
width: 80,
render: (v: number) => `v${v}`,
},
{
title: '生成时间',
dataIndex: 'createdAt',
width: 180,
},
{
title: '操作',
width: 120,
render: () => (
<Space>
<Button type="link" size="small" icon={<DownloadOutlined />}>
</Button>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="已确认被骗金额"
value={totalConfirmed}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="已确认交易笔数"
value={confirmedAssessments.length}
suffix="笔"
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card variant="borderless">
<Statistic
title="历史报告"
value={reportsList.length}
suffix="份"
prefix={<HistoryOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={24}>
<Col span={14}>
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Descriptions column={2} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="案件编号">
{currentCase.caseNo}
</Descriptions.Item>
<Descriptions.Item label="案件名称">
{currentCase.title}
</Descriptions.Item>
<Descriptions.Item label="受害人">
{currentCase.victimName}
</Descriptions.Item>
<Descriptions.Item label="承办人">
{currentCase.handler}
</Descriptions.Item>
<Descriptions.Item label="已确认金额">
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{totalConfirmed.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="确认笔数">
{confirmedAssessments.length}
</Descriptions.Item>
</Descriptions>
<Divider />
<Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}>
<Space size={16}>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FileExcelOutlined style={{ fontSize: 32, color: '#52c41a' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>Excel </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
</div>
</Card>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FilePdfOutlined style={{ fontSize: 32, color: '#cf1322' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>PDF </Typography.Text>
</div>
<div>
<Checkbox defaultChecked></Checkbox>
</div>
</Card>
<Card
hoverable
style={{ width: 140, textAlign: 'center' }}
styles={{ body: { padding: 16 } }}
>
<FileWordOutlined style={{ fontSize: 32, color: '#1677ff' }} />
<div style={{ marginTop: 8 }}>
<Typography.Text>Word </Typography.Text>
</div>
<div>
<Checkbox></Checkbox>
</div>
</Card>
</Space>
</div>
<Typography.Text strong></Typography.Text>
<div style={{ margin: '12px 0 24px' }}>
<Space direction="vertical">
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox defaultChecked></Checkbox>
<Checkbox></Checkbox>
<Checkbox></Checkbox>
</Space>
</div>
{!generated ? (
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
loading={genMutation.isPending}
onClick={() => genMutation.mutate('excel')}
block
>
{genMutation.isPending ? '正在生成报告...' : '生成报告'}
</Button>
) : (
<Result
status="success"
title="报告已生成"
subTitle="您可以下载或打印以下报告文件"
extra={[
<Button
key="excel"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 Excel')}
>
Excel
</Button>,
<Button
key="pdf"
type="primary"
icon={<DownloadOutlined />}
onClick={() => message.info('演示模式:下载 PDF')}
>
PDF
</Button>,
<Button
key="print"
icon={<PrinterOutlined />}
onClick={() => message.info('演示模式:打印')}
>
</Button>,
]}
/>
)}
</Card>
</Col>
<Col span={10}>
<Card
title={
<Space>
<HistoryOutlined />
<span></span>
</Space>
}
style={{ marginBottom: 24 }}
>
<Table
rowKey="id"
columns={historyColumns}
dataSource={reportsList}
pagination={false}
size="small"
/>
</Card>
<Card title="报告预览" style={{ minHeight: 300 }}>
<div
style={{
background: '#fafafa',
border: '1px dashed #d9d9d9',
borderRadius: 8,
padding: 24,
textAlign: 'center',
minHeight: 240,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FileTextOutlined style={{ fontSize: 48, color: '#bfbfbf' }} />
<Typography.Text type="secondary" style={{ marginTop: 12 }}>
{generated
? '点击左侧"下载"查看完整报告'
: '生成报告后可在此预览'}
</Typography.Text>
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default Reports;

View File

@@ -0,0 +1,443 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Card,
Table,
Tag,
Typography,
Space,
Button,
Modal,
Input,
Select,
Descriptions,
Row,
Col,
Statistic,
Alert,
Segmented,
Tooltip,
message,
Divider,
} from 'antd';
import {
AuditOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined,
ExclamationCircleOutlined,
SafetyCertificateOutlined,
EyeOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { FraudAssessment, ConfidenceLevel } from '../../types';
import { fetchAssessments, submitReview, fetchInquirySuggestions } from '../../services/api';
const confidenceConfig: Record<
ConfidenceLevel,
{ color: string; label: string }
> = {
high: { color: 'green', label: '高置信' },
medium: { color: 'orange', label: '中置信' },
low: { color: 'default', label: '低置信' },
};
const reviewStatusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
pending: { color: 'orange', label: '待复核', icon: <ExclamationCircleOutlined /> },
confirmed: { color: 'green', label: '已确认', icon: <CheckCircleOutlined /> },
rejected: { color: 'red', label: '已排除', icon: <CloseCircleOutlined /> },
needs_info: { color: 'blue', label: '需补充', icon: <QuestionCircleOutlined /> },
};
const Review: React.FC = () => {
const { id = '1' } = useParams();
const qc = useQueryClient();
const [filterLevel, setFilterLevel] = useState<string>('all');
const [reviewModal, setReviewModal] = useState<FraudAssessment | null>(null);
const [reviewAction, setReviewAction] = useState<string>('confirmed');
const [reviewNote, setReviewNote] = useState('');
const { data: assessData } = useQuery({
queryKey: ['assessments', id],
queryFn: () => fetchAssessments(id),
});
const { data: suggestionsData } = useQuery({
queryKey: ['suggestions', id],
queryFn: () => fetchInquirySuggestions(id),
});
const allAssessments = assessData?.items ?? [];
const suggestions = suggestionsData?.suggestions ?? [];
const reviewMutation = useMutation({
mutationFn: (params: { assessmentId: string; body: any }) =>
submitReview(params.assessmentId, params.body),
onSuccess: () => {
message.success('复核结果已保存');
qc.invalidateQueries({ queryKey: ['assessments', id] });
setReviewModal(null);
},
});
const data =
filterLevel === 'all'
? allAssessments
: allAssessments.filter((a) => a.confidenceLevel === filterLevel);
const totalConfirmed = allAssessments
.filter((a) => a.reviewStatus === 'confirmed' && a.assessedAmount > 0)
.reduce((s, a) => s + a.assessedAmount, 0);
const totalPending = allAssessments
.filter((a) => a.reviewStatus === 'pending')
.reduce((s, a) => s + a.assessedAmount, 0);
const pendingCount = allAssessments.filter(
(a) => a.reviewStatus === 'pending',
).length;
const confirmedCount = allAssessments.filter(
(a) => a.reviewStatus === 'confirmed',
).length;
const columns: ColumnsType<FraudAssessment> = [
{
title: '交易时间',
width: 170,
render: (_, r) => r.transaction.tradeTime,
sorter: (a, b) =>
a.transaction.tradeTime.localeCompare(b.transaction.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
width: 90,
render: (_, r) => {
const app = r.transaction.sourceApp;
const m: Record<string, { l: string; c: string }> = {
wechat: { l: '微信', c: 'green' },
alipay: { l: '支付宝', c: 'blue' },
bank: { l: '银行', c: 'purple' },
digital_wallet: { l: '钱包', c: 'orange' },
other: { l: '其他', c: 'default' },
};
return <Tag color={m[app].c}>{m[app].l}</Tag>;
},
},
{
title: '认定金额(元)',
dataIndex: 'assessedAmount',
width: 140,
align: 'right',
render: (v: number) =>
v > 0 ? (
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '对方',
render: (_, r) => r.transaction.counterpartyName,
ellipsis: true,
},
{
title: '置信度',
dataIndex: 'confidenceLevel',
width: 90,
render: (level: ConfidenceLevel) => (
<Tag color={confidenceConfig[level].color}>
{confidenceConfig[level].label}
</Tag>
),
},
{
title: '认定理由',
dataIndex: 'reason',
ellipsis: true,
width: 280,
render: (text: string) => (
<Tooltip title={text}>
<Typography.Text style={{ fontSize: 13 }}>{text}</Typography.Text>
</Tooltip>
),
},
{
title: '复核状态',
dataIndex: 'reviewStatus',
width: 100,
render: (s: string) => {
const cfg = reviewStatusConfig[s];
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.label}
</Tag>
);
},
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.reviewStatus === 'pending' ? (
<Button
type="primary"
size="small"
onClick={() => {
setReviewModal(r);
setReviewAction('confirmed');
setReviewNote('');
}}
>
</Button>
) : (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setReviewModal(r);
}}
>
</Button>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已确认被骗金额"
value={totalConfirmed}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待确认金额"
value={totalPending}
precision={2}
prefix="¥"
valueStyle={{ color: '#fa8c16', fontSize: 24 }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="已复核"
value={confirmedCount}
suffix={`/ ${allAssessments.length}`}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="待复核"
value={pendingCount}
suffix="笔"
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
{pendingCount > 0 && (
<Alert
message={`${pendingCount} 笔交易需要人工复核确认`}
description="系统已根据OCR识别、交易归并和规则引擎完成自动分析。高置信项可快速确认中/低置信项建议仔细核对后决定。"
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Card
title={
<Space>
<AuditOutlined />
<span></span>
</Space>
}
extra={
<Segmented
value={filterLevel}
onChange={(v) => setFilterLevel(v as string)}
options={[
{ label: '全部', value: 'all' },
{ label: '高置信', value: 'high' },
{ label: '中置信', value: 'medium' },
{ label: '低置信', value: 'low' },
]}
/>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
pagination={false}
size="middle"
/>
</Card>
<Divider />
<Card
title={
<Space>
<FileTextOutlined />
<span></span>
</Space>
}
style={{ background: '#fffbe6', borderColor: '#ffe58f' }}
>
<Typography.Paragraph>
</Typography.Paragraph>
<ol style={{ paddingLeft: 20, lineHeight: 2.2 }}>
{suggestions.map((s, idx) => (
<li key={idx}>
<Typography.Text>{s}</Typography.Text>
</li>
))}
</ol>
</Card>
<Modal
title={
reviewModal?.reviewStatus === 'pending'
? '复核认定'
: '认定详情'
}
open={!!reviewModal}
onCancel={() => setReviewModal(null)}
footer={
reviewModal?.reviewStatus === 'pending'
? [
<Button key="cancel" onClick={() => setReviewModal(null)}>
</Button>,
<Button
key="submit"
type="primary"
loading={reviewMutation.isPending}
onClick={() => {
reviewMutation.mutate({
assessmentId: reviewModal!.id,
body: {
review_status: reviewAction,
review_note: reviewNote,
reviewed_by: 'demo_user',
},
});
}}
>
</Button>,
]
: [
<Button key="close" onClick={() => setReviewModal(null)}>
</Button>,
]
}
width={600}
destroyOnClose
>
{reviewModal && (
<>
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="交易时间">
{reviewModal.transaction.tradeTime}
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text strong style={{ color: '#cf1322' }}>
¥{reviewModal.assessedAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="对方">
{reviewModal.transaction.counterpartyName}
</Descriptions.Item>
<Descriptions.Item label="来源APP">
{reviewModal.transaction.sourceApp}
</Descriptions.Item>
<Descriptions.Item label="置信等级">
<Tag color={confidenceConfig[reviewModal.confidenceLevel].color}>
{confidenceConfig[reviewModal.confidenceLevel].label}
</Tag>
</Descriptions.Item>
</Descriptions>
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Typography.Text strong></Typography.Text>
<br />
<Typography.Text>{reviewModal.reason}</Typography.Text>
</Card>
{reviewModal.excludeReason && (
<Card size="small" style={{ marginBottom: 16, background: '#fff2e8', borderColor: '#ffbb96' }}>
<Typography.Text strong></Typography.Text>
<br />
<Typography.Text>{reviewModal.excludeReason}</Typography.Text>
</Card>
)}
{reviewModal.reviewStatus === 'pending' && (
<>
<Divider />
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong></Typography.Text>
<Select
value={reviewAction}
onChange={setReviewAction}
style={{ width: '100%' }}
options={[
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
{ label: '需补充 - 需进一步调查确认', value: 'needs_info' },
]}
/>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={3}
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="请输入复核意见或备注..."
/>
</Space>
</>
)}
{reviewModal.reviewStatus !== 'pending' && reviewModal.reviewedBy && (
<Descriptions column={2} size="small" style={{ marginTop: 16 }}>
<Descriptions.Item label="复核人">
{reviewModal.reviewedBy}
</Descriptions.Item>
<Descriptions.Item label="复核时间">
{reviewModal.reviewedAt}
</Descriptions.Item>
</Descriptions>
)}
</>
)}
</Modal>
</div>
);
};
export default Review;

View File

@@ -0,0 +1,308 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Row,
Col,
Tag,
Typography,
Select,
Space,
Badge,
Descriptions,
Empty,
List,
Drawer,
Button,
Form,
Input,
InputNumber,
DatePicker,
Divider,
Segmented,
} from 'antd';
import {
FileImageOutlined,
CheckCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
EditOutlined,
ZoomInOutlined,
} from '@ant-design/icons';
import type { EvidenceImage, SourceApp, PageType } from '../../types';
import { fetchImages } from '../../services/api';
const appLabel: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
alipay: { label: '支付宝', color: 'blue' },
bank: { label: '银行', color: 'purple' },
digital_wallet: { label: '数字钱包', color: 'orange' },
other: { label: '其他', color: 'default' },
};
const pageTypeLabel: Record<PageType, string> = {
bill_list: '账单列表',
bill_detail: '账单详情',
transfer_receipt: '转账凭证',
sms_notice: '短信通知',
balance: '余额页',
unknown: '未识别',
};
const ocrStatusIcon: Record<string, React.ReactNode> = {
done: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
processing: <LoadingOutlined style={{ color: '#1677ff' }} />,
failed: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
pending: <FileImageOutlined style={{ color: '#d9d9d9' }} />,
};
const Screenshots: React.FC = () => {
const { id = '1' } = useParams();
const [filterApp, setFilterApp] = useState<string>('all');
const [selectedImage, setSelectedImage] = useState<EvidenceImage | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const { data: allImages = [] } = useQuery({
queryKey: ['images', id],
queryFn: () => fetchImages(id),
});
const filtered =
filterApp === 'all'
? allImages
: allImages.filter((img: EvidenceImage) => img.sourceApp === filterApp);
const appCounts = allImages.reduce<Record<string, number>>((acc, img: EvidenceImage) => {
acc[img.sourceApp] = (acc[img.sourceApp] || 0) + 1;
return acc;
}, {});
const handleSelect = (img: EvidenceImage) => {
setSelectedImage(img);
setDrawerOpen(true);
};
const mockOcrFields = [
{ label: '交易时间', value: '2026-03-06 10:25:00', confidence: 0.97 },
{ label: '交易金额', value: '¥50,000.00', confidence: 0.99 },
{ label: '交易方向', value: '支出', confidence: 0.95 },
{ label: '对方账户', value: '李*华 (138****5678)', confidence: 0.88 },
{ label: '订单号', value: 'AL20260306002', confidence: 0.96 },
{ label: '备注', value: '投资款', confidence: 0.92 },
];
return (
<div>
<Card
title={
<Space>
<FileImageOutlined />
<span></span>
<Tag>{allImages.length} </Tag>
</Space>
}
extra={
<Space>
<Select
value={filterApp}
onChange={setFilterApp}
style={{ width: 140 }}
options={[
{ label: '全部来源', value: 'all' },
...Object.entries(appLabel).map(([k, v]) => ({
label: `${v.label} (${appCounts[k] || 0})`,
value: k,
})),
]}
/>
<Segmented
options={[
{ label: '网格', value: 'grid' },
{ label: '列表', value: 'list' },
]}
defaultValue="grid"
/>
</Space>
}
>
<Row gutter={[16, 16]}>
{filtered.map((img) => (
<Col key={img.id} xs={12} sm={8} md={6} lg={4}>
<Card
hoverable
onClick={() => handleSelect(img)}
styles={{
body: { padding: 12 },
}}
cover={
<div
style={{
height: 200,
background: '#f5f5f5',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
}}
>
<FileImageOutlined
style={{ fontSize: 40, color: '#bfbfbf' }}
/>
<Typography.Text
type="secondary"
style={{ fontSize: 12, marginTop: 8 }}
>
</Typography.Text>
<div
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
{ocrStatusIcon[img.ocrStatus]}
</div>
<div
style={{
position: 'absolute',
top: 8,
left: 8,
}}
>
<Tag
color={appLabel[img.sourceApp].color}
style={{ fontSize: 11 }}
>
{appLabel[img.sourceApp].label}
</Tag>
</div>
</div>
}
>
<Typography.Text ellipsis style={{ fontSize: 13 }}>
{pageTypeLabel[img.pageType]}
</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{img.uploadedAt}
</Typography.Text>
</Card>
</Col>
))}
</Row>
{filtered.length === 0 && <Empty description="暂无截图" />}
</Card>
<Drawer
title={
selectedImage ? (
<Space>
<Tag color={appLabel[selectedImage.sourceApp].color}>
{appLabel[selectedImage.sourceApp].label}
</Tag>
<span>{pageTypeLabel[selectedImage.pageType]}</span>
<Badge
status={selectedImage.ocrStatus === 'done' ? 'success' : 'processing'}
text={selectedImage.ocrStatus === 'done' ? 'OCR已完成' : '处理中'}
/>
</Space>
) : '截图详情'
}
placement="right"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{selectedImage && (
<>
<div
style={{
background: '#fafafa',
borderRadius: 8,
height: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
border: '1px dashed #d9d9d9',
}}
>
<Space direction="vertical" align="center">
<FileImageOutlined style={{ fontSize: 64, color: '#bfbfbf' }} />
<Typography.Text type="secondary">
</Typography.Text>
<Button icon={<ZoomInOutlined />} size="small">
</Button>
</Space>
</div>
<Typography.Title level={5}>OCR </Typography.Title>
<Typography.Text type="secondary" style={{ marginBottom: 16, display: 'block' }}>
</Typography.Text>
<List
dataSource={mockOcrFields}
renderItem={(item) => (
<List.Item
extra={
<Space>
<Tag
color={
item.confidence >= 0.95
? 'green'
: item.confidence >= 0.85
? 'orange'
: 'red'
}
>
{(item.confidence * 100).toFixed(0)}%
</Tag>
<Button type="link" size="small" icon={<EditOutlined />}>
</Button>
</Space>
}
>
<List.Item.Meta
title={
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{item.label}
</Typography.Text>
}
description={
<Typography.Text strong>{item.value}</Typography.Text>
}
/>
</List.Item>
)}
/>
<Divider />
<Descriptions column={1} size="small">
<Descriptions.Item label="图片ID">
{selectedImage.id}
</Descriptions.Item>
<Descriptions.Item label="文件哈希">
{selectedImage.hash}
</Descriptions.Item>
<Descriptions.Item label="上传时间">
{selectedImage.uploadedAt}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
</div>
);
};
export default Screenshots;

View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Table,
Tag,
Typography,
Space,
Select,
Tooltip,
Badge,
Drawer,
Descriptions,
Button,
Alert,
Row,
Col,
Statistic,
} from 'antd';
import {
SwapOutlined,
WarningOutlined,
LinkOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types';
import { fetchTransactions } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' },
alipay: { label: '支付宝', color: 'blue' },
bank: { label: '银行', color: 'purple' },
digital_wallet: { label: '数字钱包', color: 'orange' },
other: { label: '其他', color: 'default' },
};
const Transactions: React.FC = () => {
const { id = '1' } = useParams();
const [filterDuplicate, setFilterDuplicate] = useState<string>('all');
const [detail, setDetail] = useState<TransactionRecord | null>(null);
const { data: txData } = useQuery({
queryKey: ['transactions', id],
queryFn: () => fetchTransactions(id),
});
const allTransactions = txData?.items ?? [];
const data =
filterDuplicate === 'all'
? allTransactions
: filterDuplicate === 'unique'
? allTransactions.filter((t) => !t.isDuplicate)
: allTransactions.filter((t) => t.isDuplicate);
const totalOut = allTransactions
.filter((t) => t.direction === 'out' && !t.isDuplicate)
.reduce((s, t) => s + t.amount, 0);
const totalIn = allTransactions
.filter((t) => t.direction === 'in' && !t.isDuplicate)
.reduce((s, t) => s + t.amount, 0);
const duplicateCount = allTransactions.filter((t) => t.isDuplicate).length;
const transitCount = allTransactions.filter((t) => t.isTransit).length;
const columns: ColumnsType<TransactionRecord> = [
{
title: '交易时间',
dataIndex: 'tradeTime',
width: 170,
sorter: (a, b) => a.tradeTime.localeCompare(b.tradeTime),
defaultSortOrder: 'ascend',
},
{
title: '来源',
dataIndex: 'sourceApp',
width: 100,
render: (app: SourceApp) => (
<Tag color={appTag[app].color}>{appTag[app].label}</Tag>
),
},
{
title: '金额(元)',
dataIndex: 'amount',
width: 140,
align: 'right',
render: (v: number, r) => (
<Typography.Text
strong
style={{ color: r.direction === 'out' ? '#cf1322' : '#389e0d' }}
>
{r.direction === 'out' ? '-' : '+'}¥{v.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
),
},
{
title: '方向',
dataIndex: 'direction',
width: 70,
align: 'center',
render: (d: string) =>
d === 'out' ? (
<ArrowUpOutlined style={{ color: '#cf1322' }} />
) : (
<ArrowDownOutlined style={{ color: '#389e0d' }} />
),
},
{
title: '对方',
dataIndex: 'counterpartyName',
ellipsis: true,
},
{
title: '备注',
dataIndex: 'remark',
width: 120,
ellipsis: true,
},
{
title: '标记',
width: 130,
render: (_, r) => (
<Space size={4}>
{r.isDuplicate && (
<Tooltip title="该笔为重复展示记录,已与其他截图中的同笔交易合并">
<Tag color="red"></Tag>
</Tooltip>
)}
{r.isTransit && (
<Tooltip title="该笔为本人账户间中转,不直接计入被骗金额">
<Tag color="orange"></Tag>
</Tooltip>
)}
{!r.isDuplicate && !r.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
),
},
{
title: '置信度',
dataIndex: 'confidence',
width: 80,
align: 'center',
render: (v: number) => (
<Badge
color={v >= 0.9 ? '#52c41a' : v >= 0.8 ? '#faad14' : '#ff4d4f'}
text={`${(v * 100).toFixed(0)}%`}
/>
),
},
{
title: '操作',
width: 80,
render: (_, r) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => setDetail(r)}
>
</Button>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="总支出(去重后)"
value={totalOut}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="总收入(去重后)"
value={totalIn}
precision={2}
prefix="¥"
valueStyle={{ color: '#389e0d' }}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="重复记录"
value={duplicateCount}
suffix="笔"
valueStyle={{ color: '#cf1322' }}
prefix={<WarningOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card variant="borderless">
<Statistic
title="中转记录"
value={transitCount}
suffix="笔"
valueStyle={{ color: '#fa8c16' }}
prefix={<LinkOutlined />}
/>
</Card>
</Col>
</Row>
{duplicateCount > 0 && (
<Alert
message={`系统识别出 ${duplicateCount} 笔重复展示记录`}
description={'同一笔交易可能在列表页、详情页、短信通知中多次出现。标记为「重复」的记录已被合并,不会重复计入金额汇总。'}
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
)}
<Card
title={
<Space>
<SwapOutlined />
<span></span>
<Tag>{allTransactions.length} </Tag>
</Space>
}
extra={
<Select
value={filterDuplicate}
onChange={setFilterDuplicate}
style={{ width: 160 }}
options={[
{ label: '全部交易', value: 'all' },
{ label: '仅有效交易', value: 'unique' },
{ label: '仅重复交易', value: 'duplicate' },
]}
/>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
pagination={false}
rowClassName={(r) =>
r.isDuplicate
? 'row-duplicate'
: r.isTransit
? 'row-transit'
: ''
}
size="middle"
/>
</Card>
<Drawer
title="交易详情"
placement="right"
width={480}
open={!!detail}
onClose={() => setDetail(null)}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="交易时间">
{detail.tradeTime}
</Descriptions.Item>
<Descriptions.Item label="来源APP">
<Tag color={appTag[detail.sourceApp].color}>
{appTag[detail.sourceApp].label}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">
<Typography.Text
strong
style={{
color: detail.direction === 'out' ? '#cf1322' : '#389e0d',
}}
>
{detail.direction === 'out' ? '-' : '+'}¥
{detail.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="对方">
{detail.counterpartyName}
</Descriptions.Item>
<Descriptions.Item label="对方账号">
{detail.counterpartyAccount || '-'}
</Descriptions.Item>
<Descriptions.Item label="本方账户尾号">
{detail.selfAccountTailNo || '-'}
</Descriptions.Item>
<Descriptions.Item label="订单号">
{detail.orderNo}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="置信度">
{(detail.confidence * 100).toFixed(0)}%
</Descriptions.Item>
<Descriptions.Item label="证据截图">
<Button type="link" size="small" icon={<EyeOutlined />}>
({detail.evidenceImageId})
</Button>
</Descriptions.Item>
<Descriptions.Item label="归并簇">
{detail.clusterId || '独立交易'}
</Descriptions.Item>
<Descriptions.Item label="标记">
<Space>
{detail.isDuplicate && <Tag color="red"></Tag>}
{detail.isTransit && <Tag color="orange"></Tag>}
{!detail.isDuplicate && !detail.isTransit && (
<Tag color="green"></Tag>
)}
</Space>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
};
export default Transactions;

View File

@@ -0,0 +1,300 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Steps,
Row,
Col,
Statistic,
Typography,
Upload,
Button,
Space,
Tag,
Descriptions,
Progress,
Alert,
Divider,
message,
} from 'antd';
import {
CloudUploadOutlined,
ScanOutlined,
MergeCellsOutlined,
ApartmentOutlined,
AuditOutlined,
FileTextOutlined,
InboxOutlined,
RightOutlined,
} from '@ant-design/icons';
import { fetchCase, fetchImages, fetchTransactions, fetchAssessments, uploadImages } from '../../services/api';
const { Dragger } = Upload;
const Workspace: React.FC = () => {
const { id = '1' } = useParams();
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(3);
const { data: currentCase } = useQuery({ queryKey: ['case', id], queryFn: () => fetchCase(id) });
const { data: imagesData } = useQuery({ queryKey: ['images', id], queryFn: () => fetchImages(id) });
const { data: txData } = useQuery({ queryKey: ['transactions', id], queryFn: () => fetchTransactions(id) });
const { data: assessData } = useQuery({ queryKey: ['assessments', id], queryFn: () => fetchAssessments(id) });
const images = imagesData ?? [];
const txList = txData?.items ?? [];
const assessments = assessData?.items ?? [];
const highConfirm = assessments.filter((a) => a.confidenceLevel === 'high').length;
const pendingReview = assessments.filter((a) => a.reviewStatus === 'pending').length;
if (!currentCase) return null;
const steps = [
{
title: '上传截图',
icon: <CloudUploadOutlined />,
description: `${images.length} 张已上传`,
},
{
title: 'OCR识别',
icon: <ScanOutlined />,
description: `${images.filter((i: any) => i.ocrStatus === 'done').length}/${images.length} 已完成`,
},
{
title: '交易归并',
icon: <MergeCellsOutlined />,
description: `${txList.length} 笔交易`,
},
{
title: '资金分析',
icon: <ApartmentOutlined />,
description: '已生成路径图',
},
{
title: '认定复核',
icon: <AuditOutlined />,
description: `${pendingReview} 笔待复核`,
},
{
title: '报告导出',
icon: <FileTextOutlined />,
description: '待生成',
},
];
return (
<div>
<Card style={{ marginBottom: 24 }}>
<Row justify="space-between" align="middle">
<Col>
<Space direction="vertical" size={4}>
<Space>
<Typography.Title level={4} style={{ margin: 0 }}>
{currentCase.title}
</Typography.Title>
<Tag color="orange"></Tag>
</Space>
<Typography.Text type="secondary">
{currentCase.caseNo} · {currentCase.handler} · {currentCase.victimName}
</Typography.Text>
</Space>
</Col>
<Col>
<Statistic
title="当前识别被骗金额"
value={currentCase.totalAmount}
precision={2}
prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: 28 }}
/>
</Col>
</Row>
</Card>
<Card style={{ marginBottom: 24 }}>
<Steps
current={currentStep}
items={steps}
onChange={(v) => setCurrentStep(v)}
/>
</Card>
<Row gutter={24}>
<Col span={16}>
<Card
title="快速上传截图"
style={{ marginBottom: 24 }}
extra={
<Typography.Text type="secondary">
JPG/PNG
</Typography.Text>
}
>
<Dragger
multiple
accept="image/*"
showUploadList={false}
beforeUpload={(file, fileList) => {
uploadImages(id, fileList as unknown as File[])
.then(() => message.success('截图上传成功'))
.catch(() => message.error('上传失败'));
return false;
}}
style={{ padding: '20px 0' }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ fontSize: 48, color: '#1677ff' }} />
</p>
<p className="ant-upload-text">
</p>
<p className="ant-upload-hint">
APP
</p>
</Dragger>
</Card>
<Card title="处理进度">
<Row gutter={[24, 16]}>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
images.length ? (images.filter((i: any) => i.ocrStatus === 'done').length /
images.length) *
100 : 0,
)}
size={80}
/>
<div style={{ marginTop: 8 }}>
<Typography.Text>OCR </Typography.Text>
</div>
</div>
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
txList.length ? (txList.filter((t) => !t.isDuplicate).length /
txList.length) *
100 : 0,
)}
size={80}
strokeColor="#52c41a"
/>
<div style={{ marginTop: 8 }}>
<Typography.Text></Typography.Text>
</div>
</div>
</Col>
<Col span={8}>
<div style={{ textAlign: 'center' }}>
<Progress
type="circle"
percent={Math.round(
assessments.length ? (highConfirm / assessments.length) * 100 : 0,
)}
size={80}
strokeColor="#fa8c16"
/>
<div style={{ marginTop: 8 }}>
<Typography.Text></Typography.Text>
</div>
</div>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<Card title="案件概况" style={{ marginBottom: 24 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="截图总数">
{images.length}
</Descriptions.Item>
<Descriptions.Item label="涉及APP">
<Space>
<Tag color="green"></Tag>
<Tag color="blue"></Tag>
<Tag color="purple"></Tag>
<Tag></Tag>
</Space>
</Descriptions.Item>
<Descriptions.Item label="提取交易数">
{txList.length}
</Descriptions.Item>
<Descriptions.Item label="去重后交易">
{txList.filter((t) => !t.isDuplicate).length}
</Descriptions.Item>
<Descriptions.Item label="涉诈对手方">2 </Descriptions.Item>
<Descriptions.Item label="待复核">
<Typography.Text type="warning">
{pendingReview}
</Typography.Text>
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="快捷操作">
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => navigate('/cases/1/screenshots')}
icon={<RightOutlined />}
>
OCR
</Button>
<Button
block
onClick={() => navigate('/cases/1/transactions')}
icon={<RightOutlined />}
>
</Button>
<Button
block
onClick={() => navigate('/cases/1/analysis')}
icon={<RightOutlined />}
>
</Button>
<Button
block
type="primary"
onClick={() => navigate('/cases/1/review')}
icon={<RightOutlined />}
>
</Button>
</Space>
</Card>
{pendingReview > 0 && (
<Alert
message={`${pendingReview} 笔交易待人工确认`}
description="系统已完成自动分析,请进入认定复核页面审阅并确认结果。"
type="warning"
showIcon
style={{ marginTop: 24 }}
action={
<Button
size="small"
type="primary"
onClick={() => navigate('/cases/1/review')}
>
</Button>
}
/>
)}
</Col>
</Row>
</div>
);
};
export default Workspace;

44
frontend/src/routes.tsx Normal file
View File

@@ -0,0 +1,44 @@
import React, { lazy, Suspense } from 'react';
import type { RouteObject } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
import { Spin } from 'antd';
import MainLayout from './layouts/MainLayout';
const CaseList = lazy(() => import('./pages/cases/CaseList'));
const Workspace = lazy(() => import('./pages/workspace/Workspace'));
const Screenshots = lazy(() => import('./pages/screenshots/Screenshots'));
const Transactions = lazy(() => import('./pages/transactions/Transactions'));
const Analysis = lazy(() => import('./pages/analysis/Analysis'));
const Review = lazy(() => import('./pages/review/Review'));
const Reports = lazy(() => import('./pages/reports/Reports'));
const Loading = () => (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 120 }}>
<Spin size="large" />
</div>
);
function withSuspense(Component: React.LazyExoticComponent<React.ComponentType>) {
return (
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
);
}
export const routes: RouteObject[] = [
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <Navigate to="/cases" replace /> },
{ path: 'cases', element: withSuspense(CaseList) },
{ path: 'cases/:id/workspace', element: withSuspense(Workspace) },
{ path: 'cases/:id/screenshots', element: withSuspense(Screenshots) },
{ path: 'cases/:id/transactions', element: withSuspense(Transactions) },
{ path: 'cases/:id/analysis', element: withSuspense(Analysis) },
{ path: 'cases/:id/review', element: withSuspense(Review) },
{ path: 'cases/:id/reports', element: withSuspense(Reports) },
],
},
];

View File

@@ -0,0 +1,197 @@
/**
* Centralized API client with automatic fallback to mock data.
*
* When the backend is available, all calls go to /api/v1/...
* When the backend is down (e.g. demo-only mode), responses fall back
* to the mock data in ../mock/data.ts so the frontend always works.
*/
import type {
CaseRecord,
EvidenceImage,
TransactionRecord,
FraudAssessment,
ExportReport,
} from '../types';
import {
mockCases,
mockImages,
mockTransactions,
mockAssessments,
mockFlowNodes,
mockFlowEdges,
mockReports,
} from '../mock/data';
const BASE = '/api/v1';
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const resp = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (!resp.ok) throw new Error(`API Error ${resp.status}`);
return resp.json();
}
// ── helpers ──
let backendAlive: boolean | null = null;
async function isBackendUp(): Promise<boolean> {
if (backendAlive !== null) return backendAlive;
try {
const r = await fetch('/health', { signal: AbortSignal.timeout(2000) });
backendAlive = r.ok;
} catch {
backendAlive = false;
}
return backendAlive;
}
export function resetBackendCheck() {
backendAlive = null;
}
// ── Cases ──
export async function fetchCases(params?: {
offset?: number;
limit?: number;
status?: string;
search?: string;
}): Promise<{ items: CaseRecord[]; total: number }> {
if (!(await isBackendUp())) return { items: mockCases, total: mockCases.length };
const qs = new URLSearchParams();
if (params?.offset) qs.set('offset', String(params.offset));
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.status) qs.set('status', params.status);
if (params?.search) qs.set('search', params.search);
return request(`${BASE}/cases?${qs}`);
}
export async function createCase(body: {
case_no: string;
title: string;
victim_name: string;
handler?: string;
}): Promise<CaseRecord> {
if (!(await isBackendUp())) return mockCases[0];
return request(`${BASE}/cases`, { method: 'POST', body: JSON.stringify(body) });
}
export async function fetchCase(id: string): Promise<CaseRecord> {
if (!(await isBackendUp())) return mockCases.find((c) => c.id === id) || mockCases[0];
return request(`${BASE}/cases/${id}`);
}
// ── Images ──
export async function fetchImages(
caseId: string,
params?: { source_app?: string; page_type?: string },
): Promise<EvidenceImage[]> {
if (!(await isBackendUp())) return mockImages.filter((i) => i.caseId === caseId || caseId === '1');
const qs = new URLSearchParams();
if (params?.source_app) qs.set('source_app', params.source_app);
if (params?.page_type) qs.set('page_type', params.page_type);
return request(`${BASE}/cases/${caseId}/images?${qs}`);
}
export async function uploadImages(caseId: string, files: File[]): Promise<EvidenceImage[]> {
if (!(await isBackendUp())) return mockImages.slice(0, files.length);
const form = new FormData();
files.forEach((f) => form.append('files', f));
const resp = await fetch(`${BASE}/cases/${caseId}/images`, { method: 'POST', body: form });
if (!resp.ok) throw new Error(`Upload failed ${resp.status}`);
return resp.json();
}
// ── Analysis ──
export async function triggerAnalysis(caseId: string): Promise<{ task_id: string; message: string }> {
if (!(await isBackendUp())) return { task_id: 'mock', message: '分析任务已提交' };
return request(`${BASE}/cases/${caseId}/analyze`, { method: 'POST' });
}
export async function fetchAnalysisStatus(
caseId: string,
): Promise<{ status: string; progress: number; current_step: string }> {
if (!(await isBackendUp())) return { status: 'reviewing', progress: 85, current_step: '待复核' };
return request(`${BASE}/cases/${caseId}/analyze/status`);
}
// ── Transactions ──
export async function fetchTransactions(
caseId: string,
filterType?: string,
): Promise<{ items: TransactionRecord[]; total: number }> {
if (!(await isBackendUp())) return { items: mockTransactions, total: mockTransactions.length };
const qs = filterType ? `?filter_type=${filterType}` : '';
return request(`${BASE}/cases/${caseId}/transactions${qs}`);
}
// ── Flows ──
export async function fetchFlows(
caseId: string,
): Promise<{ nodes: any[]; edges: any[] }> {
if (!(await isBackendUp())) return { nodes: mockFlowNodes, edges: mockFlowEdges };
return request(`${BASE}/cases/${caseId}/flows`);
}
// ── Assessments ──
export async function fetchAssessments(
caseId: string,
confidenceLevel?: string,
): Promise<{ items: FraudAssessment[]; total: number }> {
if (!(await isBackendUp())) return { items: mockAssessments, total: mockAssessments.length };
const qs = confidenceLevel ? `?confidence_level=${confidenceLevel}` : '';
return request(`${BASE}/cases/${caseId}/assessments${qs}`);
}
export async function submitReview(
assessmentId: string,
body: { review_status: string; review_note?: string; reviewed_by?: string },
): Promise<FraudAssessment> {
if (!(await isBackendUp())) return mockAssessments[0];
return request(`${BASE}/assessments/${assessmentId}/review`, {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function fetchInquirySuggestions(caseId: string): Promise<{ suggestions: string[] }> {
if (!(await isBackendUp()))
return {
suggestions: [
'请向受害人核实是否受对方诱导操作转账。',
'是否还有未截图的转账记录?',
'涉案金额中是否有已追回款项?',
],
};
return request(`${BASE}/cases/${caseId}/inquiry-suggestions`);
}
// ── Reports ──
export async function generateReport(
caseId: string,
body: { report_type: string },
): Promise<ExportReport> {
if (!(await isBackendUp())) return mockReports[0];
return request(`${BASE}/cases/${caseId}/reports`, {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function fetchReports(caseId: string): Promise<{ items: ExportReport[]; total: number }> {
if (!(await isBackendUp())) return { items: mockReports, total: mockReports.length };
return request(`${BASE}/cases/${caseId}/reports`);
}
export function getReportDownloadUrl(reportId: string): string {
return `${BASE}/reports/${reportId}/download`;
}

View File

@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand';
import type { CaseRecord } from '../types';
interface CaseState {
currentCase: CaseRecord | null;
setCurrentCase: (c: CaseRecord | null) => void;
}
export const useCaseStore = create<CaseState>((set) => ({
currentCase: null,
setCurrentCase: (c) => set({ currentCase: c }),
}));

View File

@@ -0,0 +1,88 @@
export type CaseStatus = 'pending' | 'uploading' | 'analyzing' | 'reviewing' | 'completed';
export interface CaseRecord {
id: string;
caseNo: string;
title: string;
victimName: string;
handler: string;
status: CaseStatus;
imageCount: number;
totalAmount: number;
createdAt: string;
updatedAt: string;
}
export type PageType = 'bill_list' | 'bill_detail' | 'transfer_receipt' | 'sms_notice' | 'balance' | 'unknown';
export type SourceApp = 'wechat' | 'alipay' | 'bank' | 'digital_wallet' | 'other';
export interface EvidenceImage {
id: string;
caseId: string;
url: string;
thumbUrl: string;
sourceApp: SourceApp;
pageType: PageType;
ocrStatus: 'pending' | 'processing' | 'done' | 'failed';
hash: string;
uploadedAt: string;
}
export interface TransactionRecord {
id: string;
caseId: string;
sourceApp: SourceApp;
tradeTime: string;
amount: number;
direction: 'in' | 'out';
counterpartyName: string;
counterpartyAccount: string;
selfAccountTailNo: string;
orderNo: string;
remark: string;
evidenceImageId: string;
confidence: number;
clusterId?: string;
isDuplicate: boolean;
isTransit: boolean;
}
export type ConfidenceLevel = 'high' | 'medium' | 'low';
export interface FraudAssessment {
id: string;
caseId: string;
transactionId: string;
transaction: TransactionRecord;
confidenceLevel: ConfidenceLevel;
assessedAmount: number;
reason: string;
excludeReason?: string;
reviewStatus: 'pending' | 'confirmed' | 'rejected' | 'needs_info';
reviewNote?: string;
reviewedBy?: string;
reviewedAt?: string;
}
export interface FundFlowNode {
id: string;
label: string;
type: 'self' | 'suspect' | 'transit' | 'unknown';
}
export interface FundFlowEdge {
source: string;
target: string;
amount: number;
count: number;
tradeTime: string;
}
export interface ExportReport {
id: string;
caseId: string;
type: 'pdf' | 'excel' | 'word';
url: string;
createdAt: string;
version: number;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1,29 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: fundtracer
POSTGRES_PASSWORD: fundtracer_dev
POSTGRES_DB: fundtracer
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fundtracer"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:

Some files were not shown because too many files have changed in this diff Show More