feat(api): add read-only certificate report lookup and original downloads
This commit is contained in:
parent
4f123ea272
commit
c51f567539
30
app/api/external/certificate-reports/route.ts
vendored
Normal file
30
app/api/external/certificate-reports/route.ts
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getApiKeyFromRequest, validateApiKey, logApiCall, getClientIp } from '@/lib/external-api'
|
||||
import { ExternalCertificateReportError, findExternalCertificateReports, parseCertificateReportQuery } from '@/lib/external-certificate-reports'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
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, 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 = parseCertificateReportQuery(request.nextUrl.searchParams)
|
||||
const data = await findExternalCertificateReports(query)
|
||||
status = 200
|
||||
return NextResponse.json({ success: true, data }, { headers })
|
||||
} catch (error) {
|
||||
status = error instanceof ExternalCertificateReportError ? error.status : 500
|
||||
if (!(error instanceof ExternalCertificateReportError)) console.error('[external/certificate-reports] lookup failed', { traceId })
|
||||
return NextResponse.json({ success: false, error: error instanceof ExternalCertificateReportError
|
||||
? { code: error.code, message: error.message } : { code: 'SERVER_ERROR', message: '服务器内部错误' } }, { status, headers })
|
||||
} finally {
|
||||
if (keyId) await logApiCall({ apiKeyId: keyId, endpoint: '/api/external/certificate-reports', method: 'GET', queryType: 'certificate-report',
|
||||
statusCode: status, duration: Date.now() - start, ipAddress: getClientIp(request), userAgent: request.headers.get('user-agent') || undefined })
|
||||
}
|
||||
}
|
||||
14
app/api/external/download/route.ts
vendored
14
app/api/external/download/route.ts
vendored
@ -18,6 +18,7 @@ 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 { resolveCertificateReportSource } from '@/lib/external-certificate-reports';
|
||||
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
|
||||
import { createArchiveDownloadStream } from '@/lib/archive-download-stream';
|
||||
import {
|
||||
@ -69,6 +70,19 @@ export async function GET(request: NextRequest) {
|
||||
return errorResponse(API_ERROR.TASK_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Additive report branch: never claim tasks or substitute report previews/ZIPs.
|
||||
if (downloadType === 'certificate-report-source') {
|
||||
const source = resolveCertificateReportSource(material, assetId);
|
||||
if (!source) return NextResponse.json(
|
||||
{ success: false, error: { code: 'REPORT_FILE_NOT_FOUND', message: '报告文件不存在或不可访问' } },
|
||||
{ status: 404, headers: { 'Cache-Control': 'private, no-store' } },
|
||||
);
|
||||
const response = await downloadSingleFile(source, rangeHeader);
|
||||
response.headers.set('Cache-Control', 'private, no-store');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
return response;
|
||||
}
|
||||
|
||||
// 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') {
|
||||
|
||||
109
docs/外部接口文档.md
109
docs/外部接口文档.md
@ -5,6 +5,7 @@
|
||||
> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名
|
||||
> 资源主域名:`https://s3.jiansun.vip`
|
||||
> 本次图片资源对接:[按 SKU/条码查询图片原始资源](#image-assets)。本文件已包含完整说明,可直接转发给外部系统负责人;真实 API Key 请单独提供。
|
||||
> 检测报告对接:[组合查询检测报告及逐份 PDF/图片下载](#certificate-reports)。此入口独立新增,不改变其他接口。
|
||||
|
||||
## 概述
|
||||
|
||||
@ -96,6 +97,114 @@ X-API-Key: your-api-key-here
|
||||
|
||||
## 接口列表
|
||||
|
||||
<a id="certificate-reports"></a>
|
||||
|
||||
### 检测报告:组合查询与逐文件下载
|
||||
|
||||
**请求:** `GET https://sc.jiansun.vip/api/external/certificate-reports`
|
||||
|
||||
使用现有素材平台 API Key,请求头 `X-API-Key: <你的Key>`。不接受 URL 中的 Key。接口只查询 `证书-检测报告`,排除回收站、未完成上传空壳和私密素材;不领取任务、不修改素材、下载次数或任务状态。鉴权最近使用时间和 API 调用审计仍按平台原流程记录。
|
||||
|
||||
所有条件均可选,组合时为 **AND**。不填条件时分页返回全部可访问报告;推荐至少指定品类、报告名称或证书编号。参数名区分大小写;未知、重复、空值和非法参数返回 400。
|
||||
|
||||
| 参数 | 匹配方式/可选值 |
|
||||
|---|---|
|
||||
| `search` | 报告名称/检测对象的关键词,忽略大小写;不搜索 PDF 正文或推断检测项目 |
|
||||
| `reportKind` | 精确:`成品`、`原材料` |
|
||||
| `productCategory` | 品类名称精确匹配,使用平台已有品类名称 |
|
||||
| `platform` | 精确:`大货`、`品牌`;“品牌”不是具体品牌名称 |
|
||||
| `spu` | SPU 精确匹配,忽略大小写;不会猜测 SKU/条码与报告的关系 |
|
||||
| `certificateNumber` | 证书编号精确匹配,忽略大小写 |
|
||||
| `materialId` | 素材 UUID 精确匹配;仍可与其他条件组合 |
|
||||
| `testedFrom` / `testedTo` | 检测日期起止,`YYYY-MM-DD`,含边界 |
|
||||
| `expiresFrom` / `expiresTo` | 到期日期起止,`YYYY-MM-DD`,含边界 |
|
||||
| `expiryStatus` | 默认 `all`;`unexpired` 未过期、`expired` 已过期、`unknown` 未录入到期日 |
|
||||
| `fileKind` | 默认 `all`;`pdf`、`image`。指定后只返回含该类报告资产的报告及该类文件 |
|
||||
| `page` | 默认 1,范围 1~10000 |
|
||||
| `pageSize` | 默认 20,范围 1~50;按报告分页,不按文件分页 |
|
||||
|
||||
文本条件最多 200 字符。日期必须真实有效,起始日不得晚于结束日。`expiryStatus=unknown` 不能与到期日区间同时使用。
|
||||
|
||||
到期状态以平台 **UTC+8** 当天计算,到期当天仍为 `unexpired`;`data.asOfDate` 返回本次计算日期。此状态只表示登记日期,不能代替检测项目、品牌、委托主体或实际使用资格核对。缺失的历史日期返回 `null`/`unknown`,不会自动补一年。日期区间不匹配未录入日期的报告。
|
||||
|
||||
查询示例(生产代码应使用 HTTP 客户端对中文和参数进行 URL 编码):
|
||||
|
||||
```bash
|
||||
curl --get 'https://sc.jiansun.vip/api/external/certificate-reports' \
|
||||
--header 'X-API-Key: <你的Key>' \
|
||||
--data-urlencode 'search=板材' \
|
||||
--data-urlencode 'reportKind=原材料' \
|
||||
--data-urlencode 'platform=大货' \
|
||||
--data-urlencode 'testedFrom=2026-01-01' \
|
||||
--data-urlencode 'expiryStatus=unexpired' \
|
||||
--data-urlencode 'pageSize=20'
|
||||
```
|
||||
|
||||
返回示例(编号、文件大小、品类及日期仅作格式示意):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"query": { "expiryStatus": "unexpired", "fileKind": "all", "search": "板材", "reportKind": "原材料", "platform": "大货", "testedFrom": "2026-01-01", "page": 1, "pageSize": 20 },
|
||||
"asOfDate": "2026-10-16",
|
||||
"items": [{
|
||||
"materialId": "02000000-0000-4000-8000-000000000001",
|
||||
"materialType": "证书-检测报告",
|
||||
"reportTitle": "板材检测报告",
|
||||
"reportKind": "原材料",
|
||||
"spu": null,
|
||||
"productCategory": "板材",
|
||||
"platform": "大货",
|
||||
"certificateNumber": "EXAMPLE-001",
|
||||
"testedOn": "2026-09-01",
|
||||
"expiresOn": "2027-09-01",
|
||||
"expiryStatus": "unexpired",
|
||||
"certificateUsageInstructions": null,
|
||||
"reportUsageRules": "使用前核对报告项目、产品与适用范围。",
|
||||
"createdAt": "2026-09-02T01:00:00.000Z",
|
||||
"updatedAt": "2026-09-02T01:00:00.000Z",
|
||||
"files": [
|
||||
{ "assetId": "file-example-pdf", "fileKind": "pdf", "fileName": "检测报告.pdf", "fileSize": 1234567, "sortOrder": 0, "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=<签名Token>" },
|
||||
{ "assetId": "file-example-image", "fileKind": "image", "fileName": "报告附图.jpg", "fileSize": 234567, "sortOrder": 1, "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=<另一个签名Token>" }
|
||||
],
|
||||
"issues": []
|
||||
}],
|
||||
"pagination": { "page": 1, "pageSize": 20, "totalReports": 1, "hasMore": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`files` 按 `sortOrder`、资产 ID 升序排列;报告按更新时间倒序、素材 ID 升序排列。查询期间的数量与列表使用同一数据库快照,但翻页期间新增或更新报告仍可能改变位置,持续同步的调用方应按 `materialId` 去重。
|
||||
|
||||
**获取某份报告:** 将其 `materialId` 传回同一接口。没有命中时返回 `200`、`items: []`、`totalReports: 0`,不区分“不存在”与“不可访问”。
|
||||
|
||||
**逐份下载:** 对每个 `files[].downloadUrl` 直接 GET,无需再传 API Key;下载链接本身即凭证,不要公开。支持 `Range: bytes=0-1023`,有效区间返回 206,越界返回 416。返回原始 PDF 或 JPEG/PNG/WebP 文件字节,不拆 PDF 页、不重新压缩、不打包、不返回封面代替。下载时再次检查素材可见状态、文件归属和报告资产类型;文件被移除或素材回收后,旧链接不能继续获取该资源。
|
||||
|
||||
沿用现有签名下载链接有效期(约 100 年);停用 API Key 会阻止新查询,但不会自动撤销已签发链接。文件下载响应为 `private, no-store`,不能据此理解为旧链接自动过期。
|
||||
|
||||
`reportTitle`、`reportKind`、日期、编号和使用说明可能因历史未录入而为 `null`。一份报告可以包含多个 PDF 与图片;`spu` 可为空,特别是原材料报告。接口没有 SKU/条码搜索条件,不将同品类报告擅自绑定到某个产品。
|
||||
|
||||
没有合规报告文件时,`files: []`,`issues` 返回 `REPORT_FILES_NOT_AVAILABLE`,不会用 ZIP、RAR、预览图或其他类型附件代替。`fileSize` 单位为字节,超出安全整数范围时为 `null`。
|
||||
|
||||
错误响应统一为:
|
||||
|
||||
```json
|
||||
{ "success": false, "error": { "code": "INVALID_QUERY", "message": "查询参数未知或重复" } }
|
||||
```
|
||||
|
||||
| HTTP | 错误码 | 含义 |
|
||||
|---|---|---|
|
||||
| 400 | `INVALID_QUERY` | 查询条件、日期、分页、未知或重复参数错误 |
|
||||
| 401 | `INVALID_API_KEY` | 缺少、错误或已停用的 API Key |
|
||||
| 404 | `REPORT_FILE_NOT_FOUND` / `FILE_NOT_FOUND` | 下载时报告文件已不可访问或不存在;素材已删除时也可能沿用通用素材不存在错误 |
|
||||
| 416 | 无 JSON 错误码 | Range 越界,见 `Content-Range` |
|
||||
| 500 | `SERVER_ERROR` / `DOWNLOAD_FAILED` | 查询或下载异常 |
|
||||
|
||||
查询响应带 `X-Trace-Id`,报错时可提供该编号排查。仅新增查询路由和专用下载 Token 类型,旧任务查询、图片原始资源查询、白底图、视频和既有下载 Token 的处理分支保持原行为。
|
||||
|
||||
---
|
||||
|
||||
### 脉象平台:按 SPU 查询买家秀图片和视频下载链接
|
||||
|
||||
脉象平台传入搜索关键词,通常是 SPU,素材平台按首页搜索栏同口径模糊匹配,并返回命中结果中的全部 `买家秀图片` 和全部视频类素材的预览地址与正式下载链接。
|
||||
|
||||
126
lib/__tests__/external-certificate-reports.test.ts
Normal file
126
lib/__tests__/external-certificate-reports.test.ts
Normal file
@ -0,0 +1,126 @@
|
||||
/** @jest-environment node */
|
||||
import { NextRequest } from 'next/server'
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
import { GET } from '@/app/api/external/certificate-reports/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, generateDownloadUrl } from '@/lib/download-token'
|
||||
import { parseCertificateReportQuery as parse, buildCertificateReportWhere as where, certificateToday, certificateExpiryStatus, findExternalCertificateReports, resolveCertificateReportSource } from '@/lib/external-certificate-reports'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { material: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() }, $transaction: jest.fn() } }))
|
||||
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.jiansun.vip' }))
|
||||
jest.mock('@/lib/external-api', () => ({ ...jest.requireActual('@/lib/external-api'), validateApiKey: jest.fn(), logApiCall: jest.fn().mockResolvedValue(undefined) }))
|
||||
const db = prisma as unknown as { material: { findMany: jest.Mock; count: jest.Mock; findUnique: jest.Mock; update: jest.Mock; updateMany: jest.Mock }; $transaction: jest.Mock }
|
||||
const id = '02000000-0000-4000-8000-000000000001'
|
||||
const a = (assetType = 'report-pdf', fileName = '报告.pdf', fileUrl = '/uploads/report.pdf') => ({ id: 'file-1', assetType, fileName, fileUrl, fileSize: BigInt(100), sortOrder: 0 })
|
||||
const material = (extra: Record<string, unknown> = {}) => ({ id, materialType: '证书-检测报告', reportKind: '原材料', reportTitle: '板材报告', productModel: '',
|
||||
productCategoryName: '板材', platformName: '大货', certificateNumber: 'CERT-01', certificateTestedOn: new Date('2026-01-01Z'), certificateExpiresOn: new Date('2027-01-01Z'),
|
||||
certificateUsageInstructions: null, reportUsageRules: '核对检测项目和使用范围', createdAt: new Date('2026-01-02Z'), updatedAt: new Date('2026-01-03Z'), taskStatus: 'completed', assets: [a()], ...extra })
|
||||
const q = (value = '') => parse(new URLSearchParams(value))
|
||||
const request = (value = '', key = 'test') => new NextRequest('https://sc.jiansun.vip/api/external/certificate-reports?' + value, { headers: key ? { 'X-API-Key': key } : {} })
|
||||
const oldSecret = process.env.DOWNLOAD_TOKEN_SECRET
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks(); process.env.DOWNLOAD_TOKEN_SECRET = 'local-certificate-report-test-secret'
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue({ id: 'key-1' })
|
||||
db.material.count.mockResolvedValue(1); db.material.findMany.mockResolvedValue([material()]); db.material.findUnique.mockResolvedValue(material())
|
||||
db.$transaction.mockImplementation((operations: unknown[]) => Promise.all(operations))
|
||||
})
|
||||
afterAll(() => { if (oldSecret === undefined) delete process.env.DOWNLOAD_TOKEN_SECRET; else process.env.DOWNLOAD_TOKEN_SECRET = oldSecret })
|
||||
|
||||
test.each(['sku=A', 'barcode=A', 'search=', 'search=%00', 'page=0', 'pageSize=51', 'page=1&page=2', 'reportKind=其他', 'platform=顾家',
|
||||
'fileKind=zip', 'expiryStatus=valid', 'testedFrom=2026-02-30', 'testedTo=2026-10-01&testedFrom=2026-10-02',
|
||||
'expiresFrom=2027-01-01&expiresTo=2026-01-01', 'expiryStatus=unknown&expiresFrom=2026-01-01', 'materialId=not-uuid'])('rejects invalid condition %s', value => {
|
||||
expect(() => q(value)).toThrow()
|
||||
})
|
||||
test('combines all report fields, inclusive date ranges and status without overwriting conditions', () => {
|
||||
const query = q(`search=板材&reportKind=成品&platform=品牌&productCategory=桌子&spu=spu-01&certificateNumber=Cert-01&materialId=${id}&testedFrom=2026-01-01&testedTo=2026-10-01&expiresFrom=2026-01-01&expiresTo=2027-01-01&expiryStatus=unexpired&fileKind=pdf&page=2&pageSize=5`)
|
||||
const actual = where(query, new Date('2026-10-16Z'))
|
||||
expect(actual).toMatchObject({ materialType: '证书-检测报告', deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false, id,
|
||||
reportTitle: { contains: '板材', mode: 'insensitive' }, reportKind: '成品', platformName: '品牌', productCategoryName: '桌子',
|
||||
productModel: { equals: 'spu-01', mode: 'insensitive' }, certificateNumber: { equals: 'Cert-01', mode: 'insensitive' },
|
||||
certificateTestedOn: { gte: new Date('2026-01-01Z'), lte: new Date('2026-10-01Z') },
|
||||
AND: [{ certificateExpiresOn: { gte: new Date('2026-01-01Z'), lte: new Date('2027-01-01Z') } }, { certificateExpiresOn: { gte: new Date('2026-10-16Z') } }],
|
||||
assets: { some: { assetType: { in: ['report-pdf'] } } } })
|
||||
})
|
||||
test('expiry date is inclusive through the UTC+8 business day and missing dates remain unknown', () => {
|
||||
expect(certificateToday(new Date('2026-10-15T15:59:59Z')).toISOString()).toBe('2026-10-15T00:00:00.000Z')
|
||||
const today = certificateToday(new Date('2026-10-15T16:00:00Z'))
|
||||
expect(certificateExpiryStatus(new Date('2026-10-16Z'), today)).toBe('unexpired')
|
||||
expect(certificateExpiryStatus(new Date('2026-10-15Z'), today)).toBe('expired')
|
||||
expect(certificateExpiryStatus(null, today)).toBe('unknown')
|
||||
expect(where(q('expiryStatus=unknown')).certificateExpiresOn).toBeNull()
|
||||
})
|
||||
test('auth precedes lookup and query validation; credentials in URL are not accepted', async () => {
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue(null)
|
||||
expect((await GET(request('apiKey=secret', ''))).status).toBe(401)
|
||||
expect(validateApiKey).toHaveBeenCalledWith(null); expect(db.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
test('returns raw-material report without SPU, original files only, metadata and isolated tokens; does not mutate task state', async () => {
|
||||
db.material.findMany.mockResolvedValue([material({ assets: [a(), { ...a('report-image', '报告.png'), id: 'file-2', sortOrder: 1 }, a('preview', 'preview.jpg'), a('zip', 'all.zip'), a('report-pdf', 'bad.exe')] })])
|
||||
const response = await GET(request('reportKind=原材料'))
|
||||
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toContain('no-store')
|
||||
const data = (await response.json()).data, item = data.items[0]
|
||||
expect(item).toMatchObject({ spu: null, reportKind: '原材料', testedOn: '2026-01-01', expiresOn: '2027-01-01', reportUsageRules: '核对检测项目和使用范围' })
|
||||
expect(item.files).toHaveLength(2)
|
||||
expect(verifyDownloadToken(new URL(item.files[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
||||
expect(JSON.stringify(data)).not.toContain('/uploads/'); expect(item.files.map((f: { fileKind: string }) => f.fileKind)).toEqual(['pdf', 'image'])
|
||||
expect(db.material.update).not.toHaveBeenCalled(); expect(db.material.updateMany).not.toHaveBeenCalled()
|
||||
expect(logApiCall).toHaveBeenCalledWith(expect.objectContaining({ endpoint: '/api/external/certificate-reports', statusCode: 200 }))
|
||||
})
|
||||
test('uses bounded report pagination and consistent count/list snapshot; an unknown ID returns an empty list', async () => {
|
||||
db.material.findMany.mockResolvedValue([]); db.material.count.mockResolvedValue(0)
|
||||
const result = await findExternalCertificateReports(q(`materialId=${id}&page=2&pageSize=5`))
|
||||
expect(result).toMatchObject({ items: [], pagination: { totalReports: 0, hasMore: false } })
|
||||
expect(db.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 5, take: 5, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }] }))
|
||||
expect(db.$transaction.mock.calls[0][1]).toEqual({ isolationLevel: 'RepeatableRead' })
|
||||
})
|
||||
test('reports missing originals explicitly and preserves null legacy dates', async () => {
|
||||
db.material.findMany.mockResolvedValue([material({ assets: [], certificateTestedOn: null, certificateExpiresOn: null })])
|
||||
expect((await findExternalCertificateReports(q())).items[0]).toMatchObject({ testedOn: null, expiresOn: null, expiryStatus: 'unknown', files: [], issues: [{ code: 'REPORT_FILES_NOT_AVAILABLE' }] })
|
||||
})
|
||||
test('invalid queries and database failures are safely reported', async () => {
|
||||
expect((await GET(request('pageSize=100'))).status).toBe(400)
|
||||
expect(db.$transaction).not.toHaveBeenCalled()
|
||||
db.$transaction.mockRejectedValue(new Error('postgres://secret@private'))
|
||||
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const response = await GET(request())
|
||||
expect(response.status).toBe(500); expect(JSON.stringify(await response.json())).not.toContain('secret')
|
||||
log.mockRestore()
|
||||
})
|
||||
test.each([{ deletedAt: new Date() }, { taskDetailPrivate: true }, { isIncompleteShell: true }, { materialType: '任务-AI原图' }])('rechecks download visibility %j', extra => {
|
||||
expect(resolveCertificateReportSource(material(extra), 'file-1')).toBeNull()
|
||||
})
|
||||
test('rejects assets of another report, ZIPs and preview substitutes', () => {
|
||||
expect(resolveCertificateReportSource(material(), 'foreign')).toBeNull()
|
||||
expect(resolveCertificateReportSource(material(), undefined)).toBeNull()
|
||||
expect(resolveCertificateReportSource(material({ assets: [a('preview', 'preview.png')] }), 'file-1')).toBeNull()
|
||||
expect(resolveCertificateReportSource(material({ assets: [a('zip', 'source.zip')] }), 'file-1')).toBeNull()
|
||||
})
|
||||
|
||||
describe('report originals through existing streaming downloader', () => {
|
||||
let dir: string
|
||||
beforeAll(async () => { dir = await mkdtemp(path.join(tmpdir(), 'certificate-api-test-')) })
|
||||
afterAll(async () => { await rm(dir, { recursive: true, force: true }) })
|
||||
test.each(['pdf', 'png'])('delivers exact %s bytes, Range and invalid-range response', async kind => {
|
||||
const bytes = kind === 'pdf' ? Buffer.from('%PDF-1.4\n' + 'report '.repeat(100)) : Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j5nUAAAAASUVORK5CYII=', 'base64')
|
||||
const file = path.join(dir, '原文件.' + kind); await writeFile(file, bytes)
|
||||
db.material.findUnique.mockResolvedValue(material({ assets: [a(kind === 'pdf' ? 'report-pdf' : 'report-image', '原文件.' + kind, file)] }))
|
||||
const url = generateDownloadUrl('https://sc.jiansun.vip', { materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
||||
const full = await download(new NextRequest(url))
|
||||
expect(full.status).toBe(200); expect(Buffer.from(await full.arrayBuffer())).toEqual(bytes)
|
||||
expect(full.headers.get('content-type')).toBe(kind === 'pdf' ? 'application/pdf' : 'image/png')
|
||||
expect(full.headers.get('cache-control')).toContain('no-store')
|
||||
const part = await download(new NextRequest(url, { headers: { Range: 'bytes=0-15' } }))
|
||||
expect(part.status).toBe(206); expect(Buffer.from(await part.arrayBuffer())).toEqual(bytes.subarray(0, 16))
|
||||
const invalid = await download(new NextRequest(url, { headers: { Range: 'bytes=99999-' } }))
|
||||
expect(invalid.status).toBe(416)
|
||||
})
|
||||
test('a previously issued report token cannot download a different asset type', async () => {
|
||||
db.material.findUnique.mockResolvedValue(material({ assets: [a('zip', 'all.zip')] }))
|
||||
const url = generateDownloadUrl('https://sc.jiansun.vip', { materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
||||
expect((await download(new NextRequest(url))).status).toBe(404)
|
||||
})
|
||||
})
|
||||
@ -19,6 +19,7 @@ export type DownloadType =
|
||||
| 'psd'
|
||||
| 'single'
|
||||
| 'image-source'
|
||||
| 'certificate-report-source'
|
||||
| 'white-background-single'
|
||||
| 'white-background-zip';
|
||||
|
||||
|
||||
125
lib/external-certificate-reports.ts
Normal file
125
lib/external-certificate-reports.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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 { CERTIFICATE_REPORT_MATERIAL_TYPE, parseCertificateDate } from '@/lib/certificate-report'
|
||||
|
||||
export class ExternalCertificateReportError extends Error {
|
||||
constructor(readonly status: number, readonly code: string, message: string) { super(message) }
|
||||
}
|
||||
|
||||
export interface CertificateReportQuery {
|
||||
search?: string; reportKind?: '成品' | '原材料'; productCategory?: string; platform?: '大货' | '品牌'
|
||||
spu?: string; certificateNumber?: string; materialId?: string
|
||||
testedFrom?: string; testedTo?: string; expiresFrom?: string; expiresTo?: string
|
||||
expiryStatus: 'all' | 'unexpired' | 'expired' | 'unknown'
|
||||
fileKind: 'all' | 'pdf' | 'image'; page: number; pageSize: number
|
||||
}
|
||||
const keys = ['search', 'reportKind', 'productCategory', 'platform', 'spu', 'certificateNumber', 'materialId',
|
||||
'testedFrom', 'testedTo', 'expiresFrom', 'expiresTo', 'expiryStatus', 'fileKind', 'page', 'pageSize']
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
export function parseCertificateReportQuery(params: URLSearchParams): CertificateReportQuery {
|
||||
const invalid = (message: string): never => { throw new ExternalCertificateReportError(400, 'INVALID_QUERY', message) }
|
||||
for (const key of Array.from(params.keys())) if (!keys.includes(key) || params.getAll(key).length !== 1) invalid('查询参数未知或重复')
|
||||
const result: Record<string, string | number> = {}
|
||||
for (const key of keys.filter(k => !['page', 'pageSize'].includes(k))) {
|
||||
if (!params.has(key)) continue
|
||||
const value = params.get(key)!.trim().normalize('NFC')
|
||||
if (!value || value.length > 200 || /[\x00-\x1f\x7f]/.test(value)) invalid(`${key} 无效或为空`)
|
||||
result[key] = value
|
||||
}
|
||||
const options = { reportKind: ['成品', '原材料'], platform: ['大货', '品牌'],
|
||||
expiryStatus: ['all', 'unexpired', 'expired', 'unknown'], fileKind: ['all', 'pdf', 'image'] }
|
||||
for (const [key, values] of Object.entries(options)) if (result[key] && !values.includes(String(result[key]))) invalid(`${key} 可选值:${values.join('、')}`)
|
||||
if (result.materialId && !uuid.test(String(result.materialId))) invalid('materialId 必须为素材 UUID')
|
||||
for (const key of ['testedFrom', 'testedTo', 'expiresFrom', 'expiresTo']) if (result[key] && !parseCertificateDate(result[key])) invalid(`${key} 必须为有效的 YYYY-MM-DD 日期`)
|
||||
for (const [from, to] of [['testedFrom', 'testedTo'], ['expiresFrom', 'expiresTo']]) if (result[from] && result[to] && result[from] > result[to]) invalid(`${from} 不能晚于 ${to}`)
|
||||
if (result.expiryStatus === 'unknown' && (result.expiresFrom || result.expiresTo)) invalid('到期日期未知时不能同时指定到期日期区间')
|
||||
for (const [key, fallback, maximum] of [['page', 1, 10000], ['pageSize', 20, 50]] as const) {
|
||||
const raw = params.get(key)
|
||||
if (raw !== null && (!/^[1-9]\d{0,5}$/.test(raw) || Number(raw) > maximum)) invalid(`${key} 超出范围`)
|
||||
result[key] = raw === null ? fallback : Number(raw)
|
||||
}
|
||||
return { expiryStatus: 'all', fileKind: 'all', ...result } as unknown as CertificateReportQuery
|
||||
}
|
||||
|
||||
/** Database dates are UTC midnight; the platform's business day is UTC+8. */
|
||||
export function certificateToday(now = new Date()): Date {
|
||||
return new Date(new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + 'T00:00:00.000Z')
|
||||
}
|
||||
export function certificateExpiryStatus(expires: Date | null, today: Date): 'unexpired' | 'expired' | 'unknown' {
|
||||
return !expires ? 'unknown' : expires < today ? 'expired' : 'unexpired'
|
||||
}
|
||||
const assetTypes = (kind: CertificateReportQuery['fileKind']) => kind === 'pdf' ? ['report-pdf'] : kind === 'image' ? ['report-image'] : ['report-pdf', 'report-image']
|
||||
export function buildCertificateReportWhere(query: CertificateReportQuery, today = certificateToday()): Prisma.MaterialWhereInput {
|
||||
const where: Prisma.MaterialWhereInput = { materialType: CERTIFICATE_REPORT_MATERIAL_TYPE, deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false }
|
||||
if (query.materialId) where.id = query.materialId
|
||||
if (query.search) where.reportTitle = { contains: query.search, mode: 'insensitive' }
|
||||
if (query.reportKind) where.reportKind = query.reportKind
|
||||
if (query.productCategory) where.productCategoryName = query.productCategory
|
||||
if (query.platform) where.platformName = query.platform
|
||||
if (query.spu) where.productModel = { equals: query.spu, mode: 'insensitive' }
|
||||
if (query.certificateNumber) where.certificateNumber = { equals: query.certificateNumber, mode: 'insensitive' }
|
||||
if (query.testedFrom || query.testedTo) where.certificateTestedOn = { ...(query.testedFrom ? { gte: parseCertificateDate(query.testedFrom)! } : {}), ...(query.testedTo ? { lte: parseCertificateDate(query.testedTo)! } : {}) }
|
||||
const dates: Prisma.MaterialWhereInput[] = []
|
||||
if (query.expiresFrom || query.expiresTo) dates.push({ certificateExpiresOn: { ...(query.expiresFrom ? { gte: parseCertificateDate(query.expiresFrom)! } : {}), ...(query.expiresTo ? { lte: parseCertificateDate(query.expiresTo)! } : {}) } })
|
||||
if (query.expiryStatus === 'expired') dates.push({ certificateExpiresOn: { lt: today } })
|
||||
if (query.expiryStatus === 'unexpired') dates.push({ certificateExpiresOn: { gte: today } })
|
||||
if (query.expiryStatus === 'unknown') where.certificateExpiresOn = null
|
||||
if (dates.length) where.AND = dates
|
||||
if (query.fileKind !== 'all') where.assets = { some: { assetType: { in: assetTypes(query.fileKind) } } }
|
||||
return where
|
||||
}
|
||||
|
||||
type ReportFile = { id: string; assetType: string; fileUrl: string; fileName?: string | null }
|
||||
function leaf(value: string): string {
|
||||
try { return decodeURIComponent(path.posix.basename(new URL(value).pathname)) } catch { return path.posix.basename(value.replace(/\\/g, '/')) }
|
||||
}
|
||||
export function reportFileKind(asset: ReportFile): 'pdf' | 'image' | null {
|
||||
if (!asset.fileUrl.trim()) return null
|
||||
const ext = path.extname(asset.fileName || leaf(asset.fileUrl)).toLowerCase()
|
||||
if (asset.assetType === 'report-pdf' && ext === '.pdf') return 'pdf'
|
||||
if (asset.assetType === 'report-image' && ['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) return 'image'
|
||||
return null
|
||||
}
|
||||
/** Only new report-source tokens use this resolver. Recheck current visibility and membership. */
|
||||
export function resolveCertificateReportSource(material: { materialType: string; deletedAt?: Date | null; isIncompleteShell?: boolean; taskDetailPrivate?: boolean; assets?: ReportFile[] }, assetId?: string) {
|
||||
if (material.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE || material.deletedAt || material.isIncompleteShell || material.taskDetailPrivate || !assetId) return null
|
||||
const asset = material.assets?.find(a => a.id === assetId)
|
||||
return asset && reportFileKind(asset) ? asset : null
|
||||
}
|
||||
|
||||
export async function findExternalCertificateReports(query: CertificateReportQuery) {
|
||||
const today = certificateToday(), where = buildCertificateReportWhere(query, today)
|
||||
const [totalReports, reports] = await prisma.$transaction([
|
||||
prisma.material.count({ where }),
|
||||
prisma.material.findMany({ where, select: {
|
||||
id: true, reportTitle: true, reportKind: true, productModel: true, productCategoryName: true, platformName: true,
|
||||
certificateNumber: true, certificateTestedOn: true, certificateExpiresOn: true, certificateUsageInstructions: true, reportUsageRules: true,
|
||||
createdAt: true, updatedAt: true,
|
||||
assets: { where: { assetType: { in: assetTypes(query.fileKind) } }, select: { id: true, assetType: true, fileName: true, fileUrl: true, fileSize: true, sortOrder: true }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
}, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
|
||||
], { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead })
|
||||
const baseUrl = getPublicAppOrigin()
|
||||
return {
|
||||
query, asOfDate: today.toISOString().slice(0, 10),
|
||||
items: reports.map(report => {
|
||||
const files = report.assets.filter(a => reportFileKind(a)).map(a => ({
|
||||
assetId: a.id, fileKind: reportFileKind(a)!, fileName: leaf(a.fileName || a.fileUrl),
|
||||
fileSize: Number.isSafeInteger(Number(a.fileSize)) ? Number(a.fileSize) : null, sortOrder: a.sortOrder,
|
||||
downloadUrl: generateDownloadUrl(baseUrl, { materialId: report.id, assetId: a.id, downloadType: 'certificate-report-source' }),
|
||||
}))
|
||||
return {
|
||||
materialId: report.id, materialType: CERTIFICATE_REPORT_MATERIAL_TYPE, reportTitle: report.reportTitle, reportKind: report.reportKind,
|
||||
spu: report.productModel || null, productCategory: report.productCategoryName, platform: report.platformName || null,
|
||||
certificateNumber: report.certificateNumber, testedOn: report.certificateTestedOn?.toISOString().slice(0, 10) || null,
|
||||
expiresOn: report.certificateExpiresOn?.toISOString().slice(0, 10) || null, expiryStatus: certificateExpiryStatus(report.certificateExpiresOn, today),
|
||||
certificateUsageInstructions: report.certificateUsageInstructions, reportUsageRules: report.reportUsageRules,
|
||||
createdAt: report.createdAt.toISOString(), updatedAt: report.updatedAt.toISOString(), files,
|
||||
issues: files.length ? [] : [{ code: 'REPORT_FILES_NOT_AVAILABLE', message: '没有可获取的报告 PDF/图片,不以预览图或压缩包代替' }],
|
||||
}
|
||||
}),
|
||||
pagination: { page: query.page, pageSize: query.pageSize, totalReports, hasMore: query.page * query.pageSize < totalReports },
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user