feat(collect): 补资料查询仅限待完善并优化页面展示

- 公开查询只返回待完善工单,待分配/已验收等状态提示「该订单当前无需补充资料」
- 404 失败查询按 IP 冷却 2 秒,成功查询不计数;前端显示「查询过于频繁」
- 提交成功后本地展示成功页,刷新后按严格模式提示无需补充资料
- 收敛补资料页重复提示,移除状态标签与工单号,商品与订单号合并展示
- 修复 React StrictMode 下刷新重复请求查询接口的问题
This commit is contained in:
2026-11-02 10:38:09 +08:00
parent db73e8f820
commit a9e2de443e
7 changed files with 391 additions and 77 deletions
+95 -4
View File
@@ -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 数量上限')
})
+81
View File
@@ -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