feat(api): query original image resources by SKU or barcode
This commit is contained in:
parent
46e8e02779
commit
493dd16d83
14
app/api/external/download/route.ts
vendored
14
app/api/external/download/route.ts
vendored
@ -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
34
app/api/external/image-assets/route.ts
vendored
Normal 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 })
|
||||
}
|
||||
}
|
||||
80
docs/图片原始资源查询接口.md
Normal file
80
docs/图片原始资源查询接口.md
Normal 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`。
|
||||
162
lib/__tests__/external-image-assets.test.ts
Normal file
162
lib/__tests__/external-image-assets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -18,6 +18,7 @@ export type DownloadType =
|
||||
| 'detail'
|
||||
| 'psd'
|
||||
| 'single'
|
||||
| 'image-source'
|
||||
| 'white-background-single'
|
||||
| 'white-background-zip';
|
||||
|
||||
|
||||
186
lib/external-image-assets.ts
Normal file
186
lib/external-image-assets.ts
Normal 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 } }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user