diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index f45b11f..b63fdc5 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -17,6 +17,7 @@ import { NotificationOutlined, ProfileOutlined, SafetyCertificateOutlined, + SecurityScanOutlined, SettingOutlined, ShareAltOutlined, TeamOutlined, @@ -95,6 +96,7 @@ const NAV_GROUPS: NavGroup[] = [ icon: , label: '监控审计', children: [ + { key: '/risk-monitor', icon: , label: '风控监控' }, { key: '/device-liveness', icon: , label: '设备存活' }, { key: '/analytics-health', icon: , label: '埋点成功率' }, { key: '/event-logs', icon: , label: '埋点日志' }, diff --git a/src/app/(main)/risk-monitor/page.module.css b/src/app/(main)/risk-monitor/page.module.css new file mode 100644 index 0000000..bc55438 --- /dev/null +++ b/src/app/(main)/risk-monitor/page.module.css @@ -0,0 +1,356 @@ +.page { + min-height: calc(100vh - 112px); + margin: -24px; + padding: 0 16px 28px; + background: #f5f5f3; + color: #171717; +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + margin: 0 -16px 18px; + padding: 0 24px; + border-bottom: 1px solid #e8e8e6; + background: #fff; +} + +.titleCluster, +.brandLine, +.updated, +.rule, +.deviceCell, +.actionGroup, +.factRow { + display: flex; + align-items: center; +} + +.back { + margin-right: 18px; + color: #555; + font-size: 13px; +} + +.brandMark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-right: 12px; + border-radius: 10px; + background: #ffd400; + color: #111; + font-size: 17px; + font-weight: 800; +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.2; +} + +.subtitle { + margin-top: 3px; + color: #929292; + font-size: 12px; +} + +.updated { + gap: 7px; + color: #898989; + font-size: 12px; +} + +.updatedMeta { + display: flex; + align-items: center; + gap: 7px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #9b9b9b; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.statusToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 2px; + padding: 12px 16px; + border: 1px solid #ecece8; + border-radius: 12px; + background: #fff; +} + +.statusToolbar > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.statusToolbar strong { + color: #222; + font-size: 14px; +} + +.statusToolbar span { + color: #888; + font-size: 12px; +} + +.summaryCard { + min-height: 148px; + padding: 26px 24px 20px; + border: 1px solid #eeeeeb; + border-radius: 16px; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.summaryTitle { + margin-bottom: 4px; + color: #111; + font-size: 14px; + font-weight: 700; +} + +.summaryValue { + color: #ee3737; + font-size: 38px; + font-weight: 800; + line-height: 1.08; + font-variant-numeric: tabular-nums; +} + +.summaryUnit { + margin-left: 5px; + color: #202020; + font-size: 16px; + font-weight: 600; +} + +.summarySub { + margin: 8px 0 8px; + color: #969696; + font-size: 13px; +} + +.rule { + width: fit-content; + padding: 4px 8px; + border: 0; + border-radius: 6px; + background: #f6f6f4; + color: #111; + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 600; + transition: background 0.18s ease, box-shadow 0.18s ease; +} + +.rule:hover, +.rule:focus-visible { + background: #fff3bf; + box-shadow: 0 0 0 2px rgba(255, 212, 0, 0.3); + outline: none; +} + +.rule strong { + margin-left: 4px; + color: #df2727; +} + +.ruleEditIcon { + margin-left: 7px; + color: #777; + font-size: 11px; +} + +.ruleDialogIntro { + margin: 0 0 16px; + padding: 10px 12px; + border-radius: 8px; + background: #faf8ef; + color: #666; + font-size: 12px; + line-height: 1.7; +} + +.ruleSettings { + border-top: 1px solid #f0f0ed; +} + +.ruleSettingRow { + display: grid; + grid-template-columns: minmax(0, 1fr) 140px; + gap: 20px; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid #f0f0ed; +} + +.ruleSettingCopy { + display: flex; + flex-direction: column; + gap: 5px; +} + +.ruleSettingCopy strong { + color: #222; + font-size: 14px; +} + +.ruleSettingCopy span { + color: #888; + font-size: 12px; + line-height: 1.55; +} + +.ruleValueEditor { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + color: #666; +} + +.ruleValueEditor :global(.ant-input-number) { + width: 110px; +} + +.section { + margin-top: 14px; + padding: 20px 20px 16px; + border: 1px solid #ecece8; + border-radius: 16px; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.sectionTitle { + margin: 0 0 10px; + font-size: 15px; + font-weight: 700; +} + +.deviceCell { + gap: 7px; +} + +.mono { + max-width: 210px; + overflow: hidden; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copyButton { + height: 25px !important; + padding: 0 7px !important; + color: #777 !important; + font-size: 12px !important; +} + +.actionGroup { + justify-content: flex-end; + gap: 6px; + white-space: nowrap; +} + +.expandButton { + border-color: #ffd400 !important; + background: #ffd400 !important; + color: #111 !important; + font-weight: 600; +} + +.detailWrap { + padding: 12px 16px 6px; + border-radius: 10px; + background: #fafaf8; +} + +.factRow { + gap: 8px; + margin-bottom: 12px; +} + +.fact { + padding: 6px 10px; + border: 1px solid #ecece7; + border-radius: 8px; + background: #fff; + color: #666; + font-size: 12px; +} + +.fact strong { + margin: 0 3px; + color: #111; + font-size: 14px; +} + +.price { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.na { + color: #aaa; +} + +.footerNote { + margin-top: 16px; + padding: 16px 20px; + border: 1px dashed #dcdcd8; + border-radius: 14px; + background: rgba(255, 255, 255, 0.42); + color: #666; + font-size: 12px; + line-height: 1.8; +} + +.footerNote strong { + color: #222; +} + +@media (max-width: 900px) { + .summaryGrid { + grid-template-columns: 1fr; + } + + .hero { + align-items: flex-start; + padding-top: 16px; + padding-bottom: 16px; + } + + .updatedMeta { + display: none; + } + + .statusToolbar { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/app/(main)/risk-monitor/page.tsx b/src/app/(main)/risk-monitor/page.tsx new file mode 100644 index 0000000..3d46201 --- /dev/null +++ b/src/app/(main)/risk-monitor/page.tsx @@ -0,0 +1,902 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + App, + Button, + Empty, + InputNumber, + Modal, + Segmented, + Skeleton, + Table, + Tag, + Tooltip, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + ClearOutlined, + CopyOutlined, + DownOutlined, + EditOutlined, + ReloadOutlined, + RightOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { api, errMsg } from '@/lib/api'; +import { formatUtcTime, utcDayjs } from '@/lib/format'; +import type { + RiskDetailItem, + RiskDetailPage, + RiskIncidentItem, + RiskIncidentPage, + RiskKind, + RiskMonitorSummary, + RiskResetResponse, + RiskRuleConfig, + RiskSummaryCard, +} from '@/lib/types'; +import styles from './page.module.css'; + +const KIND_META: Record< + RiskKind, + { title: string; summaryTitle: string; totalLabel: string; unit: string } +> = { + sms: { + title: '短信', + summaryTitle: '触发短信报警的设备', + totalLabel: '今日短信下发共', + unit: '台', + }, + oneclick: { + title: '一键登录', + summaryTitle: '触发一键登录报警的设备', + totalLabel: '今日一键登录共', + unit: '台', + }, + compare: { + title: '比价', + summaryTitle: '触发比价报警的账户', + totalLabel: '今日比价共', + unit: '个', + }, +}; + +const integer = (value: number) => new Intl.NumberFormat('zh-CN').format(value); +const LIST_PAGE_SIZE = 20; +const RISK_KINDS: RiskKind[] = ['sms', 'oneclick', 'compare']; +type IncidentStatus = 'open' | 'blocked'; +const RULE_LIMITS: Record = { + sms: { + min: 1, + max: 5, + help: '统计成功下发;最高5次,与现有每设备每小时发送上限一致。', + }, + oneclick: { + min: 1, + max: 100000, + help: '成功和失败均计入,同一设备按北京时间自然日累计。', + }, + compare: { + min: 1, + max: 100, + help: '同一账户按北京时间自然日累计;最高100次,与比价每日上限一致。', + }, +}; +const utcTime = (value: string | null, withDate = true) => + formatUtcTime(value, withDate ? 'MM-DD HH:mm' : 'HH:mm'); +const eventMoment = (value: string, kind: RiskKind) => + kind === 'compare' ? dayjs(value) : utcDayjs(value).tz('Asia/Shanghai'); +const eventTime = (value: string | null, kind: RiskKind, withDate = true) => + value ? eventMoment(value, kind).format(withDate ? 'MM-DD HH:mm' : 'HH:mm') : '-'; + +function period(item: RiskIncidentItem): string { + const start = eventMoment(item.first_event_at, item.kind); + const end = eventMoment(item.last_event_at, item.kind); + if (start.isSame(end, 'day')) { + return `${start.format('YYYY-MM-DD HH:mm')}–${end.format('HH:mm')}`; + } + return `${start.format('YYYY-MM-DD HH:mm')}–${end.format('YYYY-MM-DD HH:mm')}`; +} + +function DeviceId({ + value, + onCopy, +}: { + value: string | null; + onCopy: (value: string) => void; +}) { + if (!value) return -; + return ( + + + {value} + + + + ); +} + +function DetailTable({ + detail, + loading, + page, + onPage, +}: { + detail?: RiskDetailPage; + loading: boolean; + page: number; + onPage: (page: number) => void; +}) { + if (loading || !detail) return ; + let columns: ColumnsType; + if (detail.kind === 'sms') { + columns = [ + { + title: '发送时间', + dataIndex: 'occurred_at', + width: 150, + render: (v) => eventTime(v, detail.kind), + }, + { title: '手机号', dataIndex: 'phone', width: 140, render: (v) => v || '-' }, + { + title: '完成验证', + dataIndex: 'verified', + width: 100, + render: (v) => (v ? : ), + }, + { + title: '登录账号', + key: 'account', + render: (_, row) => row.username || (row.user_id ? `U${row.user_id}` : '-'), + }, + { + title: '账号注册时间', + dataIndex: 'account_registered_at', + width: 150, + render: (v) => utcTime(v), + }, + ]; + } else if (detail.kind === 'oneclick') { + columns = [ + { + title: '登录时间', + dataIndex: 'occurred_at', + width: 150, + render: (v) => eventTime(v, detail.kind), + }, + { title: '手机号', dataIndex: 'phone', width: 140, render: (v) => v || '-' }, + { + title: '登录账号', + key: 'account', + render: (_, row) => row.username || (row.user_id ? `U${row.user_id}` : '-'), + }, + { + title: '账号注册时间', + dataIndex: 'account_registered_at', + width: 150, + render: (v) => utcTime(v), + }, + { + title: '结果', + dataIndex: 'outcome', + width: 100, + render: (v) => + v === 'success' ? 成功 : 失败, + }, + { title: '失败原因', dataIndex: 'reason', render: (v) => v || '-' }, + ]; + } else { + const priceColumn = (platform: string) => ({ + title: platform, + key: platform, + width: 100, + render: (_: unknown, row: RiskDetailItem) => { + const value = row.prices?.[platform]; + return value == null ? ( + 未参与 + ) : ( + ¥{value.toFixed(2)} + ); + }, + }); + columns = [ + { + title: '触发时间', + dataIndex: 'occurred_at', + width: 150, + render: (v) => eventTime(v, detail.kind), + }, + { + title: '店铺 / 菜品', + key: 'shop', + render: (_, row) => ( + {[row.store_name, row.product_names].filter(Boolean).join(' · ') || '-'} + ), + }, + priceColumn('美团'), + priceColumn('淘宝'), + priceColumn('京东'), + { + title: '节省', + dataIndex: 'saved_amount_yuan', + width: 90, + render: (v) => (v == null ? '-' : {v.toFixed(2)} 元), + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (v) => + v === 'success' ? 已完成 : 失败, + }, + ]; + } + return ( +
+
+ + 当期{KIND_META[detail.kind].title} + {integer(detail.event_count)}次 + + {detail.distinct_accounts != null && ( + + 登录{integer(detail.distinct_accounts)}个不同账号 + + )} +
+ + + ); +} + +export default function RiskMonitorPage() { + const { message, modal } = App.useApp(); + const [summary, setSummary] = useState(null); + const [lists, setLists] = useState>>({}); + const [listPage, setListPage] = useState>({ + sms: 1, + oneclick: 1, + compare: 1, + }); + const [incidentStatus, setIncidentStatus] = useState('open'); + const listPageRef = useRef(listPage); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>(new Set()); + const [details, setDetails] = useState>({}); + const [detailLoading, setDetailLoading] = useState>(new Set()); + const [detailPage, setDetailPage] = useState>({}); + const [acting, setActing] = useState(null); + const [ruleOpen, setRuleOpen] = useState(false); + const [ruleSaving, setRuleSaving] = useState(false); + const [resetting, setResetting] = useState(false); + const [ruleDraft, setRuleDraft] = useState>({ + sms: 5, + oneclick: 20, + compare: 100, + }); + + const loadAll = useCallback(async (quiet = false) => { + if (!quiet) setLoading(true); + try { + const pages = listPageRef.current; + const [summaryRes, smsRes, oneclickRes, compareRes] = await Promise.all([ + api.get('/admin/api/risk-monitor/summary'), + api.get('/admin/api/risk-monitor/incidents/sms', { + params: { + status: incidentStatus, + cursor: (pages.sms - 1) * LIST_PAGE_SIZE, + limit: LIST_PAGE_SIZE, + }, + }), + api.get('/admin/api/risk-monitor/incidents/oneclick', { + params: { + status: incidentStatus, + cursor: (pages.oneclick - 1) * LIST_PAGE_SIZE, + limit: LIST_PAGE_SIZE, + }, + }), + api.get('/admin/api/risk-monitor/incidents/compare', { + params: { + status: incidentStatus, + cursor: (pages.compare - 1) * LIST_PAGE_SIZE, + limit: LIST_PAGE_SIZE, + }, + }), + ]); + setSummary(summaryRes.data); + setLists({ + sms: smsRes.data, + oneclick: oneclickRes.data, + compare: compareRes.data, + }); + } catch (error) { + void message.error(errMsg(error, '风控数据加载失败')); + } finally { + setLoading(false); + } + }, [incidentStatus, message]); + + const changeListPage = useCallback( + async (kind: RiskKind, page: number) => { + const nextPages = { ...listPageRef.current, [kind]: page }; + listPageRef.current = nextPages; + setListPage(nextPages); + try { + const { data } = await api.get( + `/admin/api/risk-monitor/incidents/${kind}`, + { + params: { + status: incidentStatus, + cursor: (page - 1) * LIST_PAGE_SIZE, + limit: LIST_PAGE_SIZE, + }, + }, + ); + setLists((current) => ({ ...current, [kind]: data })); + } catch (error) { + void message.error(errMsg(error, '风险列表加载失败')); + } + }, + [incidentStatus, message], + ); + + const changeIncidentStatus = useCallback((status: IncidentStatus) => { + const firstPage = { sms: 1, oneclick: 1, compare: 1 }; + listPageRef.current = firstPage; + setListPage(firstPage); + setLists({}); + setExpanded(new Set()); + setDetails({}); + setDetailLoading(new Set()); + setDetailPage({}); + setIncidentStatus(status); + }, []); + + useEffect(() => { + void loadAll(); + const timer = window.setInterval(() => void loadAll(true), 60 * 60 * 1000); + const onFocus = () => void loadAll(true); + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(timer); + window.removeEventListener('focus', onFocus); + }; + }, [loadAll]); + + const copy = useCallback( + async (value: string) => { + await navigator.clipboard.writeText(value); + void message.success('设备 ID 已复制'); + }, + [message], + ); + + const openRuleSettings = useCallback(() => { + if (summary) { + const current = Object.fromEntries( + summary.cards.map((card) => [card.kind, card.threshold]), + ) as Record; + setRuleDraft(current); + } + setRuleOpen(true); + }, [summary]); + + const saveRules = useCallback(async () => { + setRuleSaving(true); + try { + const payload: RiskRuleConfig = { + sms_hourly_threshold: ruleDraft.sms, + oneclick_daily_threshold: ruleDraft.oneclick, + compare_daily_threshold: ruleDraft.compare, + }; + await api.patch('/admin/api/risk-monitor/rules', payload); + setRuleOpen(false); + void message.success('规则已保存,并已重新核算当前时段'); + await loadAll(true); + } catch (error) { + void message.error(errMsg(error, '规则保存失败')); + } finally { + setRuleSaving(false); + } + }, [loadAll, message, ruleDraft]); + + const resetAlerts = useCallback(() => { + modal.confirm({ + title: '确认重置全部报警?', + content: + '短信、一键登录、比价三类待处理报警将归零,并从确认时刻重新累计。历史流水会保留,已封禁的设备和账户不会解封。', + okText: '确认重置', + okButtonProps: { danger: true }, + cancelText: '取消', + async onOk() { + setResetting(true); + try { + const { data } = await api.post( + '/admin/api/risk-monitor/reset', + ); + listPageRef.current = { sms: 1, oneclick: 1, compare: 1 }; + setListPage({ sms: 1, oneclick: 1, compare: 1 }); + setExpanded(new Set()); + setDetails({}); + setDetailPage({}); + if (data.reset_incident_count > 0) { + void message.success( + `已重置 ${integer(data.reset_incident_count)} 条报警,并从 0 重新累计`, + ); + } else { + void message.success('当前无待处理报警,新的累计基线已生效'); + } + await loadAll(true); + } catch (error) { + void message.error(errMsg(error, '报警重置失败')); + throw error; + } finally { + setResetting(false); + } + }, + }); + }, [loadAll, message, modal]); + + const loadDetail = useCallback( + async (kind: RiskKind, incidentId: number, page = 1) => { + setDetailLoading((current) => new Set(current).add(incidentId)); + try { + const { data } = await api.get( + `/admin/api/risk-monitor/incidents/${kind}/${incidentId}/details`, + { params: { cursor: (page - 1) * 20, limit: 20 } }, + ); + setDetails((current) => ({ ...current, [incidentId]: data })); + setDetailPage((current) => ({ ...current, [incidentId]: page })); + } catch (error) { + void message.error(errMsg(error, '风险明细加载失败')); + } finally { + setDetailLoading((current) => { + const next = new Set(current); + next.delete(incidentId); + return next; + }); + } + }, + [message], + ); + + const toggle = useCallback( + (row: RiskIncidentItem) => { + const willOpen = !expanded.has(row.incident_id); + setExpanded((current) => { + const next = new Set(current); + if (willOpen) next.add(row.incident_id); + else next.delete(row.incident_id); + return next; + }); + if (willOpen && !details[row.incident_id]) { + void loadDetail(row.kind, row.incident_id); + } + }, + [details, expanded, loadDetail], + ); + + const runAction = useCallback( + (row: RiskIncidentItem, action: 'ignore' | 'block') => { + const verb = action === 'block' ? '封禁' : '忽略'; + modal.confirm({ + title: `确认${verb}该${row.subject_type === 'device' ? '设备' : '账户'}?`, + content: + action === 'block' + ? row.subject_type === 'device' + ? '封禁后,该设备发送短信和一键登录会被直接拒绝,不影响它登录过的账户。' + : '封禁后,该账户不能发起比价、领取任务奖励或提现,现有金币和现金不会清零。' + : '忽略后,本风险事件会移出待处理列表,事实流水仍会保留。', + okText: verb, + okButtonProps: { danger: action === 'block' }, + cancelText: '取消', + async onOk() { + setActing(row.incident_id); + try { + await api.post(`/admin/api/risk-monitor/incidents/${row.incident_id}/${action}`, { + reason: `风控监控页人工${verb}`, + }); + void message.success(`${verb}成功`); + setExpanded((current) => { + const next = new Set(current); + next.delete(row.incident_id); + return next; + }); + if ( + (lists[row.kind]?.items.length ?? 0) === 1 + && listPageRef.current[row.kind] > 1 + ) { + const nextPages = { + ...listPageRef.current, + [row.kind]: listPageRef.current[row.kind] - 1, + }; + listPageRef.current = nextPages; + setListPage(nextPages); + } + await loadAll(true); + } catch (error) { + void message.error(errMsg(error)); + throw error; + } finally { + setActing(null); + } + }, + }); + }, + [lists, loadAll, message, modal], + ); + + const revokeRestriction = useCallback( + (row: RiskIncidentItem) => { + if (!row.restriction_id) { + void message.error('未找到有效的封禁记录,请刷新后重试'); + return; + } + modal.confirm({ + title: `确认解除该${row.subject_type === 'device' ? '设备' : '账户'}的封禁?`, + content: + row.subject_type === 'device' + ? '解除后,该设备可以重新发送短信验证码和使用一键登录。' + : '解除后,该账户可以重新发起比价、领取任务奖励和提现。', + okText: '解除封禁', + cancelText: '取消', + async onOk() { + setActing(row.incident_id); + try { + await api.post( + `/admin/api/risk-monitor/restrictions/${row.restriction_id}/revoke`, + ); + void message.success('解除封禁成功'); + setExpanded((current) => { + const next = new Set(current); + next.delete(row.incident_id); + return next; + }); + if ( + (lists[row.kind]?.items.length ?? 0) === 1 + && listPageRef.current[row.kind] > 1 + ) { + const nextPages = { + ...listPageRef.current, + [row.kind]: listPageRef.current[row.kind] - 1, + }; + listPageRef.current = nextPages; + setListPage(nextPages); + } + await loadAll(true); + } catch (error) { + void message.error(errMsg(error, '解除封禁失败')); + throw error; + } finally { + setActing(null); + } + }, + }); + }, + [lists, loadAll, message, modal], + ); + + const columns = useCallback( + (kind: RiskKind): ColumnsType => { + const actionColumn = { + title: '操作', + key: 'actions', + align: 'right' as const, + fixed: 'right' as const, + width: 180, + render: (_: unknown, row: RiskIncidentItem) => { + const open = expanded.has(row.incident_id); + return ( + + + {row.status === 'open' && ( + <> + + + + )} + {row.status === 'blocked' && ( + + )} + + ); + }, + }; + if (kind === 'compare') { + return [ + { title: '账户手机号', dataIndex: 'phone', width: 130, render: (v) => v || '-' }, + { + title: '注册时间', + dataIndex: 'registered_at', + width: 105, + render: (v) => utcTime(v), + }, + { + title: '常用设备', + dataIndex: 'common_device_id', + width: 180, + render: (v) => , + }, + { title: '触发时段', key: 'period', width: 180, render: (_, row) => period(row) }, + { + title: '首次触发', + dataIndex: 'triggered_at', + width: 125, + render: (v) => eventTime(v, kind), + }, + actionColumn, + ]; + } + return [ + { title: '设备型号', dataIndex: 'device_model', width: 120, render: (v) => v || '-' }, + { + title: '设备 ID', + dataIndex: 'subject_id', + width: 190, + render: (v) => , + }, + { + title: '首次使用', + dataIndex: 'first_used_at', + width: 110, + render: (v) => eventTime(v, kind), + }, + { title: '触发时段', key: 'period', width: 180, render: (_, row) => period(row) }, + { + title: '首次触发', + dataIndex: 'triggered_at', + width: 110, + render: (v) => eventTime(v, kind), + }, + actionColumn, + ]; + }, + [acting, copy, detailLoading, expanded, revokeRestriction, runAction, toggle], + ); + + const cards = useMemo(() => { + if (!summary) return []; + const byKind = Object.fromEntries(summary.cards.map((card) => [card.kind, card])); + return RISK_KINDS + .map((kind) => byKind[kind]) + .filter(Boolean) as RiskSummaryCard[]; + }, [summary]); + + return ( +
+
+
+ ‹ 运营后台 + +
+

风控监控

+
短信 / 一键登录 / 比价
+
+
+
+ + {summary ? dayjs(summary.date).format('YYYY-MM-DD') : '---- -- --'} + + + 每小时更新 + + + +
+
+ + {loading && !summary ? ( + + ) : ( + <> +
+ {cards.map((card) => { + const meta = KIND_META[card.kind]; + return ( +
+
{meta.summaryTitle}
+
+ {card.alert_subject_count} + {meta.unit} +
+
+ {meta.totalLabel} {integer(card.today_total)} 次 +
+ +
+ ); + })} +
+ +
+
+ 风险处置 + + {incidentStatus === 'open' + ? '查看待处理报警,可执行封禁或忽略' + : '查看当前已封禁主体,可恢复其业务权限'} + +
+ changeIncidentStatus(value as IncidentStatus)} + /> +
+ + {RISK_KINDS.map((kind) => { + const data = lists[kind]?.items ?? []; + return ( +
+

{KIND_META[kind].title}

+
+ ), + }} + pagination={{ + current: listPage[kind], + pageSize: LIST_PAGE_SIZE, + total: lists[kind]?.total ?? 0, + hideOnSinglePage: true, + showSizeChanger: false, + onChange: (page) => void changeListPage(kind, page), + }} + expandable={{ + expandedRowKeys: [...expanded], + showExpandColumn: false, + expandedRowRender: (row) => ( + void loadDetail(row.kind, row.incident_id, page)} + /> + ), + }} + /> + + ); + })} + +
+ 处理口径: + 封禁设备只拦截该设备发送短信和一键登录,不影响它登录过的账户;封禁账户只拦截该账户的比价、任务奖励和提现,不连坐设备。误封可在“已封禁”视图解除。重置报警只清空待处理计数并建立新的累计基线,不删除历史流水、不解除封禁。所有处置均写入管理员审计日志。 +
+ + )} + void saveRules()} + onCancel={() => setRuleOpen(false)} + destroyOnHidden + > +

+ 修改后会立即重算北京时间当前小时或当天的数据。提高阈值时,不再命中的待处理告警会自动收起;已忽略和已封禁记录不会改变。 +

+
+ {RISK_KINDS.map((kind) => { + const meta = KIND_META[kind]; + const limits = RULE_LIMITS[kind]; + return ( +
+
+ {meta.summaryTitle} + {limits.help} +
+
+ { + if (value == null) return; + setRuleDraft((current) => ({ ...current, [kind]: value })); + }} + /> + +
+
+ ); + })} +
+
+ + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index d3118a6..26d8d95 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -218,6 +218,92 @@ export interface UserCoinRecord { coin: number; } +// ===== 风控监控 ===== + +export type RiskKind = 'sms' | 'oneclick' | 'compare'; + +export interface RiskSummaryCard { + kind: RiskKind; + alert_subject_count: number; + today_total: number; + threshold: number; + rule_text: string; +} + +export interface RiskMonitorSummary { + date: string; + updated_at: string; + cards: RiskSummaryCard[]; +} + +export interface RiskRuleConfig { + sms_hourly_threshold: number; + oneclick_daily_threshold: number; + compare_daily_threshold: number; +} + +export interface RiskResetResponse { + ok: boolean; + reset_at: string; + reset_incident_count: number; + reset_counts: Record; +} + +export interface RiskIncidentItem { + incident_id: number; + kind: RiskKind; + subject_type: 'device' | 'user'; + subject_id: string; + device_model: string | null; + first_used_at: string | null; + phone: string | null; + user_id: number | null; + registered_at: string | null; + common_device_id: string | null; + window_start: string; + window_end: string; + first_event_at: string; + triggered_at: string; + last_event_at: string; + event_count: number; + status: 'open' | 'ignored' | 'blocked' | 'resolved'; + restricted: boolean; + restriction_id: number | null; +} + +export interface RiskIncidentPage { + items: RiskIncidentItem[]; + next_cursor: number | null; + total: number; +} + +export interface RiskDetailItem { + id: number; + occurred_at: string; + outcome: string; + phone: string | null; + user_id: number | null; + username: string | null; + account_registered_at: string | null; + verified: boolean | null; + reason: string | null; + store_name: string | null; + product_names: string | null; + prices: Record | null; + saved_amount_yuan: number | null; + status: string | null; +} + +export interface RiskDetailPage { + kind: RiskKind; + incident_id: number; + event_count: number; + distinct_accounts: number | null; + items: RiskDetailItem[]; + next_cursor: number | null; + total: number; +} + export interface WithdrawBulkItemResult { out_bill_no: string; ok: boolean;