diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts index 14722cd9..bf67040e 100644 --- a/apps/backend/src/middleware/rate-limit.test.ts +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -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 + body: any + emit: (event: string) => void +} + +function createMockResponseWithFinish(): MockResponseWithEvents { + const listeners: Record 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 数量上限') }) diff --git a/apps/backend/src/middleware/rate-limit.ts b/apps/backend/src/middleware/rate-limit.ts index 1355208c..559a7e3c 100644 --- a/apps/backend/src/middleware/rate-limit.ts +++ b/apps/backend/src/middleware/rate-limit.ts @@ -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() +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 diff --git a/apps/backend/src/routes/collect.ts b/apps/backend/src/routes/collect.ts index 21ed5db7..3fabb592 100644 --- a/apps/backend/src/routes/collect.ts +++ b/apps/backend/src/routes/collect.ts @@ -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 || '')), { diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts index b17da133..6a894ef9 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.test.ts @@ -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', diff --git a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts index 6ae31497..3c310555 100644 --- a/apps/backend/src/services/worker-platform/work-order-material-link-service.ts +++ b/apps/backend/src/services/worker-platform/work-order-material-link-service.ts @@ -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, diff --git a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx index b83a462d..01f72948 100644 --- a/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx +++ b/apps/frontend/src/pages/collect/CollectMaterialLinkPage.tsx @@ -1,4 +1,4 @@ -import { LoadingOutlined } from '@ant-design/icons' +import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons' import { Alert, App, @@ -47,33 +47,28 @@ function resolveErrorTitle(code: string): string { if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') { return '订单号无效' } + if (code === 'material_link_not_pending') return '该订单当前无需补充资料' + if (code === 'rate_limited') return '查询过于频繁' if (code === 'material_link_work_order_required') return '请选择要补充的商品' if (code === 'material_link_order_status_invalid') return '订单状态已变化' return '暂时无法打开页面' } -const STATUS_TAG_COLORS: Record = { - pending_material: 'orange', - unassigned: 'blue', - open: 'cyan', - in_progress: 'processing', - pending_acceptance: 'purple', - problem: 'red', - accepted: 'green', - cancelled: 'default', -} - -function resolveStatusColor(status: string): string { - return STATUS_TAG_COLORS[status] || 'default' +/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */ +function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string { + if (order.blockedReason === 'already_submitted') { + return '资料已提交;如需修改请联系客服。' + } + return order.message || '当前不能修改资料,如需修改请联系客服。' } /** * 客户侧补资料页。 * - * 只要知道平台订单号就能打开:一个订单号下可能有多个商品工单,页面按 orders - * 渲染多张卡片。只有「待完善 + 未指派 + 无份额」的工单返回表单,其余工单只展示 - * 商品、工单号、状态与客服提示,不暴露已填资料和截图。 - * 提交成功后工单进入「待分配」,同一工单不能再提交,需联系客服重置。 + * 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料, + * 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的 + * 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配, + * 会提示「该订单当前无需补充资料」。 */ export default function CollectMaterialLinkPage() { const { message } = App.useApp() @@ -88,7 +83,11 @@ export default function CollectMaterialLinkPage() { const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0) const submittingRef = useRef(false) const requestSeqRef = useRef(0) + /** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */ + const loadedCodeRef = useRef(null) const [screenshotsByOrder, setScreenshotsByOrder] = useState>({}) + const [submittedSuccess, setSubmittedSuccess] = useState(false) + const [submittedNames, setSubmittedNames] = useState([]) /** * 读取订单资料状态。 @@ -101,6 +100,13 @@ export default function CollectMaterialLinkPage() { const seq = requestSeqRef.current + 1 requestSeqRef.current = seq + if (!options.silent) { + // 新订单号/刷新页面时清空上一次提交成功状态;提交后不再回查 + // (严格模式只允许查询待完善订单,回查会变成「无需补充资料」)。 + setSubmittedSuccess(false) + setSubmittedNames([]) + } + if (!code) { setInfo(null) setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE }) @@ -145,8 +151,10 @@ export default function CollectMaterialLinkPage() { ) useEffect(() => { + if (loadedCodeRef.current === code) return + loadedCodeRef.current = code void loadInfo() - }, [loadInfo]) + }, [code, loadInfo]) async function handleSubmit(order: CollectMaterialLinkOrder, values: Record) { if (!code) return @@ -160,8 +168,19 @@ export default function CollectMaterialLinkPage() { fields: values, screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [], }) - message.success('资料已提交,订单已进入待分配') - await loadInfo({ silent: true }) + message.success('提交成功') + const submittedName = String(order.productName || '').trim() || '订单商品' + setSubmittedNames((previous) => [...previous, submittedName]) + const remainingOrders = (info?.orders || []).filter( + (item) => item.workOrderId !== order.workOrderId, + ) + if (remainingOrders.length > 0) { + await loadInfo({ silent: true }) + } else { + // 严格模式只查询待完善订单:最后一个商品提交后不再回查, + // 直接本地展示成功页,避免刷新成「无需补充资料」。 + setSubmittedSuccess(true) + } } catch (error) { const errorCode = resolveErrorCode(error) message.error(resolveErrorMessage(error)) @@ -181,6 +200,9 @@ export default function CollectMaterialLinkPage() { } } + const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。' + const submittedProductNames = submittedNames.join('、') + return (
@@ -191,56 +213,63 @@ export default function CollectMaterialLinkPage() { ) : loadError ? ( } /> + ) : submittedSuccess ? ( +
+ + + 提交成功 + + 资料已提交;如需修改请联系客服。 +
+ 本次提交 + {submittedProductNames || '订单资料'} +
+ + 本页面仅用于补充订单资料,请妥善保管订单号。 + +
) : info ? ( -
- - 订单资料补充 - - - {info.pendingCount > 0 - ? '请补充以下资料,提交后客服会尽快处理。' - : '以下为该订单当前的资料状态,如需修改请联系客服。'} - -
+ {info.pendingCount > 0 ? ( + 请补充以下资料,提交后客服会尽快处理。 + ) : null} {info.pendingCount > 1 ? ( ) : null} {info.pendingCount === 0 ? ( - + ) : null} {info.orders.map((order) => ( - {order.statusLabel}} - > + -
-
- 工单号 - {order.workOrderNo || '-'} +
+
+ {order.productName || '订单商品'}
-
+
订单号 {order.platformOrderId || '-'}
@@ -253,7 +282,7 @@ export default function CollectMaterialLinkPage() { type="warning" showIcon message="该商品资料仅可提交一次" - description="提交成功后该商品进入待分配,不能再修改;如需修改请联系客服。" + description="提交成功后不能再修改;如需修改请联系客服。" /> ) : null} @@ -274,13 +303,13 @@ export default function CollectMaterialLinkPage() { onSubmit={(values) => handleSubmit(order, values)} /> - ) : ( + ) : info.pendingCount > 0 ? ( - )} + ) : null} ))} diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 373a1d6c..ab5b65d8 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -508,30 +508,42 @@ margin: 0 0 4px !important; } -.collect-link-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px 20px; - padding: 14px 16px; - background: #f8fafc; - border-radius: 10px; +/* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */ +.collect-link-order-head { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px 20px; + padding-bottom: 12px; + border-bottom: 1px solid #eef2f7; } -.collect-link-meta > div { +.collect-link-order-name { min-width: 0; display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + flex-wrap: wrap; + gap: 8px; + color: #0f172a; + font-size: 16px; + font-weight: 700; + overflow-wrap: anywhere; } -.collect-link-meta span { +.collect-link-order-no { + min-width: 0; + display: flex; + align-items: baseline; + gap: 6px; color: #64748b; font-size: 12px; } -.collect-link-meta strong { +.collect-link-order-no strong { color: #0f172a; font-size: 14px; + font-weight: 600; overflow-wrap: anywhere; } @@ -544,6 +556,56 @@ gap: 16px; } +/* 提交成功:居中的成功状态,不再展示订单号 */ +.collect-link-success { + min-height: 380px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px 12px 8px; + text-align: center; +} + +.collect-link-success-icon { + font-size: 64px; + color: #22c55e; + filter: drop-shadow(0 10px 20px rgba(34, 197, 94, 0.18)); +} + +.collect-link-success-title { + margin: 2px 0 0 !important; + color: #0f172a; +} + +.collect-link-success-products { + max-width: 100%; + margin-top: 10px; + padding: 12px 22px; + border: 1px solid #dcfce7; + border-radius: 12px; + background: #f0fdf4; + display: flex; + flex-direction: column; + gap: 4px; +} + +.collect-link-success-products span { + color: #16a34a; + font-size: 12px; +} + +.collect-link-success-products strong { + color: #166534; + font-size: 15px; + overflow-wrap: anywhere; +} + +.collect-link-success .collect-link-footnote { + margin-top: 18px; +} + .collect-link-actions { display: flex; justify-content: flex-end; @@ -569,11 +631,6 @@ padding: 18px; } - .collect-link-meta { - grid-template-columns: 1fr; - gap: 8px; - } - .collect-link-actions { justify-content: stretch; } @@ -591,6 +648,19 @@ .collect-link-card .ant-card-body { padding: 14px; } + + .collect-link-success { + min-height: 320px; + padding: 16px 4px 4px; + } + + .collect-link-success-icon { + font-size: 52px; + } + + .collect-link-success-products { + padding: 10px 16px; + } } /* ===== 兑换中心 (Redeem Page) ===== */