fix: bugs-04

This commit is contained in:
2026-03-17 22:39:05 +08:00
parent 143a1b4507
commit cdc2c4ead6
50 changed files with 1694 additions and 1916 deletions

View File

@@ -16,6 +16,7 @@ from app.schemas.assessment import (
InquirySuggestionOut,
)
from app.services.assessment_service import generate_inquiry_suggestions
from app.services.case_service import recalculate_case_total
router = APIRouter()
@@ -48,6 +49,7 @@ async def review_assessment(
"reviewed_by": body.reviewed_by,
"reviewed_at": datetime.now(timezone.utc),
})
await recalculate_case_total(assessment.case_id, db)
# eager-load the transaction relationship to avoid lazy-load in async context
result = await db.execute(

View File

@@ -15,11 +15,13 @@ from app.schemas.image import (
ImageOut,
ImageDetailOut,
OcrFieldCorrection,
OcrBlockUpdateIn,
CaseOcrStartIn,
CaseImagesDeleteIn,
)
from app.utils.hash import sha256_file
from app.utils.file_storage import save_upload
from app.models.ocr_block import OcrBlock
router = APIRouter()
@@ -149,6 +151,50 @@ async def correct_ocr(
return {"message": "修正已保存", "corrections": len(corrections)}
@router.patch("/images/{image_id}/ocr-blocks")
async def update_ocr_blocks(
image_id: UUID,
blocks: list[OcrBlockUpdateIn],
db: AsyncSession = Depends(get_db),
):
repo = ImageRepository(db)
image = await repo.get(image_id)
if not image:
raise HTTPException(404, "截图不存在")
existing = sorted(list(image.ocr_blocks), key=lambda b: b.seq_order)
total = len(blocks)
if total == 0:
return {"message": "未提交可保存内容", "updated": 0}
# Update existing blocks first.
min_len = min(len(existing), total)
for idx in range(min_len):
existing[idx].content = blocks[idx].content
existing[idx].confidence = blocks[idx].confidence
existing[idx].seq_order = idx
# Create extra blocks if payload has more.
for idx in range(min_len, total):
db.add(
OcrBlock(
image_id=image_id,
content=blocks[idx].content,
bbox={},
seq_order=idx,
confidence=blocks[idx].confidence,
)
)
# Remove extra old blocks if payload has fewer.
for idx in range(total, len(existing)):
await db.delete(existing[idx])
await db.flush()
await db.commit()
return {"message": f"已保存 {total} 条OCR内容", "updated": total}
@router.get("/images/{image_id}/file")
async def get_image_file(image_id: UUID, db: AsyncSession = Depends(get_db)):
repo = ImageRepository(db)

View File

@@ -5,7 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.repositories.transaction_repo import TransactionRepository
from app.schemas.transaction import TransactionOut, TransactionListOut, FlowGraphOut
from app.schemas.transaction import (
TransactionOut,
TransactionListOut,
FlowGraphOut,
TransactionUpdateIn,
)
from app.services.flow_service import build_flow_graph
router = APIRouter()
@@ -31,6 +36,23 @@ async def get_transaction(tx_id: UUID, db: AsyncSession = Depends(get_db)):
return tx
@router.patch("/transactions/{tx_id}", response_model=TransactionOut)
async def update_transaction(
tx_id: UUID,
body: TransactionUpdateIn,
db: AsyncSession = Depends(get_db),
):
repo = TransactionRepository(db)
tx = await repo.get(tx_id)
if not tx:
raise HTTPException(404, "交易不存在")
update_data = body.model_dump(exclude_unset=True)
tx = await repo.update(tx, update_data)
await db.commit()
return tx
@router.get("/cases/{case_id}/flows", response_model=FlowGraphOut)
async def get_fund_flows(case_id: UUID, db: AsyncSession = Depends(get_db)):
return await build_flow_graph(case_id, db)

View File

@@ -4,9 +4,13 @@ Identifies transactions that are internal transfers between the victim's
own accounts (e.g. bank -> Alipay -> WeChat) and should NOT be counted
as fraud loss.
"""
from datetime import datetime
from app.models.transaction import TransactionRecord
SELF_KEYWORDS = ["本人", "自己", "余额", "充值", "提现", "银行卡转入", "银行卡充值"]
FEE_TRANSIT_WINDOW_SECONDS = 120
FEE_TOLERANCE_RATIO = 0.02
def is_self_transfer(tx: TransactionRecord, known_self_accounts: list[str]) -> bool:
@@ -33,3 +37,27 @@ def is_self_transfer(tx: TransactionRecord, known_self_accounts: list[str]) -> b
return True
return False
def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecord) -> bool:
"""Two-way transfer pattern: opposite direction, close time, similar amount."""
if tx_a.direction.value == tx_b.direction.value:
return False
amount_a = float(tx_a.amount or 0)
amount_b = float(tx_b.amount or 0)
if amount_a <= 0 or amount_b <= 0:
return False
time_a = tx_a.trade_time
time_b = tx_b.trade_time
if not isinstance(time_a, datetime) or not isinstance(time_b, datetime):
return False
if abs((time_a - time_b).total_seconds()) > FEE_TRANSIT_WINDOW_SECONDS:
return False
amount_base = max(amount_a, amount_b)
if amount_base <= 0:
return False
diff_ratio = abs(amount_a - amount_b) / amount_base
return diff_ratio <= FEE_TOLERANCE_RATIO

View File

@@ -35,6 +35,11 @@ class OcrFieldCorrection(CamelModel):
new_value: str
class OcrBlockUpdateIn(CamelModel):
content: str
confidence: float = 0.5
class CaseOcrStartIn(CamelModel):
include_done: bool = False
image_ids: list[UUID] = []

View File

@@ -25,6 +25,21 @@ class TransactionOut(CamelModel):
is_transit: bool
class TransactionUpdateIn(CamelModel):
source_app: SourceApp | None = None
trade_time: datetime | None = None
amount: float | None = None
direction: Direction | None = None
counterparty_name: str | None = None
counterparty_account: str | None = None
self_account_tail_no: str | None = None
order_no: str | None = None
remark: str | None = None
confidence: float | None = None
is_duplicate: bool | None = None
is_transit: bool | None = None
class TransactionListOut(CamelModel):
items: list[TransactionOut]
total: int

View File

@@ -9,11 +9,17 @@ from app.models.assessment import FraudAssessment, ReviewStatus
async def recalculate_case_total(case_id: UUID, db: AsyncSession) -> float:
"""Recalculate and persist the total confirmed fraud amount for a case."""
"""Recalculate and persist the recognized fraud amount for a case.
The case-level amount shown in case list/workspace represents the current
recognized amount after analysis, so it should include pending/confirmed/
needs_info items and only exclude explicitly rejected records.
"""
result = await db.execute(
select(func.coalesce(func.sum(FraudAssessment.assessed_amount), 0))
.where(FraudAssessment.case_id == case_id)
.where(FraudAssessment.review_status == ReviewStatus.confirmed)
.where(FraudAssessment.assessed_amount > 0)
.where(FraudAssessment.review_status != ReviewStatus.rejected)
)
total = float(result.scalar() or 0)
case = await db.get(Case, case_id)

View File

@@ -15,7 +15,7 @@ from app.models.transaction import TransactionRecord
from app.models.transaction_cluster import TransactionCluster
from app.repositories.transaction_repo import TransactionRepository
from app.rules.dedup_rules import is_duplicate_pair
from app.rules.transit_rules import is_self_transfer
from app.rules.transit_rules import is_self_transfer, is_fee_tolerant_transit_pair
async def run_matching(case_id: UUID, self_accounts: list[str], db: AsyncSession) -> None:
@@ -70,6 +70,15 @@ async def run_matching(case_id: UUID, self_accounts: list[str], db: AsyncSession
if is_self_transfer(tx, self_accounts):
tx.is_transit = True
# Rule extension: if an in/out pair occurs within 2 minutes and
# amount difference is within 2% (e.g. fee), mark both as transit.
non_duplicate = [tx for tx in transactions if not tx.is_duplicate]
for i, tx_a in enumerate(non_duplicate):
for tx_b in non_duplicate[i + 1 :]:
if is_fee_tolerant_transit_pair(tx_a, tx_b):
tx_a.is_transit = True
tx_b.is_transit = True
await db.flush()

View File

@@ -8,7 +8,7 @@ import pytest
from app.models.transaction import Direction
from app.models.evidence_image import SourceApp
from app.rules.dedup_rules import is_duplicate_pair
from app.rules.transit_rules import is_self_transfer
from app.rules.transit_rules import is_self_transfer, is_fee_tolerant_transit_pair
from app.rules.assessment_rules import classify_transaction
@@ -79,6 +79,32 @@ class TestTransitRules:
tx = _make_tx(counterparty_name="李*华", remark="投资款")
assert not is_self_transfer(tx, [])
def test_fee_tolerant_pair_match(self):
out_tx = _make_tx(
direction=Direction.out,
amount=10000,
trade_time=datetime(2026, 3, 8, 10, 0, tzinfo=timezone.utc),
)
in_tx = _make_tx(
direction=Direction.in_,
amount=9850, # 1.5% diff
trade_time=datetime(2026, 3, 8, 10, 1, 30, tzinfo=timezone.utc),
)
assert is_fee_tolerant_transit_pair(out_tx, in_tx)
def test_fee_tolerant_pair_time_window_exceeded(self):
out_tx = _make_tx(
direction=Direction.out,
amount=10000,
trade_time=datetime(2026, 3, 8, 10, 0, tzinfo=timezone.utc),
)
in_tx = _make_tx(
direction=Direction.in_,
amount=9900,
trade_time=datetime(2026, 3, 8, 10, 3, 1, tzinfo=timezone.utc),
)
assert not is_fee_tolerant_transit_pair(out_tx, in_tx)
class TestAssessmentRules:
def test_transit_classified_as_low(self):