From 85272cde466b6bb8a661c47a4945bd31b32aab73 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 13 Sep 2026 11:01:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(worker-hall):=20=E4=BC=98=E5=8C=96=E6=8A=A2?= =?UTF-8?q?=E5=8D=95=E5=A4=A7=E5=8E=85=E4=B8=A4=E7=BA=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E4=BD=93=E9=AA=8C=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E5=9B=BE=E7=89=87=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E9=95=BF=E6=95=88=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/routes/files.ts | 34 +++++++- apps/backend/src/routes/worker/hall.ts | 11 +++ .../file-storage/file-storage-service.test.ts | 14 ++++ .../file-storage/file-storage-service.ts | 15 +++- .../services/file-storage/object-storage.ts | 4 + .../worker-platform/worker-hall-service.ts | 11 +++ .../worker-platform/worker-service.ts | 1 + .../src/pages/worker/WorkerHallPage.tsx | 79 +++++++++++++++++-- apps/frontend/src/services/worker.ts | 5 ++ apps/frontend/src/types/worker-platform.ts | 6 ++ 10 files changed, 169 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts index baf54aea..5fa7f89e 100644 --- a/apps/backend/src/routes/files.ts +++ b/apps/backend/src/routes/files.ts @@ -1,19 +1,49 @@ import { Router } from 'express' -import { getStoredFileObject } from '../services/file-storage/file-storage-service.js' +import { + getStoredFileObject, + isPublicObjectKey, +} from '../services/file-storage/file-storage-service.js' import { buildNotFoundPayload, sendRouteError } from '../utils/http.js' const router = Router() router.get('/object', async (req, res) => { try { + const rawKey = String(req.query.key || '').trim() + const isPublic = isPublicObjectKey(rawKey) const object = await getStoredFileObject(req.query.key, req.query.variant, { expires: req.query.expires, signature: req.query.signature, }) + + if (object.etag) { + res.setHeader('ETag', object.etag) + if (req.headers['if-none-match'] === object.etag) { + res.status(304).end() + return + } + } + if (object.lastModified) { + res.setHeader('Last-Modified', object.lastModified.toUTCString()) + if (req.headers['if-modified-since']) { + const ifModifiedSince = new Date(req.headers['if-modified-since']) + if (!isNaN(ifModifiedSince.getTime()) && object.lastModified <= ifModifiedSince) { + res.status(304).end() + return + } + } + } + res.setHeader('Content-Type', object.contentType || 'application/octet-stream') - res.setHeader('Cache-Control', 'private, max-age=300') res.setHeader('X-Content-Type-Options', 'nosniff') + + if (isPublic) { + res.setHeader('Cache-Control', 'public, max-age=2592000, stale-while-revalidate=604800') + } else { + res.setHeader('Cache-Control', 'private, max-age=300') + } + object.reader.on('error', (error) => { if (!res.headersSent) { sendRouteError(res, error, '读取文件失败', '[files/object]') diff --git a/apps/backend/src/routes/worker/hall.ts b/apps/backend/src/routes/worker/hall.ts index c16516fa..7824822d 100644 --- a/apps/backend/src/routes/worker/hall.ts +++ b/apps/backend/src/routes/worker/hall.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { + getWorkerHallCategories, getWorkerHallLeaderboard, grabWorkerHallOrder, joinWorkerSharingOrder, @@ -11,6 +12,16 @@ import { createRouteHandler } from '../../utils/http.js' const router = Router() +router.get( + '/hall/categories', + requireActiveWorker, + createRouteHandler((req) => getWorkerHallCategories(getRequiredWorkerSession(req)), { + successMessage: 'ok', + errorMessage: '读取抢单大厅分类失败', + scope: '[worker/hall/categories]', + }), +) + router.get( '/hall/orders', requireActiveWorker, diff --git a/apps/backend/src/services/file-storage/file-storage-service.test.ts b/apps/backend/src/services/file-storage/file-storage-service.test.ts index 04a60778..f01fd8b2 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.test.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.test.ts @@ -50,3 +50,17 @@ test('stored file URL drops signatures and refreshes them for responses', () => 'https://example.com/image.jpg', ) }) + +test('public file scenes return stable unsigned URLs for browser caching', () => { + const publicObjectKey = 'category_icon/2026/09/13/game-icon.png' + const url = buildFileUrl(publicObjectKey, NOW_SECONDS) + assert.equal(url, `/api/v1/files/object?key=${encodeURIComponent(publicObjectKey)}`) + assert.equal(url.includes('expires='), false) + assert.equal(url.includes('signature='), false) + + const hallIconKey = 'hall_icon/2026/09/13/all-icon.png' + const hallUrl = refreshStoredFileUrl(hallIconKey) + assert.equal(hallUrl, `/api/v1/files/object?key=${encodeURIComponent(hallIconKey)}`) + assert.equal(hallUrl.includes('expires='), false) + assert.equal(hallUrl.includes('signature='), false) +}) diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts index 9527be86..fe58606d 100644 --- a/apps/backend/src/services/file-storage/file-storage-service.ts +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -192,6 +192,9 @@ export function getMaxUploadSizeBytes() { export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) { const normalizedKey = normalizeObjectKey(objectKey) + if (isPublicObjectKey(normalizedKey)) { + return buildUnsignedFileUrl(normalizedKey) + } const expires = nowSeconds + FILE_URL_TTL_SECONDS const signature = createFileAccessSignature(normalizedKey, expires) return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}` @@ -316,11 +319,17 @@ function extractObjectKeyFromFileUrl(value: string) { if (!value) return '' try { const parsed = new URL(value, 'http://localhost') - if (parsed.pathname !== '/api/v1/files/object') return '' - return normalizeObjectKey(parsed.searchParams.get('key')) + if (parsed.pathname === '/api/v1/files/object') { + return normalizeObjectKey(parsed.searchParams.get('key')) + } } catch { - return '' + // ignore } + const trimmed = value.trim() + if (!trimmed.includes('://') && !trimmed.startsWith('/') && trimmed.includes('/')) { + return normalizeObjectKey(trimmed) + } + return '' } function normalizeContentType(mimeType: unknown, data: Buffer) { diff --git a/apps/backend/src/services/file-storage/object-storage.ts b/apps/backend/src/services/file-storage/object-storage.ts index a1e6c465..85b17d55 100644 --- a/apps/backend/src/services/file-storage/object-storage.ts +++ b/apps/backend/src/services/file-storage/object-storage.ts @@ -41,6 +41,8 @@ export type StoredObject = { reader: Readable contentType: string size: number + etag?: string | undefined + lastModified?: Date | undefined } export class ObjectStorage { @@ -114,6 +116,8 @@ export class ObjectStorage { reader, contentType: String(output.ContentType || 'application/octet-stream'), size: Number(output.ContentLength || 0), + ...(output.ETag ? { etag: String(output.ETag) } : {}), + ...(output.LastModified ? { lastModified: output.LastModified } : {}), } } diff --git a/apps/backend/src/services/worker-platform/worker-hall-service.ts b/apps/backend/src/services/worker-platform/worker-hall-service.ts index 21b0e35d..014c94b2 100644 --- a/apps/backend/src/services/worker-platform/worker-hall-service.ts +++ b/apps/backend/src/services/worker-platform/worker-hall-service.ts @@ -98,6 +98,17 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work }) } +export async function getWorkerHallCategories(session: WorkerSession) { + requireActiveWorkerSession(session) + const hallConfig = getWorkerHallConfig() + const categories = (await listWorkCategories()).map(mapWorkCategory) + return { + categories, + showSubcategories: hallConfig.showSubcategories, + allIconUrl: hallConfig.allIconUrl ? refreshStoredFileUrl(hallConfig.allIconUrl) : null, + } +} + export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') { requireActiveWorkerSession(session) const leaderboardPeriod = period === 'daily' ? 'daily' : 'all' diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index c9fcdb86..0a594506 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -44,6 +44,7 @@ export { } from './worker-profile-service.js' export { + getWorkerHallCategories, getWorkerHallLeaderboard, listWorkerHallOrders, maskWorkerLeaderboardDisplayName, diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index f34e5e5a..94179ea6 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -12,7 +12,7 @@ import { TrophyOutlined, WalletOutlined, } from '@ant-design/icons' -import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, App, @@ -36,6 +36,7 @@ import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router' import { + fetchWorkerHallCategories, fetchWorkerHallOrders, fetchWorkerHallLeaderboard, fetchWorkerMyOrders, @@ -44,7 +45,12 @@ import { grabWorkerOrder, joinWorkerSharingOrder, } from '@/services/worker' -import type { WorkOrder, WorkerLeaderboardPeriod, WorkerUser } from '@/types/worker-platform' +import type { + WorkCategory, + WorkOrder, + WorkerLeaderboardPeriod, + WorkerUser, +} from '@/types/worker-platform' import { useIsMobile } from '@/utils/use-is-mobile' import { isGrabConfirmEnabled, @@ -84,6 +90,15 @@ export default function WorkerHallPage() { const [grabConfirmEnabled, setGrabConfirmEnabled] = useState(isGrabConfirmEnabled) const loadMoreRef = useRef(null) + const categoriesQuery = useQuery({ + queryKey: ['worker-hall-categories'], + queryFn: () => fetchWorkerHallCategories(), + staleTime: 5 * 60_000, + gcTime: 30 * 60_000, + refetchOnWindowFocus: false, + retry: false, + }) + const ordersQuery = useInfiniteQuery({ queryKey: ['worker-hall-orders', keyword, effectiveCategoryId], initialPageParam: 1, @@ -100,6 +115,7 @@ export default function WorkerHallPage() { ? pagination.page + 1 : undefined }, + placeholderData: keepPreviousData, staleTime: 2_000, refetchOnWindowFocus: false, retry: false, @@ -138,13 +154,51 @@ export default function WorkerHallPage() { const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || [] const firstPage = ordersQuery.data?.pages[0]?.data - const allCategories = firstPage?.categories || [] - const showSubcategoriesConfig = firstPage?.showSubcategories ?? true + + const [categoryMeta, setCategoryMeta] = useState<{ + categories: WorkCategory[] + showSubcategories: boolean + allIconUrl: string | null + }>({ + categories: [], + showSubcategories: true, + allIconUrl: null, + }) + + useEffect(() => { + const fromApi = categoriesQuery.data?.data + if (fromApi && fromApi.categories && fromApi.categories.length > 0) { + setCategoryMeta({ + categories: fromApi.categories, + showSubcategories: fromApi.showSubcategories ?? true, + allIconUrl: fromApi.allIconUrl || null, + }) + return + } + if (firstPage && firstPage.categories && firstPage.categories.length > 0) { + setCategoryMeta({ + categories: firstPage.categories, + showSubcategories: firstPage.showSubcategories ?? true, + allIconUrl: firstPage.allIconUrl || null, + }) + } + }, [categoriesQuery.data, firstPage]) + + const allCategories = + categoryMeta.categories.length > 0 + ? categoryMeta.categories + : categoriesQuery.data?.data?.categories || firstPage?.categories || [] + const showSubcategoriesConfig = categoryMeta.showSubcategories const allCategoryFromList = allCategories.find( (c) => !c.parentId && (c.categoryKey === 'all' || c.name === '全部'), ) - const allIconUrl = firstPage?.allIconUrl || allCategoryFromList?.iconUrl || null + const allIconUrl = + categoryMeta.allIconUrl || + categoriesQuery.data?.data?.allIconUrl || + firstPage?.allIconUrl || + allCategoryFromList?.iconUrl || + null const primaryCategories = allCategories.filter( (c) => !c.parentId && c.categoryKey !== 'all' && c.name !== '全部', @@ -168,6 +222,10 @@ export default function WorkerHallPage() { setPrimaryCategoryId(nextId) setSubCategoryId(0) } + + useEffect(() => { + setFailedImageKeys({}) + }, [allCategories.length, allIconUrl]) const worker = profileQuery.data?.data.worker const activeCount = Number(profileQuery.data?.data.summary.activeOrderCount || 0) const pendingAcceptanceCount = hallSummaryQuery.data?.pendingAcceptanceCount || 0 @@ -208,7 +266,8 @@ export default function WorkerHallPage() { async function refreshAll() { await Promise.all([ - queryClient.resetQueries({ queryKey: ['worker-hall-orders'] }), + ordersQuery.refetch(), + queryClient.invalidateQueries({ queryKey: ['worker-hall-categories'] }), queryClient.invalidateQueries({ queryKey: ['worker-hall-summary'] }), queryClient.invalidateQueries({ queryKey: ['worker-profile'] }), queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }), @@ -395,6 +454,8 @@ export default function WorkerHallPage() { src={allIconUrl} alt="全部" className="worker-hall-game-img" + loading="lazy" + decoding="async" onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))} /> ) : ( @@ -428,6 +489,8 @@ export default function WorkerHallPage() { src={category.iconUrl} alt={category.name} className="worker-hall-game-img" + loading="lazy" + decoding="async" onError={() => setFailedImageKeys((prev) => ({ ...prev, @@ -839,6 +902,8 @@ export default function WorkerHallPage() { src={allIconUrl} alt="全部" className="worker-hall-game-img" + loading="lazy" + decoding="async" onError={() => setFailedImageKeys((prev) => ({ ...prev, all: true }))} /> ) : ( @@ -874,6 +939,8 @@ export default function WorkerHallPage() { src={category.iconUrl} alt={category.name} className="worker-hall-game-img" + loading="lazy" + decoding="async" onError={() => setFailedImageKeys((prev) => ({ ...prev, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 1197a9ac..cfb08ac4 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -11,6 +11,7 @@ import type { WorkerAnnouncementConfig, WorkerOrderFeedback, WorkerAfterSalesCase, + WorkerHallCategoriesResponse, WorkerHallOrdersResponse, WorkerLeaderboardResponse, WorkerLeaderboardPeriod, @@ -205,6 +206,10 @@ export function renameWorkerAccount(payload: { displayName: string }) { ) } +export function fetchWorkerHallCategories() { + return apiGet('/api/v1/worker/hall/categories') +} + export function fetchWorkerHallOrders(params?: Record) { return apiGet('/api/v1/worker/hall/orders', params) } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 47b16acb..2178cbbf 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -723,6 +723,12 @@ export type WorkerHallOrdersResponse = { visibleDelaySeconds?: number } +export type WorkerHallCategoriesResponse = { + categories: WorkCategory[] + showSubcategories?: boolean + allIconUrl?: string | null +} + export type WorkerLeaderboardEntry = { rank: number workerId: number