fix(bom-costing): read stored images through object storage

This commit is contained in:
WuYongJie 2026-08-17 21:20:56 +08:00
parent c038bcedbe
commit f08d4ff5c4
2 changed files with 29 additions and 17 deletions

View File

@ -52,8 +52,11 @@ describe('Alibaba 1688 CSK signing', () => {
})
it('turns only the public same-origin storage route into an absolute image URL', () => {
expect(resolveBomCostingImageSearchSourceUrl('/api/storage/object?key=prod%2Fsku.webp')).toEqual(
new URL('https://cp.jiansun.vip/api/storage/object?key=prod%2Fsku.webp'),
expect(resolveBomCostingImageSearchSourceUrl(
'/api/storage/object?key=prod%2Fsku.webp',
(key) => `https://objects.example.com/${key}`,
)).toEqual(
new URL('https://objects.example.com/prod/sku.webp'),
)
expect(() => resolveBomCostingImageSearchSourceUrl('/api/auth/me')).toThrow('竞品链接格式不正确')
})
@ -157,6 +160,7 @@ describe('BomCosting1688ImageSearchService', () => {
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
})
const quote = await service.findRealtimeQuote({
@ -194,7 +198,7 @@ describe('BomCosting1688ImageSearchService', () => {
expect(JSON.parse(request?.body ?? '{}')).not.toHaveProperty('imageUrl')
})
it('fetches a stored same-origin product image through the public application URL', async () => {
it('fetches a stored same-origin product image through its backing object storage URL', async () => {
const imageResponse = {
ok: true,
status: 200,
@ -214,13 +218,14 @@ describe('BomCosting1688ImageSearchService', () => {
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
createStoredObjectReadUrl: (key) => `https://objects.example.com/${key}`,
})
await expect(service.findRealtimeQuote({
imageUrl: '/api/storage/object?key=prod%2Fbom-costing%2Fsku.webp',
})).resolves.toMatchObject({ status: 'NO_COMPARABLE_OFFER' })
expect(fetch.mock.calls[0]?.[0]).toEqual(
new URL('https://cp.jiansun.vip/api/storage/object?key=prod%2Fbom-costing%2Fsku.webp'),
new URL('https://objects.example.com/prod/bom-costing/sku.webp'),
)
})

View File

@ -3,6 +3,7 @@ import 'server-only'
import { createHash, createHmac, randomBytes } from 'node:crypto'
import { z } from 'zod'
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
import { createObjectReadUrl, resolveObjectStorageUrlForServerAccess } from '@/lib/storage'
import type {
DynamicBomRealtimeMarketOffer,
DynamicBomRealtimeMarketQuote,
@ -10,7 +11,6 @@ import type {
const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0'
const DEFAULT_APPLICATION_ORIGIN = 'https://cp.jiansun.vip'
const CSK_SIGNATURE_VERSION = '1.7.0'
const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024
const DEFAULT_TIMEOUT_MS = 45_000
@ -67,6 +67,7 @@ export interface BomCosting1688ImageSearchGateway {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
now(): Date
randomBytes(size: number): Buffer
createStoredObjectReadUrl?: (key: string) => string
}
function boundedPositiveInteger(
@ -170,21 +171,23 @@ export function getBomCosting1688ImageSearchConfig(): BomCosting1688ImageSearchC
/**
* Source snapshots keep object-storage links as same-origin relative paths.
* The server-side image-search request needs an absolute URL, but must never
* turn arbitrary relative input into an internal-network request. Only the
* public storage-object route is eligible for this conversion.
* The server-side image-search request must read the backing object directly:
* the browser proxy correctly requires a login cookie and therefore cannot be
* used by a background pricing job. Only its storage-object route is eligible
* for this conversion.
*/
export function resolveBomCostingImageSearchSourceUrl(rawValue: string): URL {
export function resolveBomCostingImageSearchSourceUrl(
rawValue: string,
buildStoredObjectReadUrl: (key: string) => string = createObjectReadUrl,
): URL {
const trimmed = rawValue.trim()
if (trimmed.startsWith('/api/storage/object?')) {
const configuredOrigin = (
process.env.NEXT_PUBLIC_APP_URL ??
process.env.NEXTAUTH_URL ??
DEFAULT_APPLICATION_ORIGIN
).trim()
return parseSafeCompetitorUrl(new URL(trimmed, configuredOrigin).toString())
const parsed = new URL(trimmed, 'https://bom-costing.invalid')
const objectKey = parsed.searchParams.get('key')?.trim()
if (!objectKey) throw new Error('SKU 主图缺少对象存储 key')
return parseSafeCompetitorUrl(buildStoredObjectReadUrl(objectKey))
}
return parseSafeCompetitorUrl(trimmed)
return parseSafeCompetitorUrl(resolveObjectStorageUrlForServerAccess(trimmed))
}
const defaultGateway: BomCosting1688ImageSearchGateway = {
@ -192,6 +195,7 @@ const defaultGateway: BomCosting1688ImageSearchGateway = {
fetch: (...args) => fetch(...args),
now: () => new Date(),
randomBytes: (size) => randomBytes(size),
createStoredObjectReadUrl: createObjectReadUrl,
}
interface Alibaba1688AkKeys {
@ -522,7 +526,10 @@ export class BomCosting1688ImageSearchService {
}
try {
const imageUrl = resolveBomCostingImageSearchSourceUrl(sourceImageUrl)
const imageUrl = resolveBomCostingImageSearchSourceUrl(
sourceImageUrl,
this.gateway.createStoredObjectReadUrl,
)
const imageResponse = await this.gateway.fetch(imageUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },