fix: user

This commit is contained in:
2026-03-17 23:43:19 +08:00
parent ef8ce120bb
commit ca12e55554
8 changed files with 132 additions and 26 deletions

View File

@@ -51,7 +51,8 @@ def is_fee_tolerant_transit_pair(tx_a: TransactionRecord, tx_b: TransactionRecor
amount_b = float(tx_b.amount or 0) amount_b = float(tx_b.amount or 0)
if amount_a <= 0 or amount_b <= 0: if amount_a <= 0 or amount_b <= 0:
return False 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 return False
time_a = tx_a.trade_time 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): if not isinstance(time_a, datetime) or not isinstance(time_b, datetime):
return False return False
try: 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: except TypeError:
return False return False

View File

@@ -13,6 +13,7 @@ class ReportCreate(CamelModel):
include_timeline: bool = True include_timeline: bool = True
include_reasons: bool = True include_reasons: bool = True
include_inquiry: bool = False include_inquiry: bool = False
include_record_qa: bool = False
class ReportOut(CamelModel): class ReportOut(CamelModel):

View File

@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]: 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 # Replace mode: rerun analysis for the same case should overwrite prior assessments
# instead of appending duplicated rows. # instead of appending duplicated rows.
await db.execute( await db.execute(
@@ -26,23 +26,40 @@ async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
result = await db.execute( result = await db.execute(
select(TransactionRecord) select(TransactionRecord)
.where(TransactionRecord.case_id == case_id) .where(TransactionRecord.case_id == case_id)
.where(TransactionRecord.is_duplicate.is_(False))
.order_by(TransactionRecord.trade_time.asc()) .order_by(TransactionRecord.trade_time.asc())
) )
transactions = list(result.scalars().all()) transactions = list(result.scalars().all())
assessments: list[FraudAssessment] = [] assessments: list[FraudAssessment] = []
for tx in transactions: 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( fa = FraudAssessment(
case_id=case_id, case_id=case_id,
transaction_id=tx.id, transaction_id=tx.id,
confidence_level=level, confidence_level=level,
assessed_amount=float(tx.amount) if level != ConfidenceLevel.low else 0, assessed_amount=assessed_amount,
reason=reason, reason=reason,
exclude_reason=exclude_reason, exclude_reason=exclude_reason,
review_status=ReviewStatus.pending, review_status=review_status,
) )
db.add(fa) db.add(fa)
assessments.append(fa) assessments.append(fa)

View File

@@ -102,6 +102,27 @@ def _mask_text(value: str, keep: int = 12) -> str:
return f"{text[:keep]}..." 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: async def _build_report_dataset(case_id: UUID, db: AsyncSession) -> dict:
case_obj = await db.get(Case, case_id) case_obj = await db.get(Case, case_id)
transactions = await _get_all_transactions(case_id, db) 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]) ws4.append([i, s])
sheet_created = True 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: if not sheet_created:
ws = wb.active ws = wb.active
ws.title = "报告" 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(Paragraph(f"{i}. {s}", normal_style))
elements.append(Spacer(1, 8 * mm)) 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: if not elements or len(elements) <= 2:
elements.append(Paragraph("未选择任何报告内容。", normal_style)) elements.append(Paragraph("未选择任何报告内容。", normal_style))

View File

@@ -37,7 +37,8 @@ type ContentKeys =
| 'include_flow_chart' | 'include_flow_chart'
| 'include_timeline' | 'include_timeline'
| 'include_reasons' | 'include_reasons'
| 'include_inquiry'; | 'include_inquiry'
| 'include_record_qa';
const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [ const contentOptions: Array<{ key: ContentKeys; label: string; defaultOn: boolean }> = [
{ key: 'include_summary', label: '被骗金额汇总表', defaultOn: true }, { 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_timeline', label: '交易时间轴', defaultOn: true },
{ key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true }, { key: 'include_reasons', label: '认定理由与排除说明', defaultOn: true },
{ key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false }, { key: 'include_inquiry', label: '笔录辅助问询建议', defaultOn: false },
{ key: 'include_record_qa', label: '笔录问答草稿', defaultOn: false },
]; ];
const STORAGE_PREFIX = 'report-content-'; const STORAGE_PREFIX = 'report-content-';
const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => { const loadContentSelection = (caseId: string): Record<ContentKeys, boolean> => {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${caseId}`);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
const defaults: Record<string, boolean> = {}; const defaults: Record<string, boolean> = {};
contentOptions.forEach((o) => { defaults[o.key] = o.defaultOn; }); 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<ContentKeys, boolean>; return defaults as Record<ContentKeys, boolean>;
}; };

View File

@@ -36,6 +36,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { FraudAssessment, ConfidenceLevel } from '../../types'; import type { FraudAssessment, ConfidenceLevel } from '../../types';
import { import {
fetchAssessments, fetchAssessments,
fetchCase,
submitReview, submitReview,
fetchInquirySuggestions, fetchInquirySuggestions,
triggerAnalysis, triggerAnalysis,
@@ -200,6 +201,10 @@ const Review: React.FC = () => {
queryFn: () => fetchAssessments(id), queryFn: () => fetchAssessments(id),
refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false), refetchInterval: () => (Date.now() < autoRefreshUntil ? 2000 : false),
}); });
const { data: currentCase } = useQuery({
queryKey: ['case', id],
queryFn: () => fetchCase(id),
});
const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({ const { data: suggestionsData, isLoading: suggestionsLoading, isFetching: suggestionsFetching } = useQuery({
queryKey: ['suggestions', id], queryKey: ['suggestions', id],
queryFn: () => fetchInquirySuggestions(id), queryFn: () => fetchInquirySuggestions(id),
@@ -213,6 +218,7 @@ const Review: React.FC = () => {
const allAssessments = assessData?.items ?? []; const allAssessments = assessData?.items ?? [];
const suggestions = suggestionsData?.suggestions ?? []; const suggestions = suggestionsData?.suggestions ?? [];
const reviewedByName = (currentCase?.handler || '').trim() || 'demo_user';
const hasNoAnalysisResult = const hasNoAnalysisResult =
suggestions.length === 1 && suggestions[0].includes('暂无分析结果'); suggestions.length === 1 && suggestions[0].includes('暂无分析结果');
@@ -412,7 +418,7 @@ const Review: React.FC = () => {
body: { body: {
review_status: action, review_status: action,
review_note: `操作:${aiSuggestionLabel[action]}`, review_note: `操作:${aiSuggestionLabel[action]}`,
reviewed_by: 'demo_user', reviewed_by: reviewedByName,
}, },
}); });
}; };
@@ -996,7 +1002,7 @@ const Review: React.FC = () => {
body: { body: {
review_status: reviewAction, review_status: reviewAction,
review_note: reviewNote, review_note: reviewNote,
reviewed_by: 'demo_user', reviewed_by: reviewedByName,
}, },
}); });
}} }}
@@ -1027,7 +1033,7 @@ const Review: React.FC = () => {
body: { body: {
review_status: 'needs_info', review_status: 'needs_info',
review_note: supplementNote.trim(), review_note: supplementNote.trim(),
reviewed_by: 'demo_user', reviewed_by: reviewedByName,
}, },
}); });
}} }}

View File

@@ -32,7 +32,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { TransactionRecord, SourceApp } from '../../types'; import type { TransactionRecord, SourceApp } from '../../types';
import { fetchImageDetail, fetchTransactions, updateTransaction } from '../../services/api'; import { fetchImageDetail, fetchTransactions, triggerAnalysis, updateTransaction } from '../../services/api';
const appTag: Record<SourceApp, { label: string; color: string }> = { const appTag: Record<SourceApp, { label: string; color: string }> = {
wechat: { label: '微信', color: 'green' }, wechat: { label: '微信', color: 'green' },
@@ -78,6 +78,36 @@ const Transactions: React.FC = () => {
}, },
onError: () => message.error('交易修改保存失败'), 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 allTransactions = txData?.items ?? [];
const { data: detailImage, isFetching: detailImageFetching } = useQuery({ const { data: detailImage, isFetching: detailImageFetching } = useQuery({
queryKey: ['image-detail', detail?.evidenceImageId], queryKey: ['image-detail', detail?.evidenceImageId],
@@ -356,16 +386,25 @@ const Transactions: React.FC = () => {
</Space> </Space>
} }
extra={ extra={
<Select <Space>
value={filterDuplicate} <Select
onChange={setFilterDuplicate} value={filterDuplicate}
style={{ width: 160 }} onChange={setFilterDuplicate}
options={[ style={{ width: 160 }}
{ label: '全部交易', value: 'all' }, options={[
{ label: '仅有效交易', value: 'unique' }, { label: '全部交易', value: 'all' },
{ label: '仅重复交易', value: 'duplicate' }, { label: '仅有效交易', value: 'unique' },
]} { label: '仅重复交易', value: 'duplicate' },
/> ]}
/>
<Button
type="primary"
loading={analysisMutation.isPending}
onClick={() => analysisMutation.mutate()}
>
{analysisMutation.isPending ? '分析中...' : '执行资金分析'}
</Button>
</Space>
} }
> >
<Table <Table

View File

@@ -298,6 +298,7 @@ export async function generateReport(
include_timeline?: boolean; include_timeline?: boolean;
include_reasons?: boolean; include_reasons?: boolean;
include_inquiry?: boolean; include_inquiry?: boolean;
include_record_qa?: boolean;
}, },
): Promise<ExportReport> { ): Promise<ExportReport> {
if (!(await isBackendUp())) return mockReports[0]; if (!(await isBackendUp())) return mockReports[0];