From ca12e555541dff4c3b3e83f2dc23cb3ad3243fb5 Mon Sep 17 00:00:00 2001 From: ntnt Date: Tue, 17 Mar 2026 23:43:19 +0800 Subject: [PATCH] fix: user --- backend/app/rules/transit_rules.py | 6 +- backend/app/schemas/report.py | 1 + backend/app/services/assessment_service.py | 27 ++++++-- backend/app/services/report_service.py | 38 ++++++++++++ frontend/src/pages/reports/Reports.tsx | 12 ++-- frontend/src/pages/review/Review.tsx | 12 +++- .../src/pages/transactions/Transactions.tsx | 61 +++++++++++++++---- frontend/src/services/api.ts | 1 + 8 files changed, 132 insertions(+), 26 deletions(-) diff --git a/backend/app/rules/transit_rules.py b/backend/app/rules/transit_rules.py index bbb5527..54f2bd6 100644 --- a/backend/app/rules/transit_rules.py +++ b/backend/app/rules/transit_rules.py @@ -51,7 +51,8 @@ def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecor amount_b = float(tx_b.amount or 0) if amount_a <= 0 or amount_b <= 0: return False - if _amount_ratio_diff(amount_a, amount_b) > FEE_TOLERANCE_RATIO: + diff_ratio = _amount_ratio_diff(amount_a, amount_b) + if diff_ratio > FEE_TOLERANCE_RATIO: return False time_a = tx_a.trade_time @@ -59,7 +60,8 @@ def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecor if not isinstance(time_a, datetime) or not isinstance(time_b, datetime): return False try: - return abs((time_a - time_b).total_seconds()) <= FEE_TRANSIT_WINDOW_SECONDS + seconds_gap = abs((time_a - time_b).total_seconds()) + return seconds_gap <= FEE_TRANSIT_WINDOW_SECONDS except TypeError: return False diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 44db6f5..ea14863 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -13,6 +13,7 @@ class ReportCreate(CamelModel): include_timeline: bool = True include_reasons: bool = True include_inquiry: bool = False + include_record_qa: bool = False class ReportOut(CamelModel): diff --git a/backend/app/services/assessment_service.py b/backend/app/services/assessment_service.py index 7a7bd0a..dbce944 100644 --- a/backend/app/services/assessment_service.py +++ b/backend/app/services/assessment_service.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]: - """Run rule-based assessment on all non-duplicate transactions and replace old results.""" + """Run rule-based assessment and replace old results.""" # Replace mode: rerun analysis for the same case should overwrite prior assessments # instead of appending duplicated rows. await db.execute( @@ -26,23 +26,40 @@ async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]: result = await db.execute( select(TransactionRecord) .where(TransactionRecord.case_id == case_id) - .where(TransactionRecord.is_duplicate.is_(False)) .order_by(TransactionRecord.trade_time.asc()) ) transactions = list(result.scalars().all()) assessments: list[FraudAssessment] = [] for tx in transactions: - level, reason, exclude_reason = classify_transaction(tx) + if tx.is_duplicate: + level = ConfidenceLevel.low + reason = "该笔交易在交易归并中已标记为重复记录,属于同一笔交易的重复展示。" + exclude_reason = "交易归并已判定重复(订单号一致或人工标记为重复),不重复计入被骗金额。" + review_status = ReviewStatus.rejected + assessed_amount = 0 + elif tx.is_transit: + level = ConfidenceLevel.low + reason = ( + f"该笔交易在交易归并中已标记为中转({tx.source_app.value} -> {tx.counterparty_name})," + "属于本人账户间资金流转。" + ) + exclude_reason = "交易归并已判定中转(本人账户间互转),不直接计入被骗金额。" + review_status = ReviewStatus.rejected + assessed_amount = 0 + else: + level, reason, exclude_reason = classify_transaction(tx) + review_status = ReviewStatus.pending + assessed_amount = float(tx.amount) if level != ConfidenceLevel.low else 0 fa = FraudAssessment( case_id=case_id, transaction_id=tx.id, confidence_level=level, - assessed_amount=float(tx.amount) if level != ConfidenceLevel.low else 0, + assessed_amount=assessed_amount, reason=reason, exclude_reason=exclude_reason, - review_status=ReviewStatus.pending, + review_status=review_status, ) db.add(fa) assessments.append(fa) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 1b081c0..8a772f9 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -102,6 +102,27 @@ def _mask_text(value: str, keep: int = 12) -> str: return f"{text[:keep]}..." +def _build_record_qa_draft(transactions: list[TransactionRecord], assessment_by_tx: dict) -> str: + candidates = [tx for tx in transactions if not tx.is_duplicate] + if not candidates: + return "问:具体的转账信息?\n答:目前暂无可确认的转账信息。" + + candidates = sorted(candidates, key=lambda x: x.trade_time or datetime.min) + lines: list[str] = [] + for idx, tx in enumerate(candidates, 1): + fa = assessment_by_tx.get(tx.id) + status = fa.review_status.value if fa else "pending" + cp_account = f",对方账号{tx.counterparty_account}" if tx.counterparty_account else "" + order_no = f",订单号{tx.order_no}" if tx.order_no else "" + remark = f",备注“{tx.remark}”" if tx.remark else "" + lines.append( + f"第{idx}笔是{_fmt_dt(tx.trade_time, with_seconds=True)}通过{tx.source_app.value}" + f"{'转出' if tx.direction.value == 'out' else '转入'}人民币{float(tx.amount):,.2f}元至{tx.counterparty_name}" + f"{cp_account}{order_no}{remark},当前认定状态为{status}。" + ) + return "问:具体的转账信息?\n答:" + "".join(lines) + + async def _build_report_dataset(case_id: UUID, db: AsyncSession) -> dict: case_obj = await db.get(Case, case_id) transactions = await _get_all_transactions(case_id, db) @@ -245,6 +266,14 @@ async def _gen_excel(case_id: UUID, report_dir: Path, body: ReportCreate, db: As ws4.append([i, s]) sheet_created = True + if body.include_record_qa: + ws5 = wb.active if not sheet_created else wb.create_sheet() + ws5.title = "笔录问答草稿" + ws5.append(["问答内容"]) + dataset = await _build_report_dataset(case_id, db) + ws5.append([_build_record_qa_draft(dataset["transactions"], dataset["assessment_by_tx"])]) + sheet_created = True + if not sheet_created: ws = wb.active ws.title = "报告" @@ -482,6 +511,15 @@ async def _gen_pdf(case_id: UUID, report_dir: Path, body: ReportCreate, db: Asyn elements.append(Paragraph(f"{i}. {s}", normal_style)) elements.append(Spacer(1, 8 * mm)) + # Section 9: record QA draft + if body.include_record_qa: + elements.append(Paragraph("笔录问答草稿", h2_style)) + elements.append(Spacer(1, 4 * mm)) + qa_text = _build_record_qa_draft(transactions, assessment_by_tx) + for line in qa_text.split("\n"): + elements.append(Paragraph(line, normal_style)) + elements.append(Spacer(1, 8 * mm)) + if not elements or len(elements) <= 2: elements.append(Paragraph("未选择任何报告内容。", normal_style)) diff --git a/frontend/src/pages/reports/Reports.tsx b/frontend/src/pages/reports/Reports.tsx index d1f6701..0e36f2f 100644 --- a/frontend/src/pages/reports/Reports.tsx +++ b/frontend/src/pages/reports/Reports.tsx @@ -37,7 +37,8 @@ type ContentKeys = | 'include_flow_chart' | 'include_timeline' | 'include_reasons' - | 'include_inquiry'; + | 'include_inquiry' + | 'include_record_qa'; const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [ { key: 'include_summary', label: '被骗金额汇总表', defaultOn: true }, @@ -46,17 +47,18 @@ const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolea { key: 'include_timeline', label: '交易时间轴', defaultOn: true }, { key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true }, { key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false }, + { key: 'include_record_qa', label: '笔录问答草稿', defaultOn: false }, ]; const STORAGE_PREFIX = 'report-content-'; const loadContentSelection = (caseId: string): Record => { - try { - const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`); - if (raw) return JSON.parse(raw); - } catch { /* ignore */ } const defaults: Record = {}; contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; }); + try { + const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`); + if (raw) return { ...defaults, ...JSON.parse(raw) }; + } catch { /* ignore */ } return defaults as Record; }; diff --git a/frontend/src/pages/review/Review.tsx b/frontend/src/pages/review/Review.tsx index 7a86cf2..13d9d58 100644 --- a/frontend/src/pages/review/Review.tsx +++ b/frontend/src/pages/review/Review.tsx @@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table'; import type { FraudAssessment, ConfidenceLevel } from '../../types'; import { fetchAssessments, + fetchCase, submitReview, fetchInquirySuggestions, triggerAnalysis, @@ -200,6 +201,10 @@ const Review: React.FC = () => { queryFn: () => fetchAssessments(id), refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false), }); + const { data: currentCase } = useQuery({ + queryKey: ['case', id], + queryFn: () => fetchCase(id), + }); const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({ queryKey: ['suggestions', id], queryFn: () => fetchInquirySuggestions(id), @@ -213,6 +218,7 @@ const Review: React.FC = () => { const allAssessments = assessData?.items ?? []; const suggestions = suggestionsData?.suggestions ?? []; + const reviewedByName = (currentCase?.handler || '').trim() || 'demo_user'; const hasNoAnalysisResult = suggestions.length === 1 && suggestions[0].includes('暂无分析结果'); @@ -412,7 +418,7 @@ const Review: React.FC = () => { body: { review_status: action, review_note: `操作:${aiSuggestionLabel[action]}`, - reviewed_by: 'demo_user', + reviewed_by: reviewedByName, }, }); }; @@ -996,7 +1002,7 @@ const Review: React.FC = () => { body: { review_status: reviewAction, review_note: reviewNote, - reviewed_by: 'demo_user', + reviewed_by: reviewedByName, }, }); }} @@ -1027,7 +1033,7 @@ const Review: React.FC = () => { body: { review_status: 'needs_info', review_note: supplementNote.trim(), - reviewed_by: 'demo_user', + reviewed_by: reviewedByName, }, }); }} diff --git a/frontend/src/pages/transactions/Transactions.tsx b/frontend/src/pages/transactions/Transactions.tsx index b98966e..83f9878 100644 --- a/frontend/src/pages/transactions/Transactions.tsx +++ b/frontend/src/pages/transactions/Transactions.tsx @@ -32,7 +32,7 @@ import { } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import type { TransactionRecord, SourceApp } from '../../types'; -import { fetchImageDetail, fetchTransactions, updateTransaction } from '../../services/api'; +import { fetchImageDetail, fetchTransactions, triggerAnalysis, updateTransaction } from '../../services/api'; const appTag: Record = { wechat: { label: '微信', color: 'green' }, @@ -78,6 +78,36 @@ const Transactions: React.FC = () => { }, onError: () => message.error('交易修改保存失败'), }); + const analysisMutation = useMutation({ + mutationFn: () => triggerAnalysis(id), + onMutate: () => { + message.open({ + key: 'transactions-analysis', + type: 'loading', + content: '正在执行资金分析...', + duration: 0, + }); + }, + onSuccess: (res) => { + message.open({ + key: 'transactions-analysis', + type: 'success', + content: res.message || '资金分析任务已提交', + }); + qc.invalidateQueries({ queryKey: ['transactions', id] }); + qc.invalidateQueries({ queryKey: ['assessments', id] }); + qc.invalidateQueries({ queryKey: ['flows', id] }); + qc.invalidateQueries({ queryKey: ['case', id] }); + qc.invalidateQueries({ queryKey: ['suggestions', id] }); + }, + onError: () => { + message.open({ + key: 'transactions-analysis', + type: 'error', + content: '资金分析执行失败', + }); + }, + }); const allTransactions = txData?.items ?? []; const { data: detailImage, isFetching: detailImageFetching } = useQuery({ queryKey: ['image-detail', detail?.evidenceImageId], @@ -356,16 +386,25 @@ const Transactions: React.FC = () => { } extra={ - + + } > { if (!(await isBackendUp())) return mockReports[0];