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
|
||||
|
||||
Reference in New Issue
Block a user