fix(bom-costing): fetch stored images for realtime estimates

This commit is contained in:
WuYongJie 2026-08-17 21:13:23 +08:00
parent 7f67bd858c
commit c038bcedbe
2 changed files with 59 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import {
canonicalAlibaba1688Resource,
extractAlibaba1688AkKeys,
getBomCosting1688ImageSearchConfig,
resolveBomCostingImageSearchSourceUrl,
type BomCosting1688ImageSearchConfig,
} from './bom-costing-1688-image-search'
@ -50,6 +51,13 @@ 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/auth/me')).toThrow('竞品链接格式不正确')
})
it('sorts canonical query pairs and signs exactly the body sent to the gateway', () => {
expect(canonicalAlibaba1688Resource('https://skills-gateway.1688.com/api/find_product/1.0.0?z=2&a=b&a=a')).toBe(
'/api/find_product/1.0.0?a=a&a=b&z=2',
@ -186,6 +194,36 @@ describe('BomCosting1688ImageSearchService', () => {
expect(JSON.parse(request?.body ?? '{}')).not.toHaveProperty('imageUrl')
})
it('fetches a stored same-origin product image through the public application URL', async () => {
const imageResponse = {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'image/jpeg' }),
arrayBuffer: async () => Buffer.from('image-bytes'),
} as unknown as Response
const apiResponse = {
ok: true,
status: 200,
text: async () => JSON.stringify({ success: true, model: { count: 0, data: [] } }),
} as unknown as Response
const fetch = jest.fn()
.mockResolvedValueOnce(imageResponse)
.mockResolvedValueOnce(apiResponse)
const service = new BomCosting1688ImageSearchService({
getConfig: () => config,
fetch,
now: () => new Date('2026-08-16T10:00:00.000Z'),
randomBytes: () => Buffer.from([1, 2, 3, 4]),
})
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'),
)
})
it('degrades without a hidden browser fallback when the service is not configured', async () => {
const fetch = jest.fn()
const service = new BomCosting1688ImageSearchService({

View File

@ -10,6 +10,7 @@ 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
@ -167,6 +168,25 @@ 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.
*/
export function resolveBomCostingImageSearchSourceUrl(rawValue: string): 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())
}
return parseSafeCompetitorUrl(trimmed)
}
const defaultGateway: BomCosting1688ImageSearchGateway = {
getConfig: getBomCosting1688ImageSearchConfig,
fetch: (...args) => fetch(...args),
@ -502,7 +522,7 @@ export class BomCosting1688ImageSearchService {
}
try {
const imageUrl = parseSafeCompetitorUrl(sourceImageUrl)
const imageUrl = resolveBomCostingImageSearchSourceUrl(sourceImageUrl)
const imageResponse = await this.gateway.fetch(imageUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },