fix(worker-hall): 优化抢单大厅两级分类联动体验并支持分类图片浏览器长效缓存

This commit is contained in:
yml2213
2026-09-13 11:02:10 +08:00
parent 9fcc4ef99d
commit 85272cde46
10 changed files with 169 additions and 11 deletions
+32 -2
View File
@@ -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]')
+11
View File
@@ -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,
@@ -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)
})
@@ -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) {
@@ -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 } : {}),
}
}
@@ -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'
@@ -44,6 +44,7 @@ export {
} from './worker-profile-service.js'
export {
getWorkerHallCategories,
getWorkerHallLeaderboard,
listWorkerHallOrders,
maskWorkerLeaderboardDisplayName,