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