diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 455675c..243e7b5 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -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, diff --git a/app/api/external/image-assets/route.ts b/app/api/external/image-assets/route.ts new file mode 100644 index 0000000..b1d2aac --- /dev/null +++ b/app/api/external/image-assets/route.ts @@ -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 }) + } +} diff --git a/docs/图片原始资源查询接口.md b/docs/图片原始资源查询接口.md new file mode 100644 index 0000000..71dc595 --- /dev/null +++ b/docs/图片原始资源查询接口.md @@ -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`。 diff --git a/lib/__tests__/external-image-assets.test.ts b/lib/__tests__/external-image-assets.test.ts new file mode 100644 index 0000000..6976a40 --- /dev/null +++ b/lib/__tests__/external-image-assets.test.ts @@ -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 = {}) => ({ 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) + }) +}) diff --git a/lib/download-token.ts b/lib/download-token.ts index 4c45d8c..9b8299d 100644 --- a/lib/download-token.ts +++ b/lib/download-token.ts @@ -18,6 +18,7 @@ export type DownloadType = | 'detail' | 'psd' | 'single' + | 'image-source' | 'white-background-single' | 'white-background-zip'; diff --git a/lib/external-image-assets.ts b/lib/external-image-assets.ts new file mode 100644 index 0000000..64a2019 --- /dev/null +++ b/lib/external-image-assets.ts @@ -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 } } +}