diff --git a/app/api/external/certificate-reports/route.ts b/app/api/external/certificate-reports/route.ts new file mode 100644 index 0000000..871b34e --- /dev/null +++ b/app/api/external/certificate-reports/route.ts @@ -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 }) + } +} diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 243e7b5..91e5c1a 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -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') { diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index 829cdf9..637afd2 100644 --- a/docs/外部接口文档.md +++ b/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 ## 接口列表 + + +### 检测报告:组合查询与逐文件下载 + +**请求:** `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,素材平台按首页搜索栏同口径模糊匹配,并返回命中结果中的全部 `买家秀图片` 和全部视频类素材的预览地址与正式下载链接。 diff --git a/lib/__tests__/external-certificate-reports.test.ts b/lib/__tests__/external-certificate-reports.test.ts new file mode 100644 index 0000000..8a48a44 --- /dev/null +++ b/lib/__tests__/external-certificate-reports.test.ts @@ -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 = {}) => ({ 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) + }) +}) diff --git a/lib/download-token.ts b/lib/download-token.ts index 9b8299d..0ecf8f4 100644 --- a/lib/download-token.ts +++ b/lib/download-token.ts @@ -19,6 +19,7 @@ export type DownloadType = | 'psd' | 'single' | 'image-source' + | 'certificate-report-source' | 'white-background-single' | 'white-background-zip'; diff --git a/lib/external-certificate-reports.ts b/lib/external-certificate-reports.ts new file mode 100644 index 0000000..b17e034 --- /dev/null +++ b/lib/external-certificate-reports.ts @@ -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 = {} + 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 }, + } +}