feat(api): query original image resources by SKU or barcode

This commit is contained in:
WuYongJie 2026-10-15 16:50:04 +08:00
parent 46e8e02779
commit 493dd16d83
6 changed files with 477 additions and 0 deletions

View File

@ -17,6 +17,7 @@ import { access, stat } from 'fs/promises';
import { lookup, extension } from 'mime-types';
import { isProductPackageType } from '@/lib/material-type-mapper';
import { isPrivateTaskDetail } from '@/lib/task-detail-privacy';
import { resolveExternalImageDownloadSource } from '@/lib/external-image-assets';
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
import { createArchiveDownloadStream } from '@/lib/archive-download-stream';
import {
@ -68,6 +69,19 @@ export async function GET(request: NextRequest) {
return errorResponse(API_ERROR.TASK_NOT_FOUND);
}
// Additive, new-API-only download type. Existing tokens keep their original
// branches. Never unpack, repackage or substitute previews for an original.
if (downloadType === 'image-source') {
const source = await resolveExternalImageDownloadSource(material, assetId);
if (!source) {
return NextResponse.json(
{ success: false, error: { code: 'ORIGINAL_SOURCE_NOT_FOUND', message: '原始资源不存在或不可访问' } },
{ status: 404 },
);
}
return await downloadSingleFile(source, rangeHeader);
}
const videoDownloadAvailability = getVideoDownloadAvailability(
material.materialType,
material.createdAt,

34
app/api/external/image-assets/route.ts vendored Normal file
View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { randomUUID } from 'crypto'
import { getApiKeyFromRequest, validateApiKey, logApiCall, getClientIp } from '@/lib/external-api'
import { ExternalImageAssetsError, findExternalImageAssets, parseExternalImageQuery } from '@/lib/external-image-assets'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/** New read-only resource lookup. Existing external routes and downloads are unchanged. */
export async function GET(request: NextRequest) {
const start = Date.now(), traceId = randomUUID()
const headers = { 'Cache-Control': 'private, no-store, max-age=0', 'X-Trace-Id': traceId }
let keyId: string | undefined
let queryType: string | undefined, queryValue: string | undefined
let status = 500
try {
const key = await validateApiKey(getApiKeyFromRequest(request))
if (!key) { status = 401; return NextResponse.json({ success: false, error: { code: 'INVALID_API_KEY', message: 'API Key 无效或缺失' } }, { status, headers }) }
keyId = key.id
const query = parseExternalImageQuery(request.nextUrl.searchParams)
queryType = query.type; queryValue = query.value
const data = await findExternalImageAssets(query)
status = 200
return NextResponse.json({ success: true, data }, { headers })
} catch (error) {
status = error instanceof ExternalImageAssetsError ? error.status : 500
if (!(error instanceof ExternalImageAssetsError)) console.error('[external/image-assets] lookup failed', { traceId })
return NextResponse.json({ success: false, error: error instanceof ExternalImageAssetsError
? { code: error.code, message: error.message } : { code: 'SERVER_ERROR', message: '服务器内部错误' } }, { status, headers })
} finally {
if (keyId) await logApiCall({ apiKeyId: keyId, endpoint: '/api/external/image-assets', method: 'GET', queryType, queryValue,
statusCode: status, duration: Date.now() - start, ipAddress: getClientIp(request), userAgent: request.headers.get('user-agent') || undefined })
}
}

View File

@ -0,0 +1,80 @@
# 图片原始资源查询接口
新增只读接口 `GET https://sc.jiansun.vip/api/external/image-assets`。现有外部接口、参数、响应和下载 Token 规则保持不变。
## 调用
请求头使用素材平台现有的 `X-API-Key`。不要放在 URL 或前端代码中。
```http
GET /api/external/image-assets?sku=SKU001
X-API-Key: <素材平台 API Key>
```
或 `GET /api/external/image-assets?barcode=0001234567890`。SKU 与 barcode 必须且只能提供一个;条码按字符串传递,保留前导零。SKU 大小写不敏感。
| 参数 | 默认值 | 说明 |
|---|---|---|
| `resourceType` | `all` | `image`、`ai-original`、`render-original` 或 `all` |
| `page` | `1` | 页码,1–10000 |
| `pageSize` | `20` | 每页素材数,1–50;一条素材可含多份原始资源 |
图片范围:实拍图片、买家秀图片、任务-实拍原图、任务-视频截图、任务-详情的主图/SKU/详情原包,以及标准模型的白底原图。AI 原图对应 `任务-AI原图`,渲染原图对应 `任务-3D原图`。不返回模型文件、PSD、模板、视频或预览图作为图片原件。
## 查询与资源范围
SKU/条码先通过数据库中有效的 SKU、素材图片条码绑定解析到 SPU,再查询这个产品下的图片资源。不会按名称模糊搜索、截断 SKU 猜 SPU,或修改任务领取状态。未建立有效编号绑定返回 404;关联多个产品或规格返回 409,避免取错产品。
AI、渲染及普通图片素材目前主要按 SPU 关联。此类结果的 `matchScope: "spu"`、`skuCode: null` 表示产品级资源,不能视为该 SKU 的专属图片。标准模型白底图仅返回当前 SKU 的原图,标记为 `matchScope: "sku"`。接口不自动判断原图中是哪种颜色或规格,外部系统自行选择。
## 返回格式
```json
{
"success": true,
"data": {
"query": { "type": "sku", "value": "SKU001", "resourceType": "all" },
"resolution": { "sku": "SKU001", "spu": "SPU001", "resourceScope": "spu" },
"count": 1,
"items": [{
"materialId": "素材ID",
"assetId": "资产ID或null",
"materialType": "任务-AI原图",
"resourceType": "ai-original",
"taskNo": null,
"productModel": "SPU001",
"skuCode": null,
"matchScope": "spu",
"group": null,
"fileName": "产品原图.rar",
"fileSize": 123456,
"kind": "archive",
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=...",
"uploadedAt": "2026-10-15T00:00:00.000Z"
}],
"issues": [],
"pagination": { "page": 1, "pageSize": 20, "totalMaterials": 1, "hasMore": false }
}
}
```
- `downloadUrl` 复用已有外部下载接口和签名 Token,可直接 GET;查询请求仍需 API Key。新接口签发专用 `image-source` 用途,仅流式读取精确的原始资产;旧用途仍走旧分支,不改变旧系统调用。下载支持现有的 HTTP Range/断点续传规则。Token 的生命周期沿用现有下载能力,查询密钥停用不代表已签发的长期下载链接自动失效。
- 图片原件逐张返回;源资源是 ZIP/RAR 时,返回原包。**本平台不解压、不重新打包、不缩放、不重新编码。**外部系统自行处理原包。
- `kind` 为按原文件扩展名登记的 `image`/`archive`/`source`,不承诺检查压缩包内容;`fileSize: 0` 表示库中没有有效大小,实际大小以下载响应为准。
- 任务-详情优先返回已有 `main`/`sku`/`detail` 分类原包,`group` 标明分类;只有已有完整原包时才能返回 `all`。不会触发动态打包。
- `count` 是本页已登记资源数,`totalMaterials` 是符合条件的素材数。存在素材却无原件记录时,`issues` 返回 `SOURCE_NOT_AVAILABLE`;文件实际缺失时下载返回 404,绝不改为预览图或动态打包。有效产品暂无资源时,成功返回空 `items`。
- 私密、回收站、未完成上传的素材不参与产品解析或资源查询;下载时沿用现有素材可见性复核。查询只记录接口访问审计,不改任务状态、下载次数、产品关联或文件。
## 错误码
| 状态 | code | 含义 |
|---|---|---|
| 400 | `INVALID_QUERY` | 缺少/同时提供 SKU 和条码,编号无效,或参数重复/未知 |
| 400 | `INVALID_RESOURCE_TYPE` / `INVALID_PAGINATION` | 类型或分页无效 |
| 401 | `INVALID_API_KEY` | Key 缺失、错误或停用 |
| 404 | `SKU_NOT_FOUND` / `BARCODE_NOT_FOUND` | 未找到有效编号绑定 |
| 409 | `PRODUCT_BINDING_AMBIGUOUS` | 编号指向多个产品或规格 |
| 409 | `PRODUCT_BINDING_LIMIT` | 同一编号超过 100 条绑定,需先核对数据 |
| 500 | `SERVER_ERROR` | 查询异常;通过 `X-Trace-Id` 定位 |
旧系统继续调用原接口,无需改动。新接口响应使用 `Cache-Control: private, no-store`。

View File

@ -0,0 +1,162 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { mkdtemp, writeFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import AdmZip from 'adm-zip'
import { GET } from '@/app/api/external/image-assets/route'
import { GET as download } from '@/app/api/external/download/route'
import { prisma } from '@/lib/prisma'
import { validateApiKey, logApiCall } from '@/lib/external-api'
import { verifyDownloadToken } from '@/lib/download-token'
import { findExternalImageAssets, parseExternalImageQuery, resolveExternalImageDownloadSource } from '@/lib/external-image-assets'
jest.mock('@/lib/prisma', () => ({ prisma: {
material: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn() },
materialSku: { findMany: jest.fn(), findFirst: jest.fn() }, materialAsset: { findMany: jest.fn() },
} }))
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.jiansun.vip' }))
jest.mock('@/lib/external-api', () => {
const actual = jest.requireActual('@/lib/external-api')
return { ...actual, validateApiKey: jest.fn(), logApiCall: jest.fn().mockResolvedValue(undefined) }
})
const db = prisma as unknown as { material: { findMany: jest.Mock; count: jest.Mock; findUnique: jest.Mock }; materialSku: { findMany: jest.Mock }; materialAsset: { findMany: jest.Mock } }
const request = (query: string, key = 'test-key') => new NextRequest(`https://sc.jiansun.vip/api/external/image-assets?${query}`, { headers: key ? { 'X-API-Key': key } : {} })
const query = (q = 'sku=SKU-01') => parseExternalImageQuery(new URLSearchParams(q))
const material = (overrides: Record<string, unknown> = {}) => ({ id: 'material-1', productModel: 'SPU-01', materialType: '任务-AI原图', taskNo: null,
categoryName: '品牌', zipFilePath: 'uploads/originals/source.zip', zipFileName: 'source.zip', zipFileSize: BigInt(123),
createdAt: new Date('2026-10-01T00:00:00Z'), assets: [], skus: [], ...overrides })
const asset = (id: string, assetType: string, fileName: string) => ({ id, assetType, fileName, fileUrl: `uploads/originals/${fileName}`, fileSize: BigInt(100), sortOrder: 0 })
const savedSecret = process.env.DOWNLOAD_TOKEN_SECRET
beforeEach(() => {
jest.clearAllMocks(); process.env.DOWNLOAD_TOKEN_SECRET = 'test-only-original-image-download-secret'
;(validateApiKey as jest.Mock).mockResolvedValue({ id: 'key-1', isActive: true })
db.materialSku.findMany.mockResolvedValue([{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }])
db.materialAsset.findMany.mockResolvedValue([])
db.material.findMany.mockResolvedValue([material()]); db.material.count.mockResolvedValue(1)
})
afterAll(() => { if (savedSecret === undefined) delete process.env.DOWNLOAD_TOKEN_SECRET; else process.env.DOWNLOAD_TOKEN_SECRET = savedSecret })
describe('external image resource lookup', () => {
test.each(['', 'sku=A&barcode=B', 'sku=A&sku=B', 'sku=A&resourceType=video', 'sku=A&page=0', 'sku=A&pageSize=51', 'sku=A&unknown=B', 'sku=%00A'])('rejects invalid/ambiguous query %s', value => {
expect(() => query(value)).toThrow()
})
test('preserves leading zeros and barcode case; normalizes SKU only', () => {
expect(query('barcode=0012Ab').value).toBe('0012Ab')
expect(query('sku=sku-01').value).toBe('SKU-01')
})
test('rejects missing, disabled or invalid key before product resolution; URL keys are not accepted', async () => {
;(validateApiKey as jest.Mock).mockResolvedValue(null)
const response = await GET(request('sku=SKU-01&apiKey=url-secret', ''))
expect(response.status).toBe(401); expect(validateApiKey).toHaveBeenCalledWith(null)
expect(db.materialSku.findMany).not.toHaveBeenCalled(); expect(db.material.findMany).not.toHaveBeenCalled()
})
test('returns original archive without storage paths or preview URLs and does not update materials', async () => {
const response = await GET(request('sku=sku-01'))
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toContain('no-store')
const data = (await response.json()).data
expect(data.resolution).toEqual({ sku: 'SKU-01', spu: 'SPU-01', resourceScope: 'spu' })
expect(data.items[0]).toMatchObject({ kind: 'archive', fileName: 'source.zip', matchScope: 'spu', skuCode: null })
expect(verifyDownloadToken(new URL(data.items[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ materialId: 'material-1', downloadType: 'image-source' })
expect(JSON.stringify(data)).not.toContain('uploads/'); expect(JSON.stringify(data)).not.toContain('previewUrl')
expect(db.material.findMany.mock.calls[0][0].where).toMatchObject({ productModel: { equals: 'SPU-01', mode: 'insensitive' }, deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false })
expect(logApiCall).toHaveBeenCalledWith(expect.objectContaining({ endpoint: '/api/external/image-assets', queryType: 'sku', statusCode: 200 }))
})
test('resolves barcode using active database bindings and enforces visibility at both lookups', async () => {
await findExternalImageAssets(query('barcode=0000123'))
expect(db.materialSku.findMany.mock.calls[0][0].where).toMatchObject({ deletedAt: null, barcodes: { some: { barcode: '0000123', deletedAt: null } }, material: { is: { deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false } } })
expect(db.materialAsset.findMany.mock.calls[0][0].where).toMatchObject({ barcodes: { some: { barcode: '0000123' } }, material: { is: { taskDetailPrivate: false } } })
})
test('also resolves SKU/barcode bindings on task-detail asset rows', async () => {
db.materialSku.findMany.mockResolvedValue([])
db.materialAsset.findMany.mockResolvedValue([{ skuCode: 'SKU-01', material: { productModel: 'SPU-01' } }])
expect((await findExternalImageAssets(query('barcode=00001'))).resolution.sku).toBe('SKU-01')
})
test('does not invent a SKU to SPU relation if binding is absent', async () => {
db.materialSku.findMany.mockResolvedValue([])
const response = await GET(request('sku=SKU-01'))
expect(response.status).toBe(404); expect(db.material.findMany).not.toHaveBeenCalled()
})
test.each([
[{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }, { skuName: 'SKU-01', material: { productModel: 'SPU-02' } }],
[{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }, { skuName: 'SKU-02', material: { productModel: 'SPU-01' } }],
])('refuses conflicting product/variant bindings', async (...bindings) => {
db.materialSku.findMany.mockResolvedValue(bindings)
await expect(findExternalImageAssets(query())).rejects.toMatchObject({ status: 409, code: 'PRODUCT_BINDING_AMBIGUOUS' })
expect(db.material.findMany).not.toHaveBeenCalled()
})
test('only includes image resource types and paginates without title contains searches', async () => {
await findExternalImageAssets(query('sku=SKU-01&resourceType=render-original&page=2&pageSize=5'))
expect(db.material.findMany.mock.calls[0][0]).toMatchObject({ where: { materialType: { in: ['任务-3D原图'] } }, skip: 5, take: 5 })
})
test('white-background images are returned individually for the exact active SKU; model ZIP and previews excluded', async () => {
db.material.findMany.mockResolvedValue([material({ materialType: '标准模型', skus: [{ id: 'sku-a', skuName: 'SKU-01' }], assets: [
asset('a', 'standard-model-white-bg-view:sku-a:rightFront45', '原图.png'), asset('b', 'standard-model-white-bg-view:sku-b:rightFront45', '另一规格.png'),
asset('c', 'standard-model-white-bg-zip:sku-a', '3d-model.zip'), asset('d', 'preview', 'preview.webp'),
] })])
const data = await findExternalImageAssets(query())
expect(data.items).toHaveLength(1); expect(data.items[0]).toMatchObject({ assetId: 'a', kind: 'image', skuCode: 'SKU-01', matchScope: 'sku' })
expect(verifyDownloadToken(new URL(data.items[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ downloadType: 'image-source', assetId: 'a' })
})
test('task-detail returns stored category archives unchanged, excluding PSD and preview slices', async () => {
db.material.findMany.mockResolvedValue([material({ materialType: '任务-详情', assets: [asset('m', 'main', 'main.rar'), asset('s', 'sku', 'sku.zip'), asset('d', 'detail', 'detail.zip'), asset('p', 'psd', 'source.psd'), asset('v', 'detail-preview', 'slice.webp')] })])
const data = await findExternalImageAssets(query())
expect(data.items.map(i => i.group)).toEqual(['main', 'sku', 'detail'])
expect(data.items.every(i => i.kind === 'archive')).toBe(true)
})
test('does not substitute previews and gives each stored original package its own download token', async () => {
db.material.findMany.mockResolvedValue([material({ zipFilePath: '', assets: [asset('p', 'preview', 'preview.webp')] }),
material({ id: 'ambiguous', assets: [asset('z1', 'zip', 'one.zip'), asset('z2', 'zip', 'two.rar')] })])
const data = await findExternalImageAssets(query())
expect(data.items).toHaveLength(2); expect(data.issues.map(i => i.code)).toEqual(['SOURCE_NOT_AVAILABLE'])
expect(data.items.map(i => verifyDownloadToken(new URL(i.downloadUrl).searchParams.get('token')!)?.assetId)).toEqual(['z1', 'z2'])
})
test('returns an empty page for a known product without image resources', async () => {
db.material.findMany.mockResolvedValue([]); db.material.count.mockResolvedValue(0)
expect(await findExternalImageAssets(query())).toMatchObject({ count: 0, items: [], pagination: { totalMaterials: 0, hasMore: false } })
})
test('does not expose database errors or credentials in failure responses', async () => {
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
db.materialSku.findMany.mockRejectedValue(new Error('postgres://private:secret@database/production'))
const response = await GET(request('sku=SKU-01'))
expect(response.status).toBe(500); expect(JSON.stringify(await response.json())).not.toContain('private')
log.mockRestore()
})
test.each([{ taskDetailPrivate: true }, { deletedAt: new Date() }, { isIncompleteShell: true }, { materialType: '3D场景' }])('rechecks original-download visibility %j', async denied => {
expect(await resolveExternalImageDownloadSource(material(denied))).toBeNull()
})
test('new download type rejects another material asset, previews and model sources', async () => {
expect(await resolveExternalImageDownloadSource(material(), 'foreign')).toBeNull()
expect(await resolveExternalImageDownloadSource(material({ assets: [asset('v', 'preview', 'v.webp')] }), 'v')).toBeNull()
expect(await resolveExternalImageDownloadSource(material({ materialType: '标准模型' }))).toBeNull()
})
})
describe('new lookup to existing download integration', () => {
let dir: string
beforeAll(async () => { dir = await mkdtemp(path.join(tmpdir(), 'image-resource-download-')) })
afterAll(async () => { await rm(dir, { recursive: true, force: true }) })
test.each(['zip', 'rar', 'png'])('%s is delivered byte-for-byte through the old download route, with Range support', async extension => {
const zip = new AdmZip(); zip.addFile('原图.png', Buffer.alloc(4096, 7))
const original = extension === 'zip' ? zip.toBuffer() : extension === 'rar'
? Buffer.concat([Buffer.from('526172211a070100', 'hex'), Buffer.alloc(2048, 17)])
: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j5nUAAAAASUVORK5CYII=', 'base64')
const filePath = path.join(dir, `original.${extension}`); await writeFile(filePath, original)
const row = material({ materialType: '任务-3D原图', assets: [{ ...asset('source', 'zip', `original.${extension}`), fileUrl: filePath, fileSize: BigInt(original.length) }] })
db.material.findMany.mockResolvedValue([row]); db.material.findUnique.mockResolvedValue(row)
const listed = await findExternalImageAssets(query()), url = listed.items[0].downloadUrl
expect(listed.items[0].kind).toBe(extension === 'png' ? 'image' : 'archive')
const response = await download(new NextRequest(url))
expect(response.status).toBe(200); expect(Buffer.from(await response.arrayBuffer())).toEqual(original)
const ranged = await download(new NextRequest(url, { headers: { Range: 'bytes=0-31' } }))
expect(ranged.status).toBe(206); expect(Buffer.from(await ranged.arrayBuffer())).toEqual(original.subarray(0, 32))
expect(ranged.headers.get('Content-Range')).toBe(`bytes 0-31/${original.length}`)
})
test('a missing original returns 404 even when preview files exist; it never invokes archive fallback', async () => {
const row = material({ zipFilePath: path.join(dir, 'does-not-exist.rar'), assets: [asset('v', 'preview', 'preview.webp')] })
db.material.findMany.mockResolvedValue([row]); db.material.findUnique.mockResolvedValue(row)
const listed = await findExternalImageAssets(query())
const response = await download(new NextRequest(listed.items[0].downloadUrl))
expect(response.status).toBe(404)
})
})

View File

@ -18,6 +18,7 @@ export type DownloadType =
| 'detail'
| 'psd'
| 'single'
| 'image-source'
| 'white-background-single'
| 'white-background-zip';

View File

@ -0,0 +1,186 @@
import path from 'path'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { generateDownloadUrl } from '@/lib/download-token'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { parseStandardModelWhiteBgAssetType } from '@/lib/standard-model-white-background'
// These are resource categories, not filename guesses. Models, PSD templates,
// video and task-other attachments are deliberately outside this API's scope.
export const EXTERNAL_IMAGE_RESOURCE_TYPES = {
image: ['实拍图片', '买家秀图片', '任务-实拍原图', '任务-视频截图', '任务-详情', '标准模型'],
'ai-original': ['任务-AI原图'],
'render-original': ['任务-3D原图'],
} as const
type ResourceType = keyof typeof EXTERNAL_IMAGE_RESOURCE_TYPES
export class ExternalImageAssetsError extends Error {
constructor(readonly status: number, readonly code: string, message: string) { super(message) }
}
export interface ExternalImageQuery {
type: 'sku' | 'barcode'
value: string
resourceType: ResourceType | 'all'
page: number
pageSize: number
}
export function parseExternalImageQuery(params: URLSearchParams): ExternalImageQuery {
const allowed = new Set(['sku', 'barcode', 'resourceType', 'page', 'pageSize'])
for (const key of Array.from(params.keys())) {
if (!allowed.has(key) || params.getAll(key).length !== 1) {
throw new ExternalImageAssetsError(400, 'INVALID_QUERY', '查询参数无效或重复')
}
}
const sku = (params.get('sku') || '').trim(), barcode = (params.get('barcode') || '').trim()
if (Boolean(sku) === Boolean(barcode)) throw new ExternalImageAssetsError(400, 'INVALID_QUERY', 'sku 与 barcode 必须且只能提供一个')
const value = (sku || barcode).normalize('NFC')
if (value.length > 200 || /[\x00-\x1f\x7f]/.test(value)) throw new ExternalImageAssetsError(400, 'INVALID_QUERY', '产品编号无效')
const resourceType = params.get('resourceType') || 'all'
if (resourceType !== 'all' && !Object.hasOwn(EXTERNAL_IMAGE_RESOURCE_TYPES, resourceType)) {
throw new ExternalImageAssetsError(400, 'INVALID_RESOURCE_TYPE', 'resourceType 仅支持 image、ai-original、render-original 或 all')
}
const integer = (key: string, fallback: number, max: number) => {
const raw = params.get(key)
if (raw === null) return fallback
if (!/^[1-9]\d{0,5}$/.test(raw) || Number(raw) > max) throw new ExternalImageAssetsError(400, 'INVALID_PAGINATION', '分页参数无效')
return Number(raw)
}
return { type: sku ? 'sku' : 'barcode', value: sku ? value.toUpperCase() : value,
resourceType: resourceType as ExternalImageQuery['resourceType'], page: integer('page', 1, 10000), pageSize: integer('pageSize', 20, 50) }
}
const VISIBLE: Prisma.MaterialWhereInput = { deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false }
const equal = (value: string) => ({ equals: value, mode: 'insensitive' as const })
async function resolveProduct(query: ExternalImageQuery) {
// Reuse the database's SKU and barcode bindings; never derive an SPU by
// truncating a SKU or use a broad title/name search for a product identity.
const [skus, assets] = await Promise.all([
prisma.materialSku.findMany({
where: { deletedAt: null, material: { is: VISIBLE }, ...(query.type === 'sku'
? { skuName: equal(query.value) }
: { barcodes: { some: { barcode: query.value, deletedAt: null } } }) },
select: { skuName: true, material: { select: { productModel: true } } }, take: 101,
}),
prisma.materialAsset.findMany({
where: { material: { is: VISIBLE }, ...(query.type === 'sku'
? { skuCode: equal(query.value) }
: { barcodes: { some: { barcode: query.value } } }) },
select: { skuCode: true, material: { select: { productModel: true } } }, take: 101,
}),
])
if (skus.length > 100 || assets.length > 100) throw new ExternalImageAssetsError(409, 'PRODUCT_BINDING_LIMIT', '产品绑定过多,请先核对 SKU/条码关联')
const targets = [...skus.map(s => ({ sku: s.skuName, spu: s.material.productModel })),
...assets.map(a => ({ sku: a.skuCode, spu: a.material.productModel }))]
if (!targets.length) throw new ExternalImageAssetsError(404, query.type === 'sku' ? 'SKU_NOT_FOUND' : 'BARCODE_NOT_FOUND', '未找到有效的产品编号绑定')
const spus = Array.from(new Set(targets.map(t => t.spu.trim().toUpperCase()).filter(Boolean)))
const skuNames = Array.from(new Set(targets.map(t => t.sku?.trim().toUpperCase()).filter((s): s is string => Boolean(s))))
if (spus.length !== 1 || skuNames.length > 1) throw new ExternalImageAssetsError(409, 'PRODUCT_BINDING_AMBIGUOUS', '该编号关联多个产品或规格,请先核对绑定')
return { sku: skuNames[0] || null, spu: spus[0], resourceScope: 'spu' as const }
}
const select = {
id: true, productModel: true, materialType: true, taskNo: true, categoryName: true, createdAt: true,
zipFilePath: true, zipFileName: true, zipFileSize: true,
assets: { select: { id: true, assetType: true, fileUrl: true, fileName: true, fileSize: true, sortOrder: true }, orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
skus: { where: { deletedAt: null }, select: { id: true, skuName: true } },
} satisfies Prisma.MaterialSelect
type ImageMaterial = Prisma.MaterialGetPayload<{ select: typeof select }>
export interface ExternalImageResource {
materialId: string
assetId: string | null
materialType: string
resourceType: ResourceType
taskNo: string | null
productModel: string
skuCode: string | null
matchScope: 'sku' | 'spu'
group: string | null
fileName: string
fileSize: number
kind: 'image' | 'archive' | 'source'
downloadUrl: string
uploadedAt: string
}
/** Only the new image-source token reaches this resolver; old callers do not. */
export async function resolveExternalImageDownloadSource(material: {
id: string; materialType: string; deletedAt?: Date | null; taskDetailPrivate?: boolean; isIncompleteShell?: boolean;
zipFilePath?: string; zipFileName?: string; assets?: Array<{ id: string; assetType: string; fileUrl: string; fileName?: string | null }>;
}, assetId?: string) {
const allowed = Object.values(EXTERNAL_IMAGE_RESOURCE_TYPES).flat() as readonly string[]
if (material.deletedAt || material.taskDetailPrivate || material.isIncompleteShell || !allowed.includes(material.materialType)) return null
if (assetId) {
const asset = material.assets?.find(a => a.id === assetId)
if (!asset?.fileUrl.trim()) return null
if (material.materialType === '标准模型') {
const parsed = parseStandardModelWhiteBgAssetType(asset.assetType)
if (!parsed || parsed.kind === 'zip') return null
const sku = await prisma.materialSku.findFirst({ where: { id: parsed.skuId, materialId: material.id, deletedAt: null }, select: { id: true } })
return sku ? asset : null
}
const sourceTypes = material.materialType === '任务-详情' ? ['main', 'sku', 'detail', 'all'] : ['zip']
return sourceTypes.includes(asset.assetType) ? asset : null
}
if (['标准模型', '任务-详情'].includes(material.materialType) || !material.zipFilePath?.trim()) return null
return { fileUrl: material.zipFilePath, fileName: material.zipFileName || leaf(material.zipFilePath) }
}
function leaf(value: string): string {
try { return decodeURIComponent(path.posix.basename(new URL(value).pathname)) } catch { return path.posix.basename(value.replace(/\\/g, '/')) }
}
function kind(fileName: string, storedPath: string): ExternalImageResource['kind'] {
const extension = (path.extname(fileName) || path.extname(leaf(storedPath))).toLowerCase()
if (['.zip', '.rar'].includes(extension)) return 'archive'
if (/^\.(png|jpe?g|webp|gif|bmp|tiff?|avif|heic|heif)$/.test(extension)) return 'image'
return 'source'
}
export async function findExternalImageAssets(query: ExternalImageQuery) {
const resolution = await resolveProduct(query)
const types = query.resourceType === 'all' ? Object.values(EXTERNAL_IMAGE_RESOURCE_TYPES).flat() : [...EXTERNAL_IMAGE_RESOURCE_TYPES[query.resourceType]]
const where: Prisma.MaterialWhereInput = { ...VISIBLE, productModel: equal(resolution.spu), materialType: { in: types } }
const [totalMaterials, materials] = await Promise.all([
prisma.material.count({ where }),
prisma.material.findMany({ where, select, orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
])
const baseUrl = getPublicAppOrigin(), items: ExternalImageResource[] = []
const issues: Array<{ materialId: string; code: string; message: string }> = []
const append = (m: ImageMaterial, source: { id?: string; fileUrl: string; fileName: string | null; fileSize: bigint },
group: string | null = null, skuCode: string | null = null) => {
if (!source.fileUrl.trim()) return
const resourceType = (Object.entries(EXTERNAL_IMAGE_RESOURCE_TYPES).find(([, values]) => (values as readonly string[]).includes(m.materialType))?.[0] || 'image') as ResourceType
const size = Number(source.fileSize)
items.push({ materialId: m.id, assetId: source.id || null, materialType: m.materialType, resourceType, taskNo: m.taskNo,
productModel: m.productModel, skuCode, matchScope: skuCode ? 'sku' : 'spu', group,
fileName: leaf(source.fileName || source.fileUrl), fileSize: Number.isSafeInteger(size) && size >= 0 ? size : 0,
kind: kind(source.fileName || '', source.fileUrl),
downloadUrl: generateDownloadUrl(baseUrl, { materialId: m.id, downloadType: 'image-source', ...(source.id ? { assetId: source.id } : {}) }),
uploadedAt: m.createdAt.toISOString() })
}
for (const m of materials) {
const countBefore = items.length
if (m.materialType === '标准模型') {
// Standard-model source ZIPs contain 3D models, not image originals.
// Only return white-background image assets belonging to the exact SKU.
const ids = new Set(m.skus.filter(s => s.skuName.trim().toUpperCase() === resolution.sku).map(s => s.id))
for (const a of m.assets) {
const parsed = parseStandardModelWhiteBgAssetType(a.assetType)
if (parsed && parsed.kind !== 'zip' && ids.has(parsed.skuId)) append(m, a, 'white-background', resolution.sku)
}
} else if (m.materialType === '任务-详情') {
const groups = m.assets.filter(a => ['main', 'sku', 'detail'].includes(a.assetType))
for (const a of groups) append(m, a, a.assetType)
if (!groups.length) for (const a of m.assets.filter(a => a.assetType === 'all')) append(m, a, 'all')
} else {
const archives = m.assets.filter(a => a.assetType === 'zip' && a.fileUrl.trim())
if (archives.length) for (const a of archives) append(m, a)
else if (m.zipFilePath.trim()) append(m, { fileUrl: m.zipFilePath, fileName: m.zipFileName, fileSize: m.zipFileSize })
}
if (items.length === countBefore) issues.push({ materialId: m.id, code: 'SOURCE_NOT_AVAILABLE', message: '暂无对应的原始资源,不返回预览图代替' })
}
return { query: { type: query.type, value: query.value, resourceType: query.resourceType }, resolution, count: items.length, items, issues,
pagination: { page: query.page, pageSize: query.pageSize, totalMaterials, hasMore: query.page * query.pageSize < totalMaterials } }
}