修复接单平台并发、分页与补资料

This commit is contained in:
yml2213
2026-07-28 10:15:44 +08:00
parent a2587eda01
commit f0ba0fe17f
22 changed files with 1096 additions and 145 deletions
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildFileUrl,
normalizeStoredFileUrl,
refreshStoredFileUrl,
verifyFileAccessSignature,
} from './file-storage-service.js'
const NOW_SECONDS = 1_800_000_000
const OBJECT_KEY = 'worker-acceptance/2026/07/26/example.jpg'
test('file access URL carries a valid short-lived signature', () => {
const url = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
const parsed = new URL(url, 'http://localhost')
assert.equal(parsed.searchParams.get('key'), OBJECT_KEY)
assert.doesNotThrow(() =>
verifyFileAccessSignature(
OBJECT_KEY,
parsed.searchParams.get('expires'),
parsed.searchParams.get('signature'),
NOW_SECONDS + 899,
),
)
})
test('file access URL rejects expired and tampered signatures', () => {
const parsed = new URL(buildFileUrl(OBJECT_KEY, NOW_SECONDS), 'http://localhost')
const expires = parsed.searchParams.get('expires')
const signature = parsed.searchParams.get('signature')
assert.throws(() => verifyFileAccessSignature(OBJECT_KEY, expires, signature, NOW_SECONDS + 901))
assert.throws(() =>
verifyFileAccessSignature(`${OBJECT_KEY}.changed`, expires, signature, NOW_SECONDS),
)
})
test('stored file URL drops signatures and refreshes them for responses', () => {
const signedUrl = buildFileUrl(OBJECT_KEY, NOW_SECONDS)
const storedUrl = normalizeStoredFileUrl(signedUrl)
const refreshedUrl = refreshStoredFileUrl(storedUrl)
assert.equal(storedUrl, `/api/v1/files/object?key=${encodeURIComponent(OBJECT_KEY)}`)
assert.match(refreshedUrl, /[?&]expires=\d+/)
assert.match(refreshedUrl, /[?&]signature=/)
assert.equal(
refreshStoredFileUrl('https://example.com/image.jpg'),
'https://example.com/image.jpg',
)
})
@@ -13,6 +13,7 @@ const IMAGE_VARIANT_THUMB = 'thumb'
const IMAGE_VARIANT_MEDIUM = 'medium'
const THUMB_MAX_SIDE = 480
const MEDIUM_MAX_SIDE = 1280
const FILE_URL_TTL_SECONDS = 15 * 60
const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf'])
@@ -98,9 +99,9 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
}
const now = nowIso()
const fileUrl = buildFileUrl(objectKey)
const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl
const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl
const fileUrl = buildUnsignedFileUrl(objectKey)
const thumbnailUrl = thumbnailObjectKey ? buildUnsignedFileUrl(thumbnailObjectKey) : fileUrl
const mediumUrl = mediumObjectKey ? buildUnsignedFileUrl(mediumObjectKey) : fileUrl
const asset = await createFileAsset({
objectKey,
scene,
@@ -134,8 +135,13 @@ export async function uploadFileAsset(input: UploadFileAssetInput) {
}
}
export async function getStoredFileObject(key: unknown, variant: unknown = '') {
export async function getStoredFileObject(
key: unknown,
variant: unknown = '',
access: { expires?: unknown; signature?: unknown } = {},
) {
const objectKey = normalizeObjectKey(key)
verifyFileAccessSignature(objectKey, access.expires, access.signature)
const variantName = normalizeVariant(variant)
const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey]
let lastError: unknown = null
@@ -159,8 +165,67 @@ export function getMaxUploadSizeBytes() {
return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024
}
export function buildFileUrl(objectKey: string) {
return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}`
export function buildFileUrl(objectKey: string, nowSeconds = Math.floor(Date.now() / 1000)) {
const normalizedKey = normalizeObjectKey(objectKey)
const expires = nowSeconds + FILE_URL_TTL_SECONDS
const signature = createFileAccessSignature(normalizedKey, expires)
return `${buildUnsignedFileUrl(normalizedKey)}&expires=${expires}&signature=${encodeURIComponent(signature)}`
}
export function refreshStoredFileUrl(value: unknown, fallbackObjectKey = '') {
const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildFileUrl(objectKey) : rawUrl
}
export function normalizeStoredFileUrl(value: unknown, fallbackObjectKey = '') {
const rawUrl = String(value || '').trim()
const objectKey = extractObjectKeyFromFileUrl(rawUrl) || String(fallbackObjectKey || '').trim()
return objectKey ? buildUnsignedFileUrl(objectKey) : rawUrl
}
export function refreshUploadedFileUrls(value: unknown) {
const source =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
const objectKey = String(source.objectKey || source.object_key || '').trim()
if (!objectKey) return source
return {
...source,
objectKey,
url: buildFileUrl(objectKey),
thumbnailUrl: refreshStoredFileUrl(source.thumbnailUrl || source.thumbnail_url, objectKey),
mediumUrl: refreshStoredFileUrl(source.mediumUrl || source.medium_url, objectKey),
}
}
export function verifyFileAccessSignature(
objectKey: string,
expiresValue: unknown,
signatureValue: unknown,
nowSeconds = Math.floor(Date.now() / 1000),
) {
const expires = Number(expiresValue || 0)
const signature = String(signatureValue || '').trim()
if (!Number.isSafeInteger(expires) || expires < nowSeconds || !signature) {
throw createHttpError('文件访问链接无效或已过期', {
statusCode: 403,
errorCode: 'file_access_expired',
})
}
const expected = createFileAccessSignature(normalizeObjectKey(objectKey), expires)
const expectedBuffer = Buffer.from(expected)
const actualBuffer = Buffer.from(signature)
if (
expectedBuffer.length !== actualBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, actualBuffer)
) {
throw createHttpError('文件访问签名不正确', {
statusCode: 403,
errorCode: 'file_access_signature_invalid',
})
}
}
export function imageVariantKey(objectKey: string, variant: string) {
@@ -183,15 +248,45 @@ function mapUploadedFile(
): UploadedFileDto {
return {
objectKey: asset?.object_key || fallback.objectKey,
url: asset?.url || fallback.fileUrl,
thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl,
mediumUrl: asset?.medium_url || fallback.mediumUrl,
url: buildFileUrl(asset?.object_key || fallback.objectKey),
thumbnailUrl: buildFileUrl(
asset?.thumbnail_object_key ||
extractObjectKeyFromFileUrl(fallback.thumbnailUrl) ||
fallback.objectKey,
),
mediumUrl: buildFileUrl(
asset?.medium_object_key ||
extractObjectKeyFromFileUrl(fallback.mediumUrl) ||
fallback.objectKey,
),
filename: asset?.original_filename || fallback.filename,
contentType: asset?.content_type || fallback.contentType,
size: Number(asset?.size_bytes || fallback.size || 0),
}
}
function buildUnsignedFileUrl(objectKey: string) {
return `/api/v1/files/object?key=${encodeURIComponent(normalizeObjectKey(objectKey))}`
}
function createFileAccessSignature(objectKey: string, expires: number) {
const secret =
String(runtimeConfig.admin.sessionSecret || '').trim() ||
String(runtimeConfig.storage.secretAccessKey || '').trim()
return crypto.createHmac('sha256', secret).update(`${objectKey}\n${expires}`).digest('base64url')
}
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'))
} catch {
return ''
}
}
function normalizeContentType(mimeType: unknown, data: Buffer) {
const detected = detectContentType(data)
if (detected) {