126 lines
9.5 KiB
TypeScript
126 lines
9.5 KiB
TypeScript
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 },
|
||
}
|
||
}
|