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