fix(asset-center): 修复生产缩略图渲染域名来源

This commit is contained in:
WuYongJie 2026-06-04 21:32:19 +08:00
parent 3cd1102cd2
commit b4f088d70a
8 changed files with 203 additions and 23 deletions

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCadImportService } from '@/server/services'
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
import { assetCadImportPayloadSchema } from '../../platform/schemas'
function parseJsonStringArray(value: FormDataEntryValue | null): string[] | undefined {
@ -105,7 +106,12 @@ export async function POST(request: NextRequest) {
try {
const { familyMode: _familyMode, ...serviceInput } = payloadData
const requestOrigin = new URL(request.url).origin
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
requestUrl: request.url,
forwardedHost: request.headers.get('x-forwarded-host'),
host: request.headers.get('host'),
forwardedProto: request.headers.get('x-forwarded-proto'),
})
const result = await assetCadImportService.importAsset({
...serviceInput,
zipFile: file,

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
import { assetVariantUpdateSchema } from '../../../platform/schemas'
function shouldSkipThumbnail(request: NextRequest): boolean {
@ -28,7 +29,12 @@ export async function PUT(
}
try {
const requestOrigin = new URL(request.url).origin
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
requestUrl: request.url,
forwardedHost: request.headers.get('x-forwarded-host'),
host: request.headers.get('host'),
forwardedProto: request.headers.get('x-forwarded-proto'),
})
const variant = await assetCenterService.updateVariant(id, {
...parsed.data,
updatedBy: ctx.user.id,

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService } from '@/server/services'
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
export async function POST(
request: NextRequest,
@ -13,7 +14,12 @@ export async function POST(
const { id } = await context.params
try {
const requestOrigin = new URL(request.url).origin
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
requestUrl: request.url,
forwardedHost: request.headers.get('x-forwarded-host'),
host: request.headers.get('host'),
forwardedProto: request.headers.get('x-forwarded-proto'),
})
const variant = await assetCenterService.regenerateVariantThumbnail(id, {
renderBaseUrl: requestOrigin,
updatedBy: ctx.user.id,

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assetCenterService, assetOperationProgressService } from '@/server/services'
import { resolveAssetThumbnailRenderBaseUrl } from '@/lib/asset-thumbnail-auth'
import { assetVariantCreateSchema } from '../../platform/schemas'
function readOperationId(request: NextRequest): string | null {
@ -65,7 +66,12 @@ export async function POST(request: NextRequest) {
}
try {
const requestOrigin = new URL(request.url).origin
const requestOrigin = resolveAssetThumbnailRenderBaseUrl({
requestUrl: request.url,
forwardedHost: request.headers.get('x-forwarded-host'),
host: request.headers.get('host'),
forwardedProto: request.headers.get('x-forwarded-proto'),
})
const result = await assetCenterService.createVariantWithSimilarity({
...parsed.data,
createdBy: ctx.user.id,

View File

@ -6,10 +6,23 @@ const mockBuildPublicS3ObjectUrl = jest.fn()
const mockDoesS3ObjectExist = jest.fn()
const mockResolveObjectStorageUrlForServerAccess = jest.fn()
const mockGenerateGenericThumbnail = jest.fn()
const mockBuildObjectReadResponse = jest.fn()
const ENV_KEYS = [
'ASSET_THUMBNAIL_RENDER_BASE_URL',
'NEXT_PUBLIC_APP_URL',
'NEXTAUTH_URL',
] as const
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof ENV_KEYS)[number], string | undefined>
jest.mock('@/lib/asset-thumbnail-auth', () => ({
buildAssetThumbnailImageStorageKey: (...args: unknown[]) =>
mockBuildAssetThumbnailImageStorageKey(...args),
resolveAssetThumbnailRenderBaseUrl:
jest.requireActual('@/lib/asset-thumbnail-auth').resolveAssetThumbnailRenderBaseUrl,
verifyAssetThumbnailRenderPayload: (...args: unknown[]) =>
mockVerifyAssetThumbnailRenderPayload(...args),
}))
@ -21,6 +34,10 @@ jest.mock('@/lib/storage', () => ({
mockResolveObjectStorageUrlForServerAccess(...args),
}))
jest.mock('@/lib/object-storage-read-response', () => ({
buildObjectReadResponse: (...args: unknown[]) => mockBuildObjectReadResponse(...args),
}))
jest.mock('@/server/services/asset-variant-thumbnail.service', () => ({
assetVariantThumbnailService: {
generateGenericThumbnail: (...args: unknown[]) =>
@ -46,6 +63,9 @@ function buildRequest(): NextRequest {
describe('GET /internal/asset-thumbnail-image', () => {
beforeEach(() => {
jest.clearAllMocks()
for (const key of ENV_KEYS) {
delete process.env[key]
}
mockBuildAssetThumbnailImageStorageKey.mockReturnValue(
'asset-center/runtime-thumbnails/component/thumb.png',
)
@ -58,6 +78,23 @@ describe('GET /internal/asset-thumbnail-image', () => {
? 'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed'
: url,
)
mockBuildObjectReadResponse.mockImplementation((readUrl: string) =>
new Response(null, {
status: 307,
headers: { location: readUrl },
}),
)
})
afterAll(() => {
for (const key of ENV_KEYS) {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
})
it('redirects cached private thumbnails to a signed object URL', async () => {
@ -72,6 +109,10 @@ describe('GET /internal/asset-thumbnail-image', () => {
expect(mockResolveObjectStorageUrlForServerAccess).toHaveBeenCalledWith(
'/api/storage/object?key=asset-center%2Fruntime-thumbnails%2Fcomponent%2Fthumb.png',
)
expect(mockBuildObjectReadResponse).toHaveBeenCalledWith(
'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed',
expect.anything(),
)
expect(getRedirectLocation(response)).toBe(
'https://cdn.example.com/asset-center/runtime-thumbnails/component/thumb.png?token=signed',
)

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import {
buildAssetThumbnailImageStorageKey,
resolveAssetThumbnailRenderBaseUrl,
verifyAssetThumbnailRenderPayload,
type AssetThumbnailRenderAssetType,
} from '@/lib/asset-thumbnail-auth'
@ -19,24 +20,6 @@ function readStringParam(value: string | null): string | null {
return value && value.trim().length > 0 ? value.trim() : null
}
function resolveRequestOrigin(request: NextRequest): string {
const configuredOrigin =
process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.NEXTAUTH_URL?.trim()
if (configuredOrigin) {
return configuredOrigin.replace(/\/$/, '')
}
const forwardedHost = request.headers.get('x-forwarded-host')
const host = forwardedHost ?? request.headers.get('host')
const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol.replace(/:$/, '')
if (!host) {
return request.nextUrl.origin.replace(/\/$/, '')
}
return `${protocol}://${host}`.replace(/\/$/, '')
}
export async function GET(request: NextRequest) {
const modelUrl = readStringParam(request.nextUrl.searchParams.get('modelUrl'))
const assetType = readStringParam(
@ -86,7 +69,12 @@ export async function GET(request: NextRequest) {
identifier: storageKey,
assetType,
modelUrl,
renderBaseUrl: resolveRequestOrigin(request),
renderBaseUrl: resolveAssetThumbnailRenderBaseUrl({
requestUrl: request.nextUrl,
forwardedHost: request.headers.get('x-forwarded-host'),
host: request.headers.get('host'),
forwardedProto: request.headers.get('x-forwarded-proto'),
}),
storageKey,
materialConfig,
})

View File

@ -0,0 +1,73 @@
import { resolveAssetThumbnailRenderBaseUrl } from './asset-thumbnail-auth'
const ENV_KEYS = [
'ASSET_THUMBNAIL_RENDER_BASE_URL',
'NEXT_PUBLIC_APP_URL',
'NEXTAUTH_URL',
] as const
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof ENV_KEYS)[number], string | undefined>
function restoreEnv() {
for (const key of ENV_KEYS) {
const value = originalEnv[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}
describe('resolveAssetThumbnailRenderBaseUrl', () => {
beforeEach(() => {
for (const key of ENV_KEYS) {
delete process.env[key]
}
})
afterAll(() => {
restoreEnv()
})
it('prefers the dedicated thumbnail render base url over request origin', () => {
process.env.ASSET_THUMBNAIL_RENDER_BASE_URL = 'https://cp.jiansun.vip/'
process.env.NEXT_PUBLIC_APP_URL = 'https://ignored.example.com'
expect(
resolveAssetThumbnailRenderBaseUrl({
requestUrl: 'https://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
}),
).toBe('https://cp.jiansun.vip')
})
it('uses the public app url before falling back to the internal request origin', () => {
process.env.NEXT_PUBLIC_APP_URL = 'https://cp.jiansun.vip'
expect(
resolveAssetThumbnailRenderBaseUrl({
requestUrl: 'https://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
}),
).toBe('https://cp.jiansun.vip')
})
it('falls back to forwarded host headers when no public url is configured', () => {
expect(
resolveAssetThumbnailRenderBaseUrl({
requestUrl: 'http://0.0.0.0:3001/api/asset-center/variants/id/thumbnail',
forwardedHost: 'cp.jiansun.vip',
forwardedProto: 'https',
}),
).toBe('https://cp.jiansun.vip')
})
it('uses request origin only after configured and forwarded origins are absent', () => {
expect(
resolveAssetThumbnailRenderBaseUrl({
requestUrl: 'http://localhost:3000/api/asset-center/variants/id/thumbnail',
}),
).toBe('http://localhost:3000')
})
})

View File

@ -15,6 +15,13 @@ export interface AssetThumbnailRenderPayload {
materialConfig?: MaterialConfig | null
}
export interface AssetThumbnailRenderBaseUrlInput {
requestUrl?: string | URL | null
forwardedHost?: string | null
host?: string | null
forwardedProto?: string | null
}
export function buildAssetThumbnailExpiryTimestamp(
ttlMs: number = 24 * 60 * 60 * 1000,
nowMs: number = Date.now(),
@ -22,6 +29,53 @@ export function buildAssetThumbnailExpiryTimestamp(
return nowMs + ttlMs
}
function readFirstHeaderValue(value: string | null | undefined): string | null {
const next = value?.split(',')[0]?.trim()
return next || null
}
function normalizeOrigin(value: string | URL | null | undefined): string | null {
const next = typeof value === 'string' ? value.trim() : value?.toString().trim()
if (!next) return null
try {
return new URL(next).origin.replace(/\/$/, '')
} catch {
return null
}
}
function buildForwardedOrigin(input: AssetThumbnailRenderBaseUrlInput): string | null {
const host = readFirstHeaderValue(input.forwardedHost) || readFirstHeaderValue(input.host)
if (!host) return null
const requestOrigin = normalizeOrigin(input.requestUrl)
const protocol =
readFirstHeaderValue(input.forwardedProto) ||
(requestOrigin ? new URL(requestOrigin).protocol.replace(/:$/, '') : 'http')
return normalizeOrigin(`${protocol}://${host}`)
}
export function resolveAssetThumbnailRenderBaseUrl(
input: AssetThumbnailRenderBaseUrlInput = {},
): string {
const candidates = [
process.env.ASSET_THUMBNAIL_RENDER_BASE_URL,
process.env.NEXT_PUBLIC_APP_URL,
process.env.NEXTAUTH_URL,
buildForwardedOrigin(input),
input.requestUrl,
]
for (const candidate of candidates) {
const origin = normalizeOrigin(candidate)
if (origin) return origin
}
return 'http://localhost:3000'
}
function getThumbnailSigningSecret(): string {
const secret =
process.env.ASSET_THUMBNAIL_SECRET ||