feat(risk-control): 新增 IP/账号手动封禁,并收口财务同步的全表更新
- 风控:worker_risk_bans 表 + 前置封禁中间件(登录一并拦截)+ 后台列表/新增/解封 - 财务:成本快照补商品维度收口到本批 order_item_id,不再整表扫 899MB 的 order_items - 性能:order_items 覆盖索引(迁移 110)、大厅 pageSize 上限 20、已读/上传接口限流
This commit is contained in:
@@ -164,6 +164,7 @@ export async function normalizeManualProducts(
|
||||
|
||||
// 回填订单项:按 id 分批,每批重新取日期参数,避免单条语句超过 15s。
|
||||
let updatedItemCount = 0
|
||||
let updatedSnapshotCount = 0
|
||||
let lastId = 0
|
||||
for (let batch = 0; batch < 40; batch += 1) {
|
||||
const range = buildRangeClause(3)
|
||||
@@ -197,26 +198,29 @@ export async function normalizeManualProducts(
|
||||
updatedItemCount += rowCount
|
||||
const ids = updated.rows as Array<{ id: number }>
|
||||
lastId = Number(ids[ids.length - 1]?.id || lastId)
|
||||
// 已有成本快照补商品维度:**只处理本批刚归一过的订单项**。
|
||||
// 原来是无条件全表条件更新,要扫 900MB 的 order_items(堆 + 两个位图索引回表),
|
||||
// 生产实测每次 15~16 秒、必然撞 statement_timeout,而且失败会把后半段
|
||||
// (销售事实重算、影子总账同步)整条链跳过。收口到本批 id 后走 idx_cost_snapshots_item,
|
||||
// 毫秒级完成,且结果完全一样(唯一会变的行就是这些刚补上商品的订单项)。
|
||||
if (ids.length > 0) {
|
||||
const updatedSnapshots = await query(
|
||||
`
|
||||
UPDATE cost_snapshots cs
|
||||
SET canonical_product_id = oi.canonical_product_id
|
||||
FROM order_items oi
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.canonical_product_id IS NULL
|
||||
AND oi.canonical_product_id IS NOT NULL
|
||||
`,
|
||||
[ids.map((row) => Number(row.id))],
|
||||
)
|
||||
updatedSnapshotCount += updatedSnapshots.rowCount || 0
|
||||
}
|
||||
if (rowCount < NORMALIZE_BATCH_SIZE) break
|
||||
}
|
||||
|
||||
// 已有实际成本快照补上商品维度,让毛利能按商品归集。
|
||||
let updatedSnapshotCount = 0
|
||||
if (updatedItemCount > 0) {
|
||||
const updatedSnapshots = await query(
|
||||
`
|
||||
UPDATE cost_snapshots cs
|
||||
SET canonical_product_id = oi.canonical_product_id
|
||||
FROM order_items oi
|
||||
WHERE cs.order_item_id = oi.id
|
||||
AND cs.canonical_product_id IS NULL
|
||||
AND oi.fulfillment_mode = 'manual'
|
||||
AND oi.canonical_product_id IS NOT NULL
|
||||
`,
|
||||
)
|
||||
updatedSnapshotCount = updatedSnapshots.rowCount || 0
|
||||
}
|
||||
|
||||
let resolvedExceptionCount = 0
|
||||
if (input.resolveExceptions !== false && updatedItemCount > 0) {
|
||||
const resolved = await query(
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 2026-10-30 生产事故复盘:cost-finance-sync 每 15 分钟的「成本快照补商品维度」
|
||||
* 原先是一条无条件全表更新,要扫 899MB 的 order_items,实测 15.2s / 16.5s 撞
|
||||
* statement_timeout,并且失败会把后面的「销售事实重算 + 影子总账同步」整条跳过。
|
||||
*
|
||||
* 修复口径:只更新本批刚归一过的 order_item_id(走 idx_cost_snapshots_item),
|
||||
* 结果完全一样但毫秒级完成。这里用源码断言把它钉住。
|
||||
*/
|
||||
test('成本快照补商品维度必须按本批 order_item_id 收口,不能再全表更新', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(
|
||||
new URL('./manual-product-normalization-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
src.includes('oi.id = ANY($1::bigint[])'),
|
||||
'成本快照更新缺少本批 id 收口:会退化成扫 900MB 的 order_items(实测 15 秒超时)',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('AND cs.order_item_id = oi.id'),
|
||||
'收口后仍要按 order_item_id 关联,不能改成按 subject_id 猜',
|
||||
)
|
||||
|
||||
// 更新必须发生在分批循环内部(本批 ids 还在作用域里),不能在循环之后
|
||||
const loopStart = src.indexOf('for (let batch = 0; batch < 40; batch += 1) {')
|
||||
const loopEnd = src.indexOf('let resolvedExceptionCount = 0')
|
||||
const snapshotUpdateAt = src.indexOf('UPDATE cost_snapshots cs')
|
||||
assert.ok(loopStart > 0 && loopEnd > loopStart, '找不到归一的分批循环')
|
||||
assert.ok(
|
||||
snapshotUpdateAt > loopStart && snapshotUpdateAt < loopEnd,
|
||||
'成本快照更新必须留在分批循环内:循环外拿不到本批 ids,就只能退回全表条件',
|
||||
)
|
||||
|
||||
// 兜底:不允许再出现「无 id 限制 + fulfillment_mode 条件」的老写法
|
||||
const legacyPattern = /UPDATE cost_snapshots cs[\s\S]{0,400}?oi\.fulfillment_mode = 'manual'/
|
||||
assert.equal(
|
||||
legacyPattern.test(src),
|
||||
false,
|
||||
'不能再按 fulfillment_mode 全表更新(会整表 bitmap 回表)',
|
||||
)
|
||||
})
|
||||
|
||||
test('迁移 110 提供 order_items(fulfillment_mode, canonical_product_id) 覆盖索引', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const migration = await readFile(
|
||||
new URL('../../db/migrations/110_order_items_fulfillment_product_index.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(migration.includes('idx_order_items_fulfillment_product'))
|
||||
assert.ok(migration.includes('(fulfillment_mode, canonical_product_id)'))
|
||||
assert.ok(migration.includes('INCLUDE (id)'))
|
||||
assert.ok(
|
||||
!/^\s*CREATE INDEX CONCURRENTLY/m.test(migration),
|
||||
'迁移在事务内执行,不能使用 CONCURRENTLY',
|
||||
)
|
||||
})
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { isIP } from 'node:net'
|
||||
import { invalidateWorkerBanCache, normalizeWorkerIp } from '../../middleware/worker-ban-guard.js'
|
||||
import {
|
||||
listActiveWorkerRiskTrackers,
|
||||
markWorkerRiskBanned,
|
||||
releaseWorkerRiskTracker,
|
||||
} from '../../middleware/worker-risk-control.js'
|
||||
import {
|
||||
countWorkerRiskBans,
|
||||
insertWorkerRiskBan,
|
||||
listWorkerRiskBans,
|
||||
revokeWorkerRiskBanById,
|
||||
type WorkerRiskBanRow,
|
||||
} from '../../repositories/worker-platform/worker-risk-ban-repo.js'
|
||||
import {
|
||||
countWorkerRiskEvents,
|
||||
insertWorkerRiskEvent,
|
||||
@@ -15,6 +24,7 @@ import {
|
||||
} from '../../repositories/worker-platform/worker-risk-repo.js'
|
||||
import {
|
||||
getWorkerUserById,
|
||||
getWorkerUserByUsername,
|
||||
updateWorkerUser,
|
||||
} from '../../repositories/worker-platform/worker-repo.js'
|
||||
import { revokeAllWorkerSessions } from '../../repositories/worker-platform/worker-session-repo.js'
|
||||
@@ -199,6 +209,253 @@ function normalizeWorkerId(value: number | string): number {
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动封禁列表(IP / 账号)。
|
||||
*
|
||||
* 与「实时风控」的区别:这里读的是持久表 worker_risk_bans,进程重启不丢;
|
||||
* 列表默认把生效中的排在最前(ORDER BY revoked_at NULLS FIRST)。
|
||||
*/
|
||||
export async function listAdminWorkerRiskBans(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const activeRaw = query.activeOnly ?? query.active_only
|
||||
const activeOnly =
|
||||
activeRaw === undefined || activeRaw === null || activeRaw === ''
|
||||
? undefined
|
||||
: String(activeRaw) === 'true' || activeRaw === true
|
||||
|
||||
const filters = {
|
||||
targetType: String(query.targetType ?? query.target_type ?? '').trim(),
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
activeOnly,
|
||||
}
|
||||
const [rows, total] = await Promise.all([
|
||||
listWorkerRiskBans({ ...filters, limit: pageSize, offset: (page - 1) * pageSize }),
|
||||
countWorkerRiskBans(filters),
|
||||
])
|
||||
return { items: rows.map(mapWorkerRiskBan), total, page, pageSize }
|
||||
}
|
||||
|
||||
/** 最多封 30 天;0 / 不传 = 永久(运营处理外挂时一般选永久,再由人工解封)。 */
|
||||
const MAX_BAN_DURATION_MINUTES = 30 * 24 * 60
|
||||
|
||||
/**
|
||||
* 手动封禁一个 IP 或一个接单账号。
|
||||
*
|
||||
* · IP:写封禁表即可,中间件按请求 IP 拦截(登录也算,连门都进不来);
|
||||
* · 账号:写封禁表 + 冻结账号 + 踢掉全部登录设备 + 置位内存风控标记,
|
||||
* 与「后台封号」等价,但多了一条可追溯的封禁记录。
|
||||
*/
|
||||
export async function banAdminWorkerRiskTarget(
|
||||
actor: WorkerRiskActor = {},
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const targetType = String(payload.targetType ?? payload.target_type ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (targetType !== 'ip' && targetType !== 'worker') {
|
||||
throw createHttpError('封禁类型只支持 IP 或账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_risk_ban_type_invalid',
|
||||
})
|
||||
}
|
||||
const rawTarget = String(
|
||||
payload.target ?? payload.targetValue ?? payload.target_value ?? '',
|
||||
).trim()
|
||||
if (!rawTarget) {
|
||||
throw createHttpError('请填写要封禁的 IP 或账号', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_risk_ban_target_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
const reason =
|
||||
String(payload.reason || '').trim() ||
|
||||
(targetType === 'ip' ? '后台人工封禁 IP' : '后台人工封禁账号')
|
||||
const durationMinutes = normalizeBanDuration(payload.durationMinutes ?? payload.duration_minutes)
|
||||
const expiresAt =
|
||||
durationMinutes > 0 ? new Date(Date.now() + durationMinutes * 60_000).toISOString() : null
|
||||
const actorName = actor.displayName || actor.username || ''
|
||||
|
||||
if (targetType === 'ip') {
|
||||
const ip = normalizeWorkerIp(rawTarget)
|
||||
if (!isIP(ip)) {
|
||||
throw createHttpError('IP 格式不正确(示例:203.0.113.9)', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_risk_ban_target_invalid',
|
||||
})
|
||||
}
|
||||
const row = await insertWorkerRiskBan({
|
||||
targetType: 'ip',
|
||||
targetValue: ip,
|
||||
reason,
|
||||
expiresAt,
|
||||
createdBy: actor.userId ?? null,
|
||||
createdByUsername: actorName,
|
||||
})
|
||||
if (!row) {
|
||||
throw createHttpError('该 IP 已在封禁列表中', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_risk_ban_exists',
|
||||
})
|
||||
}
|
||||
invalidateWorkerBanCache()
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_banned',
|
||||
targetKey: `ip:${ip}`,
|
||||
ip,
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actorName,
|
||||
detail: { banId: row.id, targetType: 'ip', expiresAt },
|
||||
})
|
||||
return { ban: mapWorkerRiskBan(row) }
|
||||
}
|
||||
|
||||
const worker = await resolveWorkerForBan(rawTarget)
|
||||
if (!worker) {
|
||||
throw createHttpError('接单账号不存在', { statusCode: 404, errorCode: 'worker_not_found' })
|
||||
}
|
||||
const row = await insertWorkerRiskBan({
|
||||
targetType: 'worker',
|
||||
targetValue: String(worker.id),
|
||||
workerId: Number(worker.id),
|
||||
workerUsername: worker.username,
|
||||
reason,
|
||||
expiresAt,
|
||||
createdBy: actor.userId ?? null,
|
||||
createdByUsername: actorName,
|
||||
})
|
||||
if (!row) {
|
||||
throw createHttpError('该账号已在封禁列表中', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_risk_ban_exists',
|
||||
})
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await updateWorkerUser(worker.id, {
|
||||
status: 'frozen',
|
||||
frozen_reason: reason,
|
||||
frozen_at: now,
|
||||
session_version: Number(worker.session_version || 1) + 1,
|
||||
updated_at: now,
|
||||
})
|
||||
await revokeAllWorkerSessions(worker.id, now)
|
||||
markWorkerRiskBanned(Number(worker.id))
|
||||
invalidateWorkerBanCache()
|
||||
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_banned',
|
||||
targetKey: `worker:${worker.id}`,
|
||||
workerId: Number(worker.id),
|
||||
ip: String(payload.ip || '').trim(),
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actorName,
|
||||
detail: { banId: row.id, targetType: 'worker', expiresAt },
|
||||
})
|
||||
|
||||
return { ban: mapWorkerRiskBan(row), workerId: Number(worker.id), status: 'frozen' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 解除一条手动封禁。
|
||||
*
|
||||
* 账号类型的封禁会连同解冻账号 + 释放内存风控标记——否则封禁记录没了、账号还是 frozen,
|
||||
* 运营会以为没生效。IP 类型只改封禁表,不影响任何账号状态。
|
||||
*/
|
||||
export async function revokeAdminWorkerRiskBan(
|
||||
banId: number | string,
|
||||
actor: WorkerRiskActor = {},
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const id = Number(banId)
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw createHttpError('封禁记录 ID 无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'worker_risk_ban_id_invalid',
|
||||
})
|
||||
}
|
||||
const reason = String(payload.reason || '').trim() || '后台人工解封'
|
||||
const actorName = actor.displayName || actor.username || ''
|
||||
const row = await revokeWorkerRiskBanById(id, { revokedByUsername: actorName, reason })
|
||||
if (!row) {
|
||||
throw createHttpError('封禁记录不存在或已解除', {
|
||||
statusCode: 404,
|
||||
errorCode: 'worker_risk_ban_not_found',
|
||||
})
|
||||
}
|
||||
invalidateWorkerBanCache()
|
||||
|
||||
const workerId = row.worker_id === null ? null : Number(row.worker_id)
|
||||
if (row.target_type === 'worker' && workerId) {
|
||||
const worker = await getWorkerUserById(workerId)
|
||||
if (worker && worker.status === 'frozen') {
|
||||
const now = nowIso()
|
||||
await updateWorkerUser(workerId, {
|
||||
status: 'active',
|
||||
frozen_reason: '',
|
||||
frozen_at: null,
|
||||
session_version: Number(worker.session_version || 1) + 1,
|
||||
updated_at: now,
|
||||
})
|
||||
await revokeAllWorkerSessions(workerId, now)
|
||||
}
|
||||
releaseWorkerRiskTracker({ workerId })
|
||||
}
|
||||
|
||||
await insertWorkerRiskEvent({
|
||||
eventType: 'manual_unbanned',
|
||||
targetKey: row.target_type === 'ip' ? `ip:${row.target_value}` : `worker:${workerId}`,
|
||||
workerId,
|
||||
ip: row.target_type === 'ip' ? row.target_value : '',
|
||||
reason,
|
||||
action: 'manual',
|
||||
actorUserId: actor.userId ?? null,
|
||||
actorUsername: actorName,
|
||||
detail: { banId: id, targetType: row.target_type },
|
||||
})
|
||||
|
||||
return { ban: mapWorkerRiskBan(row) }
|
||||
}
|
||||
|
||||
function normalizeBanDuration(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 0
|
||||
return Math.min(MAX_BAN_DURATION_MINUTES, Math.trunc(parsed))
|
||||
}
|
||||
|
||||
async function resolveWorkerForBan(target: string) {
|
||||
const numeric = Number(target)
|
||||
if (Number.isInteger(numeric) && numeric > 0) {
|
||||
return getWorkerUserById(numeric)
|
||||
}
|
||||
return getWorkerUserByUsername(target)
|
||||
}
|
||||
|
||||
function mapWorkerRiskBan(row: WorkerRiskBanRow) {
|
||||
const expiresAt = row.expires_at ? new Date(row.expires_at) : null
|
||||
const revokedAt = row.revoked_at ? new Date(row.revoked_at) : null
|
||||
return {
|
||||
id: String(row.id),
|
||||
targetType: row.target_type,
|
||||
target: row.target_value,
|
||||
workerId: row.worker_id === null ? null : Number(row.worker_id),
|
||||
workerUsername: row.worker_username,
|
||||
reason: row.reason,
|
||||
expiresAtIso: expiresAt ? expiresAt.toISOString() : null,
|
||||
active: !revokedAt && (!expiresAt || expiresAt.getTime() > Date.now()),
|
||||
createdByUsername: row.created_by_username,
|
||||
createdAtIso: new Date(row.created_at).toISOString(),
|
||||
revokedAtIso: revokedAt ? revokedAt.toISOString() : null,
|
||||
revokedByUsername: row.revoked_by_username,
|
||||
revokeReason: row.revoke_reason,
|
||||
}
|
||||
}
|
||||
|
||||
function mapWorkerRiskEvent(row: WorkerRiskEventRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
|
||||
@@ -26,6 +26,15 @@ import { WorkerListCache } from './worker-list-cache.js'
|
||||
const HALL_LIST_CACHE_TTL_MS = 2_000
|
||||
const hallListCache = new WorkerListCache(HALL_LIST_CACHE_TTL_MS)
|
||||
|
||||
/**
|
||||
* 大厅列表单页硬上限。
|
||||
*
|
||||
* 每张卡片都要拼单汇总 + 权限判定,pageSize=100 在生产实测单次接近 2 秒(DB 执行时间),
|
||||
* 而 2026-10-30 那个脚本固定请求 pageSize=100 轮询,是唯一能低成本放大的入口。
|
||||
* 前端本来就按 page 翻页,收到更小的 pageSize 也只是多翻几页。
|
||||
*/
|
||||
const HALL_MAX_PAGE_SIZE = 20
|
||||
|
||||
// 排行榜每次都要对全量 work_orders + work_order_shares 做去重聚合(生产实测
|
||||
// 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。
|
||||
// 大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。
|
||||
@@ -38,7 +47,7 @@ const hallLeaderboardCache = new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS)
|
||||
export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) {
|
||||
requireActiveWorkerSession(session)
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const pageSize = Math.min(HALL_MAX_PAGE_SIZE, normalizePageSize(query.pageSize))
|
||||
const categoryId = normalizeOptionalId(query.categoryId)
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
|
||||
@@ -2990,6 +2990,37 @@ test('后台「店铺」筛选下拉必须缓存 + 写路径失效(单次全
|
||||
)
|
||||
})
|
||||
|
||||
test('接单端入口必须有手动封禁守卫,大厅单页上限 20,高频写接口有独立限流', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
|
||||
// 2026-10-30:脚本固定 pageSize=100 轮询大厅,单次查询近 2 秒(每张卡都要拼单汇总)
|
||||
const hallSrc = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8')
|
||||
assert.ok(hallSrc.includes('HALL_MAX_PAGE_SIZE = 20'), '大厅缺少单页上限常量')
|
||||
assert.ok(
|
||||
hallSrc.includes('Math.min(HALL_MAX_PAGE_SIZE, normalizePageSize(query.pageSize))'),
|
||||
'大厅 pageSize 必须被服务端夹到 20,不能信任客户端',
|
||||
)
|
||||
|
||||
// 手动封禁守卫挂在整个 /worker 路由之前(登录也算),否则封了 IP 还能重新登录
|
||||
const routerSrc = await readFile(new URL('../../routes/worker.ts', import.meta.url), 'utf8')
|
||||
const guardAt = routerSrc.indexOf('createWorkerBanGuardMiddleware()')
|
||||
const authAt = routerSrc.indexOf('router.use(workerAuthPublicRoutes)')
|
||||
assert.ok(guardAt > 0, '缺少手动封禁守卫')
|
||||
assert.ok(guardAt < authAt, '封禁守卫必须在鉴权路由之前挂载:被封 IP 连登录都不能放')
|
||||
|
||||
// 两个最贵的写接口各自限流(读接口 337 次/10 分钟、上传 95 次/10 分钟都是生产实测)
|
||||
const ordersSrc = await readFile(
|
||||
new URL('../../routes/worker/orders.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(ordersSrc.includes("scope: 'worker:order-read'"), '标记已读缺少限流')
|
||||
const profileSrc = await readFile(
|
||||
new URL('../../routes/worker/profile.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(profileSrc.includes("scope: 'worker:files-upload'"), '附件上传缺少限流')
|
||||
})
|
||||
|
||||
test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user