fix: bugs-04
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user