feat(collect): 补资料查询仅限待完善并优化页面展示
- 公开查询只返回待完善工单,待分配/已验收等状态提示「该订单当前无需补充资料」 - 404 失败查询按 IP 冷却 2 秒,成功查询不计数;前端显示「查询过于频繁」 - 提交成功后本地展示成功页,刷新后按严格模式提示无需补充资料 - 收敛补资料页重复提示,移除状态标签与工单号,商品与订单号合并展示 - 修复 React StrictMode 下刷新重复请求查询接口的问题
This commit is contained in:
@@ -2,7 +2,11 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
resetRateLimitBucketsForTest,
|
||||
} from './rate-limit.js'
|
||||
|
||||
test('createRateLimitMiddleware allows requests within the window', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
@@ -96,13 +100,13 @@ test('createRateLimitMiddleware can cap an IP across different accounts', () =>
|
||||
assert.equal(response.statusCode, 429)
|
||||
})
|
||||
|
||||
function createMockRequest(): Request {
|
||||
function createMockRequest(ip = '127.0.0.1'): Request {
|
||||
return {
|
||||
ip: '127.0.0.1',
|
||||
ip,
|
||||
originalUrl: '/test',
|
||||
headers: {},
|
||||
socket: {
|
||||
remoteAddress: '127.0.0.1',
|
||||
remoteAddress: ip,
|
||||
},
|
||||
} as Request
|
||||
}
|
||||
@@ -137,10 +141,97 @@ function createMockResponse(): Response & {
|
||||
}
|
||||
}
|
||||
|
||||
type MockResponseWithEvents = Response & {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
emit: (event: string) => void
|
||||
}
|
||||
|
||||
function createMockResponseWithFinish(): MockResponseWithEvents {
|
||||
const listeners: Record<string, Array<() => void>> = {}
|
||||
const res = createMockResponse() as MockResponseWithEvents
|
||||
res.on = (event, handler) => {
|
||||
const handlers = listeners[event] || []
|
||||
handlers.push(handler)
|
||||
listeners[event] = handlers
|
||||
return res
|
||||
}
|
||||
res.emit = (event: string) => {
|
||||
for (const handler of listeners[event] || []) handler()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
test('failure cooldown 只对 404 生效,按 IP 隔离且到期恢复', async () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createFailureCooldownMiddleware({
|
||||
scope: 'test:failure',
|
||||
cooldownMs: 60,
|
||||
})
|
||||
const req = createMockRequest('10.0.0.1')
|
||||
const first = createMockResponseWithFinish()
|
||||
let nextCount = 0
|
||||
limiter(req, first, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
first.status(404).json({ code: 1 })
|
||||
first.emit('finish')
|
||||
|
||||
const blocked = createMockResponseWithFinish()
|
||||
limiter(req, blocked, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 1)
|
||||
assert.equal(blocked.statusCode, 429)
|
||||
assert.equal(blocked.headers['Retry-After'], '1')
|
||||
assert.equal(blocked.body.errorCode, 'rate_limited')
|
||||
|
||||
const otherIp = createMockResponseWithFinish()
|
||||
limiter(createMockRequest('10.0.0.2'), otherIp, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 2)
|
||||
assert.equal(otherIp.statusCode, 200)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
const recovered = createMockResponseWithFinish()
|
||||
limiter(req, recovered, () => {
|
||||
nextCount += 1
|
||||
})
|
||||
assert.equal(nextCount, 3)
|
||||
assert.equal(recovered.statusCode, 200)
|
||||
})
|
||||
|
||||
test('failure cooldown 不记录成功查询', () => {
|
||||
resetRateLimitBucketsForTest()
|
||||
|
||||
const limiter = createFailureCooldownMiddleware({
|
||||
scope: 'test:failure-success',
|
||||
cooldownMs: 60_000,
|
||||
})
|
||||
const req = createMockRequest('10.0.0.3')
|
||||
const first = createMockResponseWithFinish()
|
||||
limiter(req, first, () => {})
|
||||
first.status(200).json({ code: 0 })
|
||||
first.emit('finish')
|
||||
|
||||
const second = createMockResponseWithFinish()
|
||||
let called = false
|
||||
limiter(req, second, () => {
|
||||
called = true
|
||||
})
|
||||
assert.equal(called, true)
|
||||
assert.equal(second.statusCode, 200)
|
||||
})
|
||||
|
||||
test('限流桶有数量硬上限:key 含用户可控字段时不能无限膨胀', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(new URL('./rate-limit.ts', import.meta.url), 'utf8')
|
||||
// collect 路由的限流 key 里带请求体 orderNo(可被构造),只按过期清理挡不住高基数灌键
|
||||
assert.ok(src.includes('MAX_RATE_LIMIT_BUCKETS'), '缺少限流桶数量上限')
|
||||
assert.ok(src.includes('buckets.size > MAX_RATE_LIMIT_BUCKETS'), '必须在清理时按上限淘汰最老的桶')
|
||||
assert.ok(src.includes('MAX_FAILURE_COOLDOWN_KEYS'), '缺少失败冷却 key 数量上限')
|
||||
})
|
||||
|
||||
@@ -18,6 +18,14 @@ type RateLimitOptions = {
|
||||
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void
|
||||
}
|
||||
|
||||
type FailureCooldownOptions = {
|
||||
scope: string
|
||||
cooldownMs: number
|
||||
/** 只对这些响应状态码触发冷却,默认 404。 */
|
||||
statusCodes?: number[]
|
||||
key?: RateLimitKeyResolver
|
||||
}
|
||||
|
||||
type RateLimitBucket = {
|
||||
count: number
|
||||
resetAt: number
|
||||
@@ -28,6 +36,11 @@ let lastCleanupAt = 0
|
||||
/** 限流桶数量硬上限:key 含请求体字段时可被构造成高基数,必须兜底防止内存无限增长。 */
|
||||
const MAX_RATE_LIMIT_BUCKETS = 50_000
|
||||
|
||||
/** 失败冷却:仅记录指定状态码(默认 404)后的 IP,冷却期内直接拒绝。 */
|
||||
const failureCooldowns = new Map<string, number>()
|
||||
let lastFailureCleanupAt = 0
|
||||
const MAX_FAILURE_COOLDOWN_KEYS = 50_000
|
||||
|
||||
export function createRateLimitMiddleware({
|
||||
scope,
|
||||
windowMs,
|
||||
@@ -82,9 +95,54 @@ export function createRateLimitMiddleware({
|
||||
}
|
||||
}
|
||||
|
||||
export function createFailureCooldownMiddleware({
|
||||
scope,
|
||||
cooldownMs,
|
||||
statusCodes = [404],
|
||||
key = defaultRateLimitKey,
|
||||
}: FailureCooldownOptions) {
|
||||
const normalizedScope = String(scope || 'failure-cooldown').trim() || 'failure-cooldown'
|
||||
const normalizedCooldownMs = Math.max(0, Number(cooldownMs || 0))
|
||||
const trackedStatusCodes = new Set(statusCodes.map((statusCode) => Number(statusCode)))
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const now = Date.now()
|
||||
cleanupExpiredFailureCooldowns(now)
|
||||
|
||||
const bucketKey = `${normalizedScope}:${key(req)}`
|
||||
const cooldownUntil = failureCooldowns.get(bucketKey) || 0
|
||||
if (cooldownUntil > now) {
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((cooldownUntil - now) / 1000))
|
||||
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||
|
||||
const message = '查询过于频繁,请稍后再试'
|
||||
const error = createHttpError(message, {
|
||||
statusCode: 429,
|
||||
errorCode: 'rate_limited',
|
||||
})
|
||||
res.status(429).json(buildErrorPayload(error, message))
|
||||
return
|
||||
}
|
||||
|
||||
// 响应结束后再判断:只有失败(默认 404)才进入冷却,正常查询不计数。
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
failureCooldowns.delete(bucketKey)
|
||||
return
|
||||
}
|
||||
if (!trackedStatusCodes.has(Number(res.statusCode))) return
|
||||
failureCooldowns.set(bucketKey, Date.now() + normalizedCooldownMs)
|
||||
})
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRateLimitBucketsForTest(): void {
|
||||
buckets.clear()
|
||||
failureCooldowns.clear()
|
||||
lastCleanupAt = 0
|
||||
lastFailureCleanupAt = 0
|
||||
}
|
||||
|
||||
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
|
||||
@@ -111,6 +169,29 @@ function normalizeKeyPart(value: unknown): string {
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function cleanupExpiredFailureCooldowns(now: number): void {
|
||||
if (now - lastFailureCleanupAt < 60_000) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFailureCleanupAt = now
|
||||
for (const [key, cooldownUntil] of failureCooldowns.entries()) {
|
||||
if (cooldownUntil <= now) {
|
||||
failureCooldowns.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (failureCooldowns.size > MAX_FAILURE_COOLDOWN_KEYS) {
|
||||
const overflow = failureCooldowns.size - MAX_FAILURE_COOLDOWN_KEYS
|
||||
let removed = 0
|
||||
for (const key of failureCooldowns.keys()) {
|
||||
failureCooldowns.delete(key)
|
||||
removed += 1
|
||||
if (removed >= overflow) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredBuckets(now: number): void {
|
||||
if (now - lastCleanupAt < 60_000) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createFailureCooldownMiddleware,
|
||||
createRateLimitMiddleware,
|
||||
getBodyFieldRateLimitKey,
|
||||
getParamRateLimitKey,
|
||||
@@ -39,6 +40,12 @@ const materialLinkReadIpRateLimit = createRateLimitMiddleware({
|
||||
max: 300,
|
||||
})
|
||||
|
||||
/** 查询失败(404)后按 IP 冷却 2 秒;正常查询不计数,避免误伤用户。 */
|
||||
const materialLinkReadFailureCooldown = createFailureCooldownMiddleware({
|
||||
scope: 'collect:link:read:fail:ip',
|
||||
cooldownMs: 2_000,
|
||||
})
|
||||
|
||||
const materialLinkWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'collect:link:write',
|
||||
windowMs: 60_000,
|
||||
@@ -108,6 +115,7 @@ router.post(
|
||||
|
||||
router.get(
|
||||
'/link/:code',
|
||||
materialLinkReadFailureCooldown,
|
||||
materialLinkReadIpRateLimit,
|
||||
materialLinkReadRateLimit,
|
||||
createRouteHandler((req) => getPublicWorkOrderMaterialLink(String(req.params.code || '')), {
|
||||
|
||||
@@ -3,10 +3,12 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatPublicMaterialStatus,
|
||||
pickPendingMaterialOrders,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
resolvePublicMaterialOrderState,
|
||||
} from './work-order-material-link-service.js'
|
||||
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
|
||||
|
||||
test('公开补资料:只有待完善且未指派/无份额可提交', () => {
|
||||
const editable = resolvePublicMaterialOrderState({
|
||||
@@ -128,6 +130,22 @@ test('客服重置补资料:只允许待分配、未指派、无份额、未
|
||||
)
|
||||
})
|
||||
|
||||
test('公开补资料:只查询待完善工单,其他状态一律不返回', () => {
|
||||
const orders = [
|
||||
{ status: 'pending_material' },
|
||||
{ status: 'unassigned' },
|
||||
{ status: 'open' },
|
||||
{ status: 'accepted' },
|
||||
{ status: 'cancelled' },
|
||||
] as WorkOrderRow[]
|
||||
|
||||
assert.deepEqual(
|
||||
pickPendingMaterialOrders(orders).map((order) => order.status),
|
||||
['pending_material'],
|
||||
)
|
||||
assert.deepEqual(pickPendingMaterialOrders([]), [])
|
||||
})
|
||||
|
||||
test('公开补资料:提交字段只保留 requirements 白名单,额外字段被丢弃', () => {
|
||||
const result = pickSubmittedMaterialFields([{ key: 'gameId' }, { key: 'gameNickname' }], {
|
||||
gameId: '123',
|
||||
|
||||
@@ -232,12 +232,21 @@ async function resolvePublicMaterialTarget(
|
||||
return orders[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页只允许查询待完善工单。
|
||||
*
|
||||
* 待分配、待抢单、代练中、已验收、已取消等状态一律不返回,
|
||||
* 避免客户用订单号看到已完成订单的商品与状态。
|
||||
*/
|
||||
export function pickPendingMaterialOrders(orders: WorkOrderRow[]): WorkOrderRow[] {
|
||||
return orders.filter((order) => order.status === WORK_ORDER_STATUS.PENDING_MATERIAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开页按平台订单号读取补资料内容。
|
||||
*
|
||||
* 不再要求客服先生成链接;只要订单号存在,就返回该订单号下所有工单的
|
||||
* 状态与准入信息。只有可提交的待完善工单返回字段/已填值/截图,其他状态
|
||||
* 只返回商品、工单号、状态和客服提示。
|
||||
* 不再要求客服先生成链接;订单号存在且仍有待完善工单时才返回内容。
|
||||
* 只返回待完善工单;已提交(待分配)及其他状态统一提示无需补充资料。
|
||||
*/
|
||||
export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
const code = String(platformOrderId || '').trim()
|
||||
@@ -256,7 +265,15 @@ export async function getPublicWorkOrderMaterialLink(platformOrderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const publicOrders = orders.map(mapPublicMaterialOrder)
|
||||
const pendingOrders = pickPendingMaterialOrders(orders)
|
||||
if (pendingOrders.length === 0) {
|
||||
throw createHttpError('该订单当前无需补充资料', {
|
||||
statusCode: 404,
|
||||
errorCode: 'material_link_not_pending',
|
||||
})
|
||||
}
|
||||
|
||||
const publicOrders = pendingOrders.map(mapPublicMaterialOrder)
|
||||
return {
|
||||
platformOrderId: code,
|
||||
orders: publicOrders,
|
||||
|
||||
Reference in New Issue
Block a user