first commit
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
DATABASE_URL=sqlite+aiosqlite:///./fund_tracer.db
|
||||
LLM_PROVIDER=openai
|
||||
|
||||
# Optional: choose model names
|
||||
OPENAI_MODEL=gpt-4o
|
||||
ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
|
||||
# API keys
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# SQLite / local DB
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Uploads / generated files
|
||||
backend/uploads/*
|
||||
!backend/uploads/.gitkeep
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
tmp/
|
||||
*.log
|
||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Fund Tracer - 电信诈骗资金追踪智能体
|
||||
|
||||
通过网页上传受害人手机 APP 账单截图,利用大模型多模态能力提取交易数据,自动汇总并可视化跨 APP 资金流向,支持案件管理、时间线分析、报告导出。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python 3.11+ / FastAPI / SQLAlchemy / Pydantic
|
||||
- **前端**: React 18 + TypeScript / Ant Design / React Flow / Recharts
|
||||
- **数据库**: SQLite(可切换 PostgreSQL)
|
||||
- **LLM**: 支持 OpenAI、Anthropic、DeepSeek 多模型切换
|
||||
- **部署**: Docker Compose
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
# 配置 .env:OPENAI_API_KEY、ANTHROPIC_API_KEY、DEEPSEEK_API_KEY 等
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
浏览器访问 http://localhost:5173,API 代理到 http://localhost:8000。
|
||||
|
||||
### 环境变量示例(.env)
|
||||
|
||||
```env
|
||||
DATABASE_URL=sqlite+aiosqlite:///./fund_tracer.db
|
||||
LLM_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=...
|
||||
DEEPSEEK_API_KEY=...
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 编辑填入 API Key
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- 前端: http://localhost:3000
|
||||
- 后端 API: http://localhost:8000
|
||||
|
||||
## 功能概览
|
||||
|
||||
- **案件管理**: 创建/编辑/删除案件,记录受害人信息
|
||||
- **截图上传**: 多图上传,自动调用 LLM Vision 提取交易
|
||||
- **资金流向图**: 以有向图展示账户间资金流动
|
||||
- **时间线**: 按时间顺序展示每笔交易
|
||||
- **汇总表格**: 交易明细筛选、排序
|
||||
- **报告导出**: Excel 明细、PDF 报告(含流向图与文字概述)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
9
backend/Dockerfile
Normal file
9
backend/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Fund Tracer backend application."""
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API routes
|
||||
41
backend/app/api/analysis.py
Normal file
41
backend/app/api/analysis.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Analysis API: get flow graph and summary for a case."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_db
|
||||
from app.models import Case, Transaction
|
||||
from app.schemas import TransactionResponse, AnalysisSummaryResponse, FlowGraphResponse
|
||||
from app.services.analyzer import build_flow_graph
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{case_id}/transactions", response_model=list[TransactionResponse])
|
||||
async def list_transactions(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
r = await db.execute(
|
||||
select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time, Transaction.id)
|
||||
)
|
||||
txns = r.scalars().all()
|
||||
return [TransactionResponse.model_validate(t) for t in txns]
|
||||
|
||||
|
||||
@router.get("/{case_id}/analysis")
|
||||
async def get_analysis(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id))
|
||||
txns = r.scalars().all()
|
||||
items = [TransactionResponse.model_validate(t) for t in txns]
|
||||
graph, summary = build_flow_graph(items)
|
||||
return {"summary": summary.model_dump(), "graph": graph.model_dump()}
|
||||
|
||||
|
||||
@router.post("/{case_id}/analysis")
|
||||
async def run_analysis(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
return await get_analysis(case_id, db)
|
||||
72
backend/app/api/cases.py
Normal file
72
backend/app/api/cases.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Case CRUD API."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_db
|
||||
from app.models import Case
|
||||
from app.schemas import CaseCreate, CaseUpdate, CaseResponse, CaseListResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CaseListResponse)
|
||||
async def list_cases(db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).order_by(Case.created_at.desc()))
|
||||
cases = r.scalars().all()
|
||||
return CaseListResponse(items=[CaseResponse.model_validate(c) for c in cases])
|
||||
|
||||
|
||||
@router.post("", response_model=CaseResponse)
|
||||
async def create_case(body: CaseCreate, db: AsyncSession = Depends(get_db)):
|
||||
case = Case(
|
||||
case_number=body.case_number,
|
||||
victim_name=body.victim_name,
|
||||
description=body.description or "",
|
||||
)
|
||||
db.add(case)
|
||||
await db.commit()
|
||||
await db.refresh(case)
|
||||
return CaseResponse.model_validate(case)
|
||||
|
||||
|
||||
@router.get("/{case_id}", response_model=CaseResponse)
|
||||
async def get_case(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
return CaseResponse.model_validate(case)
|
||||
|
||||
|
||||
@router.put("/{case_id}", response_model=CaseResponse)
|
||||
async def update_case(case_id: int, body: CaseUpdate, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
if body.case_number is not None:
|
||||
case.case_number = body.case_number
|
||||
if body.victim_name is not None:
|
||||
case.victim_name = body.victim_name
|
||||
if body.description is not None:
|
||||
case.description = body.description
|
||||
if body.total_loss is not None:
|
||||
case.total_loss = body.total_loss
|
||||
if body.status is not None:
|
||||
case.status = body.status
|
||||
await db.commit()
|
||||
await db.refresh(case)
|
||||
return CaseResponse.model_validate(case)
|
||||
|
||||
|
||||
@router.delete("/{case_id}")
|
||||
async def delete_case(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
await db.delete(case)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
47
backend/app/api/export.py
Normal file
47
backend/app/api/export.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Export API: Excel and PDF report download."""
|
||||
|
||||
from io import BytesIO
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import get_db
|
||||
from app.models import Case, Transaction
|
||||
from app.schemas import TransactionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{case_id}/export/excel")
|
||||
async def export_excel(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.report import build_excel_report
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time))
|
||||
txns = [TransactionResponse.model_validate(t) for t in r.scalars().all()]
|
||||
data = await build_excel_report(case, txns)
|
||||
return StreamingResponse(
|
||||
BytesIO(data),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename=case_{case_id}_report.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{case_id}/export/pdf")
|
||||
async def export_pdf(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
from app.services.report import build_pdf_report
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
r = await db.execute(select(Transaction).where(Transaction.case_id == case_id).order_by(Transaction.transaction_time))
|
||||
txns = [TransactionResponse.model_validate(t) for t in r.scalars().all()]
|
||||
data = await build_pdf_report(case, txns)
|
||||
return StreamingResponse(
|
||||
BytesIO(data),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=case_{case_id}_report.pdf"},
|
||||
)
|
||||
101
backend/app/api/screenshots.py
Normal file
101
backend/app/api/screenshots.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Screenshot upload and extraction API."""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.database import get_db
|
||||
from app.models import Case, Screenshot, Transaction
|
||||
from app.schemas import ScreenshotResponse, ScreenshotListResponse, TransactionListResponse
|
||||
from app.services.extractor import extract_and_save
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _allowed(filename: str) -> bool:
|
||||
ext = (Path(filename).suffix or "").lstrip(".").lower()
|
||||
return ext in get_settings().allowed_extensions
|
||||
|
||||
|
||||
@router.get("/{case_id}/screenshots", response_model=ScreenshotListResponse)
|
||||
async def list_screenshots(case_id: int, db: AsyncSession = Depends(get_db)):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
if not r.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
r = await db.execute(select(Screenshot).where(Screenshot.case_id == case_id).order_by(Screenshot.created_at))
|
||||
screenshots = r.scalars().all()
|
||||
return ScreenshotListResponse(items=[ScreenshotResponse.model_validate(s) for s in screenshots])
|
||||
|
||||
|
||||
@router.post("/{case_id}/screenshots", response_model=ScreenshotListResponse)
|
||||
async def upload_screenshots(
|
||||
case_id: int,
|
||||
files: list[UploadFile] = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(select(Case).where(Case.id == case_id))
|
||||
case = r.scalar_one_or_none()
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
settings = get_settings()
|
||||
upload_dir = settings.upload_dir.resolve()
|
||||
case_dir = upload_dir / str(case_id)
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
created: list[Screenshot] = []
|
||||
for f in files:
|
||||
if not f.filename or not _allowed(f.filename):
|
||||
continue
|
||||
stem = uuid.uuid4().hex[:12]
|
||||
suffix = Path(f.filename).suffix
|
||||
path = case_dir / f"{stem}{suffix}"
|
||||
content = await f.read()
|
||||
path.write_bytes(content)
|
||||
rel_path = str(path.relative_to(upload_dir))
|
||||
screenshot = Screenshot(
|
||||
case_id=case_id,
|
||||
filename=f.filename,
|
||||
file_path=rel_path,
|
||||
status="pending",
|
||||
)
|
||||
db.add(screenshot)
|
||||
created.append(screenshot)
|
||||
await db.commit()
|
||||
for s in created:
|
||||
await db.refresh(s)
|
||||
return ScreenshotListResponse(items=[ScreenshotResponse.model_validate(s) for s in created])
|
||||
|
||||
|
||||
@router.post("/{case_id}/screenshots/{screenshot_id}/extract", response_model=TransactionListResponse)
|
||||
async def extract_transactions(
|
||||
case_id: int,
|
||||
screenshot_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id, Screenshot.case_id == case_id))
|
||||
screenshot = r.scalar_one_or_none()
|
||||
if not screenshot:
|
||||
raise HTTPException(status_code=404, detail="Screenshot not found")
|
||||
settings = get_settings()
|
||||
full_path = settings.upload_dir.resolve() / screenshot.file_path
|
||||
if not full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
image_bytes = full_path.read_bytes()
|
||||
try:
|
||||
transactions = await extract_and_save(case_id, screenshot_id, image_bytes)
|
||||
except Exception as e:
|
||||
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id))
|
||||
sc = r.scalar_one_or_none()
|
||||
if sc:
|
||||
sc.status = "failed"
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=502, detail=f"Extraction failed: {e!s}")
|
||||
r = await db.execute(select(Screenshot).where(Screenshot.id == screenshot_id))
|
||||
sc = r.scalar_one_or_none()
|
||||
if sc:
|
||||
sc.status = "extracted"
|
||||
await db.commit()
|
||||
return TransactionListResponse(items=transactions)
|
||||
30
backend/app/api/settings.py
Normal file
30
backend/app/api/settings.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Runtime settings API for LLM provider and API keys."""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import public_settings, update_runtime_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
llm_provider: str | None = None
|
||||
openai_api_key: str | None = None
|
||||
anthropic_api_key: str | None = None
|
||||
deepseek_api_key: str | None = None
|
||||
custom_openai_api_key: str | None = None
|
||||
custom_openai_base_url: str | None = None
|
||||
custom_openai_model: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_runtime_settings():
|
||||
return public_settings()
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def update_settings(body: SettingsUpdate):
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
update_runtime_settings(payload)
|
||||
return public_settings()
|
||||
94
backend/app/config.py
Normal file
94
backend/app/config.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Application configuration from environment + runtime overrides."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""App settings loaded from env."""
|
||||
|
||||
app_name: str = "Fund Tracer API"
|
||||
debug: bool = False
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite+aiosqlite:///./fund_tracer.db"
|
||||
|
||||
# Uploads
|
||||
upload_dir: Path = Path("./uploads")
|
||||
max_upload_size_mb: int = 20
|
||||
allowed_extensions: set[str] = {"png", "jpg", "jpeg", "webp"}
|
||||
|
||||
# LLM
|
||||
llm_provider: str = "openai" # openai | anthropic | deepseek | custom_openai
|
||||
openai_api_key: str | None = None
|
||||
anthropic_api_key: str | None = None
|
||||
deepseek_api_key: str | None = None
|
||||
custom_openai_api_key: str | None = None
|
||||
custom_openai_base_url: str | None = None
|
||||
openai_model: str = "gpt-4o"
|
||||
anthropic_model: str = "claude-3-5-sonnet-20241022"
|
||||
deepseek_model: str = "deepseek-chat"
|
||||
custom_openai_model: str = "gpt-4o-mini"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
_runtime_overrides: dict[str, str | None] = {}
|
||||
|
||||
|
||||
def _apply_overrides(settings: Settings) -> Settings:
|
||||
for key, value in _runtime_overrides.items():
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
return settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return _apply_overrides(Settings())
|
||||
|
||||
|
||||
def update_runtime_settings(payload: dict[str, str | None]) -> Settings:
|
||||
"""Update runtime settings and refresh cached Settings object."""
|
||||
allowed = {
|
||||
"llm_provider",
|
||||
"openai_api_key",
|
||||
"anthropic_api_key",
|
||||
"deepseek_api_key",
|
||||
"custom_openai_api_key",
|
||||
"custom_openai_base_url",
|
||||
"custom_openai_model",
|
||||
}
|
||||
for key, value in payload.items():
|
||||
if key in allowed:
|
||||
_runtime_overrides[key] = value
|
||||
get_settings.cache_clear()
|
||||
return get_settings()
|
||||
|
||||
|
||||
def public_settings() -> dict:
|
||||
s = get_settings()
|
||||
return {
|
||||
"llm_provider": s.llm_provider,
|
||||
"providers": ["openai", "anthropic", "deepseek", "custom_openai"],
|
||||
"models": {
|
||||
"openai": s.openai_model,
|
||||
"anthropic": s.anthropic_model,
|
||||
"deepseek": s.deepseek_model,
|
||||
"custom_openai": s.custom_openai_model,
|
||||
},
|
||||
"base_urls": {
|
||||
"custom_openai": s.custom_openai_base_url or "",
|
||||
},
|
||||
"has_keys": {
|
||||
"openai": bool(s.openai_api_key),
|
||||
"anthropic": bool(s.anthropic_api_key),
|
||||
"deepseek": bool(s.deepseek_api_key),
|
||||
"custom_openai": bool(s.custom_openai_api_key),
|
||||
},
|
||||
}
|
||||
52
backend/app/main.py
Normal file
52
backend/app/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.database import init_db
|
||||
from app.api import cases, screenshots, analysis, export, settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cfg = get_settings()
|
||||
cfg.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
cfg = get_settings()
|
||||
app = FastAPI(
|
||||
title=cfg.app_name,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(cases.router, prefix="/api/cases", tags=["cases"])
|
||||
app.include_router(screenshots.router, prefix="/api/cases", tags=["screenshots"])
|
||||
app.include_router(analysis.router, prefix="/api/cases", tags=["analysis"])
|
||||
app.include_router(export.router, prefix="/api/cases", tags=["export"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["settings"])
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
8
backend/app/models/__init__.py
Normal file
8
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""SQLAlchemy models - export Base and all models for create_all."""
|
||||
|
||||
from app.models.database import Base, get_db, init_db, engine, async_session_maker
|
||||
from app.models.case import Case
|
||||
from app.models.screenshot import Screenshot
|
||||
from app.models.transaction import Transaction
|
||||
|
||||
__all__ = ["Base", "Case", "Screenshot", "Transaction", "get_db", "init_db", "engine", "async_session_maker"]
|
||||
28
backend/app/models/case.py
Normal file
28
backend/app/models/case.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Case model - 案件."""
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, Text, DateTime, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.database import Base
|
||||
|
||||
|
||||
class Case(Base):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
case_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
victim_name: Mapped[str] = mapped_column(String(128))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
total_loss: Mapped[Decimal] = mapped_column(Numeric(18, 2), default=0)
|
||||
status: Mapped[str] = mapped_column(String(32), default="in_progress") # in_progress | completed
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
screenshots: Mapped[list["Screenshot"]] = relationship("Screenshot", back_populates="case", cascade="all, delete-orphan")
|
||||
transactions: Mapped[list["Transaction"]] = relationship("Transaction", back_populates="case", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Case(id={self.id}, case_number={self.case_number})>"
|
||||
33
backend/app/models/database.py
Normal file
33
backend/app/models/database.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Database session and initialization."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = None
|
||||
async_session_maker = None
|
||||
|
||||
|
||||
async def init_db():
|
||||
global engine, async_session_maker
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
from app.models import Case, Screenshot, Transaction # noqa: F401
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
27
backend/app/models/screenshot.py
Normal file
27
backend/app/models/screenshot.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Screenshot model - 截图记录."""
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.database import Base
|
||||
|
||||
|
||||
class Screenshot(Base):
|
||||
__tablename__ = "screenshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("cases.id", ondelete="CASCADE"), index=True)
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
file_path: Mapped[str] = mapped_column(String(512))
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending") # pending | extracted | failed
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
case: Mapped["Case"] = relationship("Case", back_populates="screenshots")
|
||||
transactions: Mapped[list["Transaction"]] = relationship(
|
||||
"Transaction", back_populates="screenshot", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Screenshot(id={self.id}, filename={self.filename})>"
|
||||
35
backend/app/models/transaction.py
Normal file
35
backend/app/models/transaction.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Transaction model - 交易记录."""
|
||||
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, Text, DateTime, Numeric, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.database import Base
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
case_id: Mapped[int] = mapped_column(ForeignKey("cases.id", ondelete="CASCADE"), index=True)
|
||||
screenshot_id: Mapped[int] = mapped_column(ForeignKey("screenshots.id", ondelete="CASCADE"), index=True)
|
||||
app_source: Mapped[str] = mapped_column(String(128))
|
||||
transaction_type: Mapped[str] = mapped_column(String(32)) # 转出/转入/消费/收款/提现/充值
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(18, 2))
|
||||
currency: Mapped[str] = mapped_column(String(16), default="CNY")
|
||||
counterparty_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
counterparty_account: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
transaction_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
confidence: Mapped[str] = mapped_column(String(16), default="medium") # high | medium | low
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
case: Mapped["Case"] = relationship("Case", back_populates="transactions")
|
||||
screenshot: Mapped["Screenshot"] = relationship("Screenshot", back_populates="transactions")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Transaction(id={self.id}, amount={self.amount}, app={self.app_source})>"
|
||||
1
backend/app/prompts/__init__.py
Normal file
1
backend/app/prompts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Prompts
|
||||
41
backend/app/prompts/extract_transaction.py
Normal file
41
backend/app/prompts/extract_transaction.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Prompt for extracting transactions from billing screenshots."""
|
||||
|
||||
EXTRACT_TRANSACTION_SYSTEM = """你是一个专业的金融交易数据提取助手,专门用于从手机APP账单或交易记录截图中提取结构化信息。"""
|
||||
|
||||
EXTRACT_TRANSACTION_USER = """请分析这张手机APP账单/交易记录截图,提取所有可见的交易记录。
|
||||
|
||||
要求:
|
||||
1. 只返回一个JSON数组,不要包含其他说明文字。
|
||||
2. 数组的每个元素是一条交易,包含以下字段(若截图中无该信息则填 null):
|
||||
- app_source: string,APP来源,如 "微信支付"、"支付宝"、"XX银行"、"XX钱包"
|
||||
- transaction_type: string,交易类型,如 "转出"、"转入"、"消费"、"收款"、"提现"、"充值"
|
||||
- amount: number,金额(数字,不含货币符号)
|
||||
- currency: string,币种,如 "CNY"、"USDT",默认 "CNY"
|
||||
- counterparty_name: string | null,对方名称/姓名
|
||||
- counterparty_account: string | null,对方账号、卡号尾号、钱包地址等
|
||||
- order_number: string | null,订单号/交易号
|
||||
- transaction_time: string | null,交易时间,请用 ISO 8601 格式,如 "2024-01-15T14:30:00"
|
||||
- remark: string | null,备注/摘要
|
||||
- confidence: string,识别置信度,取 "high"、"medium"、"low" 之一
|
||||
|
||||
3. 注意区分转入和转出方向;金额统一为正数,方向由 transaction_type 体现。
|
||||
4. 若截图中没有交易记录或无法识别,返回空数组 []。
|
||||
|
||||
直接输出JSON数组,不要用 markdown 代码块包裹。"""
|
||||
|
||||
|
||||
def get_extract_messages(image_b64: str) -> list[dict]:
|
||||
"""Build messages for vision API: system + user with image."""
|
||||
return [
|
||||
{"role": "system", "content": EXTRACT_TRANSACTION_SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": EXTRACT_TRANSACTION_USER},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
37
backend/app/schemas/__init__.py
Normal file
37
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Pydantic schemas for API request/response."""
|
||||
|
||||
from app.schemas.case import (
|
||||
CaseCreate,
|
||||
CaseUpdate,
|
||||
CaseResponse,
|
||||
CaseListResponse,
|
||||
)
|
||||
from app.schemas.screenshot import (
|
||||
ScreenshotResponse,
|
||||
ScreenshotListResponse,
|
||||
)
|
||||
from app.schemas.transaction import (
|
||||
TransactionCreate,
|
||||
TransactionResponse,
|
||||
TransactionListResponse,
|
||||
TransactionExtractItem,
|
||||
)
|
||||
from app.schemas.analysis import (
|
||||
AnalysisSummaryResponse,
|
||||
FlowGraphResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CaseCreate",
|
||||
"CaseUpdate",
|
||||
"CaseResponse",
|
||||
"CaseListResponse",
|
||||
"ScreenshotResponse",
|
||||
"ScreenshotListResponse",
|
||||
"TransactionCreate",
|
||||
"TransactionResponse",
|
||||
"TransactionListResponse",
|
||||
"TransactionExtractItem",
|
||||
"AnalysisSummaryResponse",
|
||||
"FlowGraphResponse",
|
||||
]
|
||||
35
backend/app/schemas/analysis.py
Normal file
35
backend/app/schemas/analysis.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Analysis response schemas."""
|
||||
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AppSummary(BaseModel):
|
||||
in_amount: Decimal
|
||||
out_amount: Decimal
|
||||
|
||||
|
||||
class AnalysisSummaryResponse(BaseModel):
|
||||
total_out: Decimal
|
||||
total_in: Decimal
|
||||
net_loss: Decimal
|
||||
by_app: dict[str, AppSummary]
|
||||
counterparty_count: int
|
||||
|
||||
|
||||
class FlowNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
type: str | None = None # victim_app | counterparty
|
||||
|
||||
|
||||
class FlowEdge(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
amount: Decimal
|
||||
count: int = 1
|
||||
|
||||
|
||||
class FlowGraphResponse(BaseModel):
|
||||
nodes: list[FlowNode]
|
||||
edges: list[FlowEdge]
|
||||
36
backend/app/schemas/case.py
Normal file
36
backend/app/schemas/case.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Case schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CaseBase(BaseModel):
|
||||
case_number: str
|
||||
victim_name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class CaseCreate(CaseBase):
|
||||
pass
|
||||
|
||||
|
||||
class CaseUpdate(BaseModel):
|
||||
case_number: str | None = None
|
||||
victim_name: str | None = None
|
||||
description: str | None = None
|
||||
total_loss: Decimal | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class CaseResponse(CaseBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
total_loss: Decimal
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CaseListResponse(BaseModel):
|
||||
items: list[CaseResponse]
|
||||
18
backend/app/schemas/screenshot.py
Normal file
18
backend/app/schemas/screenshot.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Screenshot schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ScreenshotResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
case_id: int
|
||||
filename: str
|
||||
file_path: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ScreenshotListResponse(BaseModel):
|
||||
items: list[ScreenshotResponse]
|
||||
51
backend/app/schemas/transaction.py
Normal file
51
backend/app/schemas/transaction.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Transaction schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class TransactionBase(BaseModel):
|
||||
app_source: str
|
||||
transaction_type: str
|
||||
amount: Decimal
|
||||
currency: str = "CNY"
|
||||
counterparty_name: str | None = None
|
||||
counterparty_account: str | None = None
|
||||
order_number: str | None = None
|
||||
transaction_time: datetime | None = None
|
||||
remark: str | None = None
|
||||
confidence: str = "medium"
|
||||
|
||||
|
||||
class TransactionCreate(TransactionBase):
|
||||
case_id: int
|
||||
screenshot_id: int
|
||||
raw_text: str | None = None
|
||||
|
||||
|
||||
class TransactionResponse(TransactionBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
case_id: int
|
||||
screenshot_id: int
|
||||
raw_text: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
items: list[TransactionResponse]
|
||||
|
||||
|
||||
class TransactionExtractItem(BaseModel):
|
||||
"""Single item as returned by LLM extraction (before DB insert)."""
|
||||
app_source: str
|
||||
transaction_type: str
|
||||
amount: Decimal
|
||||
currency: str = "CNY"
|
||||
counterparty_name: str | None = None
|
||||
counterparty_account: str | None = None
|
||||
order_number: str | None = None
|
||||
transaction_time: datetime | None = None
|
||||
remark: str | None = None
|
||||
confidence: str = "medium"
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services
|
||||
107
backend/app/services/analyzer.py
Normal file
107
backend/app/services/analyzer.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Fund flow analysis: build directed graph and summary from transactions."""
|
||||
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from app.schemas.analysis import (
|
||||
AnalysisSummaryResponse,
|
||||
AppSummary,
|
||||
FlowGraphResponse,
|
||||
FlowNode,
|
||||
FlowEdge,
|
||||
)
|
||||
from app.schemas.transaction import TransactionResponse
|
||||
|
||||
# Transaction types that mean money leaving victim's app (outflow)
|
||||
OUT_TYPES = {"转出", "消费", "付款", "提现"}
|
||||
# Transaction types that mean money entering victim's app (inflow)
|
||||
IN_TYPES = {"转入", "收款", "充值"}
|
||||
|
||||
|
||||
def _is_out(t: TransactionResponse) -> bool:
|
||||
return t.transaction_type in OUT_TYPES or "转出" in (t.transaction_type or "") or "消费" in (t.transaction_type or "")
|
||||
|
||||
|
||||
def _is_in(t: TransactionResponse) -> bool:
|
||||
return t.transaction_type in IN_TYPES or "转入" in (t.transaction_type or "") or "收款" in (t.transaction_type or "")
|
||||
|
||||
|
||||
def _node_id(app_or_counterparty: str, kind: str) -> str:
|
||||
"""Generate stable node id; kind in ('victim_app', 'counterparty')."""
|
||||
import hashlib
|
||||
safe = (app_or_counterparty or "").strip() or "unknown"
|
||||
h = hashlib.sha256(f"{kind}:{safe}".encode()).hexdigest()[:12]
|
||||
return f"{kind}_{h}"
|
||||
|
||||
|
||||
def build_flow_graph(transactions: list[TransactionResponse]) -> tuple[FlowGraphResponse, AnalysisSummaryResponse]:
|
||||
"""
|
||||
Build directed graph and summary from transaction list.
|
||||
Node: victim's app (app_source when outflow) or counterparty (counterparty_name or counterparty_account).
|
||||
Edge: source -> target with total amount and count.
|
||||
"""
|
||||
out_by_app: dict[str, Decimal] = defaultdict(Decimal)
|
||||
in_by_app: dict[str, Decimal] = defaultdict(Decimal)
|
||||
total_out = Decimal(0)
|
||||
total_in = Decimal(0)
|
||||
counterparties: set[str] = set()
|
||||
# (source_id, target_id) -> (amount, count)
|
||||
edges_agg: dict[tuple[str, str], tuple[Decimal, int]] = defaultdict(lambda: (Decimal(0), 0))
|
||||
node_labels: dict[str, str] = {}
|
||||
node_types: dict[str, str] = {}
|
||||
|
||||
for t in transactions:
|
||||
amount = t.amount if isinstance(t.amount, Decimal) else Decimal(str(t.amount))
|
||||
app = (t.app_source or "").strip() or "未知APP"
|
||||
counterparty = (t.counterparty_name or t.counterparty_account or "未知对方").strip() or "未知对方"
|
||||
counterparties.add(counterparty)
|
||||
|
||||
victim_node_id = _node_id(app, "victim_app")
|
||||
node_labels[victim_node_id] = app
|
||||
node_types[victim_node_id] = "victim_app"
|
||||
|
||||
cp_node_id = _node_id(counterparty, "counterparty")
|
||||
node_labels[cp_node_id] = counterparty
|
||||
node_types[cp_node_id] = "counterparty"
|
||||
|
||||
if _is_out(t):
|
||||
out_by_app[app] += amount
|
||||
total_out += amount
|
||||
key = (victim_node_id, cp_node_id)
|
||||
am, cnt = edges_agg[key]
|
||||
edges_agg[key] = (am + amount, cnt + 1)
|
||||
elif _is_in(t):
|
||||
in_by_app[app] += amount
|
||||
total_in += amount
|
||||
key = (cp_node_id, victim_node_id)
|
||||
am, cnt = edges_agg[key]
|
||||
edges_agg[key] = (am + amount, cnt + 1)
|
||||
|
||||
all_apps = set(out_by_app.keys()) | set(in_by_app.keys())
|
||||
by_app = {
|
||||
app: AppSummary(
|
||||
in_amount=in_by_app.get(app, Decimal(0)),
|
||||
out_amount=out_by_app.get(app, Decimal(0)),
|
||||
)
|
||||
for app in all_apps
|
||||
}
|
||||
summary = AnalysisSummaryResponse(
|
||||
total_out=total_out,
|
||||
total_in=total_in,
|
||||
net_loss=total_out - total_in,
|
||||
by_app=by_app,
|
||||
counterparty_count=len(counterparties),
|
||||
)
|
||||
|
||||
nodes = [
|
||||
FlowNode(id=nid, label=node_labels[nid], type=node_types.get(nid))
|
||||
for nid in node_labels
|
||||
]
|
||||
edges = [
|
||||
FlowEdge(source=src, target=tgt, amount=am, count=cnt)
|
||||
for (src, tgt), (am, cnt) in edges_agg.items()
|
||||
]
|
||||
graph = FlowGraphResponse(nodes=nodes, edges=edges)
|
||||
return graph, summary
|
||||
42
backend/app/services/extractor.py
Normal file
42
backend/app/services/extractor.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Transaction data extraction: LLM Vision + persistence."""
|
||||
|
||||
from app.models import Transaction
|
||||
from app.models.database import async_session_maker
|
||||
from app.schemas.transaction import TransactionExtractItem, TransactionResponse
|
||||
from app.services.llm import get_llm_provider
|
||||
|
||||
|
||||
async def extract_and_save(
|
||||
case_id: int,
|
||||
screenshot_id: int,
|
||||
image_bytes: bytes,
|
||||
) -> list[TransactionResponse]:
|
||||
"""
|
||||
Run vision extraction on image and persist transactions to DB.
|
||||
Returns list of created transactions; low-confidence items are still saved but flagged.
|
||||
"""
|
||||
provider = get_llm_provider()
|
||||
items: list[TransactionExtractItem] = await provider.extract_from_image(image_bytes)
|
||||
results: list[TransactionResponse] = []
|
||||
async with async_session_maker() as session:
|
||||
for it in items:
|
||||
t = Transaction(
|
||||
case_id=case_id,
|
||||
screenshot_id=screenshot_id,
|
||||
app_source=it.app_source,
|
||||
transaction_type=it.transaction_type,
|
||||
amount=it.amount,
|
||||
currency=it.currency or "CNY",
|
||||
counterparty_name=it.counterparty_name,
|
||||
counterparty_account=it.counterparty_account,
|
||||
order_number=it.order_number,
|
||||
transaction_time=it.transaction_time,
|
||||
remark=it.remark,
|
||||
confidence=it.confidence if it.confidence in ("high", "medium", "low") else "medium",
|
||||
raw_text=None,
|
||||
)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
results.append(TransactionResponse.model_validate(t))
|
||||
await session.commit()
|
||||
return results
|
||||
16
backend/app/services/llm/__init__.py
Normal file
16
backend/app/services/llm/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# LLM providers
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.services.llm.router import get_llm_provider
|
||||
from app.services.llm.openai_vision import OpenAIVisionProvider
|
||||
from app.services.llm.claude_vision import ClaudeVisionProvider
|
||||
from app.services.llm.deepseek_vision import DeepSeekVisionProvider
|
||||
from app.services.llm.custom_openai_vision import CustomOpenAICompatibleProvider
|
||||
|
||||
__all__ = [
|
||||
"BaseLLMProvider",
|
||||
"get_llm_provider",
|
||||
"OpenAIVisionProvider",
|
||||
"ClaudeVisionProvider",
|
||||
"DeepSeekVisionProvider",
|
||||
"CustomOpenAICompatibleProvider",
|
||||
]
|
||||
18
backend/app/services/llm/base.py
Normal file
18
backend/app/services/llm/base.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Base LLM provider - abstract interface for vision extraction."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.schemas.transaction import TransactionExtractItem
|
||||
|
||||
|
||||
class BaseLLMProvider(ABC):
|
||||
"""Abstract base for LLM vision providers. Each provider implements extract_from_image."""
|
||||
|
||||
@abstractmethod
|
||||
async def extract_from_image(self, image_bytes: bytes) -> list[TransactionExtractItem]:
|
||||
"""
|
||||
Analyze a billing screenshot and return structured transaction list.
|
||||
:param image_bytes: Raw image file content (PNG/JPEG)
|
||||
:return: List of extracted transactions (may be empty or partial on failure)
|
||||
"""
|
||||
pass
|
||||
49
backend/app/services/llm/claude_vision.py
Normal file
49
backend/app/services/llm/claude_vision.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Anthropic Claude Vision provider."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.transaction import TransactionExtractItem
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.prompts.extract_transaction import get_extract_messages
|
||||
from app.services.llm.openai_vision import _parse_json_array
|
||||
|
||||
|
||||
class ClaudeVisionProvider(BaseLLMProvider):
|
||||
async def extract_from_image(self, image_bytes: bytes) -> list[TransactionExtractItem]:
|
||||
settings = get_settings()
|
||||
if not settings.anthropic_api_key:
|
||||
raise ValueError("ANTHROPIC_API_KEY is not set")
|
||||
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
b64 = base64.standard_b64encode(image_bytes).decode("ascii")
|
||||
messages = get_extract_messages(b64)
|
||||
# Claude API: user message with content block list
|
||||
user_content = messages[1]["content"]
|
||||
content_blocks = []
|
||||
for block in user_content:
|
||||
if block["type"] == "text":
|
||||
content_blocks.append({"type": "text", "text": block["text"]})
|
||||
elif block["type"] == "image_url":
|
||||
# Claude expects base64 without data URL prefix
|
||||
content_blocks.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": block["image_url"]["url"].split(",", 1)[-1],
|
||||
},
|
||||
})
|
||||
response = await client.messages.create(
|
||||
model=settings.anthropic_model,
|
||||
max_tokens=4096,
|
||||
system=messages[0]["content"],
|
||||
messages=[{"role": "user", "content": content_blocks}],
|
||||
)
|
||||
text = ""
|
||||
for block in response.content:
|
||||
if hasattr(block, "text"):
|
||||
text += block.text
|
||||
return _parse_json_array(text or "[]")
|
||||
32
backend/app/services/llm/custom_openai_vision.py
Normal file
32
backend/app/services/llm/custom_openai_vision.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Custom OpenAI-compatible vision provider."""
|
||||
|
||||
import base64
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.transaction import TransactionExtractItem
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.prompts.extract_transaction import get_extract_messages
|
||||
from app.services.llm.openai_vision import _parse_json_array
|
||||
|
||||
|
||||
class CustomOpenAICompatibleProvider(BaseLLMProvider):
|
||||
async def extract_from_image(self, image_bytes: bytes) -> list[TransactionExtractItem]:
|
||||
settings = get_settings()
|
||||
if not settings.custom_openai_api_key:
|
||||
raise ValueError("CUSTOM_OPENAI_API_KEY is not set")
|
||||
if not settings.custom_openai_base_url:
|
||||
raise ValueError("CUSTOM_OPENAI_BASE_URL is not set")
|
||||
client = AsyncOpenAI(
|
||||
api_key=settings.custom_openai_api_key,
|
||||
base_url=settings.custom_openai_base_url,
|
||||
)
|
||||
b64 = base64.standard_b64encode(image_bytes).decode("ascii")
|
||||
messages = get_extract_messages(b64)
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.custom_openai_model,
|
||||
messages=messages,
|
||||
max_tokens=4096,
|
||||
)
|
||||
text = response.choices[0].message.content or "[]"
|
||||
return _parse_json_array(text)
|
||||
34
backend/app/services/llm/deepseek_vision.py
Normal file
34
backend/app/services/llm/deepseek_vision.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""DeepSeek Vision provider (uses OpenAI-compatible API)."""
|
||||
|
||||
import base64
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.transaction import TransactionExtractItem
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.prompts.extract_transaction import get_extract_messages
|
||||
from app.services.llm.openai_vision import _parse_json_array
|
||||
|
||||
|
||||
# DeepSeek vision endpoint (OpenAI-compatible)
|
||||
DEEPSEEK_BASE = "https://api.deepseek.com"
|
||||
|
||||
|
||||
class DeepSeekVisionProvider(BaseLLMProvider):
|
||||
async def extract_from_image(self, image_bytes: bytes) -> list[TransactionExtractItem]:
|
||||
settings = get_settings()
|
||||
if not settings.deepseek_api_key:
|
||||
raise ValueError("DEEPSEEK_API_KEY is not set")
|
||||
client = AsyncOpenAI(
|
||||
api_key=settings.deepseek_api_key,
|
||||
base_url=DEEPSEEK_BASE,
|
||||
)
|
||||
b64 = base64.standard_b64encode(image_bytes).decode("ascii")
|
||||
messages = get_extract_messages(b64)
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.deepseek_model,
|
||||
messages=messages,
|
||||
max_tokens=4096,
|
||||
)
|
||||
text = response.choices[0].message.content or "[]"
|
||||
return _parse_json_array(text)
|
||||
56
backend/app/services/llm/openai_vision.py
Normal file
56
backend/app/services/llm/openai_vision.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""OpenAI Vision provider (GPT-4o)."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import get_settings
|
||||
from app.schemas.transaction import TransactionExtractItem
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.prompts.extract_transaction import get_extract_messages
|
||||
|
||||
|
||||
class OpenAIVisionProvider(BaseLLMProvider):
|
||||
async def extract_from_image(self, image_bytes: bytes) -> list[TransactionExtractItem]:
|
||||
settings = get_settings()
|
||||
if not settings.openai_api_key:
|
||||
raise ValueError("OPENAI_API_KEY is not set")
|
||||
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
||||
b64 = base64.standard_b64encode(image_bytes).decode("ascii")
|
||||
messages = get_extract_messages(b64)
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.openai_model,
|
||||
messages=messages,
|
||||
max_tokens=4096,
|
||||
)
|
||||
text = response.choices[0].message.content or "[]"
|
||||
return _parse_json_array(text)
|
||||
|
||||
|
||||
def _parse_json_array(text: str) -> list[TransactionExtractItem]:
|
||||
"""Parse LLM response into list of TransactionExtractItem. Tolerates markdown and extra text."""
|
||||
text = text.strip()
|
||||
# Remove optional markdown code block
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```(?:json)?\s*", "", text)
|
||||
text = re.sub(r"\s*```\s*$", "", text)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
result: list[TransactionExtractItem] = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
# Normalize transaction_time: allow string -> datetime
|
||||
if isinstance(item.get("transaction_time"), str) and item["transaction_time"]:
|
||||
from dateutil import parser as date_parser
|
||||
item["transaction_time"] = date_parser.isoparse(item["transaction_time"])
|
||||
result.append(TransactionExtractItem.model_validate(item))
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
22
backend/app/services/llm/router.py
Normal file
22
backend/app/services/llm/router.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""LLM provider factory - returns provider by config."""
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.llm.base import BaseLLMProvider
|
||||
from app.services.llm.openai_vision import OpenAIVisionProvider
|
||||
from app.services.llm.claude_vision import ClaudeVisionProvider
|
||||
from app.services.llm.deepseek_vision import DeepSeekVisionProvider
|
||||
from app.services.llm.custom_openai_vision import CustomOpenAICompatibleProvider
|
||||
|
||||
|
||||
def get_llm_provider() -> BaseLLMProvider:
|
||||
settings = get_settings()
|
||||
provider = (settings.llm_provider or "openai").lower()
|
||||
if provider == "openai":
|
||||
return OpenAIVisionProvider()
|
||||
if provider == "anthropic":
|
||||
return ClaudeVisionProvider()
|
||||
if provider == "deepseek":
|
||||
return DeepSeekVisionProvider()
|
||||
if provider == "custom_openai":
|
||||
return CustomOpenAICompatibleProvider()
|
||||
return OpenAIVisionProvider()
|
||||
125
backend/app/services/report.py
Normal file
125
backend/app/services/report.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Report generation: Excel and PDF export."""
|
||||
|
||||
from io import BytesIO
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# WeasyPrint optional for PDF
|
||||
try:
|
||||
from weasyprint import HTML, CSS
|
||||
HAS_WEASYPRINT = True
|
||||
except ImportError:
|
||||
HAS_WEASYPRINT = False
|
||||
|
||||
|
||||
async def build_excel_report(case, transactions: list) -> bytes:
|
||||
"""Build Excel workbook: summary sheet + transaction detail sheet. Returns file bytes."""
|
||||
wb = Workbook()
|
||||
ws_summary = wb.active
|
||||
ws_summary.title = "汇总"
|
||||
ws_summary.append(["案件编号", case.case_number])
|
||||
ws_summary.append(["受害人", case.victim_name])
|
||||
ws_summary.append(["总损失", str(case.total_loss)])
|
||||
ws_summary.append(["交易笔数", len(transactions)])
|
||||
total_out = sum(
|
||||
(t.amount if isinstance(t.amount, Decimal) else Decimal(str(t.amount)))
|
||||
for t in transactions
|
||||
if t.transaction_type in ("转出", "消费", "付款", "提现") or "转出" in (t.transaction_type or "") or "消费" in (t.transaction_type or "")
|
||||
)
|
||||
total_in = sum(
|
||||
(t.amount if isinstance(t.amount, Decimal) else Decimal(str(t.amount)))
|
||||
for t in transactions
|
||||
if t.transaction_type in ("转入", "收款", "充值") or "转入" in (t.transaction_type or "") or "收款" in (t.transaction_type or "")
|
||||
)
|
||||
ws_summary.append(["转出合计", str(total_out)])
|
||||
ws_summary.append(["转入合计", str(total_in)])
|
||||
ws_summary.append(["净损失", str(total_out - total_in)])
|
||||
for row in range(1, 8):
|
||||
ws_summary.cell(row=row, column=1).font = Font(bold=True)
|
||||
|
||||
ws_detail = wb.create_sheet("交易明细")
|
||||
headers = ["APP来源", "类型", "金额", "币种", "对方名称", "对方账号", "订单号", "交易时间", "备注", "置信度"]
|
||||
ws_detail.append(headers)
|
||||
for t in transactions:
|
||||
ws_detail.append([
|
||||
t.app_source,
|
||||
t.transaction_type or "",
|
||||
str(t.amount),
|
||||
t.currency or "CNY",
|
||||
t.counterparty_name or "",
|
||||
t.counterparty_account or "",
|
||||
t.order_number or "",
|
||||
t.transaction_time.isoformat() if t.transaction_time else "",
|
||||
t.remark or "",
|
||||
t.confidence or "",
|
||||
])
|
||||
for col in range(1, len(headers) + 1):
|
||||
ws_detail.cell(row=1, column=col).font = Font(bold=True)
|
||||
for col in range(1, ws_detail.max_column + 1):
|
||||
ws_detail.column_dimensions[get_column_letter(col)].width = 16
|
||||
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _pdf_html(case, transactions: list) -> str:
|
||||
rows = []
|
||||
for t in transactions:
|
||||
time_str = t.transaction_time.strftime("%Y-%m-%d %H:%M") if t.transaction_time else ""
|
||||
rows.append(
|
||||
f"<tr><td>{t.app_source}</td><td>{t.transaction_type or ''}</td><td>{t.amount}</td>"
|
||||
f"<td>{t.counterparty_name or ''}</td><td>{t.counterparty_account or ''}</td><td>{time_str}</td></tr>"
|
||||
)
|
||||
table_rows = "\n".join(rows)
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"/><title>案件报告</title></head>
|
||||
<body>
|
||||
<h1>资金追踪报告</h1>
|
||||
<p><strong>案件编号:</strong>{case.case_number}</p>
|
||||
<p><strong>受害人:</strong>{case.victim_name}</p>
|
||||
<p><strong>总损失:</strong>{case.total_loss}</p>
|
||||
<p><strong>交易笔数:</strong>{len(transactions)}</p>
|
||||
<h2>交易明细</h2>
|
||||
<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
|
||||
<thead><tr><th>APP</th><th>类型</th><th>金额</th><th>对方名称</th><th>对方账号</th><th>时间</th></tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def build_pdf_report(case, transactions: list) -> bytes:
|
||||
"""Build PDF report. Returns file bytes. Falls back to empty PDF if weasyprint not available."""
|
||||
if not HAS_WEASYPRINT:
|
||||
return b"%PDF-1.4 (WeasyPrint not installed)"
|
||||
html_str = _pdf_html(case, transactions)
|
||||
html = HTML(string=html_str)
|
||||
buf = BytesIO()
|
||||
html.write_pdf(buf)
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def build_excel_report_path(case, transactions: list, path: str) -> str:
|
||||
"""Write Excel to file path; return path."""
|
||||
data = await build_excel_report(case, transactions)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
|
||||
async def build_pdf_report_path(case, transactions: list, path: str) -> str:
|
||||
"""Write PDF to file path; return path."""
|
||||
data = await build_pdf_report(case, transactions)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
29
backend/requirements.txt
Normal file
29
backend/requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# FastAPI & server
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
aiosqlite==0.19.0
|
||||
greenlet
|
||||
|
||||
# Validation & config
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# LLM providers
|
||||
openai==1.12.0
|
||||
anthropic==0.18.1
|
||||
httpx==0.26.0
|
||||
|
||||
# Analysis
|
||||
networkx==3.2.1
|
||||
|
||||
# Export
|
||||
openpyxl==3.1.2
|
||||
weasyprint==60.2
|
||||
jinja2==3.1.3
|
||||
|
||||
# Utils
|
||||
python-multipart==0.0.6
|
||||
python-dateutil==2.8.2
|
||||
0
backend/uploads/.gitkeep
Normal file
0
backend/uploads/.gitkeep
Normal file
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports: ["8000:8000"]
|
||||
volumes: ["./backend/uploads:/app/uploads"]
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:///./fund_tracer.db
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports: ["3000:80"]
|
||||
depends_on: [backend]
|
||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
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>Fund Tracer - 电信诈骗资金追踪</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/nginx.conf
Normal file
16
frontend/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
3625
frontend/package-lock.json
generated
Normal file
3625
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "fund-tracer-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"recharts": "^2.10.3",
|
||||
"@xyflow/react": "^12.0.0",
|
||||
"zustand": "^4.4.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"axios": "^1.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "~5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import AppLayout from "./components/Layout";
|
||||
import CaseList from "./pages/CaseList";
|
||||
import CaseDetail from "./pages/CaseDetail";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<CaseList />} />
|
||||
<Route path="/cases/:caseId" element={<CaseDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
87
frontend/src/components/FundFlowGraph.tsx
Normal file
87
frontend/src/components/FundFlowGraph.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import type { FlowGraph } from "../services/api";
|
||||
|
||||
interface Props {
|
||||
graph: FlowGraph | null;
|
||||
}
|
||||
|
||||
function buildNodesAndEdges(graph: FlowGraph | null): { nodes: Node[]; edges: Edge[] } {
|
||||
if (!graph || !graph.nodes.length) return { nodes: [], edges: [] };
|
||||
const nodeMap = new Map<string | number, { x: number; y: number }>();
|
||||
const cols = Math.ceil(Math.sqrt(graph.nodes.length));
|
||||
graph.nodes.forEach((n, i) => {
|
||||
const row = Math.floor(i / cols);
|
||||
const col = i % cols;
|
||||
nodeMap.set(n.id, { x: col * 220, y: row * 120 });
|
||||
});
|
||||
const nodes: Node[] = graph.nodes.map((n, i) => {
|
||||
const pos = nodeMap.get(n.id) ?? { x: (i % 3) * 220, y: Math.floor(i / 3) * 120 };
|
||||
return {
|
||||
id: n.id,
|
||||
type: "default",
|
||||
position: pos,
|
||||
data: { label: n.label },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
});
|
||||
const edges: Edge[] = graph.edges.map((e, i) => ({
|
||||
id: `e-${e.source}-${e.target}-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: `¥${Number(e.amount).toFixed(2)}`,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
type: "smoothstep",
|
||||
}));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function FundFlowGraph({ graph }: Props) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = buildNodesAndEdges(graph);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
const onInit = useCallback(() => {
|
||||
const { nodes: n, edges: e } = buildNodesAndEdges(graph);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [graph, setNodes, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes: n, edges: e } = buildNodesAndEdges(graph);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [graph, setNodes, setEdges]);
|
||||
|
||||
if (!graph?.nodes?.length) {
|
||||
return <div style={{ padding: 24, color: "#999" }}>暂无资金流向数据,请先上传截图并识别交易</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: 500 }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onInit={onInit}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/Layout.tsx
Normal file
32
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Outlet, Link, useLocation } from "react-router-dom";
|
||||
import { Layout, Menu } from "antd";
|
||||
import { UnorderedListOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
export default function AppLayout() {
|
||||
const loc = useLocation();
|
||||
const selected = loc.pathname === "/settings" ? "settings" : "cases";
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Header style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
<Link to="/" style={{ color: "#fff", fontWeight: 600, fontSize: 18 }}>
|
||||
Fund Tracer
|
||||
</Link>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[selected]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
items={[
|
||||
{ key: "cases", label: <Link to="/">案件列表</Link>, icon: <UnorderedListOutlined /> },
|
||||
{ key: "settings", label: <Link to="/settings">设置</Link>, icon: <SettingOutlined /> },
|
||||
]}
|
||||
/>
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ReportSummary.tsx
Normal file
35
frontend/src/components/ReportSummary.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Descriptions, Card } from "antd";
|
||||
import type { AnalysisSummary } from "../services/api";
|
||||
|
||||
interface Props {
|
||||
summary: AnalysisSummary | null;
|
||||
}
|
||||
|
||||
export default function ReportSummary({ summary }: Props) {
|
||||
if (!summary) {
|
||||
return <div style={{ color: "#999" }}>暂无汇总数据,请先上传截图并识别交易</div>;
|
||||
}
|
||||
const byApp = summary.by_app || {};
|
||||
return (
|
||||
<div>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="转出合计">¥{Number(summary.total_out).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="转入合计">¥{Number(summary.total_in).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="净损失">¥{Number(summary.net_loss).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及对方数">{summary.counterparty_count}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{Object.keys(byApp).length > 0 && (
|
||||
<Card title="按APP统计" style={{ marginTop: 16 }}>
|
||||
<Descriptions column={1} size="small">
|
||||
{Object.entries(byApp).map(([app, s]) => (
|
||||
<Descriptions.Item key={app} label={app}>
|
||||
转入 ¥{Number((s as { in_amount: number }).in_amount).toFixed(2)} / 转出 ¥
|
||||
{Number((s as { out_amount: number }).out_amount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/ScreenshotUploader.tsx
Normal file
107
frontend/src/components/ScreenshotUploader.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Upload, List, Button, Card, Tag, message } from "antd";
|
||||
import { InboxOutlined, ThunderboltOutlined } from "@ant-design/icons";
|
||||
import { api, type ScreenshotItem } from "../services/api";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface Props {
|
||||
caseId: number;
|
||||
onExtracted?: () => void;
|
||||
}
|
||||
|
||||
export default function ScreenshotUploader({ caseId, onExtracted }: Props) {
|
||||
const [screenshots, setScreenshots] = useState<ScreenshotItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [extractingId, setExtractingId] = useState<number | null>(null);
|
||||
|
||||
const loadScreenshots = async () => {
|
||||
try {
|
||||
const items = await api.screenshots.list(caseId);
|
||||
setScreenshots(items);
|
||||
} catch {
|
||||
message.error("加载截图列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.screenshots.upload(caseId, [file]);
|
||||
await loadScreenshots();
|
||||
message.success("上传成功");
|
||||
} catch {
|
||||
message.error("上传失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return false; // prevent default upload
|
||||
};
|
||||
|
||||
const handleExtract = async (screenshotId: number) => {
|
||||
setExtractingId(screenshotId);
|
||||
try {
|
||||
await api.screenshots.extract(caseId, screenshotId);
|
||||
message.success("识别完成");
|
||||
await loadScreenshots();
|
||||
onExtracted?.();
|
||||
} catch (e: unknown) {
|
||||
const msg = e && typeof e === "object" && "response" in e
|
||||
? (e as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: "识别失败";
|
||||
message.error(msg || "识别失败");
|
||||
} finally {
|
||||
setExtractingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (caseId) loadScreenshots();
|
||||
}, [caseId]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger
|
||||
multiple
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => { handleUpload(file as File); return false; }}
|
||||
disabled={loading}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽账单截图到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 png / jpg / webp,单次可多选</p>
|
||||
</Dragger>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="link" onClick={loadScreenshots} style={{ padding: 0 }}>刷新截图列表</Button>
|
||||
<List
|
||||
style={{ marginTop: 8 }}
|
||||
grid={{ gutter: 16, column: 4 }}
|
||||
dataSource={screenshots}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Card size="small" title={item.filename}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={item.status === "extracted" ? "green" : item.status === "failed" ? "red" : "default"}>
|
||||
{item.status === "extracted" ? "已识别" : item.status === "failed" ? "失败" : "待识别"}
|
||||
</Tag>
|
||||
</div>
|
||||
{item.status === "pending" && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={extractingId === item.id}
|
||||
onClick={() => handleExtract(item.id)}
|
||||
>
|
||||
识别交易
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/TransactionTable.tsx
Normal file
59
frontend/src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { api, type Transaction } from "../services/api";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface Props {
|
||||
caseId: number | undefined;
|
||||
}
|
||||
|
||||
export default function TransactionTable({ caseId }: Props) {
|
||||
const [list, setList] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseId) return;
|
||||
setLoading(true);
|
||||
api.transactions
|
||||
.list(caseId)
|
||||
.then(setList)
|
||||
.finally(() => setLoading(false));
|
||||
}, [caseId]);
|
||||
|
||||
const columns: ColumnsType<Transaction> = [
|
||||
{ title: "APP", dataIndex: "app_source", key: "app_source", width: 100 },
|
||||
{ title: "类型", dataIndex: "transaction_type", key: "transaction_type", width: 80 },
|
||||
{ title: "金额", dataIndex: "amount", key: "amount", width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: "币种", dataIndex: "currency", key: "currency", width: 70 },
|
||||
{ title: "对方名称", dataIndex: "counterparty_name", key: "counterparty_name", ellipsis: true },
|
||||
{ title: "对方账号", dataIndex: "counterparty_account", key: "counterparty_account", ellipsis: true },
|
||||
{ title: "订单号", dataIndex: "order_number", key: "order_number", ellipsis: true },
|
||||
{
|
||||
title: "交易时间",
|
||||
dataIndex: "transaction_time",
|
||||
key: "transaction_time",
|
||||
width: 160,
|
||||
render: (v: string | null) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm") : "-"),
|
||||
},
|
||||
{
|
||||
title: "置信度",
|
||||
dataIndex: "confidence",
|
||||
key: "confidence",
|
||||
width: 80,
|
||||
render: (v: string) =>
|
||||
v === "low" ? <Tag color="orange">低</Tag> : v === "high" ? <Tag color="green">高</Tag> : <Tag>中</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/TransactionTimeline.tsx
Normal file
42
frontend/src/components/TransactionTimeline.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Timeline, Spin } from "antd";
|
||||
import { api, type Transaction } from "../services/api";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
interface Props {
|
||||
caseId: number | undefined;
|
||||
}
|
||||
|
||||
export default function TransactionTimeline({ caseId }: Props) {
|
||||
const [list, setList] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseId) return;
|
||||
setLoading(true);
|
||||
api.transactions
|
||||
.list(caseId)
|
||||
.then(setList)
|
||||
.finally(() => setLoading(false));
|
||||
}, [caseId]);
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!list.length) return <div style={{ color: "#999" }}>暂无交易记录</div>;
|
||||
|
||||
const items = list
|
||||
.map((t) => ({
|
||||
color: t.transaction_type?.includes("转出") || t.transaction_type?.includes("消费") ? "red" : "green",
|
||||
children: (
|
||||
<div key={t.id}>
|
||||
<strong>{t.app_source}</strong> · {t.transaction_type} ¥{Number(t.amount).toFixed(2)}
|
||||
{t.counterparty_name && ` → ${t.counterparty_name}`}
|
||||
<div style={{ fontSize: 12, color: "#888" }}>
|
||||
{t.transaction_time ? dayjs(t.transaction_time).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
{t.confidence === "low" && <span style={{ marginLeft: 8, color: "orange" }}>低置信度</span>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return <Timeline items={items} />;
|
||||
}
|
||||
7
frontend/src/index.css
Normal file
7
frontend/src/index.css
Normal file
@@ -0,0 +1,7 @@
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
119
frontend/src/pages/CaseDetail.tsx
Normal file
119
frontend/src/pages/CaseDetail.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Card, Descriptions, Button, Tabs, message, Space, Spin } from "antd";
|
||||
import { ArrowLeftOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||
import { api, type CaseItem, type AnalysisSummary, type FlowGraph } from "../services/api";
|
||||
import ScreenshotUploader from "../components/ScreenshotUploader";
|
||||
import FundFlowGraph from "../components/FundFlowGraph";
|
||||
import TransactionTimeline from "../components/TransactionTimeline";
|
||||
import TransactionTable from "../components/TransactionTable";
|
||||
import ReportSummary from "../components/ReportSummary";
|
||||
|
||||
export default function CaseDetail() {
|
||||
const { caseId } = useParams<{ caseId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const id = caseId ? parseInt(caseId, 10) : 0;
|
||||
const [caseData, setCaseData] = useState<CaseItem | null>(null);
|
||||
const [summary, setSummary] = useState<AnalysisSummary | null>(null);
|
||||
const [graph, setGraph] = useState<FlowGraph | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadCase = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await api.cases.get(id);
|
||||
setCaseData(c);
|
||||
} catch {
|
||||
message.error("加载案件失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAnalysis = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const { summary: s, graph: g } = await api.analysis.get(id);
|
||||
setSummary(s);
|
||||
setGraph(g);
|
||||
} catch {
|
||||
setSummary(null);
|
||||
setGraph(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCase();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadAnalysis();
|
||||
}, [id]);
|
||||
|
||||
const refreshAnalysis = () => {
|
||||
loadAnalysis();
|
||||
};
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!caseData) return null;
|
||||
const excelUrl = id ? api.export.excelUrl(id) : "";
|
||||
const pdfUrl = id ? api.export.pdfUrl(id) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/")} style={{ marginBottom: 16 }}>
|
||||
返回列表
|
||||
</Button>
|
||||
{caseData && (
|
||||
<Card title={`案件:${caseData.case_number}`}>
|
||||
<Descriptions column={2}>
|
||||
<Descriptions.Item label="受害人">{caseData.victim_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="总损失">¥{Number(caseData.total_loss).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{caseData.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{caseData.description || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
<Card title="截图上传与识别" style={{ marginTop: 16 }}>
|
||||
<ScreenshotUploader caseId={id} onExtracted={refreshAnalysis} />
|
||||
</Card>
|
||||
<Card
|
||||
title="资金分析"
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={refreshAnalysis}>刷新分析</Button>
|
||||
<Button href={excelUrl} download target="_blank" icon={<DownloadOutlined />}>导出 Excel</Button>
|
||||
<Button href={pdfUrl} download target="_blank" icon={<DownloadOutlined />}>导出 PDF</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: "summary",
|
||||
label: "汇总",
|
||||
children: <ReportSummary summary={summary} />,
|
||||
},
|
||||
{
|
||||
key: "graph",
|
||||
label: "资金流向图",
|
||||
children: <FundFlowGraph graph={graph} />,
|
||||
},
|
||||
{
|
||||
key: "timeline",
|
||||
label: "时间线",
|
||||
children: <TransactionTimeline caseId={id} />,
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
label: "交易明细表",
|
||||
children: <TransactionTable caseId={id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
104
frontend/src/pages/CaseList.tsx
Normal file
104
frontend/src/pages/CaseList.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button, Card, Table, Space, Modal, Form, Input, message } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { api, type CaseItem } from "../services/api";
|
||||
|
||||
export default function CaseList() {
|
||||
const [list, setList] = useState<CaseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const items = await api.cases.list();
|
||||
setList(items);
|
||||
} catch (e) {
|
||||
message.error("加载案件列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const onFinish = async (v: { case_number: string; victim_name: string; description?: string }) => {
|
||||
try {
|
||||
const c = await api.cases.create(v);
|
||||
message.success("案件已创建");
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
setList((prev) => [c, ...prev]);
|
||||
} catch (e) {
|
||||
message.error("创建失败");
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "案件编号", dataIndex: "case_number", key: "case_number", width: 140 },
|
||||
{ title: "受害人", dataIndex: "victim_name", key: "victim_name", width: 120 },
|
||||
{ title: "总损失", dataIndex: "total_loss", key: "total_loss", width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: "状态", dataIndex: "status", key: "status", width: 90 },
|
||||
{ title: "创建时间", dataIndex: "created_at", key: "created_at", render: (v: string) => v?.slice(0, 19).replace("T", " ") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
render: (_: unknown, r: CaseItem) => (
|
||||
<Space>
|
||||
<Link to={`/cases/${r.id}`}>
|
||||
<Button type="link" size="small">查看</Button>
|
||||
</Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="案件列表"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
|
||||
新建案件
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
<Modal
|
||||
title="新建案件"
|
||||
open={modalOpen}
|
||||
onCancel={() => { setModalOpen(false); form.resetFields(); }}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item name="case_number" label="案件编号" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:2024-001" />
|
||||
</Form.Item>
|
||||
<Form.Item name="victim_name" label="受害人姓名" rules={[{ required: true }]}>
|
||||
<Input placeholder="受害人姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="案件描述">
|
||||
<Input.TextArea rows={2} placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">创建</Button>
|
||||
<Button onClick={() => setModalOpen(false)}>取消</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
frontend/src/pages/Settings.tsx
Normal file
143
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Form, Input, Select, Button, Alert, Space, message } from "antd";
|
||||
import {
|
||||
api,
|
||||
type RuntimeSettings,
|
||||
getApiBaseUrl,
|
||||
setApiBaseUrl,
|
||||
} from "../services/api";
|
||||
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runtime, setRuntime] = useState<RuntimeSettings | null>(null);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.settings.get();
|
||||
setRuntime(data);
|
||||
form.setFieldsValue({
|
||||
system_api_base_url: getApiBaseUrl(),
|
||||
llm_provider: data.llm_provider,
|
||||
custom_openai_base_url: data.base_urls?.custom_openai || "",
|
||||
custom_openai_model: data.models?.custom_openai || "gpt-4o-mini",
|
||||
});
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const onFinish = async (values: {
|
||||
system_api_base_url?: string;
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
custom_openai_api_key?: string;
|
||||
custom_openai_base_url?: string;
|
||||
custom_openai_model?: string;
|
||||
}) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
setApiBaseUrl(values.system_api_base_url || "");
|
||||
const payload = {
|
||||
llm_provider: values.llm_provider,
|
||||
openai_api_key: values.openai_api_key?.trim() || undefined,
|
||||
anthropic_api_key: values.anthropic_api_key?.trim() || undefined,
|
||||
deepseek_api_key: values.deepseek_api_key?.trim() || undefined,
|
||||
custom_openai_api_key: values.custom_openai_api_key?.trim() || undefined,
|
||||
custom_openai_base_url: values.custom_openai_base_url?.trim() || undefined,
|
||||
custom_openai_model: values.custom_openai_model?.trim() || undefined,
|
||||
};
|
||||
const data = await api.settings.update(payload);
|
||||
setRuntime(data);
|
||||
message.success("设置已保存并生效(含系统 API BaseURL)");
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="LLM 设置" loading={loading}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="LLM API Key 仅在当前服务进程运行期内生效,不会自动写入磁盘。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label="系统 API BaseURL(前端请求后端)"
|
||||
name="system_api_base_url"
|
||||
extra="默认 /api;若前后端分离部署,可填如 http://127.0.0.1:8000/api"
|
||||
>
|
||||
<Input placeholder="/api 或 http://127.0.0.1:8000/api" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="默认模型提供商"
|
||||
name="llm_provider"
|
||||
rules={[{ required: true, message: "请选择提供商" }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Anthropic", value: "anthropic" },
|
||||
{ label: "DeepSeek", value: "deepseek" },
|
||||
{ label: "自定义(OpenAI兼容)", value: "custom_openai" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="OpenAI API Key" name="openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="Anthropic API Key" name="anthropic_api_key">
|
||||
<Input.Password placeholder="sk-ant-..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="DeepSeek API Key" name="deepseek_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自定义厂商 BaseURL(OpenAI兼容)"
|
||||
name="custom_openai_base_url"
|
||||
extra="例如 https://api.xxx.com/v1"
|
||||
>
|
||||
<Input placeholder="https://api.xxx.com/v1" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 Model" name="custom_openai_model">
|
||||
<Input placeholder="gpt-4o-mini / qwen-vl-plus / ..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义厂商 API Key" name="custom_openai_api_key">
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button onClick={loadSettings}>刷新</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{runtime && (
|
||||
<Card title="当前状态" size="small" style={{ marginTop: 16 }}>
|
||||
<div>系统 API BaseURL: {getApiBaseUrl()}</div>
|
||||
<div>当前提供商: {runtime.llm_provider}</div>
|
||||
<div>OpenAI Key: {runtime.has_keys.openai ? "已配置" : "未配置"}</div>
|
||||
<div>Anthropic Key: {runtime.has_keys.anthropic ? "已配置" : "未配置"}</div>
|
||||
<div>DeepSeek Key: {runtime.has_keys.deepseek ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 Key: {runtime.has_keys.custom_openai ? "已配置" : "未配置"}</div>
|
||||
<div>自定义厂商 BaseURL: {runtime.base_urls.custom_openai || "-"}</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
134
frontend/src/services/api.ts
Normal file
134
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const API_BASE_DEFAULT = "/api";
|
||||
const API_BASE_STORAGE_KEY = "fund_tracer_api_base_url";
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return localStorage.getItem(API_BASE_STORAGE_KEY) || API_BASE_DEFAULT;
|
||||
}
|
||||
|
||||
export function setApiBaseUrl(url: string): void {
|
||||
const value = (url || "").trim() || API_BASE_DEFAULT;
|
||||
localStorage.setItem(API_BASE_STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
timeout: 60000,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
config.baseURL = getApiBaseUrl();
|
||||
return config;
|
||||
});
|
||||
|
||||
export interface CaseItem {
|
||||
id: number;
|
||||
case_number: string;
|
||||
victim_name: string;
|
||||
description: string;
|
||||
total_loss: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
case_id: number;
|
||||
screenshot_id: number;
|
||||
app_source: string;
|
||||
transaction_type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
counterparty_name: string | null;
|
||||
counterparty_account: string | null;
|
||||
order_number: string | null;
|
||||
transaction_time: string | null;
|
||||
remark: string | null;
|
||||
confidence: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotItem {
|
||||
id: number;
|
||||
case_id: number;
|
||||
filename: string;
|
||||
file_path: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AnalysisSummary {
|
||||
total_out: number;
|
||||
total_in: number;
|
||||
net_loss: number;
|
||||
by_app: Record<string, { in_amount: number; out_amount: number }>;
|
||||
counterparty_count: number;
|
||||
}
|
||||
|
||||
export interface FlowGraph {
|
||||
nodes: Array<{ id: string; label: string; type?: string }>;
|
||||
edges: Array<{ source: string; target: string; amount: number; count?: number }>;
|
||||
}
|
||||
|
||||
export interface RuntimeSettings {
|
||||
llm_provider: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
providers: Array<"openai" | "anthropic" | "deepseek" | "custom_openai">;
|
||||
models: Record<string, string>;
|
||||
base_urls: Record<string, string>;
|
||||
has_keys: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsUpdate {
|
||||
llm_provider?: "openai" | "anthropic" | "deepseek" | "custom_openai";
|
||||
openai_api_key?: string;
|
||||
anthropic_api_key?: string;
|
||||
deepseek_api_key?: string;
|
||||
custom_openai_api_key?: string;
|
||||
custom_openai_base_url?: string;
|
||||
custom_openai_model?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
cases: {
|
||||
list: () => client.get<{ items: CaseItem[] }>("/cases").then((r) => r.data.items),
|
||||
get: (id: number) => client.get<CaseItem>(`/cases/${id}`).then((r) => r.data),
|
||||
create: (data: { case_number: string; victim_name: string; description?: string }) =>
|
||||
client.post<CaseItem>("/cases", data).then((r) => r.data),
|
||||
update: (id: number, data: Partial<CaseItem>) =>
|
||||
client.put<CaseItem>(`/cases/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => client.delete(`/cases/${id}`),
|
||||
},
|
||||
screenshots: {
|
||||
list: (caseId: number) =>
|
||||
client.get<{ items: ScreenshotItem[] }>(`/cases/${caseId}/screenshots`).then((r) => r.data.items),
|
||||
upload: (caseId: number, files: File[]) => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("files", f));
|
||||
return client.post<{ items: ScreenshotItem[] }>(`/cases/${caseId}/screenshots`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
}).then((r) => r.data.items);
|
||||
},
|
||||
extract: (caseId: number, screenshotId: number) =>
|
||||
client.post<{ items: Transaction[] }>(`/cases/${caseId}/screenshots/${screenshotId}/extract`).then((r) => r.data.items),
|
||||
},
|
||||
transactions: {
|
||||
list: (caseId: number) => client.get<Transaction[]>(`/cases/${caseId}/transactions`).then((r) => r.data),
|
||||
},
|
||||
analysis: {
|
||||
get: (caseId: number) =>
|
||||
client.get<{ summary: AnalysisSummary; graph: FlowGraph }>(`/cases/${caseId}/analysis`).then((r) => r.data),
|
||||
},
|
||||
export: {
|
||||
excelUrl: (caseId: number) => `${getApiBaseUrl()}/cases/${caseId}/export/excel`,
|
||||
pdfUrl: (caseId: number) => `${getApiBaseUrl()}/cases/${caseId}/export/pdf`,
|
||||
},
|
||||
settings: {
|
||||
get: () => client.get<RuntimeSettings>("/settings").then((r) => r.data),
|
||||
update: (payload: RuntimeSettingsUpdate) =>
|
||||
client.put<RuntimeSettings>("/settings", payload).then((r) => r.data),
|
||||
},
|
||||
};
|
||||
|
||||
export default client;
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
2
frontend/vite.config.d.ts
vendored
Normal file
2
frontend/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user