From c72fbc9a149d08f291a5d9148e10495fd16b45b6 Mon Sep 17 00:00:00 2001 From: ntnt Date: Fri, 13 Mar 2026 23:29:55 +0800 Subject: [PATCH] fix: mock --- backend/app/api/v1/images.py | 21 +++++-- backend/app/core/config.py | 6 +- backend/app/repositories/image_repo.py | 8 ++- backend/app/services/ocr_service.py | 19 ++++++ backend/app/workers/ocr_tasks.py | 3 + frontend/src/pages/analysis/Analysis.tsx | 49 ++++++++++++--- frontend/src/pages/review/Review.tsx | 80 ++++++++++++++++++++++-- 7 files changed, 165 insertions(+), 21 deletions(-) diff --git a/backend/app/api/v1/images.py b/backend/app/api/v1/images.py index 122e769..d313b2d 100644 --- a/backend/app/api/v1/images.py +++ b/backend/app/api/v1/images.py @@ -65,7 +65,13 @@ async def upload_images( # trigger OCR tasks in-process background (non-blocking for API response) from app.workers.ocr_tasks import process_images_ocr_batch_async - pending_ids = [str(img.id) for img in results if img.ocr_status.value == "pending"] + pending_imgs = [img for img in results if img.ocr_status.value == "pending"] + for img in pending_imgs: + img.ocr_status = OcrStatus.processing + if pending_imgs: + await db.flush() + await db.commit() + pending_ids = [str(img.id) for img in pending_imgs] if pending_ids: asyncio.create_task( process_images_ocr_batch_async( @@ -171,20 +177,25 @@ async def start_case_ocr( image_ids = payload.image_ids if payload else [] if image_ids: images = await repo.list_by_ids_in_case(case_id, image_ids) + # Never submit images that are already processing: this prevents + # duplicate OCR tasks when users trigger OCR from multiple pages. + images = [img for img in images if img.ocr_status != OcrStatus.processing] # For explicit re-run, mark selected images as processing immediately # so frontend can reflect state transition without full page refresh. for img in images: img.ocr_status = OcrStatus.processing - await db.flush() - await db.commit() + if images: + await db.flush() + await db.commit() else: images = await repo.list_for_ocr(case_id, include_done=include_done) # Mark queued images as processing immediately, including when OCR is # triggered from workspace page, so UI can show progress right away. for img in images: img.ocr_status = OcrStatus.processing - await db.flush() - await db.commit() + if images: + await db.flush() + await db.commit() from app.workers.ocr_tasks import process_images_ocr_batch_async diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 01b4306..fc633b8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,9 +2,12 @@ from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict +ENV_FILE_PATH = Path(__file__).resolve().parents[2] / ".env" + + class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + env_file=ENV_FILE_PATH, env_file_encoding="utf-8", extra="ignore", ) @@ -18,6 +21,7 @@ class Settings(BaseSettings): OCR_API_KEY: str = "" OCR_API_URL: str = "" OCR_MODEL: str = "" + OCR_ALLOW_MOCK_FALLBACK: bool = False OCR_PARALLELISM: int = 4 LLM_API_KEY: str = "" LLM_API_URL: str = "" diff --git a/backend/app/repositories/image_repo.py b/backend/app/repositories/image_repo.py index 38df492..08b0ea5 100644 --- a/backend/app/repositories/image_repo.py +++ b/backend/app/repositories/image_repo.py @@ -51,8 +51,12 @@ class ImageRepository(BaseRepository[EvidenceImage]): async def list_for_ocr(self, case_id: UUID, include_done: bool = False) -> list[EvidenceImage]: query = select(EvidenceImage).where(EvidenceImage.case_id == case_id) - if not include_done: - query = query.where(EvidenceImage.ocr_status != OcrStatus.done) + # Always exclude currently-processing images to avoid duplicate OCR + # submission from different trigger paths (upload/workspace/screenshots). + if include_done: + query = query.where(EvidenceImage.ocr_status != OcrStatus.processing) + else: + query = query.where(EvidenceImage.ocr_status.in_([OcrStatus.pending, OcrStatus.failed])) result = await self.session.execute(query.order_by(EvidenceImage.uploaded_at.desc())) return list(result.scalars().all()) diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py index ef6fa4e..30adf57 100644 --- a/backend/app/services/ocr_service.py +++ b/backend/app/services/ocr_service.py @@ -43,6 +43,17 @@ def _ocr_available() -> bool: return bool(url and key and model) +def _missing_ocr_fields() -> list[str]: + missing: list[str] = [] + if not (settings.OCR_API_URL or settings.LLM_API_URL): + missing.append("OCR_API_URL(or LLM_API_URL)") + if not (settings.OCR_API_KEY or settings.LLM_API_KEY): + missing.append("OCR_API_KEY(or LLM_API_KEY)") + if not (settings.OCR_MODEL or settings.LLM_MODEL): + missing.append("OCR_MODEL(or LLM_MODEL)") + return missing + + def _llm_available() -> bool: url, key, model = _llm_config() return bool(url and key and model) @@ -54,6 +65,10 @@ async def classify_page(image_path: str) -> tuple[SourceApp, PageType]: """Identify the source app and page type of a screenshot.""" if _ocr_available(): return await _classify_via_api(image_path) + if not settings.OCR_ALLOW_MOCK_FALLBACK: + missing = ", ".join(_missing_ocr_fields()) or "unknown" + raise RuntimeError(f"OCR configuration missing: {missing}") + logger.warning("OCR unavailable, falling back to mock classification for image: %s", image_path) return _classify_mock(image_path) @@ -63,6 +78,10 @@ async def extract_transaction_fields( """Extract structured transaction fields from a screenshot.""" if _ocr_available(): return await _extract_via_api(image_path, source_app, page_type) + if not settings.OCR_ALLOW_MOCK_FALLBACK: + missing = ", ".join(_missing_ocr_fields()) or "unknown" + raise RuntimeError(f"OCR configuration missing: {missing}") + logger.warning("OCR unavailable, falling back to mock extraction for image: %s", image_path) mock_data = _extract_mock(image_path, source_app, page_type) return mock_data, json.dumps(mock_data, ensure_ascii=False) diff --git a/backend/app/workers/ocr_tasks.py b/backend/app/workers/ocr_tasks.py index 32222b5..781bd88 100644 --- a/backend/app/workers/ocr_tasks.py +++ b/backend/app/workers/ocr_tasks.py @@ -28,6 +28,9 @@ async def process_images_ocr_batch_async(image_ids: list[str], max_concurrency: """Process many images with bounded OCR concurrency.""" if not image_ids: return + # De-duplicate in-memory to prevent repeated processing of same image id + # in a single batch submission. + image_ids = list(dict.fromkeys(image_ids)) concurrency = max(1, max_concurrency) semaphore = asyncio.Semaphore(concurrency) diff --git a/frontend/src/pages/analysis/Analysis.tsx b/frontend/src/pages/analysis/Analysis.tsx index 5ee2e8c..7933efb 100644 --- a/frontend/src/pages/analysis/Analysis.tsx +++ b/frontend/src/pages/analysis/Analysis.tsx @@ -175,6 +175,40 @@ const Analysis: React.FC = () => { a.tradeTime.localeCompare(b.tradeTime), ); + const payeeAgg = useMemo(() => { + const targetTx = validTx.filter((t) => t.direction === 'out' && !t.isTransit); + const map = new Map(); + const riskKeywords = ['刷单', '客服', '店铺', '商家', '返利', '任务', '垫付', '佣金', '淘宝']; + for (const tx of targetTx) { + const name = (tx.counterpartyName || '未知收款方').trim() || '未知收款方'; + const key = name.toLowerCase(); + const cur = map.get(key) ?? { + name, + amount: 0, + count: 0, + hasKeyword: false, + }; + cur.amount += tx.amount; + cur.count += 1; + if (!cur.hasKeyword) { + cur.hasKeyword = riskKeywords.some((kw) => name.includes(kw) || (tx.remark || '').includes(kw)); + } + map.set(key, cur); + } + return Array.from(map.values()) + .map((item) => { + const risk = + item.hasKeyword || item.amount >= 50000 || item.count >= 3 + ? 'high' + : item.amount >= 20000 || item.count >= 2 + ? 'medium' + : 'low'; + return { ...item, risk }; + }) + .sort((a, b) => b.amount - a.amount) + .slice(0, 10); + }, [validTx]); + return (
@@ -221,7 +255,7 @@ const Analysis: React.FC = () => { @@ -280,11 +314,10 @@ const Analysis: React.FC = () => { - {[ - { name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' }, - { name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' }, - { name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' }, - ].map((item, idx) => ( + {payeeAgg.length === 0 && ( + 暂无可聚合的收款方数据 + )} + {payeeAgg.map((item, idx) => ( @@ -299,8 +332,8 @@ const Analysis: React.FC = () => { ¥{item.amount.toLocaleString()} - - {item.risk === 'high' ? '高风险' : '中风险'} + + {item.risk === 'high' ? '高风险' : item.risk === 'medium' ? '中风险' : '一般'} diff --git a/frontend/src/pages/review/Review.tsx b/frontend/src/pages/review/Review.tsx index f0982e2..47ab320 100644 --- a/frontend/src/pages/review/Review.tsx +++ b/frontend/src/pages/review/Review.tsx @@ -21,6 +21,7 @@ import { Segmented, Divider, Dropdown, + Modal, } from 'antd'; import { AuditOutlined, @@ -154,6 +155,8 @@ const Review: React.FC = () => { const [editableTx, setEditableTx] = useState(null); const [reviewAction, setReviewAction] = useState('confirmed'); const [reviewNote, setReviewNote] = useState(''); + const [supplementModal, setSupplementModal] = useState(null); + const [supplementNote, setSupplementNote] = useState(''); const { data: assessData } = useQuery({ queryKey: ['assessments', id], @@ -181,6 +184,8 @@ const Review: React.FC = () => { message.success('复核结果已保存'); qc.invalidateQueries({ queryKey: ['assessments', id] }); setReviewModal(null); + setSupplementModal(null); + setSupplementNote(''); }, }); @@ -232,6 +237,11 @@ const Review: React.FC = () => { (a) => a.reviewStatus === 'confirmed', ).length; + const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => { + setSupplementModal(assessment); + setSupplementNote(initialNote ?? assessment.reviewNote ?? ''); + }; + const columns: ColumnsType = [ { title: '交易时间', @@ -303,6 +313,8 @@ const Review: React.FC = () => { render: (_, r) => { const aiAction = getAiSuggestedAction(r, allAssessments); const isPending = r.reviewStatus === 'pending'; + const isSupplemented = + r.reviewStatus === 'needs_info' && !!(r.reviewNote || '').trim(); const status = isPending ? aiAction : r.reviewStatus; const pendingStyle: Record = { @@ -322,9 +334,18 @@ const Review: React.FC = () => { const doneLabel: Record = { confirmed: '已确认', rejected: '已排除', needs_info: '已补充', }; - const label = isPending ? (pendingLabel[aiAction] || '待确认') : (doneLabel[status] || status); + + const label = isPending + ? (pendingLabel[aiAction] || '待确认') + : (status === 'needs_info' + ? (isSupplemented ? doneLabel.needs_info : pendingLabel.needs_info) + : (doneLabel[status] || status)); const submitReviewAction = (action: ReviewAction) => { + if (action === 'needs_info') { + openSupplementDialog(r); + return; + } reviewMutation.mutate({ assessmentId: r.id, body: { @@ -343,11 +364,18 @@ const Review: React.FC = () => { - {v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '需补充'} + {v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '补充信息'} ), })); + if (!isPending && r.reviewStatus === 'needs_info') { + otherOptions.push({ + key: 'needs_info', + label: 更新补充, + }); + } + if (isPending) { const ps = pendingStyle[aiAction] || pendingStyle.confirmed; return ( @@ -445,7 +473,7 @@ const Review: React.FC = () => { ? getAiSuggestedAction(r, allAssessments) : (r.reviewStatus as ReviewAction), ); - setReviewNote(''); + setReviewNote(r.reviewNote || ''); }} > 详情 @@ -807,7 +835,7 @@ const Review: React.FC = () => { options={[ { label: '确认 - 该笔计入被骗金额', value: 'confirmed' }, { label: '排除 - 该笔不计入被骗金额', value: 'rejected' }, - { label: '需补充 - 需进一步调查确认', value: 'needs_info' }, + { label: '待补充 - 需录入补充说明', value: 'needs_info' }, ]} /> 备注说明: @@ -815,7 +843,7 @@ const Review: React.FC = () => { rows={3} value={reviewNote} onChange={(e) => setReviewNote(e.target.value)} - placeholder="请输入复核意见或备注..." + placeholder={reviewAction === 'needs_info' ? '请填写已补充的证据/线索说明(必填)' : '请输入复核意见或备注...'} /> @@ -845,6 +873,10 @@ const Review: React.FC = () => { type="primary" loading={reviewMutation.isPending} onClick={() => { + if (reviewAction === 'needs_info') { + openSupplementDialog(reviewModal, reviewNote); + return; + } reviewMutation.mutate({ assessmentId: reviewModal.id, body: { @@ -861,6 +893,44 @@ const Review: React.FC = () => { )} + + { + setSupplementModal(null); + setSupplementNote(''); + }} + onOk={() => { + if (!supplementModal) return; + if (!supplementNote.trim()) { + message.warning('请填写补充说明后再提交'); + return; + } + reviewMutation.mutate({ + assessmentId: supplementModal.id, + body: { + review_status: 'needs_info', + review_note: supplementNote.trim(), + reviewed_by: 'demo_user', + }, + }); + }} + confirmLoading={reviewMutation.isPending} + okText="提交补充" + cancelText="取消" + > + + 该操作将状态更新为“已补充”,并记录补充说明以便后续复核。 + + setSupplementNote(e.target.value)} + placeholder="请输入补充的证据、线索、核验结果等内容..." + /> +
); };