feat(risk-control): 新增 IP/账号手动封禁,并收口财务同步的全表更新

- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封
- 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items
- 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
This commit is contained in:
yml2213
2026-10-30 20:15:14 +08:00
parent 1b170d3f45
commit fb485f7e36
17 changed files with 1373 additions and 18 deletions
@@ -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
}