fix: mock
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, { name: string; amount: number; count: number; hasKeyword: boolean }>();
|
||||
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 (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
@@ -221,7 +255,7 @@ const Analysis: React.FC = () => {
|
||||
<Card variant="borderless">
|
||||
<Statistic
|
||||
title="涉诈对手方"
|
||||
value={3}
|
||||
value={payeeAgg.length}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
@@ -280,11 +314,10 @@ const Analysis: React.FC = () => {
|
||||
|
||||
<Card title="收款方聚合" style={{ marginTop: 24 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{[
|
||||
{ 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 && (
|
||||
<Typography.Text type="secondary">暂无可聚合的收款方数据</Typography.Text>
|
||||
)}
|
||||
{payeeAgg.map((item, idx) => (
|
||||
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
@@ -299,8 +332,8 @@ const Analysis: React.FC = () => {
|
||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||
¥{item.amount.toLocaleString()}
|
||||
</Typography.Text>
|
||||
<Tag color={item.risk === 'high' ? 'red' : 'orange'}>
|
||||
{item.risk === 'high' ? '高风险' : '中风险'}
|
||||
<Tag color={item.risk === 'high' ? 'red' : item.risk === 'medium' ? 'orange' : 'default'}>
|
||||
{item.risk === 'high' ? '高风险' : item.risk === 'medium' ? '中风险' : '一般'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
@@ -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<FraudAssessment['transaction'] | null>(null);
|
||||
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
|
||||
const [reviewNote, setReviewNote] = useState('');
|
||||
const [supplementModal, setSupplementModal] = useState<FraudAssessment | null>(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<FraudAssessment> = [
|
||||
{
|
||||
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<string, { bg: string; border: string; color: string }> = {
|
||||
@@ -322,9 +334,18 @@ const Review: React.FC = () => {
|
||||
const doneLabel: Record<string, string> = {
|
||||
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 = () => {
|
||||
<span style={{
|
||||
color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff',
|
||||
}}>
|
||||
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '需补充'}
|
||||
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '补充信息'}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
if (!isPending && r.reviewStatus === 'needs_info') {
|
||||
otherOptions.push({
|
||||
key: 'needs_info',
|
||||
label: <span style={{ color: '#1677ff' }}>更新补充</span>,
|
||||
});
|
||||
}
|
||||
|
||||
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' },
|
||||
]}
|
||||
/>
|
||||
<Typography.Text strong>备注说明:</Typography.Text>
|
||||
@@ -815,7 +843,7 @@ const Review: React.FC = () => {
|
||||
rows={3}
|
||||
value={reviewNote}
|
||||
onChange={(e) => setReviewNote(e.target.value)}
|
||||
placeholder="请输入复核意见或备注..."
|
||||
placeholder={reviewAction === 'needs_info' ? '请填写已补充的证据/线索说明(必填)' : '请输入复核意见或备注...'}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -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 = () => {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="补充信息"
|
||||
open={!!supplementModal}
|
||||
zIndex={1400}
|
||||
onCancel={() => {
|
||||
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="取消"
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
该操作将状态更新为“已补充”,并记录补充说明以便后续复核。
|
||||
</Typography.Paragraph>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
value={supplementNote}
|
||||
onChange={(e) => setSupplementNote(e.target.value)}
|
||||
placeholder="请输入补充的证据、线索、核验结果等内容..."
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user