feat(risk-control): 新增 IP/账号手动封禁,并收口财务同步的全表更新
- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封 - 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items - 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
This commit is contained in:
@@ -19,14 +19,19 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
banAdminWorkerRisk,
|
||||
createAdminWorkerRiskBan,
|
||||
fetchAdminWorkerRiskBans,
|
||||
fetchAdminWorkerRiskEvents,
|
||||
fetchAdminWorkerRiskOverview,
|
||||
releaseAdminWorkerRiskTracker,
|
||||
revokeAdminWorkerRiskBan,
|
||||
saveAdminWorkerRiskConfig,
|
||||
unbanAdminWorkerRisk,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
WorkerRiskActiveTracker,
|
||||
WorkerRiskBan,
|
||||
WorkerRiskBanTargetType,
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskEvent,
|
||||
WorkerRiskEventType,
|
||||
@@ -84,6 +89,29 @@ export default function RiskControlPanel() {
|
||||
})
|
||||
const events = eventsQuery.data?.data
|
||||
|
||||
// 手动封禁:落库的持久事实(进程重启不丢),中间件按它拦截整个接单端
|
||||
const [banForm] = Form.useForm<{
|
||||
targetType: WorkerRiskBanTargetType
|
||||
target: string
|
||||
durationMinutes?: number
|
||||
reason?: string
|
||||
}>()
|
||||
const [banPage, setBanPage] = useState(1)
|
||||
const [banPageSize, setBanPageSize] = useState(20)
|
||||
const [banKeyword, setBanKeyword] = useState('')
|
||||
const [banStatusFilter, setBanStatusFilter] = useState<'active' | 'inactive' | 'all'>('active')
|
||||
const bansQuery = useQuery({
|
||||
queryKey: ['admin-worker-risk-bans', banPage, banPageSize, banKeyword, banStatusFilter],
|
||||
queryFn: () =>
|
||||
fetchAdminWorkerRiskBans({
|
||||
page: banPage,
|
||||
pageSize: banPageSize,
|
||||
keyword: banKeyword.trim() || undefined,
|
||||
activeOnly: banStatusFilter === 'all' ? undefined : banStatusFilter === 'active',
|
||||
}),
|
||||
})
|
||||
const bans = bansQuery.data?.data
|
||||
|
||||
useEffect(() => {
|
||||
if (overview?.config) {
|
||||
form.setFieldsValue(overview.config)
|
||||
@@ -94,6 +122,7 @@ export default function RiskControlPanel() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-risk-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-risk-events'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-risk-bans'] }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -110,6 +139,58 @@ export default function RiskControlPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一条手动封禁(IP 或账号)。账号类型会同时冻结账号并踢掉登录设备。 */
|
||||
async function submitBan(values: {
|
||||
targetType: WorkerRiskBanTargetType
|
||||
target: string
|
||||
durationMinutes?: number
|
||||
reason?: string
|
||||
}) {
|
||||
setActing(true)
|
||||
try {
|
||||
await createAdminWorkerRiskBan({
|
||||
targetType: values.targetType,
|
||||
target: String(values.target || '').trim(),
|
||||
durationMinutes: Number(values.durationMinutes || 0),
|
||||
reason: String(values.reason || '').trim(),
|
||||
})
|
||||
banForm.resetFields(['target', 'reason'])
|
||||
await refreshAll()
|
||||
message.success(values.targetType === 'ip' ? 'IP 已封禁' : '账号已封禁')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '封禁失败')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 实时风控表格里的一键封 IP:脚本最常换账号,但出口 IP 换起来更贵。 */
|
||||
function quickBanIp(ip: string) {
|
||||
confirmAction(
|
||||
'封禁 IP',
|
||||
`确认封禁 IP ${ip}?该出口下的所有接单请求(含登录)都会立即被拒绝,可在下方封禁列表解除。`,
|
||||
async () => {
|
||||
await createAdminWorkerRiskBan({
|
||||
targetType: 'ip',
|
||||
target: ip,
|
||||
reason: '后台风控页一键封禁',
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function confirmRevokeBan(ban: WorkerRiskBan) {
|
||||
const label =
|
||||
ban.targetType === 'ip' ? `IP ${ban.target}` : `账号 #${ban.workerId} ${ban.workerUsername}`
|
||||
confirmAction(
|
||||
'解除封禁',
|
||||
`确认解除对 ${label} 的封禁?账号类型会同时解冻并释放内存风控状态。`,
|
||||
async () => {
|
||||
await revokeAdminWorkerRiskBan(ban.id, '后台风控页人工解封')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function confirmAction(title: string, content: string, run: () => Promise<void>) {
|
||||
modal.confirm({
|
||||
title,
|
||||
@@ -171,6 +252,16 @@ export default function RiskControlPanel() {
|
||||
width: 240,
|
||||
render: (_value, row) => (
|
||||
<Space size="small" wrap>
|
||||
{row.ip && row.ip !== 'unknown' ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
disabled={acting || !canManage}
|
||||
onClick={() => quickBanIp(row.ip)}
|
||||
>
|
||||
封 IP
|
||||
</Button>
|
||||
) : null}
|
||||
{row.workerId ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -273,6 +364,71 @@ export default function RiskControlPanel() {
|
||||
{ title: '原因', dataIndex: 'reason', ellipsis: true },
|
||||
]
|
||||
|
||||
const banColumns: ColumnsType<WorkerRiskBan> = [
|
||||
{
|
||||
title: '封禁对象',
|
||||
dataIndex: 'target',
|
||||
render: (_value, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span>
|
||||
{row.targetType === 'ip'
|
||||
? `IP ${row.target}`
|
||||
: `账号 #${row.workerId} ${row.workerUsername || ''}`}
|
||||
</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{row.targetType === 'ip' ? '按出口 IP 拦截' : '账号级封禁(同时冻结账号)'}
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'active',
|
||||
width: 110,
|
||||
render: (_value, row) => {
|
||||
if (row.revokedAtIso) return <Tag>已解除</Tag>
|
||||
if (row.active) return <Tag color="red">生效中</Tag>
|
||||
return <Tag color="default">已过期</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '到期时间',
|
||||
dataIndex: 'expiresAtIso',
|
||||
width: 170,
|
||||
render: (value: string | null) => (value ? formatDateTime(value) : '永久'),
|
||||
},
|
||||
{ title: '原因', dataIndex: 'reason', ellipsis: true },
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'createdByUsername',
|
||||
width: 110,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAtIso',
|
||||
width: 170,
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (_value, row) =>
|
||||
row.revokedAtIso ? (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>-</span>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
disabled={acting || !canManage}
|
||||
onClick={() => confirmRevokeBan(row)}
|
||||
>
|
||||
解除
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const summary = overview?.summary24h || []
|
||||
|
||||
return (
|
||||
@@ -391,6 +547,107 @@ export default function RiskControlPanel() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="手动封禁(IP / 账号)"
|
||||
bordered={false}
|
||||
extra={
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
落库持久化,命中后直接 403,连登录一并拦截
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={banForm}
|
||||
layout="inline"
|
||||
initialValues={{ targetType: 'ip', durationMinutes: 0 }}
|
||||
onFinish={submitBan}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Form.Item name="targetType" label="类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'ip', label: 'IP' },
|
||||
{ value: 'worker', label: '接单账号' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="target"
|
||||
label="目标"
|
||||
tooltip="账号可填 ID 或用户名"
|
||||
rules={[{ required: true, message: '请填写要封禁的 IP 或账号' }]}
|
||||
>
|
||||
<Input style={{ width: 220 }} placeholder="203.0.113.9 / 1234 / username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="durationMinutes" label="时长(分钟)" tooltip="0 = 永久封禁">
|
||||
<InputNumber min={0} max={43200} style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因">
|
||||
<Input style={{ width: 240 }} placeholder="选填,会写进风控留痕" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" danger htmlType="submit" loading={acting} disabled={!canManage}>
|
||||
封禁
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space size="middle" wrap style={{ marginBottom: 12 }}>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="按 IP / 账号关键词筛选"
|
||||
style={{ width: 220 }}
|
||||
value={banKeyword}
|
||||
onChange={(event) => setBanKeyword(event.target.value)}
|
||||
onPressEnter={() => {
|
||||
setBanPage(1)
|
||||
void bansQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBanPage(1)
|
||||
void bansQuery.refetch()
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={banStatusFilter}
|
||||
onChange={(value: 'active' | 'inactive' | 'all') => {
|
||||
setBanStatusFilter(value)
|
||||
setBanPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'active', label: '只看生效中' },
|
||||
{ value: 'inactive', label: '已解除/已过期' },
|
||||
{ value: 'all', label: '全部记录' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={banColumns}
|
||||
dataSource={bans?.items || []}
|
||||
loading={bansQuery.isLoading}
|
||||
locale={{ emptyText: '暂无封禁记录' }}
|
||||
pagination={{
|
||||
current: banPage,
|
||||
pageSize: banPageSize,
|
||||
total: bans?.total || 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setBanPage(nextPage)
|
||||
setBanPageSize(nextPageSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="风控事件留痕" bordered={false}>
|
||||
<Space size="middle" wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
WorkerRiskConfig,
|
||||
WorkerRiskOverview,
|
||||
WorkerRiskEventList,
|
||||
WorkerRiskBan,
|
||||
WorkerRiskBanList,
|
||||
WorkerRiskBanTargetType,
|
||||
WorkerFinanceRequest,
|
||||
WorkerCancelRequest,
|
||||
WorkerOrderFeedback,
|
||||
@@ -1021,3 +1024,34 @@ export function releaseAdminWorkerRiskTracker(workerId: number) {
|
||||
`/api/v1/admin/worker-platform/risk-control/workers/${workerId}/release`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 手动封禁(IP / 账号):落库、进程重启不丢,中间件按它拦截整个接单端 */
|
||||
export function fetchAdminWorkerRiskBans(params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
targetType?: WorkerRiskBanTargetType
|
||||
keyword?: string
|
||||
activeOnly?: boolean
|
||||
}) {
|
||||
return apiGet<WorkerRiskBanList>('/api/v1/admin/worker-platform/risk-control/bans', params)
|
||||
}
|
||||
|
||||
export function createAdminWorkerRiskBan(payload: {
|
||||
targetType: WorkerRiskBanTargetType
|
||||
target: string
|
||||
/** 0 / 不传 = 永久 */
|
||||
durationMinutes?: number
|
||||
reason?: string
|
||||
}) {
|
||||
return apiPost<{ ban: WorkerRiskBan; workerId?: number; status?: string }>(
|
||||
'/api/v1/admin/worker-platform/risk-control/bans',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function revokeAdminWorkerRiskBan(banId: string, reason?: string) {
|
||||
return apiPost<{ ban: WorkerRiskBan }>(
|
||||
`/api/v1/admin/worker-platform/risk-control/bans/${banId}/revoke`,
|
||||
{ reason },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -927,3 +927,30 @@ export type WorkerRiskEventList = {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 手动封禁目标:IP 或接单账号 */
|
||||
export type WorkerRiskBanTargetType = 'ip' | 'worker'
|
||||
|
||||
/** 落库的手动封禁记录(进程重启不丢,中间件按它拦截) */
|
||||
export type WorkerRiskBan = {
|
||||
id: string
|
||||
targetType: WorkerRiskBanTargetType
|
||||
target: string
|
||||
workerId: number | null
|
||||
workerUsername: string
|
||||
reason: string
|
||||
expiresAtIso: string | null
|
||||
active: boolean
|
||||
createdByUsername: string
|
||||
createdAtIso: string
|
||||
revokedAtIso: string | null
|
||||
revokedByUsername: string
|
||||
revokeReason: string
|
||||
}
|
||||
|
||||
export type WorkerRiskBanList = {
|
||||
items: WorkerRiskBan[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user