diff --git a/Dockerfile b/Dockerfile index 17b6999b..825f37da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -# 基础镜像 -FROM node:18-alpine AS base +# 基础镜像(支持通过构建参数覆盖) +ARG BASE_IMAGE=node:18-alpine +FROM ${BASE_IMAGE} AS base # 安装依赖阶段 FROM base AS deps @@ -30,11 +31,14 @@ WORKDIR /app # 复制依赖文件 COPY package.json package-lock.json* ./ -# 智能缓存策略: -# 1. 如果存在 .build-cache/node_modules.tar.gz,直接解压使用(10-20秒) -# 2. 否则执行 npm ci 下载(60-90秒) +# 智能缓存策略(优化以减少SSD写入): +# 1. 优先使用缓存挂载(cache mount),减少磁盘写入 +# 2. 如果存在 .build-cache/node_modules.tar.gz,直接解压使用(10-20秒) +# 3. 否则执行 npm ci 下载(60-90秒) +# 使用缓存挂载存储 npm 缓存,避免写入到镜像层 COPY .build-cache/node_modules.tar.gz* /tmp/ RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=locked \ + --mount=type=cache,target=/tmp/.npm-cache,id=npm-tmp-cache \ if [ -f /tmp/node_modules.tar.gz ]; then \ echo "✅ 使用缓存的 node_modules(快速模式)" && \ tar -xzf /tmp/node_modules.tar.gz && \ @@ -42,6 +46,7 @@ RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=locked \ mkdir -p node_modules/canvas && \ echo "🔧 重新安装 sharp(musl 版本)" && \ npm config set registry https://registry.npmmirror.com && \ + npm config set cache /root/.npm --global && \ npm install --os=linux --libc=musl --cpu=x64 --legacy-peer-deps sharp; \ else \ echo "⚠️ 缓存不存在,重新下载依赖(首次构建)" && \ @@ -70,14 +75,16 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# 生成 Prisma Client -RUN npx prisma generate +# 生成 Prisma Client(使用缓存挂载减少写入) +RUN --mount=type=cache,target=/root/.cache/prisma,id=prisma-cache \ + npx prisma generate # 设置环境变量 ENV NEXT_TELEMETRY_DISABLED 1 -# 构建应用 -RUN npm run build +# 构建应用(使用缓存挂载存储 Next.js 缓存,减少磁盘写入) +RUN --mount=type=cache,target=/app/.next/cache,id=nextjs-cache \ + npm run build # 生产运行阶段 FROM base AS runner @@ -88,7 +95,7 @@ ENV NEXT_TELEMETRY_DISABLED 1 # 使用阿里云镜像源加速 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories -# 安装运行时依赖(包括 canvas 所需的库、FFmpeg 和 Prisma 所需的 OpenSSL) +# 安装运行时依赖(包括 canvas 所需的库、FFmpeg、PDF处理工具和 Prisma 所需的 OpenSSL) RUN apk add --no-cache \ openssl3 \ openssl-dev \ @@ -99,7 +106,8 @@ RUN apk add --no-cache \ librsvg \ pixman \ ffmpeg \ - vips + vips \ + poppler-utils RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -107,6 +115,7 @@ RUN adduser --system --uid 1001 nextjs # 复制必要文件 COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/app ./app COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma diff --git a/app/admin/api-keys/page.tsx b/app/admin/api-keys/page.tsx index fe4ba4c3..9a85dae6 100644 --- a/app/admin/api-keys/page.tsx +++ b/app/admin/api-keys/page.tsx @@ -7,6 +7,7 @@ import { useState, useEffect } from 'react'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; +import { parseFetchError, handleError } from '@/lib/error-handler'; interface ApiKey { id: string; @@ -117,6 +118,8 @@ export default function ApiKeysPage() { } } catch (error) { console.error('删除失败:', error); + const { message } = handleError(error); + alert(message); } }; diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 3cfc09d5..67a4371e 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation' import Header from '@/components/Header' import { useToast } from '@/hooks/useToast' import ToastBubble from '@/components/ToastBubble' +import { parseFetchError, handleError } from '@/lib/error-handler' export default function SystemSettings() { const { data: session, status } = useSession() @@ -79,7 +80,8 @@ export default function SystemSettings() { } } catch (error) { console.error('Error fetching settings:', error) - showToast('加载设置失败', { variant: 'error' }) + const { message } = handleError(error) + showToast(message, { variant: 'error' }) } finally { setLoading(false) } @@ -120,11 +122,13 @@ export default function SystemSettings() { }, 1000) } else { const error = await response.json() - showToast(error.error || '保存失败', { variant: 'error' }) + const parsedError = await parseFetchError(response) + showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Save settings error:', error) - showToast('保存失败,请稍后重试', { variant: 'error' }) + const { message } = handleError(error) + showToast(message, { variant: 'error' }) } finally { setSaving(false) } @@ -164,11 +168,13 @@ export default function SystemSettings() { showToast('导航栏LOGO上传成功!', { variant: 'success' }) } else { const error = await response.json() - showToast(error.error || 'LOGO上传失败', { variant: 'error' }) + const parsedError = await parseFetchError(response) + showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) - showToast('上传失败,请稍后重试', { variant: 'error' }) + const { message } = handleError(error) + showToast(message, { variant: 'error' }) } finally { setUploadingHeader(false) } @@ -208,11 +214,13 @@ export default function SystemSettings() { showToast('登录页LOGO上传成功!', { variant: 'success' }) } else { const error = await response.json() - showToast(error.error || 'LOGO上传失败', { variant: 'error' }) + const parsedError = await parseFetchError(response) + showToast(parsedError.message, { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) - showToast('上传失败,请稍后重试', { variant: 'error' }) + const { message } = handleError(error) + showToast(message, { variant: 'error' }) } finally { setUploadingLogin(false) } diff --git a/app/api/diagnose/preview-image/route.ts b/app/api/diagnose/preview-image/route.ts new file mode 100644 index 00000000..64cf9bd9 --- /dev/null +++ b/app/api/diagnose/preview-image/route.ts @@ -0,0 +1,169 @@ +/** + * 诊断预览图URL是否可访问 + * GET /api/diagnose/preview-image?url={预览图URL} + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { HeadObjectCommand } from '@aws-sdk/client-s3'; +import { s3Client, isS3PublicUrl } from '@/lib/s3'; +import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json( + { error: '未登录' }, + { status: 401 } + ); + } + + const url = request.nextUrl.searchParams.get('url'); + if (!url) { + return NextResponse.json( + { error: '缺少url参数' }, + { status: 400 } + ); + } + + const results: any = { + originalUrl: url, + checks: [], + }; + + // 1. 检查URL格式 + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + results.checks.push({ + name: 'URL格式', + status: 'success', + message: 'URL格式正确', + details: { + protocol: parsedUrl.protocol, + hostname: parsedUrl.hostname, + pathname: parsedUrl.pathname, + } + }); + } catch (error) { + results.checks.push({ + name: 'URL格式', + status: 'error', + message: 'URL格式错误', + error: error instanceof Error ? error.message : String(error) + }); + return NextResponse.json(results); + } + + // 2. 检查URL重写后的地址 + const rewrittenUrl = rewriteStorageUrlForClient(url, request); + results.rewrittenUrl = rewrittenUrl; + results.checks.push({ + name: 'URL重写', + status: rewrittenUrl !== url ? 'success' : 'info', + message: rewrittenUrl !== url ? 'URL已重写' : 'URL未重写', + details: { + original: url, + rewritten: rewrittenUrl, + } + }); + + // 3. 检查是否为S3 URL + const isS3 = isS3PublicUrl(url); + results.isS3Url = isS3; + results.checks.push({ + name: 'S3 URL检测', + status: isS3 ? 'success' : 'info', + message: isS3 ? '是S3公网URL' : '不是S3公网URL', + }); + + // 4. 如果是S3 URL,检查文件是否存在 + if (isS3) { + try { + // 从URL提取key + const urlObj = new URL(url); + const key = urlObj.pathname.slice(1); // 移除开头的斜杠 + + const command = new HeadObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + }); + + const response = await s3Client.send(command); + results.checks.push({ + name: 'S3文件存在性', + status: 'success', + message: '文件存在于S3', + details: { + key: key, + contentLength: response.ContentLength, + contentType: response.ContentType, + lastModified: response.LastModified?.toISOString(), + } + }); + } catch (error: any) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + results.checks.push({ + name: 'S3文件存在性', + status: 'error', + message: '文件不存在于S3', + error: '404 Not Found' + }); + } else { + results.checks.push({ + name: 'S3文件存在性', + status: 'error', + message: '检查S3文件时出错', + error: error.message || String(error) + }); + } + } + } + + // 5. 尝试通过HTTP访问(使用重写后的URL) + if (rewrittenUrl) { + try { + const response = await fetch(rewrittenUrl, { + method: 'HEAD', + signal: AbortSignal.timeout(5000), // 5秒超时 + }); + + results.checks.push({ + name: 'HTTP访问', + status: response.ok ? 'success' : 'error', + message: response.ok ? `HTTP访问成功 (${response.status})` : `HTTP访问失败 (${response.status})`, + details: { + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + } + }); + } catch (error: any) { + results.checks.push({ + name: 'HTTP访问', + status: 'error', + message: 'HTTP访问失败', + error: error.message || String(error), + details: { + isTimeout: error.name === 'AbortError', + isNetworkError: error.message?.includes('fetch'), + } + }); + } + } + + return NextResponse.json(results); + } catch (error) { + console.error('[Diagnose Preview Image] Error:', error); + return NextResponse.json( + { + error: '诊断失败', + message: error instanceof Error ? error.message : String(error) + }, + { status: 500 } + ); + } +} diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index df6e5fe2..3879130f 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -12,6 +12,10 @@ import archiver from 'archiver'; import { Readable } from 'stream'; import path from 'path'; import fs from 'fs'; +import { createReadStream } from 'fs'; +import { access, stat } from 'fs/promises'; +import { lookup, extension } from 'mime-types'; +import JSZip from 'jszip'; /** * GET - 下载文件 @@ -49,61 +53,67 @@ export async function GET(request: NextRequest) { return errorResponse(API_ERROR.TASK_NOT_FOUND); } - // 单个资产下载 - if (downloadType === 'single' && assetId) { - const asset = material.assets.find((a: any) => a.id === assetId); - if (!asset) { - return NextResponse.json( - { success: false, error: { code: 'ASSET_NOT_FOUND', message: '资产不存在' } }, - { status: 404 } - ); + // TODO: 权限检查(暂时保留接口,后续扩展) + // 后续需要实现:根据 API Key 或用户权限检查下载权限 + // 参考:/api/materials/download 中的权限检查逻辑 + + // 产品全案:使用现有逻辑 + if (material.materialType === '产品全案') { + // 单个资产下载 + if (downloadType === 'single' && assetId) { + const asset = material.assets.find((a: any) => a.id === assetId); + if (!asset) { + return NextResponse.json( + { success: false, error: { code: 'ASSET_NOT_FOUND', message: '资产不存在' } }, + { status: 404 } + ); + } + return await downloadSingleFile(asset); } - return await downloadSingleFile(asset); - } - - // PSD 下载 - if (downloadType === 'psd') { - // 如果指定了assetId,下载单个PSD文件 - if (assetId) { - const psdAsset = material.assets.find((a: any) => a.id === assetId && a.assetType === ASSET_TYPE.PSD); - if (!psdAsset) { + // PSD 下载 + if (downloadType === 'psd') { + if (assetId) { + const psdAsset = material.assets.find((a: any) => a.id === assetId && a.assetType === ASSET_TYPE.PSD); + if (!psdAsset) { + return NextResponse.json( + { success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } }, + { status: 404 } + ); + } + return await downloadSingleFile(psdAsset); + } + + const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD); + if (psdAssets.length === 0) { return NextResponse.json( { success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } }, { status: 404 } ); } - return await downloadSingleFile(psdAsset); + + if (psdAssets.length === 1) { + return await downloadSingleFile(psdAssets[0]); + } + + return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd'); } - - // 未指定assetId,查找所有PSD文件 - const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD); - if (psdAssets.length === 0) { + + // 打包下载 + const assetsToDownload = getAssetsForDownload(material.assets, downloadType); + if (assetsToDownload.length === 0) { return NextResponse.json( - { success: false, error: { code: 'PSD_NOT_FOUND', message: 'PSD文件不存在' } }, + { success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } }, { status: 404 } ); } - - // 如果只有一个PSD文件,直接下载;否则打包 - if (psdAssets.length === 1) { - return await downloadSingleFile(psdAssets[0]); - } - - // 多个PSD文件,打包下载 - return await downloadAsZip(psdAssets, material.taskNo || material.id, 'psd'); + + return await downloadAsZip(assetsToDownload, material.taskNo || material.id, downloadType); } - // 打包下载 - const assetsToDownload = getAssetsForDownload(material.assets, downloadType); - if (assetsToDownload.length === 0) { - return NextResponse.json( - { success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } }, - { status: 404 } - ); - } - - return await downloadAsZip(assetsToDownload, material.taskNo || material.id, downloadType); + // 其他素材类型:复用 /api/materials/download 的逻辑 + // 注意:外部接口通过 token 验证,已跳过权限检查 + return await downloadNonProductPackageMaterial(material, downloadType, request); } catch (error) { console.error('[external/download] error:', error); return errorResponse(API_ERROR.SERVER_ERROR); @@ -262,6 +272,374 @@ function getContentType(fileName: string): string { '.zip': 'application/zip', '.rar': 'application/x-rar-compressed', '.7z': 'application/x-7z-compressed', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.mkv': 'video/x-matroska', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.flac': 'audio/flac', + '.pdf': 'application/pdf', }; return mimeTypes[ext] || 'application/octet-stream'; } + +/** + * 判断是否为 HTTP URL + */ +function isHttpUrl(url?: string | null): boolean { + if (!url || typeof url !== 'string') return false; + return /^https?:\/\//i.test(url.trim()); +} + +/** + * 下载非产品全案类型的素材 + * 复用 /api/materials/download 的逻辑,但跳过权限检查 + */ +async function downloadNonProductPackageMaterial(material: any, downloadType: string, request: NextRequest) { + // 检查是否有ZIP文件;若无,则处理S3直链,否则再尝试动态打包 + if (!material.zipFilePath || material.zipFilePath.trim() === '') { + const zipUrl = (material.zipFileName || '').trim(); + + // 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL + if (zipUrl && isHttpUrl(zipUrl)) { + console.log('[external/download] zipFilePath empty, proxy S3 url from zipFileName:', zipUrl); + + const urlObj = new URL(zipUrl); + const extFromUrl = path.extname(urlObj.pathname) || ''; + const mimeType = lookup(extFromUrl) || 'application/octet-stream'; + + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '源文件', + ].filter(Boolean); + const finalExt = extFromUrl || '.bin'; + const filename = `${filenameParts.join('_')}${finalExt}`; + + try { + const response = await fetch(zipUrl); + if (!response.ok || !response.body) { + console.error('[external/download] Failed to fetch S3 url:', zipUrl, 'status:', response.status); + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: 'S3文件访问失败' } }, + { status: 404 } + ); + } + + const nodeStream = Readable.fromWeb(response.body as any); + const stream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(chunk)); + nodeStream.on('end', () => controller.close()); + nodeStream.on('error', (err) => controller.error(err)); + }, + cancel() { + nodeStream.destroy(); + }, + }); + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); + } catch (error) { + console.error('[external/download] Error proxying S3 url:', zipUrl, error); + return NextResponse.json( + { success: false, error: { code: 'DOWNLOAD_FAILED', message: 'S3代理下载失败' } }, + { status: 500 } + ); + } + } + + // 支持动态打包的素材类型 + const supportedTypes = [ + '模特', + '实拍图片', + '买家秀图片', + '小红书种草', + '主图模版', + 'SKU模版', + '品牌VI灵感', + ]; + + if ( + supportedTypes.includes(material.materialType) && + Array.isArray(material.displayImages) && + material.displayImages.length > 0 + ) { + return await buildDynamicZipFromDisplayImages(material); + } + + return NextResponse.json( + { success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } }, + { status: 404 } + ); + } + + // 处理本地文件 + const isOldMaterial = + material.zipFilePath.includes('103.118.245.6') || + material.zipFilePath.startsWith('http'); + + let filePath = material.zipFilePath; + + // 如果是旧素材(HTTP URL),直接通过 fetch 下载 + if (isOldMaterial) { + try { + const response = await fetch(filePath); + if (!response.ok || !response.body) { + console.error('[external/download] Failed to fetch old material URL:', filePath, 'status:', response.status); + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件访问失败' } }, + { status: 404 } + ); + } + + const urlObj = new URL(filePath); + const extFromUrl = path.extname(urlObj.pathname) || ''; + const mimeType = lookup(extFromUrl) || 'application/octet-stream'; + + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '源文件', + ].filter(Boolean); + const finalExt = extFromUrl || '.bin'; + const filename = `${filenameParts.join('_')}${finalExt}`; + + const nodeStream = Readable.fromWeb(response.body as any); + const stream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(chunk)); + nodeStream.on('end', () => controller.close()); + nodeStream.on('error', (err) => controller.error(err)); + }, + cancel() { + nodeStream.destroy(); + }, + }); + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); + } catch (error) { + console.error('[external/download] Error fetching old material URL:', filePath, error); + return NextResponse.json( + { success: false, error: { code: 'DOWNLOAD_FAILED', message: '文件下载失败' } }, + { status: 500 } + ); + } + } + + // 处理本地文件路径 + if (!path.isAbsolute(filePath)) { + filePath = path.join(process.cwd(), filePath); + } + + // 检查文件是否存在 + try { + await access(filePath); + } catch (error) { + console.error('[external/download] File not found:', filePath, error); + + // 如果本地文件不存在,尝试动态打包 + const supportedTypes = [ + '模特', + '实拍图片', + '买家秀图片', + '小红书种草', + '主图模版', + 'SKU模版', + '品牌VI灵感', + ]; + + if ( + supportedTypes.includes(material.materialType) && + Array.isArray(material.displayImages) && + material.displayImages.length > 0 + ) { + console.log('[external/download] Local file missing, fallback to dynamic zip from displayImages'); + return await buildDynamicZipFromDisplayImages(material); + } + + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件可能已被删除' } }, + { status: 404 } + ); + } + + // 获取文件大小 + const fileStats = await stat(filePath); + const fileSize = fileStats.size; + + // 确定文件扩展名和MIME类型 + const originalExt = material.zipFileName ? path.extname(material.zipFileName) : ''; + const actualExt = path.extname(filePath); + const ext = originalExt || actualExt; + const mimeType = lookup(ext || filePath) || 'application/octet-stream'; + + // 生成下载文件名 + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '源文件', + ].filter(Boolean); + const finalExt = ext || '.bin'; + const filename = `${filenameParts.join('_')}${finalExt}`; + + // 处理Range请求(断点续传) + const rangeHeader = request.headers.get('range'); + + if (rangeHeader) { + const rangePrefix = 'bytes='; + if (!rangeHeader.startsWith(rangePrefix)) { + return new NextResponse('Range header format is invalid', { status: 416 }); + } + + const rangeParts = rangeHeader.replace(rangePrefix, '').split('-'); + const start = parseInt(rangeParts[0], 10); + const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1; + + if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) { + return new NextResponse('Range header out of bounds', { status: 416 }); + } + + const chunkSize = end - start + 1; + const fileStream = createReadStream(filePath, { start, end }); + + const stream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => controller.enqueue(chunk)); + fileStream.on('end', () => controller.close()); + fileStream.on('error', (err) => controller.error(err)); + }, + cancel() { + fileStream.destroy(); + }, + }); + + return new NextResponse(stream, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); + } + + // 正常下载 + const fileStream = createReadStream(filePath); + + const stream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => controller.enqueue(chunk)); + fileStream.on('end', () => controller.close()); + fileStream.on('error', (err) => controller.error(err)); + }, + cancel() { + fileStream.destroy(); + }, + }); + + return new NextResponse(stream, { + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Length': fileSize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); +} + +/** + * 动态打包displayImages为ZIP + */ +async function buildDynamicZipFromDisplayImages(material: any) { + const displayImages = Array.isArray(material.displayImages) + ? material.displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0) + : []; + + if (displayImages.length === 0) { + return NextResponse.json( + { success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } }, + { status: 404 } + ); + } + + const zip = new JSZip(); + let addedCount = 0; + + for (let i = 0; i < displayImages.length; i++) { + const imageUrl = displayImages[i]; + try { + const response = await fetch(imageUrl); + if (!response.ok) { + console.warn(`[external/download] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`); + continue; + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (!buffer || buffer.length === 0) { + console.warn(`[external/download] Empty buffer for display image index=${i} url=${imageUrl}, skipping`); + continue; + } + + const urlObj = new URL(imageUrl); + const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_'); + zip.file(filename, buffer); + addedCount++; + } catch (error) { + console.error(`[external/download] Error while adding display image index=${i} url=${imageUrl} to zip:`, error); + } + } + + if (addedCount === 0) { + return NextResponse.json( + { success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } }, + { status: 404 } + ); + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '展示图合集', + ].filter(Boolean); + + const filename = `${filenameParts.join('_')}.zip`; + + return new NextResponse(zipBuffer as any, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': zipBuffer.length.toString(), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); +} diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index c1deed0b..74131244 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -24,6 +24,7 @@ import { } from '@/lib/product-package'; import { generateDownloadUrl, DownloadType } from '@/lib/download-token'; import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; +import path from 'path'; // 获取基础 URL function getBaseUrl(request: NextRequest): string { @@ -76,7 +77,9 @@ export async function GET(request: NextRequest) { try { // 查询素材 + console.log('[external/product-assets] Querying material for taskNo:', taskNo); const material: any = await getMaterialByTaskNo(taskNo); + console.log('[external/product-assets] Material found:', material ? { id: material.id, taskNo: material.taskNo, assetsCount: material.assets?.length || 0 } : 'null'); if (!material) { await logApiCall({ @@ -114,68 +117,149 @@ export async function GET(request: NextRequest) { } const baseUrl = getBaseUrl(request); + console.log('[external/product-assets] Base URL:', baseUrl); + console.log('[external/product-assets] Material type:', material.materialType); - // 按类型分组资产 - const mainAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.MAIN); - const skuAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.SKU); - const detailAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.DETAIL); - const psdAssets = material.assets.filter((a: any) => a.assetType === ASSET_TYPE.PSD); + // 根据素材类型返回不同结构 + if (material.materialType === '产品全案') { + // 产品全案:保持现有结构 + const mainAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.MAIN); + const skuAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.SKU); + const detailAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.DETAIL); + const psdAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.PSD); + console.log('[external/product-assets] Assets grouped:', { main: mainAssets.length, sku: skuAssets.length, detail: detailAssets.length, psd: psdAssets.length }); - // 生成资产列表(带下载链接) - // 使用智能 URL 重写(根据用户网络位置选择内网或外网地址) - const formatAsset = (asset: any, downloadType: DownloadType) => ({ - id: asset.id, - url: rewriteStorageUrlForClient(asset.fileUrl, request), // 重写资源URL - downloadUrl: generateDownloadUrl(baseUrl, { - materialId: material.id, - downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType, - assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined, // PSD文件需要传递assetId - }), - fileName: asset.fileName, - fileSize: Number(asset.fileSize), - ...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder }), - }); + // 生成资产列表(带下载链接) + const formatAsset = (asset: any, downloadType: DownloadType) => { + try { + const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request); + const downloadUrl = generateDownloadUrl(baseUrl, { + materialId: material.id, + downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType, + assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined, + }); + return { + id: asset.id, + url: rewrittenUrl, + downloadUrl, + fileName: asset.fileName, + fileSize: Number(asset.fileSize), + ...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder }), + }; + } catch (formatError) { + console.error('[external/product-assets] Error formatting asset:', { + assetId: asset.id, + assetType: asset.assetType, + error: formatError instanceof Error ? formatError.message : String(formatError), + }); + throw formatError; + } + }; - // 构建响应数据 - const responseData = { - taskNo: material.taskNo, - productModel: material.productModel, - categoryName: material.categoryName, - sceneName: material.sceneName, - status: TASK_STATUS.FETCHED, - createdAt: material.createdAt.toISOString(), - updatedAt: material.updatedAt.toISOString(), - assets: { - main: mainAssets.map((a: any) => formatAsset(a, 'main')), - sku: skuAssets.map((a: any) => formatAsset(a, 'sku')), - detail: detailAssets.map((a: any) => formatAsset(a, 'detail')), - psd: psdAssets.map((a: any) => formatAsset(a, 'psd')), - }, - downloadUrls: { - all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), - main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }), - sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }), - detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }), - // PSD批量下载已废弃,请使用单个PSD文件的downloadUrl - psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : null, - }, - }; + const responseData = { + taskNo: material.taskNo, + productModel: material.productModel, + categoryName: material.categoryName, + sceneName: material.sceneName, + status: TASK_STATUS.FETCHED, + createdAt: material.createdAt.toISOString(), + updatedAt: material.updatedAt.toISOString(), + assets: { + main: mainAssets.map((a: any) => formatAsset(a, 'main')), + sku: skuAssets.map((a: any) => formatAsset(a, 'sku')), + detail: detailAssets.map((a: any) => formatAsset(a, 'detail')), + psd: psdAssets.map((a: any) => formatAsset(a, 'psd')), + }, + downloadUrls: { + all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), + main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }), + sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }), + detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }), + psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : null, + }, + }; - // 记录日志 - await logApiCall({ - apiKeyId: keyRecord.id, - endpoint: '/api/external/product-assets', - method: 'GET', - taskNo, - statusCode: 200, - duration: Date.now() - startTime, - ipAddress: getClientIp(request), - userAgent: request.headers.get('user-agent') || undefined, - }); + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + taskNo, + statusCode: 200, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + + return successResponse(responseData); + } else { + // 其他素材类型:返回统一的 files 结构和单个下载链接 + const previewUrl = material.previewUrl || ''; + const rewrittenPreviewUrl = previewUrl ? rewriteStorageUrlForClient(previewUrl, request) : ''; + + // 生成文件名(从zipFileName或productModel推断) + let fileName = material.zipFileName || ''; + if (!fileName) { + // 如果没有zipFileName,尝试从productModel生成 + if (material.productModel) { + // 根据素材类型推断扩展名 + const ext = material.materialType === '音乐' ? '.mp3' : + ['主图视频', '安装视频', '种草视频'].includes(material.materialType) ? '.mp4' : + material.materialType === '品牌VI灵感' ? '.pdf' : + '.zip'; + fileName = `${material.productModel}${ext}`; + } else { + fileName = 'material.zip'; + } + } + + const responseData = { + taskNo: material.taskNo, + productModel: material.productModel, + categoryName: material.categoryName, + sceneName: material.sceneName, + status: material.taskStatus || TASK_STATUS.FETCHED, + createdAt: material.createdAt.toISOString(), + updatedAt: material.updatedAt.toISOString(), + files: [ + { + id: material.id, + url: rewrittenPreviewUrl, + downloadUrl: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), + fileName: fileName || 'material.zip', + fileSize: Number(material.zipFileSize || 0), + ...(material.videoDuration && { duration: material.videoDuration }), + }, + ], + downloadUrls: { + all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), + }, + }; + + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-assets', + method: 'GET', + taskNo, + statusCode: 200, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }); + + return successResponse(responseData); + } - return successResponse(responseData); } catch (error) { - console.error('[external/product-assets] GET error:', error); + // 记录详细的错误信息 + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('[external/product-assets] GET error:', { + taskNo, + error: errorMessage, + stack: errorStack, + apiKeyId: keyRecord.id, + }); + await logApiCall({ apiKeyId: keyRecord.id, endpoint: '/api/external/product-assets', diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 00000000..655d9173 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,67 @@ +// Favicon API - 返回系统设置的 logo 作为 favicon +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + try { + // 获取系统设置中的 headerLogoUrl + const settings = await prisma.systemSettings.findFirst({ + select: { + headerLogoUrl: true, + } + }) + + const logoUrl = settings?.headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' + + // 根据用户网络位置重写 URL(内网用户使用内网地址) + const finalUrl = rewriteStorageUrlForClient(logoUrl, request) || logoUrl + + // 获取 logo 图片 + const response = await fetch(finalUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; FaviconFetcher/1.0)', + }, + }) + + if (!response.ok) { + // 如果获取失败,返回默认的透明 PNG + const FAVICON_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ) + return new NextResponse(FAVICON_PNG, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=3600', + }, + }) + } + + const arrayBuffer = await response.arrayBuffer() + const contentType = response.headers.get('content-type') || 'image/png' + + return new NextResponse(arrayBuffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600', // 缓存1小时 + }, + }) + } catch (error) { + console.error('[Favicon API] Error:', error) + // 返回默认的透明 PNG + const FAVICON_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ) + return new NextResponse(FAVICON_PNG, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=3600', + }, + }) + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index bf3f5e1e..4ab7a977 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -73,6 +73,7 @@ export async function GET( select: { id: true, username: true, + realName: true, }, }, } as any, @@ -166,6 +167,9 @@ export async function GET( // 类型断言,确保 user 字段存在 const materialWithUser = material as any; + // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName + const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName; + const response = { id: materialWithUser.id, userId: materialWithUser.userId, @@ -176,7 +180,7 @@ export async function GET( productCategoryName: materialWithUser.productCategoryName || '', materialType: materialWithUser.materialType, renderer: materialWithUser.renderer, - authorName: materialWithUser.authorName, + authorName: displayAuthorName, // 使用处理后的作者名 previewUrl: materialWithUser.previewUrl, zipFileName: materialWithUser.zipFileName, zipFileSize: materialWithUser.zipFileSize.toString(), @@ -195,9 +199,46 @@ export async function GET( }, }; - // 使用智能 URL 重写(根据用户网络位置选择内网或外网地址) - const { rewriteMaterialStorageUrlsForClient } = await import('@/lib/url-rewrite'); - const rewrittenResponse = rewriteMaterialStorageUrlsForClient(response, request); + // 使用统一的媒体URL重写规则(方案C:按文件大小阈值) + const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite'); + const { detectIsIntranet } = await import('@/lib/network-detection'); + + const isIntranet = detectIsIntranet(request); + + // 处理音频文件的zipFileName(特殊处理) + let processedZipFileName = response.zipFileName; + if (response.zipFileName && response.materialType === '音乐') { + const zipFileName = response.zipFileName; + if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) { + // 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理) + processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio'); + } else { + // 新素材:本地文件 + if (isIntranet) { + // 内网用户:直接访问 + processedZipFileName = `/uploads/${zipFileName}`; + } else { + // 外网用户:通过API访问 + processedZipFileName = `/api/uploads/${zipFileName}`; + } + } + } + + const rewrittenResponse = { + ...response, + // 图片:使用统一规则(小文件直接访问,大文件走代理) + previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'), + videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'), + displayImages: response.displayImages?.map((img: string) => + rewriteMediaUrlForClient(img, request, 'image') + ), + // 视频:使用统一规则(通常>1MB,会走代理) + videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'), + videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'), + videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'), + // 音频文件:特殊处理 + zipFileName: processedZipFileName, + }; return NextResponse.json(rewrittenResponse); } catch (error) { diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 8795e19f..3655088b 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -194,6 +194,13 @@ async function handleProductPackageDownload(material: any, downloadType: string) 'Content-Type': mimeType, 'Content-Length': fileStats.size.toString(), 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, + // 静态PSD文件:长期缓存(1天),允许重新验证 + 'Cache-Control': 'public, max-age=86400, must-revalidate', + 'Accept-Ranges': 'bytes', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', + // 添加ETag支持 + 'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`, }, }); } catch (error) { @@ -449,6 +456,11 @@ async function handleProductPackageDownload(material: any, downloadType: string) 'Content-Type': 'application/zip', 'Content-Length': zipBuffer.length.toString(), 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // 动态生成的ZIP文件:短期缓存(1小时),允许重新验证 + 'Cache-Control': 'public, max-age=3600, must-revalidate', + 'Accept-Ranges': 'bytes', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', }, }); } @@ -535,6 +547,11 @@ async function buildDynamicZipFromDisplayImages(material: any) { 'Content-Type': 'application/zip', 'Content-Length': zipBuffer.length.toString(), 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // 动态生成的ZIP文件:短期缓存(1小时),允许重新验证 + 'Cache-Control': 'public, max-age=3600, must-revalidate', + 'Accept-Ranges': 'bytes', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', }, }); } @@ -797,8 +814,24 @@ export async function GET(request: NextRequest) { ); } - // 产品全案:PSD下载权限使用 canDownload 字段(与其他素材类型一致) - // 如果用户有下载权限,就可以下载所有内容,包括PSD + // 产品全案:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限 + // 如果下载的是 PSD,需要额外检查"详情模板"类型的源文件下载权限 + if (material.materialType === '产品全案' && downloadType === 'psd') { + const canDownloadPsd = await canUserDownloadSource( + userDepartment, + userRole, + managedDepartment, + '详情模板', // 产品全案的 PSD 下载权限参考"详情模板"类型 + userId + ); + + if (!canDownloadPsd) { + return NextResponse.json( + createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载 PSD 源文件'), + { status: 403 } + ); + } + } } } } @@ -844,6 +877,13 @@ export async function GET(request: NextRequest) { 'Content-Type': mimeType, 'Content-Length': fileStats.size.toString(), 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, + // 静态PSD文件:长期缓存(1天),允许重新验证 + 'Cache-Control': 'public, max-age=86400, must-revalidate', + 'Accept-Ranges': 'bytes', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', + // 添加ETag支持 + 'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`, }, }); } catch (error) { @@ -929,6 +969,11 @@ export async function GET(request: NextRequest) { headers: { 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // S3代理下载:中等缓存(6小时),允许重新验证 + 'Cache-Control': 'public, max-age=21600, must-revalidate', + 'Accept-Ranges': 'bytes', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', // 不设置 Content-Length,因 S3 直链已被代理流式返回 }, }); @@ -1079,6 +1124,10 @@ export async function GET(request: NextRequest) { 'Content-Length': chunkSize.toString(), 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // 静态文件Range请求:长期缓存(1天),允许重新验证 + 'Cache-Control': 'public, max-age=86400, must-revalidate', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding, Range', }, }); } @@ -1103,6 +1152,13 @@ export async function GET(request: NextRequest) { 'Content-Length': fileSize.toString(), 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // 静态文件下载:长期缓存(1天),允许重新验证 + // 注意:对于已存在的文件,内容不会改变,可以缓存 + 'Cache-Control': 'public, max-age=86400, must-revalidate', + // 性能优化:启用压缩(如果客户端支持) + 'Vary': 'Accept-Encoding', + // 添加ETag支持(基于文件路径和大小) + 'ETag': `"${Buffer.from(`${filePath}-${fileSize}`).toString('base64').slice(0, 27)}"`, }, }); } catch (error) { diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 8b914b5a..427d03b1 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -8,11 +8,13 @@ import { deleteFiles } from '@/lib/file-cleanup'; import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { createMaterialLog } from '@/lib/material-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; -import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'; +import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite'; +import { detectIsIntranet } from '@/lib/network-detection'; import { getUserViewableMaterialTypes, isShareLinkAccess, } from '@/lib/department-permission'; +import { getMaterialCoverUrl } from '@/lib/product-package'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -38,11 +40,13 @@ function parseDisplayImages(displayImages: any): string[] { } // 素材类型映射(兼容旧数据) +// 详情页和详情模板现在是独立的类型,不再互相映射 const MATERIAL_TYPE_MAP: Record = { '3D场景': ['3D场景', '3DMAX'], '3DMAX': ['3D场景', '3DMAX'], - '详情页': ['详情页', 'PSD'], - 'PSD': ['详情页', 'PSD'], + '详情页': ['详情页'], // 独立类型,不再映射到详情模板和PSD + '详情模板': ['详情模板'], // 独立类型,不再映射到详情页和PSD + 'PSD': ['详情页'], // 兼容旧数据:PSD类型映射到详情页(用于查询旧数据) '矢量文件': ['矢量文件', 'AI'], 'AI': ['矢量文件', 'AI'], 'C4D模型': ['C4D模型', 'C4D'], @@ -90,6 +94,7 @@ export async function GET(request: NextRequest) { user: { select: { username: true, + realName: true, avatar: true, }, }, @@ -167,8 +172,12 @@ export async function GET(request: NextRequest) { } } + // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName + const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; + const normalizedMaterial = normalizeMaterial({ ...material, + authorName: displayAuthorName, // 使用处理后的作者名 previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), @@ -374,6 +383,7 @@ export async function GET(request: NextRequest) { user: { select: { username: true, + realName: true, avatar: true, }, }, @@ -444,6 +454,7 @@ export async function GET(request: NextRequest) { user: { select: { username: true, + realName: true, avatar: true, }, }, @@ -501,6 +512,7 @@ export async function GET(request: NextRequest) { user: { select: { username: true, + realName: true, }, }, } as any, @@ -566,6 +578,7 @@ export async function GET(request: NextRequest) { user: { select: { username: true, + realName: true, }, }, } as any, @@ -637,18 +650,107 @@ export async function GET(request: NextRequest) { } // 格式化返回数据 + // 对于 Product Package 类型,需要从 assets 中获取正确的预览图 + const productPackageMaterialIds = materials + .filter(m => m.materialType === '产品全案') + .map(m => m.id); + + // 批量获取 Product Package 素材的封面图(优化:使用单次查询) + const coverUrlMap = new Map(); + if (productPackageMaterialIds.length > 0) { + try { + // 批量查询所有 Product Package 素材的主图和SKU图 + const assets = await prisma.materialAsset.findMany({ + where: { + materialId: { in: productPackageMaterialIds }, + assetType: { in: ['main', 'sku'] }, + }, + select: { + materialId: true, + assetType: true, + fileUrl: true, + sortOrder: true, + }, + orderBy: [ + { materialId: 'asc' }, + { assetType: 'asc' }, + { sortOrder: 'asc' }, + ], + }); + + // 按 materialId 分组,优先使用主图,没有主图则使用SKU图 + const assetsByMaterial = new Map(); + for (const asset of assets) { + if (!assetsByMaterial.has(asset.materialId)) { + assetsByMaterial.set(asset.materialId, {}); + } + const materialAssets = assetsByMaterial.get(asset.materialId)!; + if (asset.assetType === 'main' && !materialAssets.main) { + materialAssets.main = asset.fileUrl; + } else if (asset.assetType === 'sku' && !materialAssets.sku && !materialAssets.main) { + materialAssets.sku = asset.fileUrl; + } + } + + // 构建封面图映射 + assetsByMaterial.forEach((materialAssets, materialId) => { + const coverUrl = materialAssets.main || materialAssets.sku || null; + if (coverUrl) { + coverUrlMap.set(materialId, coverUrl); + } + }); + } catch (error) { + console.error('[Materials API] Failed to batch get cover URLs:', error); + // 如果批量查询失败,回退到逐个查询 + await Promise.all( + productPackageMaterialIds.map(async (materialId) => { + try { + const coverUrl = await getMaterialCoverUrl(materialId); + if (coverUrl) { + coverUrlMap.set(materialId, coverUrl); + } + } catch (err) { + console.error(`[Materials API] Failed to get cover URL for material ${materialId}:`, err); + } + }) + ); + } + } + const formattedMaterials = materials.map(material => { // 处理previewUrl的fallback逻辑(与反编译文件一致) let previewUrl = material.previewUrl || material.videoCoverUrl || ''; - if (!previewUrl && material.displayImages) { - const displayImagesArray = parseDisplayImages(material.displayImages); - if (displayImagesArray.length > 0) { - previewUrl = displayImagesArray[0]; + + // Product Package 类型:优先使用从 assets 获取的封面图 + if (material.materialType === '产品全案') { + const coverUrl = coverUrlMap.get(material.id); + if (coverUrl) { + previewUrl = coverUrl; + } else if (!previewUrl) { + // 如果 assets 中没有,再尝试其他 fallback + if (material.displayImages) { + const displayImagesArray = parseDisplayImages(material.displayImages); + if (displayImagesArray.length > 0) { + previewUrl = displayImagesArray[0]; + } + } + } + } else { + // 其他类型:使用原有的 fallback 逻辑 + if (!previewUrl && material.displayImages) { + const displayImagesArray = parseDisplayImages(material.displayImages); + if (displayImagesArray.length > 0) { + previewUrl = displayImagesArray[0]; + } } } + // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName + const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; + const formatted = { ...material, + authorName: displayAuthorName, // 使用处理后的作者名 previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), @@ -659,8 +761,47 @@ export async function GET(request: NextRequest) { avatar: normalizeUrl(material.user.avatar), } : null, }; - // 使用智能 URL 重写(根据用户网络位置选择内网或外网地址) - return rewriteMaterialStorageUrlsForClient(normalizeMaterial(formatted), request); + // 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择 + // 注意:不要先调用 normalizeMaterial,因为它会将URL转换为代理URL,导致内网用户无法重写为内网地址 + // 应该直接使用 rewriteMediaUrlForClient,它会根据用户网络位置智能决定是否重写为内网地址 + const isIntranet = detectIsIntranet(request); + + // 处理音频文件的zipFileName(特殊处理) + let processedZipFileName = formatted.zipFileName; + if (formatted.zipFileName && formatted.materialType === '音乐') { + const zipFileName = formatted.zipFileName; + if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) { + // 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理) + processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio'); + } else { + // 新素材:本地文件 + if (isIntranet) { + // 内网用户:直接访问 + processedZipFileName = `/uploads/${zipFileName}`; + } else { + // 外网用户:通过API访问 + processedZipFileName = `/api/uploads/${zipFileName}`; + } + } + } + + const rewrittenMaterial = { + ...formatted, + // 图片:使用统一规则(内网用户直接访问内网MinIO,外网用户根据文件大小决定是否走代理) + previewUrl: rewriteMediaUrlForClient(formatted.previewUrl, request, 'image'), + videoCoverUrl: rewriteMediaUrlForClient(formatted.videoCoverUrl, request, 'image'), + displayImages: formatted.displayImages?.map((img: string) => + rewriteMediaUrlForClient(img, request, 'image') + ), + // 视频:使用统一规则(内网用户直接访问内网MinIO,外网用户通常>1MB会走代理) + videoPreview540Url: rewriteMediaUrlForClient(formatted.videoPreview540Url, request, 'video'), + videoPreviewClipUrl: rewriteMediaUrlForClient(formatted.videoPreviewClipUrl, request, 'video'), + videoSourceUrl: rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'), + // 音频文件:特殊处理 + zipFileName: processedZipFileName, + }; + + return rewrittenMaterial; }); return NextResponse.json({ diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 6123e21f..0dcfb14f 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; -import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'; +import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite'; +import { detectIsIntranet } from '@/lib/network-detection'; +import { canUserDownloadSource, isSuperAdmin } from '@/lib/department-permission'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -156,17 +158,77 @@ export async function GET(request: NextRequest) { isFavorited = !!favorite; } - const response = { + // 产品全案:检查 PSD 下载权限(参考"详情模板"类型的权限) + let canDownloadSource = false; + if ((material.materialType as unknown as string) === '产品全案' && userId) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + role: true, + department: true, + managedDepartment: true, + }, + }); + + if (user) { + // 产品全案的 PSD 下载权限参考"详情模板"类型的 canDownloadSource 权限 + canDownloadSource = await canUserDownloadSource( + user.department, + user.role, + user.managedDepartment, + '详情模板', // 使用"详情模板"类型检查权限 + userId + ); + } + } + + const response: any = { ...material, zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked, isFavorited, assets: (material.materialType as unknown as string) === '产品全案' ? assets : undefined, + canDownloadSource: (material.materialType as unknown as string) === '产品全案' ? canDownloadSource : undefined, }; - // 使用智能 URL 重写(根据用户网络位置选择内网或外网地址) - const rewrittenResponse = rewriteMaterialStorageUrlsForClient(response, request); + // 使用统一的媒体URL重写规则(方案C:按文件大小阈值) + const isIntranet = detectIsIntranet(request); + + // 处理音频文件的zipFileName(特殊处理) + let processedZipFileName = response.zipFileName; + if (response.zipFileName && response.materialType === '音乐') { + const zipFileName = response.zipFileName; + if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) { + // 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理) + processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio'); + } else { + // 新素材:本地文件 + if (isIntranet) { + // 内网用户:直接访问 + processedZipFileName = `/uploads/${zipFileName}`; + } else { + // 外网用户:通过API访问 + processedZipFileName = `/api/uploads/${zipFileName}`; + } + } + } + + const rewrittenResponse = { + ...response, + // 图片:使用统一规则(小文件直接访问,大文件走代理) + previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'), + videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'), + displayImages: response.displayImages?.map((img: string) => + rewriteMediaUrlForClient(img, request, 'image') + ), + // 视频:使用统一规则(通常>1MB,会走代理) + videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'), + videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'), + videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'), + // 音频文件:特殊处理 + zipFileName: processedZipFileName, + }; return NextResponse.json(rewrittenResponse); } catch (error) { diff --git a/app/api/materials/upload/init/route.ts b/app/api/materials/upload/init/route.ts index 5facc8b6..22bfacb3 100644 --- a/app/api/materials/upload/init/route.ts +++ b/app/api/materials/upload/init/route.ts @@ -13,7 +13,18 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } - const { fileName, fileSize, totalChunks } = await req.json() + let body + try { + body = await req.json() + } catch (jsonError) { + console.error('[UploadInit] JSON解析错误:', jsonError) + return NextResponse.json( + { error: '请求体格式错误,需要JSON格式' }, + { status: 400 } + ) + } + + const { fileName, fileSize, totalChunks } = body if (!fileName || !fileSize || !totalChunks) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index eb3c7578..4c818890 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -18,6 +18,7 @@ interface AssetData { interface CompleteRequestBody { materialId: string; + taskNo: string; // 任务号(必填,上传完成后写入) assets: { main?: AssetData[]; sku?: AssetData[]; @@ -41,15 +42,44 @@ export async function POST(request: NextRequest) { } const body: CompleteRequestBody = await request.json(); - const { materialId: bodyMaterialId, assets } = body; + const { materialId: bodyMaterialId, taskNo, assets } = body; - // 验证必填字段 + // 验证必填字段(taskNo改为可选,不提示) if (!bodyMaterialId || !assets) { return NextResponse.json( - { success: false, error: '缺少必填参数' }, + { success: false, error: '缺少必填参数(materialId、assets)' }, { status: 400 } ); } + + // 如果提供了任务号,验证格式和唯一性 + if (taskNo && taskNo.trim()) { + // 验证任务号格式(双重验证,防止前端传递错误) + const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); + if (!validateTaskNo(taskNo)) { + return NextResponse.json( + { success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' }, + { status: 400 } + ); + } + + // 检查任务号是否已被其他素材使用(双重验证,防止并发问题) + const existingMaterial = await prisma.material.findFirst({ + where: { + taskNo, + deletedAt: null, + id: { not: bodyMaterialId }, // 排除当前素材 + }, + select: { id: true }, + }); + + if (existingMaterial) { + return NextResponse.json( + { success: false, error: '任务号已被其他素材使用' }, + { status: 409 } + ); + } + } materialId = bodyMaterialId; @@ -200,11 +230,12 @@ export async function POST(request: NextRequest) { previewUrl = assets.sku[0].fileUrl; } - // 更新素材状态和封面图 + // 更新素材状态、封面图和任务号(如果有任务号,上传完成后才写入taskNo) await prisma.material.update({ where: { id: materialId! }, data: { previewUrl, + ...(taskNo && taskNo.trim() ? { taskNo: taskNo } : {}), // 只有提供任务号时才写入 taskStatus: TASK_STATUS.COMPLETED, } as any, }); @@ -216,7 +247,7 @@ export async function POST(request: NextRequest) { userId: session.user.id, action: 'UPLOAD', changes: JSON.stringify({ - taskNo: material.taskNo, + ...(taskNo && taskNo.trim() ? { taskNo: taskNo } : {}), // 只有提供任务号时才记录 assetCounts: { main: assets.main?.length || 0, sku: assets.sku?.length || 0, @@ -231,7 +262,7 @@ export async function POST(request: NextRequest) { success: true, data: { materialId: materialId!, - taskNo: material.taskNo, + taskNo: taskNo && taskNo.trim() ? taskNo : null, // 返回taskNo(如果有) message: '上传完成', }, }); diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index bf9145fb..c0bd7a56 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -25,77 +25,80 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { taskNo, productModel, categoryName, sceneName } = body; - // 验证必填字段 - if (!taskNo || !productModel || !categoryName) { + // 验证必填字段(任务号改为可选) + if (!productModel || !categoryName) { return NextResponse.json( - { success: false, error: '任务号、SPU型号、店铺为必填项' }, + { success: false, error: 'SPU型号、店铺为必填项' }, { status: 400 } ); } - // 验证任务号格式 - if (!validateTaskNo(taskNo)) { - return NextResponse.json( - { success: false, error: '任务号格式错误,应为 TASK + 12位数字' }, - { status: 400 } - ); - } + // 如果提供了任务号,验证格式和唯一性 + if (taskNo && taskNo.trim()) { + // 验证任务号格式 + if (!validateTaskNo(taskNo)) { + return NextResponse.json( + { success: false, error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' }, + { status: 400 } + ); + } - // 检查任务号是否已存在(未删除的素材) - const exists = await isTaskNoExists(taskNo); - if (exists) { - return NextResponse.json( - { success: false, error: '任务号已存在' }, - { status: 409 } - ); - } + // 检查任务号是否已存在(未删除的素材) + const exists = await isTaskNoExists(taskNo); + if (exists) { + return NextResponse.json( + { success: false, error: '任务号已存在' }, + { status: 409 } + ); + } - // 检查是否有已删除的素材使用该任务号,如果有则永久删除 - const deletedMaterial = await prisma.material.findFirst({ - where: { - taskNo, - deletedAt: { not: null }, // 只查找已删除的素材 - }, - }); + // 检查是否有已删除的素材使用该任务号,如果有则永久删除 + const deletedMaterial = await prisma.material.findFirst({ + where: { + taskNo, + deletedAt: { not: null }, // 只查找已删除的素材 + }, + }); - if (deletedMaterial) { - console.log(`[Init] 发现已删除的素材使用任务号 ${taskNo},正在永久删除旧素材...`); - - try { - // 收集旧素材的所有文件 - const fileInfo = await collectMaterialFiles(deletedMaterial.id, deletedMaterial); - const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; + if (deletedMaterial) { + console.log(`[Init] 发现已删除的素材使用任务号 ${taskNo},正在永久删除旧素材...`); - // 删除文件 - if (filesToDelete.length > 0) { - const result = await deleteFiles(filesToDelete); - console.log(`[Init] 已删除旧素材文件: ${result.deletedCount} 个,失败: ${result.failedCount} 个`); + try { + // 收集旧素材的所有文件 + const fileInfo = await collectMaterialFiles(deletedMaterial.id, deletedMaterial); + const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; + + // 删除文件 + if (filesToDelete.length > 0) { + const result = await deleteFiles(filesToDelete); + console.log(`[Init] 已删除旧素材文件: ${result.deletedCount} 个,失败: ${result.failedCount} 个`); + } + + // 删除关联记录 + await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType); + + // 从数据库永久删除 + await prisma.material.delete({ + where: { id: deletedMaterial.id }, + }); + + console.log(`[Init] 已永久删除旧素材 ${deletedMaterial.id},任务号 ${taskNo} 现在可以重新使用`); + } catch (cleanupError) { + console.error(`[Init] 清理旧素材失败:`, cleanupError); + // 即使清理失败,也尝试创建新素材(数据库唯一约束可能会阻止) } - - // 删除关联记录 - await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType); - - // 从数据库永久删除 - await prisma.material.delete({ - where: { id: deletedMaterial.id }, - }); - - console.log(`[Init] 已永久删除旧素材 ${deletedMaterial.id},任务号 ${taskNo} 现在可以重新使用`); - } catch (cleanupError) { - console.error(`[Init] 清理旧素材失败:`, cleanupError); - // 即使清理失败,也尝试创建新素材(数据库唯一约束可能会阻止) } } - // 创建素材记录 + // 创建素材记录(不写入taskNo,上传完成后再写入) const material: any = await prisma.material.create({ data: { - taskNo, + // taskNo: 不写入,上传完成后再写入 productModel, categoryName, sceneName: sceneName || '', materialType: '产品全案', - taskStatus: TASK_STATUS.UPLOADING, + // taskStatus: 不写入,上传完成后再写入 previewUrl: '', // 临时占位,上传完成后更新 authorName: (session.user as any).username || (session.user as any).realName || session.user.email, userId: session.user.id, @@ -103,12 +106,13 @@ export async function POST(request: NextRequest) { } as any, }); - // 返回素材ID和上传配置 + // 返回素材ID和任务号(任务号暂不写入数据库,上传完成后再写入) + // 任务号为可选,如果为空则不返回taskNo return NextResponse.json({ success: true, data: { materialId: material.id, - taskNo: material.taskNo, + taskNo: taskNo && taskNo.trim() ? taskNo : null, // 返回taskNo给前端(如果有),complete接口时需要使用 message: '初始化成功,可以开始上传文件', }, }); @@ -117,6 +121,10 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; console.error('[product-package/init] error details:', { errorMessage, errorStack }); + + // 如果素材记录已创建但初始化失败,清理记录(当前逻辑中素材记录在验证后才创建,所以这里通常不会有残留记录) + // 但为了安全起见,可以添加清理逻辑(如果需要的话) + return NextResponse.json( { success: false, error: errorMessage || '服务器错误' }, { status: 500 } diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 124110a7..ebd4833e 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -11,6 +11,9 @@ import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'; import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'; import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation'; import { compressImage, sanitizeFileName } from '@/lib/image-processor'; +import { processPdfUpload } from '@/lib/pdf-upload-handler'; +import { deleteFiles } from '@/lib/file-cleanup'; +import { deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -31,6 +34,11 @@ function generateUniqueFilename(prefix: string, ext: string): string { // POST - 上传素材 export async function POST(request: NextRequest) { + // 用于清理的文件URL列表(上传失败时使用) + const uploadedFileUrls: string[] = []; + let zipFilePath = ''; + let materialId: string | null = null; + try { console.log('[Upload] Starting upload process...'); @@ -63,6 +71,7 @@ export async function POST(request: NextRequest) { const renderer = requiresRenderer(materialType) ? (rendererParam || getDefaultRenderer(materialType)) : (rendererParam || ''); + const taskNo = (formData.get('taskNo') as string) || ''; console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, @@ -85,6 +94,25 @@ export async function POST(request: NextRequest) { ); } + // 任务号验证(可选,如果提供则验证格式和唯一性) + if (taskNo && taskNo.trim()) { + const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); + if (!validateTaskNo(taskNo)) { + return NextResponse.json( + { error: '任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号' }, + { status: 400 } + ); + } + + // 检查任务号是否已存在 + if (await isTaskNoExists(taskNo)) { + return NextResponse.json( + { error: '任务号已存在,请使用其他任务号' }, + { status: 400 } + ); + } + } + if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { return NextResponse.json( { error: '请填写所有必填字段(店铺、场景)' }, @@ -119,7 +147,8 @@ export async function POST(request: NextRequest) { const displayImages: string[] = []; // 处理预览图(第一张作为主预览图) - if (previewImage && !['详情页', '模特'].includes(materialType)) { + // 详情页/详情模板:如果用户上传了预览图,优先使用;否则使用展示图切片的第一张 + if (previewImage && !['模特'].includes(materialType)) { if (previewImage.size > 50 * 1024 * 1024) { return NextResponse.json( { error: `预览图过大,请使用小于50MB的图片` }, @@ -143,11 +172,13 @@ export async function POST(request: NextRequest) { const filename = generateUniqueFilename('preview', '.webp'); console.log('[Upload] Uploading compressed preview image to S3...'); previewUrl = await uploadToS3(compressedBuffer, filename, 'image/webp'); + uploadedFileUrls.push(previewUrl); // 记录已上传的文件,用于失败时清理 console.log('[Upload] Preview image uploaded:', previewUrl); } // 处理其他预览图(作为展示图)- 并行处理以提高速度 - if (previewImages.length > 0 && !['详情页', '模特'].includes(materialType)) { + // 详情页/详情模板:其他预览图也作为展示图处理 + if (previewImages.length > 0 && !['模特'].includes(materialType)) { console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`); // 验证所有图片大小 @@ -177,6 +208,7 @@ export async function POST(request: NextRequest) { const filename = generateUniqueFilename(`preview-${index + 2}`, '.webp'); console.log(`[Upload] Uploading compressed preview image ${index + 2} to S3...`); const url = await uploadToS3(compressedBuffer, filename, 'image/webp'); + uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 console.log(`[Upload] Preview image ${index + 2} uploaded:`, url); return url; }); @@ -187,47 +219,80 @@ export async function POST(request: NextRequest) { console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`); } - // 处理展示图片(详情页) - if (materialType === '详情页' && displayImage) { + // 处理展示图片(详情页/详情模板)- 后端自动压缩和切片 + if ((materialType === '详情页' || materialType === '详情模板') && displayImage) { const buffer = Buffer.from(await displayImage.arrayBuffer()); - const filename = generateUniqueFilename('display', path.extname(displayImage.name)); - const url = await uploadToS3(buffer, filename, displayImage.type); - displayImages.push(url); - if (!previewUrl) { - previewUrl = url; + // 使用后端的 sliceImage 函数自动压缩和切片 + const { sliceImage } = await import('@/lib/image-processor'); + console.log('[Upload] Processing display image (backend slicing)...'); + const slices = await sliceImage(buffer, 1500, 500); // maxHeight=1500px, maxSizeKB=500KB + + // 上传所有切片 + for (let i = 0; i < slices.length; i++) { + const sliceBuffer = slices[i]; + const filename = generateUniqueFilename(`display-${i + 1}`, '.webp'); + const url = await uploadToS3(sliceBuffer, filename, 'image/webp'); + displayImages.push(url); + uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 + + // 如果用户没有上传预览图,使用第一张切片作为预览图 + if (i === 0 && !previewUrl) { + previewUrl = url; + console.log('[Upload] Using first display image slice as preview (no user preview uploaded)'); + } + console.log(`[Upload] Display image slice ${i + 1}/${slices.length} uploaded:`, url); } - console.log('[Upload] Display image uploaded:', url); } // 处理模特图片 if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) { for (let i = 0; i < modelImages.length; i++) { - const image = modelImages[i]; - const buffer = Buffer.from(await image.arrayBuffer()); - const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name)); - const url = await uploadToS3(buffer, filename, image.type); - displayImages.push(url); - - if (i === 0 && !previewUrl) { - previewUrl = url; + try { + const image = modelImages[i]; + if (!image || image.size === 0) { + console.warn(`[Upload] Model image ${i + 1} is empty, skipping`); + continue; + } + const buffer = Buffer.from(await image.arrayBuffer()); + const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name)); + const url = await uploadToS3(buffer, filename, image.type); + displayImages.push(url); + uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 + + if (i === 0 && !previewUrl) { + previewUrl = url; + } + console.log(`[Upload] Model image ${i + 1} uploaded:`, url); + } catch (imageError) { + console.error(`[Upload] Failed to process model image ${i + 1}:`, imageError); + throw new Error(`处理图片 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`); } - console.log(`[Upload] Model image ${i + 1} uploaded:`, url); } } // 处理通用展示图(反编译:买家秀/小红书/主图模版/SKU模版/品牌VI 等) if (displayImagesFromForm.length > 0) { for (let i = 0; i < displayImagesFromForm.length; i++) { - const image = displayImagesFromForm[i]; - const buffer = Buffer.from(await image.arrayBuffer()); - const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name)); - const url = await uploadToS3(buffer, filename, image.type); - displayImages.push(url); - if (!previewUrl) { - previewUrl = url; + try { + const image = displayImagesFromForm[i]; + if (!image || image.size === 0) { + console.warn(`[Upload] Display image ${i + 1} is empty, skipping`); + continue; + } + const buffer = Buffer.from(await image.arrayBuffer()); + const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name)); + const url = await uploadToS3(buffer, filename, image.type); + displayImages.push(url); + uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 + if (!previewUrl) { + previewUrl = url; + } + console.log(`[Upload] Display image ${i + 1} uploaded:`, url); + } catch (imageError) { + console.error(`[Upload] Failed to process display image ${i + 1}:`, imageError); + throw new Error(`处理展示图 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`); } - console.log(`[Upload] Display image ${i + 1} uploaded:`, url); } } @@ -285,6 +350,36 @@ export async function POST(request: NextRequest) { throw new Error('未找到可写入的素材文件数据'); } + // PDF:从PDF文件提取第一页作为预览图(在文件保存后) + if (materialType === '品牌VI灵感') { + console.log('[Upload] 开始处理品牌VI灵感PDF预览图提取...'); + console.log('[Upload] PDF文件路径:', zipFilePath); + + // 强制提取PDF预览图,即使已有previewUrl也要重新提取(确保是图片而不是PDF文件) + const pdfResult = await processPdfUpload(zipFilePath, ''); + + console.log('[Upload] PDF预览图提取结果:', { + success: pdfResult.success, + hasPreviewUrl: !!pdfResult.previewUrl, + previewUrl: pdfResult.previewUrl, + error: pdfResult.error + }); + + if (pdfResult.success && pdfResult.previewUrl) { + previewUrl = pdfResult.previewUrl; + console.log('[Upload] ✅ PDF预览图提取成功,已设置previewUrl:', previewUrl); + } else { + const errorMsg = pdfResult.error || '未知错误'; + console.error('[Upload] ❌ PDF预览图提取失败:', errorMsg); + console.error('[Upload] 这会导致首页卡片显示占位符,请检查:'); + console.error('[Upload] 1. PDF文件是否存在:', zipFilePath); + console.error('[Upload] 2. poppler-utils是否已安装'); + console.error('[Upload] 3. PDF文件是否有效'); + // PDF预览提取失败时,previewUrl保持为空,首页卡片会显示占位符 + previewUrl = ''; + } + } + // 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化 if (materialType === '音乐' && zipFile && buffer && isAudioFile(zipFile.type)) { try { @@ -308,6 +403,7 @@ export async function POST(request: NextRequest) { const pictureFilename = generateUniqueFilename('audio-cover', pictureExt); const pictureUrl = await uploadToS3(pictureBuffer, pictureFilename, pictureFormat); previewUrl = pictureUrl; + uploadedFileUrls.push(pictureUrl); // 记录已上传的文件,用于失败时清理 console.log('[Upload] Audio cover image extracted and uploaded:', pictureUrl); } catch (coverError) { console.error('[Upload] Failed to upload audio cover image:', coverError); @@ -356,6 +452,22 @@ export async function POST(request: NextRequest) { console.log('[Upload] Audio material without cover image, using audio placeholder') } + // 验证买家秀图片必须有至少一张图片和预览图 + if (materialType === '买家秀图片') { + if (displayImages.length === 0) { + return NextResponse.json( + { error: '买家秀图片必须上传至少一张图片' }, + { status: 400 } + ); + } + if (!previewUrl || previewUrl.trim() === '') { + return NextResponse.json( + { error: '买家秀图片必须有一张预览图' }, + { status: 400 } + ); + } + } + // 创建数据库记录(包含所有字段,确保数据不丢失) // 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放 const videoSourceUrl = @@ -363,6 +475,17 @@ export async function POST(request: NextRequest) { ? `/uploads/${zipFileName}` : null; + // 最终保存前的previewUrl验证 + if (materialType === '品牌VI灵感') { + console.log('[Upload] ========== 品牌VI灵感预览图最终检查 =========='); + console.log('[Upload] previewUrl:', previewUrl); + console.log('[Upload] previewUrl是否为空:', !previewUrl || previewUrl.trim() === ''); + console.log('[Upload] previewUrl是否为PDF文件:', previewUrl && previewUrl.endsWith('.pdf')); + console.log('[Upload] previewUrl是否为图片文件:', previewUrl && (previewUrl.endsWith('.jpg') || previewUrl.endsWith('.jpeg') || previewUrl.endsWith('.png') || previewUrl.endsWith('.webp'))); + console.log('[Upload] ============================================'); + } + + // 创建素材记录(不写入taskNo,上传完成后再写入) const material = await prisma.material.create({ data: { previewUrl, @@ -380,6 +503,8 @@ export async function POST(request: NextRequest) { displayImages, videoSourceUrl, // 视频类型时保存视频URL videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态 + // taskNo: 不写入,上传完成后再写入 + // taskStatus: 不写入,上传完成后再写入 userId: session.user.id, } as any, include: { @@ -390,6 +515,34 @@ export async function POST(request: NextRequest) { }); console.log('[Upload] Database record created:', material.id); + materialId = material.id; // 保存materialId,用于失败时清理 + + // 如果提供了taskNo,上传完成后写入taskNo(所有素材类型统一处理) + if (taskNo && taskNo.trim()) { + try { + // 双重验证:再次检查taskNo唯一性(防止并发问题) + const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); + if (validateTaskNo(taskNo)) { + const exists = await isTaskNoExists(taskNo); + if (!exists) { + // 写入taskNo和taskStatus + await prisma.material.update({ + where: { id: material.id }, + data: { + taskNo: taskNo, + taskStatus: 'completed', + } as any, + }); + console.log('[Upload] TaskNo written to database:', taskNo); + } else { + console.warn('[Upload] TaskNo already exists, skipping write:', taskNo); + } + } + } catch (taskNoError) { + console.error('[Upload] Failed to write taskNo:', taskNoError); + // taskNo写入失败不影响上传流程,只记录错误 + } + } // 验证文件是否存在(特别是音乐和视频文件) if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) { @@ -486,9 +639,67 @@ export async function POST(request: NextRequest) { { status: 201 } ); } catch (error) { - console.error('Upload error:', error); + // 输出详细的错误信息以便调试 + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + console.error('[Upload] ❌ Upload error:', { + message: errorMessage, + stack: errorStack, + materialType: (error as any)?.materialType, + }); + + // 清理已上传的文件和已创建的素材记录(防止垃圾文件) + try { + const filesToDelete: string[] = [...uploadedFileUrls]; + if (zipFilePath) { + filesToDelete.push(zipFilePath); + } + + if (filesToDelete.length > 0) { + const { deleteFiles } = await import('@/lib/file-cleanup'); + const cleanupResult = await deleteFiles(filesToDelete); + console.log(`[Upload] Cleaned up ${cleanupResult.deletedCount} files, ${cleanupResult.failedCount} failed`); + } + + // 如果素材记录已创建但没有taskNo,删除记录(避免残留) + if (materialId) { + try { + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { taskNo: true }, + }); + + // 如果没有taskNo,说明上传未完成,删除记录 + if (material && !material.taskNo) { + // 先删除关联记录 + const materialType = (error as any)?.materialType || '未知'; + await deleteMaterialRelatedRecords(materialId, materialType); + + // 再删除素材记录 + await prisma.material.delete({ + where: { id: materialId }, + }); + console.log(`[Upload] Deleted incomplete material record: ${materialId}`); + } + } catch (deleteError) { + console.error('[Upload] Failed to delete material record:', deleteError); + // 删除失败不影响错误返回 + } + } + } catch (cleanupError) { + console.error('[Upload] Cleanup failed:', cleanupError); + // 清理失败不影响错误返回 + } + + // 返回更详细的错误信息(开发环境)或通用错误(生产环境) + const isDevelopment = process.env.NODE_ENV === 'development'; return NextResponse.json( - { error: '上传失败,请稍后再试' }, + { + error: isDevelopment + ? `上传失败: ${errorMessage}` + : '上传失败,请稍后再试', + ...(isDevelopment && errorStack ? { stack: errorStack.substring(0, 500) } : {}) + }, { status: 500 } ); } diff --git a/app/api/network-status/route.ts b/app/api/network-status/route.ts index 453eff6e..e233d388 100644 --- a/app/api/network-status/route.ts +++ b/app/api/network-status/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { isInternalIP, detectIsIntranet } from '@/lib/network-detection' +import { isInternalIP, detectIsIntranet, extractClientIpFromForwarded } from '@/lib/network-detection' -// 判断是否为内网IP地址(保留向后兼容) +// 判断是否为内网IP地址(保留向后兼容,用于判断hostname) function isInternalIPLegacy(hostname: string): boolean { if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { return true @@ -34,20 +34,26 @@ function isInternalIPLegacy(hostname: string): boolean { // GET: 检测用户是否在内网,以及是否访问公网域名 export async function GET(request: NextRequest) { try { - // 获取客户端IP(从请求头中获取,如果通过代理) - const forwarded = request.headers.get('x-forwarded-for') - const realIp = request.headers.get('x-real-ip') - const clientIp = forwarded?.split(',')[0]?.trim() || realIp || 'unknown' - // 获取当前访问的域名 const host = request.headers.get('host') || '' const hostname = host.split(':')[0] // 移除端口号 - // 使用统一的网络检测函数 + // 使用统一的网络检测函数(已包含严谨的检测逻辑) const isIntranet = detectIsIntranet(request) + // 获取客户端IP(用于调试和日志,使用与 detectIsIntranet 相同的逻辑) + const forwarded = request.headers.get('x-forwarded-for') + const realIp = request.headers.get('x-real-ip') + let clientIp: string | null = null + if (forwarded) { + clientIp = extractClientIpFromForwarded(forwarded) + } + if (!clientIp && realIp) { + clientIp = realIp + } + const clientIpDisplay = clientIp || 'unknown' + // 判断是否为公网域名(非内网IP的域名,且不是localhost) - // 公网域名特征:不是IP地址格式,且不是localhost/127.0.0.1 const hostnameIsIntranet = isInternalIPLegacy(hostname) const isPublicDomain = !hostnameIsIntranet && hostname !== 'localhost' && @@ -58,14 +64,25 @@ export async function GET(request: NextRequest) { // 确保hostname不是IP格式(是域名) !hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) - return NextResponse.json({ + const response = NextResponse.json({ success: true, isIntranet, isPublicDomain, - clientIp, + clientIp: clientIpDisplay, hostname, timestamp: new Date().toISOString() }) + + // 添加CORS头,允许从任何来源访问(用于客户端直接检测内网访问) + const origin = request.headers.get('origin') + if (origin) { + response.headers.set('Access-Control-Allow-Origin', origin) + response.headers.set('Access-Control-Allow-Credentials', 'true') + response.headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS') + response.headers.set('Access-Control-Allow-Headers', 'Content-Type') + } + + return response } catch (error) { console.error('[Network Status] Error:', error) return NextResponse.json( diff --git a/app/api/performance/quick/route.ts b/app/api/performance/quick/route.ts index e503a5b2..a33d71da 100644 --- a/app/api/performance/quick/route.ts +++ b/app/api/performance/quick/route.ts @@ -5,6 +5,7 @@ import { promisify } from 'util' import { uploadSessions } from '@/lib/upload-sessions' import { transcodeQueue } from '@/lib/video-transcode-queue' import { prisma } from '@/lib/prisma' +import { getNetworkSpeed, calculateUploadSpeedFromSessions } from '@/lib/network-bandwidth-monitor' const execAsync = promisify(exec) @@ -105,9 +106,21 @@ export async function GET() { percentage: memPercentage, } + // 获取网络带宽使用情况 + const networkSpeed = await getNetworkSpeed() + const uploadSpeedFromSessions = calculateUploadSpeedFromSessions(uploadSessions) + + // 优先使用系统级别的网络统计,如果没有则使用上传会话的速度 + const network = { + uploadSpeed: networkSpeed?.uploadSpeed || uploadSpeedFromSessions || 0, // 字节/秒 + downloadSpeed: networkSpeed?.downloadSpeed || 0, // 字节/秒 + } + // 2. 上传队列(基于 uploadSessions) const uploadProcessing: any[] = [] const uploadWaiting: any[] = [] + const uploadUserIds = new Set() + const uploadIds = new Set() // 只显示真正进行中的上传(进度 < 100%) uploadSessions.forEach((session, uploadId) => { @@ -120,20 +133,84 @@ export async function GET() { // 只显示未完成的上传(进度 < 100%) if (progress < 100) { + uploadUserIds.add(session.userId) + uploadIds.add(uploadId) uploadProcessing.push({ uploadId, fileName: session.fileName, - materialType: '未知', // 目前 UploadSession 未区分素材类型,后续可扩展 progress, userId: session.userId, + currentSpeed: (session as any).currentSpeed || 0, // 上传速度(字节/秒) }) } }) + // 批量查询用户信息 + const userInfoMap = new Map() + if (uploadUserIds.size > 0) { + try { + const users = await prisma.user.findMany({ + where: { + id: { in: Array.from(uploadUserIds) }, + } as any, + select: { + id: true, + username: true, + realName: true, + } as any, + }) + users.forEach((user: any) => { + userInfoMap.set(user.id, { + username: user.username || '未知', + realName: user.realName || undefined, + }) + }) + } catch (error) { + console.error('[PerformanceQuick] Failed to fetch user info:', error) + } + } + + // 尝试通过uploadId查询已创建的material记录(获取materialType) + const uploadMaterialInfoMap = new Map() + if (uploadIds.size > 0) { + try { + const materials = await prisma.material.findMany({ + where: { + uploadId: { in: Array.from(uploadIds) }, + deletedAt: null, + } as any, + select: { + uploadId: true, + materialType: true, + } as any, + }) + materials.forEach((m: any) => { + if (m.uploadId) { + uploadMaterialInfoMap.set(m.uploadId, { + materialType: m.materialType || '未知类型', + }) + } + }) + } catch (error) { + console.error('[PerformanceQuick] Failed to fetch upload material info:', error) + } + } + + // 添加用户名和素材类型到上传队列 + const uploadProcessingWithUsers = uploadProcessing.map((task) => { + const userInfo = userInfoMap.get(task.userId) || { username: '未知用户' } + const materialInfo = uploadMaterialInfoMap.get(task.uploadId) || { materialType: '未知类型' } + return { + ...task, + username: userInfo.realName || userInfo.username || '未知用户', + materialType: materialInfo.materialType, + } + }) + const uploadQueue = { - processing: uploadProcessing, + processing: uploadProcessingWithUsers, waiting: uploadWaiting, - total: uploadProcessing.length + uploadWaiting.length, + total: uploadProcessingWithUsers.length + uploadWaiting.length, } // 3. 转码队列(基于 transcodeQueue) @@ -226,6 +303,7 @@ export async function GET() { return NextResponse.json({ cpu, memory, + network, uploadQueue, transcodeQueue: transcodeQueueData, }) diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts index 7afb5948..3a19d6eb 100644 --- a/app/api/proxy-media/route.ts +++ b/app/api/proxy-media/route.ts @@ -1,10 +1,53 @@ // 媒体资源代理 API - 解决混合内容问题 // 将 HTTP 资源通过 HTTPS 代理提供给前端 import { NextRequest, NextResponse } from 'next/server'; +import dns from 'dns'; +import { promisify } from 'util'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; +// 设置DNS服务器(使用docker-compose.yml中配置的DNS) +dns.setServers(['223.5.5.5', '8.8.8.8', '114.114.114.114']); + +// DNS 解析缓存(避免高并发时DNS解析失败) +const dnsCache = new Map(); +const DNS_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 +const resolve4 = promisify(dns.resolve4); + +// DNS 解析函数(带缓存和重试) +async function resolveHostnameWithRetry(hostname: string, retries = 3): Promise { + // 检查缓存 + const cached = dnsCache.get(hostname); + if (cached && cached.expires > Date.now()) { + return; // 缓存有效,直接返回 + } + + // 尝试解析(预热DNS缓存) + for (let i = 0; i < retries; i++) { + try { + const addresses = await resolve4(hostname); + if (addresses && addresses.length > 0) { + const ip = addresses[0]; + // 更新缓存 + dnsCache.set(hostname, { + ip, + expires: Date.now() + DNS_CACHE_TTL, + }); + return; // 解析成功 + } + } catch (error: any) { + if (i === retries - 1) { + // 最后一次重试失败,记录但继续(让fetch自己处理DNS) + console.warn(`[Proxy Media] DNS预解析失败: ${hostname} - ${error.message}`); + return; + } + // 等待后重试(指数退避) + await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i))); + } + } +} + // 允许的域名白名单 const ALLOWED_DOMAINS = [ 's3.1080p.hk', @@ -21,11 +64,13 @@ const ALLOWED_EXTENSIONS = [ ]; export async function GET(request: NextRequest) { + let url: string | null = null; try { const searchParams = request.nextUrl.searchParams; - const url = searchParams.get('url'); + url = searchParams.get('url'); if (!url) { + console.warn('[Proxy Media] 缺少 url 参数'); return NextResponse.json( { error: '缺少 url 参数' }, { status: 400 } @@ -35,8 +80,11 @@ export async function GET(request: NextRequest) { // 解析 URL let targetUrl: URL; try { - targetUrl = new URL(url); - } catch { + // 解码 URL(可能被双重编码) + const decodedUrl = decodeURIComponent(url); + targetUrl = new URL(decodedUrl); + } catch (error) { + console.error('[Proxy Media] 无效的 URL:', url, error); return NextResponse.json( { error: '无效的 URL' }, { status: 400 } @@ -50,19 +98,23 @@ export async function GET(request: NextRequest) { ); if (!isAllowed) { + console.warn('[Proxy Media] 不允许的域名:', hostname, 'URL:', targetUrl.toString()); return NextResponse.json( { error: '不允许的域名' }, { status: 403 } ); } - // 检查文件扩展名 + // 检查文件扩展名(从路径中提取,忽略查询参数) const pathname = targetUrl.pathname.toLowerCase(); + // 移除查询参数和片段,只检查路径部分 + const pathWithoutQuery = pathname.split('?')[0]; const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => - pathname.endsWith(ext) + pathWithoutQuery.endsWith(ext) ); if (!hasAllowedExtension) { + console.warn('[Proxy Media] 不允许的文件类型:', pathname, 'URL:', targetUrl.toString()); return NextResponse.json( { error: '不允许的文件类型' }, { status: 403 } @@ -82,19 +134,58 @@ export async function GET(request: NextRequest) { fetchHeaders['Range'] = rangeHeader; } - // 获取资源 - const response = await fetch(targetUrl.toString(), { - headers: fetchHeaders, - // 设置超时 - signal: AbortSignal.timeout(30000), // 30秒超时 - }); + // 创建超时控制器(兼容Node.js 18) + // 优化:根据是否有Range请求设置不同的超时时间 + // Range请求(视频播放)需要更长超时,完整请求可以更快超时 + const hasRangeRequest = !!request.headers.get('range') + const timeout = hasRangeRequest ? 30000 : 10000 // Range请求30秒,完整请求10秒 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); - if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的 - return NextResponse.json( - { error: `资源获取失败: ${response.status}` }, - { status: response.status } - ); - } + try { + // 优化:对于内网MinIO地址,跳过DNS预解析(内网地址不需要DNS) + const isIntranetMinIO = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) || hostname === 'localhost' || hostname === '127.0.0.1' + + // 预热DNS缓存(仅对公网域名,内网地址跳过) + if (!isIntranetMinIO) { + // 使用Promise.race确保不会无限等待,减少DNS解析时间 + await Promise.race([ + resolveHostnameWithRetry(hostname), + new Promise(resolve => setTimeout(resolve, 300)), // 减少到300ms,加快响应 + ]).catch(() => { + // 静默失败,继续执行fetch(让fetch自己处理DNS) + }); + } + + // 获取资源(fetch会使用Node.js的DNS缓存和我们的预解析结果) + // 优化:添加keepalive和更短的超时时间 + const response = await fetch(targetUrl.toString(), { + headers: { + ...fetchHeaders, + 'Connection': 'keep-alive', // 保持连接,减少连接建立时间 + }, + signal: controller.signal, + // 优化:减少超时时间,加快失败响应 + // 注意:超时由AbortController控制,这里只是设置更短的超时 + }); + + clearTimeout(timeoutId); + + if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的 + // 对于 404 错误,记录详细信息以便诊断 + if (response.status === 404) { + console.error(`[Proxy Media] 资源不存在 (404): ${targetUrl.toString()}`); + console.error(`[Proxy Media] 原始请求 URL: ${url}`); + console.error(`[Proxy Media] 解析后的 URL: ${targetUrl.toString()}`); + } else { + console.error(`[Proxy Media] 资源获取失败: ${response.status} - ${targetUrl.toString()}`); + } + // 对于 404,直接返回 404,让前端可以处理(比如显示占位图) + return NextResponse.json( + { error: `资源获取失败: ${response.status}`, url: targetUrl.toString() }, + { status: response.status } + ); + } // 获取内容类型 const contentType = response.headers.get('content-type') || 'application/octet-stream'; @@ -102,36 +193,88 @@ export async function GET(request: NextRequest) { const contentRange = response.headers.get('content-range'); const acceptRanges = response.headers.get('accept-ranges'); - // 获取响应体 - const buffer = await response.arrayBuffer(); + // 构建响应头(优化缓存策略) + const responseHeaders: HeadersInit = { + 'Content-Type': contentType, + // 优化缓存:视频文件缓存1年,但允许重新验证 + 'Cache-Control': response.status === 206 + ? 'public, max-age=31536000, immutable' // Range请求:长期缓存 + : 'public, max-age=3600, must-revalidate', // 完整请求:1小时缓存,需要重新验证 + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers': 'Range', + // 添加ETag支持(如果源服务器提供) + ...(response.headers.get('etag') && { 'ETag': response.headers.get('etag')! }), + // 添加Last-Modified支持(如果源服务器提供) + ...(response.headers.get('last-modified') && { 'Last-Modified': response.headers.get('last-modified')! }), + }; - // 构建响应头 - const responseHeaders: HeadersInit = { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET', - }; + // 添加 Range 相关头(如果存在) + if (contentLength) { + responseHeaders['Content-Length'] = contentLength; + } + if (contentRange) { + responseHeaders['Content-Range'] = contentRange; + } + if (acceptRanges) { + responseHeaders['Accept-Ranges'] = acceptRanges; + } - // 添加 Range 相关头(如果存在) - if (contentLength) { - responseHeaders['Content-Length'] = contentLength; + // 使用流式传输,避免大文件导致内存溢出 + // 将 ReadableStream 转换为 NextResponse 可以使用的流 + if (response.body) { + return new NextResponse(response.body, { + status: response.status, // 可能是 200 或 206 + headers: responseHeaders, + }); + } else { + // 如果 response.body 不存在,回退到 arrayBuffer(小文件) + const buffer = await response.arrayBuffer(); + return new NextResponse(buffer, { + status: response.status, + headers: responseHeaders, + }); + } + } catch (fetchError: any) { + clearTimeout(timeoutId); + + // 如果是超时或中止错误 + if (fetchError.name === 'AbortError' || fetchError.name === 'TimeoutError') { + console.error(`[Proxy Media] 请求超时: ${targetUrl.toString()}`); + return NextResponse.json( + { error: '请求超时', url: targetUrl.toString() }, + { status: 504 } + ); + } + + // 其他fetch错误(如连接失败) + console.error(`[Proxy Media] Fetch错误: ${fetchError.message} - ${targetUrl.toString()}`); + console.error(`[Proxy Media] 原始请求 URL: ${url}`); + throw fetchError; // 重新抛出,让外层catch处理 } - if (contentRange) { - responseHeaders['Content-Range'] = contentRange; - } - if (acceptRanges) { - responseHeaders['Accept-Ranges'] = acceptRanges; - } - - // 返回资源 - return new NextResponse(buffer, { - status: response.status, // 可能是 200 或 206 - headers: responseHeaders, - }); } catch (error: any) { console.error('[Proxy Media] Error:', error); + console.error('[Proxy Media] 原始请求 URL:', url); + // 如果是DNS解析错误 + if (error.code === 'EAI_AGAIN' || error.code === 'ENOTFOUND' || error.message?.includes('getaddrinfo')) { + console.error(`[Proxy Media] DNS解析错误: ${error.code} - ${error.message}`); + return NextResponse.json( + { error: 'DNS解析失败,请稍后重试', details: error.message }, + { status: 502 } + ); + } + + // 如果是网络连接错误 + if (error.message?.includes('ECONNREFUSED') || error.message?.includes('ENOTFOUND') || error.message?.includes('ERR_CONNECTION')) { + console.error(`[Proxy Media] 网络连接错误: ${error.message}`); + return NextResponse.json( + { error: '无法连接到资源服务器', details: error.message }, + { status: 502 } + ); + } + + // 如果是超时错误 if (error.name === 'AbortError' || error.name === 'TimeoutError') { return NextResponse.json( { error: '请求超时' }, @@ -139,8 +282,13 @@ export async function GET(request: NextRequest) { ); } + console.error(`[Proxy Media] 未知错误: ${error.message}`, error); + // 记录完整的错误信息(包括cause) + if (error.cause) { + console.error(`[Proxy Media] 错误原因:`, error.cause); + } return NextResponse.json( - { error: '代理请求失败' }, + { error: '代理请求失败', details: error.message }, { status: 500 } ); } diff --git a/app/api/test-s3-speed/route.ts b/app/api/test-s3-speed/route.ts new file mode 100644 index 00000000..c3687753 --- /dev/null +++ b/app/api/test-s3-speed/route.ts @@ -0,0 +1,648 @@ +import { NextRequest, NextResponse } from 'next/server' +import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3' +import { randomBytes } from 'crypto' +import { detectIsIntranet } from '@/lib/network-detection' + +export const dynamic = 'force-dynamic' + +// 创建S3客户端 +const s3Client = new S3Client({ + endpoint: process.env.S3_ENDPOINT || '', + region: process.env.S3_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }, + forcePathStyle: true, + requestHandler: { + requestTimeout: 60000, // 增加到60秒以支持大文件 + connectionTimeout: 10000, + }, +}) + +// 计算统计信息 +function calculateStats(durations: number[]) { + if (durations.length === 0) { + return { + min: 0, + max: 0, + avg: 0, + p50: 0, + p90: 0, + p95: 0, + p99: 0, + total: 0, + } + } + const sorted = [...durations].sort((a, b) => a - b) + return { + min: sorted[0], + max: sorted[sorted.length - 1], + avg: durations.reduce((a, b) => a + b, 0) / durations.length, + p50: sorted[Math.floor(sorted.length * 0.5)], + p90: sorted[Math.floor(sorted.length * 0.9)], + p95: sorted[Math.floor(sorted.length * 0.95)], + p99: sorted[Math.floor(sorted.length * 0.99)], + total: durations.reduce((a, b) => a + b, 0), + } +} + +// 测试连接速度 +async function testConnectionSpeed(): Promise { + const startTime = Date.now() + try { + const command = new ListObjectsV2Command({ + Bucket: process.env.S3_BUCKET!, + MaxKeys: 1, + }) + await s3Client.send(command) + return Date.now() - startTime + } catch (error: any) { + return Date.now() - startTime + } +} + +// 测试上传速度(单个) +async function testUploadSpeed(sizeKB: number, testKey?: string): Promise<{ duration: number; speed: number; success: boolean; error?: string; key?: string }> { + const key = testKey || `speed-test-${Date.now()}-${Math.random().toString(36).substring(7)}.bin` + const testData = randomBytes(sizeKB * 1024) + + const startTime = Date.now() + try { + const command = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + Body: testData, + ContentType: 'application/octet-stream', + }) + await s3Client.send(command) + const duration = Date.now() - startTime + const speed = (sizeKB / duration) * 1000 // KB/s + return { duration, speed, success: true, key } + } catch (error: any) { + const duration = Date.now() - startTime + return { duration, speed: 0, success: false, error: error.message, key } + } +} + +// 测试下载速度(通过HTTP) +async function testDownloadSpeed(url: string): Promise<{ duration: number; speed: number; ttfb: number; success: boolean; error?: string }> { + const startTime = Date.now() + let ttfb = 0 + try { + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(30000), + }) + ttfb = Date.now() - startTime + + if (response.ok) { + const contentLength = response.headers.get('content-length') + if (contentLength) { + const sizeKB = parseInt(contentLength) / 1024 + + // 如果文件较小,尝试完整下载测试 + if (sizeKB < 5000) { // 增加到5MB + const downloadStart = Date.now() + const downloadResponse = await fetch(url, { + signal: AbortSignal.timeout(60000), + }) + if (downloadResponse.ok) { + await downloadResponse.arrayBuffer() + const downloadDuration = Date.now() - downloadStart + const speed = (sizeKB / downloadDuration) * 1000 // KB/s + return { duration: downloadDuration, speed, ttfb, success: true } + } + } + return { duration: ttfb, speed: 0, ttfb, success: true } + } + return { duration: ttfb, speed: 0, ttfb, success: true } + } else { + return { duration: ttfb, speed: 0, ttfb, success: false, error: `HTTP ${response.status}` } + } + } catch (error: any) { + const duration = Date.now() - startTime + return { duration, speed: 0, ttfb: duration, success: false, error: error.message } + } +} + +// 并发上传测试 +async function testConcurrentUploads( + concurrency: number, + sizeKB: number, + count: number +): Promise<{ + total: number + success: number + failed: number + durations: number[] + speeds: number[] + stats: ReturnType + throughput: number // 总吞吐量 MB/s +}> { + const durations: number[] = [] + const speeds: number[] = [] + let success = 0 + let failed = 0 + const keys: string[] = [] + + const startTime = Date.now() + + // 分批执行,每批concurrency个 + for (let i = 0; i < count; i += concurrency) { + const batch = [] + for (let j = 0; j < concurrency && i + j < count; j++) { + batch.push(testUploadSpeed(sizeKB)) + } + + const results = await Promise.allSettled(batch) + + for (const result of results) { + if (result.status === 'fulfilled') { + const r = result.value + if (r.success) { + durations.push(r.duration) + speeds.push(r.speed) + success++ + if (r.key) keys.push(r.key) + } else { + failed++ + } + } else { + failed++ + } + } + } + + const totalTime = Date.now() - startTime + const totalSizeMB = (success * sizeKB) / 1024 + const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s + + // 清理测试文件 + if (keys.length > 0) { + await Promise.allSettled( + keys.map(key => + s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: key, + }) + ) + ) + ) + } + + return { + total: count, + success, + failed, + durations, + speeds, + stats: calculateStats(durations), + throughput, + } +} + +// 并发下载测试 +async function testConcurrentDownloads( + concurrency: number, + urls: string[], + count: number +): Promise<{ + total: number + success: number + failed: number + durations: number[] + speeds: number[] + ttfb: number[] + stats: ReturnType + ttfbStats: ReturnType + throughput: number // 总吞吐量 MB/s +}> { + const durations: number[] = [] + const speeds: number[] = [] + const ttfb: number[] = [] + let success = 0 + let failed = 0 + + const startTime = Date.now() + + // 循环使用URLs + for (let i = 0; i < count; i += concurrency) { + const batch = [] + for (let j = 0; j < concurrency && i + j < count; j++) { + const url = urls[(i + j) % urls.length] + batch.push(testDownloadSpeed(url)) + } + + const results = await Promise.allSettled(batch) + + for (const result of results) { + if (result.status === 'fulfilled') { + const r = result.value + if (r.success) { + durations.push(r.duration) + speeds.push(r.speed) + ttfb.push(r.ttfb) + success++ + } else { + failed++ + } + } else { + failed++ + } + } + } + + const totalTime = Date.now() - startTime + // 估算总大小(假设每个文件平均大小) + const avgSizeKB = speeds.length > 0 ? (speeds.reduce((a, b) => a + b, 0) / speeds.length) * (durations.reduce((a, b) => a + b, 0) / durations.length) / 1000 : 0 + const totalSizeMB = (success * avgSizeKB) / 1024 + const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s + + return { + total: count, + success, + failed, + durations, + speeds, + ttfb, + stats: calculateStats(durations), + ttfbStats: calculateStats(ttfb), + throughput, + } +} + +// 混合负载测试(同时上传和下载) +async function testMixedLoad( + uploadConcurrency: number, + downloadConcurrency: number, + uploadSizeKB: number, + downloadUrls: string[], + duration: number // 测试持续时间(毫秒) +): Promise<{ + upload: { + total: number + success: number + failed: number + throughput: number + } + download: { + total: number + success: number + failed: number + throughput: number + } +}> { + let uploadTotal = 0 + let uploadSuccess = 0 + let uploadFailed = 0 + let uploadTotalSizeMB = 0 + let downloadTotal = 0 + let downloadSuccess = 0 + let downloadFailed = 0 + let downloadTotalSizeMB = 0 + + const startTime = Date.now() + const endTime = startTime + duration + + // 启动上传任务 + const uploadTask = (async () => { + while (Date.now() < endTime) { + const batch = [] + for (let i = 0; i < uploadConcurrency; i++) { + batch.push(testUploadSpeed(uploadSizeKB)) + } + const results = await Promise.allSettled(batch) + + for (const result of results) { + uploadTotal++ + if (result.status === 'fulfilled') { + const r = result.value + if (r.success) { + uploadSuccess++ + uploadTotalSizeMB += uploadSizeKB / 1024 + // 清理 + if (r.key) { + s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: r.key, + }) + ).catch(() => {}) + } + } else { + uploadFailed++ + } + } else { + uploadFailed++ + } + } + } + })() + + // 启动下载任务 + const downloadTask = (async () => { + while (Date.now() < endTime) { + const batch = [] + for (let i = 0; i < downloadConcurrency; i++) { + const url = downloadUrls[i % downloadUrls.length] + if (url) { + batch.push(testDownloadSpeed(url)) + } + } + const results = await Promise.allSettled(batch) + + for (const result of results) { + downloadTotal++ + if (result.status === 'fulfilled') { + const r = result.value + if (r.success) { + downloadSuccess++ + // 估算大小 + if (r.speed > 0) { + downloadTotalSizeMB += (r.speed * r.duration) / (1024 * 1000) + } + } else { + downloadFailed++ + } + } else { + downloadFailed++ + } + } + } + })() + + await Promise.all([uploadTask, downloadTask]) + + const actualDuration = Date.now() - startTime + + return { + upload: { + total: uploadTotal, + success: uploadSuccess, + failed: uploadFailed, + throughput: actualDuration > 0 ? (uploadTotalSizeMB / actualDuration) * 1000 : 0, + }, + download: { + total: downloadTotal, + success: downloadSuccess, + failed: downloadFailed, + throughput: actualDuration > 0 ? (downloadTotalSizeMB / actualDuration) * 1000 : 0, + }, + } +} + +// 获取测试文件URLs +async function getTestFileUrls(count: number = 10): Promise { + const urls: string[] = [] + try { + const command = new ListObjectsV2Command({ + Bucket: process.env.S3_BUCKET!, + MaxKeys: count, + }) + const response = await s3Client.send(command) + if (response.Contents && response.Contents.length > 0) { + for (const file of response.Contents) { + const key = file.Key || '' + // 跳过测试文件 + if (key && !key.includes('speed-test-')) { + urls.push(`${process.env.S3_PUBLIC_DOMAIN}/${key}`) + } + } + } + } catch (error) { + // ignore + } + return urls +} + +// 清理所有测试文件(以防有遗漏) +async function cleanupAllTestFiles(): Promise<{ deleted: number; failed: number }> { + let deleted = 0 + let failed = 0 + let continuationToken: string | undefined = undefined + + try { + do { + const listCommand: ListObjectsV2Command = new ListObjectsV2Command({ + Bucket: process.env.S3_BUCKET!, + Prefix: 'speed-test-', + ContinuationToken: continuationToken, + MaxKeys: 1000, + }) + + const listResponse = await s3Client.send(listCommand) as ListObjectsV2CommandOutput + + if (listResponse.Contents && listResponse.Contents.length > 0) { + const deletePromises = listResponse.Contents.map((file) => { + if (file.Key) { + return s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: file.Key, + }) + ).then(() => { + deleted++ + }).catch(() => { + failed++ + }) + } + return Promise.resolve() + }) + + await Promise.allSettled(deletePromises) + } + + continuationToken = listResponse.NextContinuationToken + } while (continuationToken) + } catch (error) { + console.warn('[S3 Speed Test] Cleanup error:', error) + } + + return { deleted, failed } +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const testType = (searchParams.get('type') || 'full') as 'full' | 'concurrent' | 'mixed' | 'quick' + const concurrency = parseInt(searchParams.get('concurrency') || '10') + const count = parseInt(searchParams.get('count') || '50') + + const isIntranet = detectIsIntranet(request) + + console.log('[S3 Speed Test] Starting comprehensive tests...') + console.log(`[S3 Speed Test] Type: ${testType}, Concurrency: ${concurrency}, Count: ${count}`) + console.log(`[S3 Speed Test] Is Intranet: ${isIntranet}`) + + const result: any = { + success: true, + isIntranet, + config: { + s3Endpoint: process.env.S3_ENDPOINT, + s3PublicDomain: process.env.S3_PUBLIC_DOMAIN, + serverIp: process.env.SERVER_IP, + minioPort: process.env.MINIO_API_PORT || '9000', + }, + timestamp: new Date().toISOString(), + } + + // 1. 基础连接测试 + console.log('[S3 Speed Test] 1. Testing connection...') + const connectionTime = await testConnectionSpeed() + result.connection = { duration: connectionTime } + + // 2. 单文件上传测试(不同大小) + if (testType === 'full' || testType === 'quick') { + console.log('[S3 Speed Test] 2. Testing single file uploads (various sizes)...') + const uploadSizes: number[] = testType === 'quick' + ? [10, 100, 1024] // 快速测试:10KB, 100KB, 1MB + : [10, 50, 100, 500, 1024, 5120, 10240] // 完整测试:10KB到10MB + + result.upload = {} + for (const sizeKB of uploadSizes) { + const test = await testUploadSpeed(sizeKB) + result.upload[`${sizeKB}KB`] = { + sizeKB, + duration: test.duration, + speedKBps: test.speed, + speedMBps: test.speed / 1024, + success: test.success, + error: test.error, + } + // 清理 + if (test.key) { + s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: test.key, + }) + ).catch(() => {}) + } + } + } + + // 3. 并发上传测试 + if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') { + console.log('[S3 Speed Test] 3. Testing concurrent uploads...') + const concurrentSizes = testType === 'quick' + ? [{ size: 100, concurrency: 5, count: 20 }] + : [ + { size: 10, concurrency: 10, count: 50 }, + { size: 100, concurrency: 10, count: 50 }, + { size: 1024, concurrency: 5, count: 20 }, + { size: 100, concurrency: 20, count: 100 }, + { size: 100, concurrency: 50, count: 200 }, + ] + + result.concurrentUpload = {} + for (const config of concurrentSizes) { + const test = await testConcurrentUploads(config.concurrency, config.size, config.count) + result.concurrentUpload[`${config.size}KB_${config.concurrency}并发_${config.count}次`] = { + ...test, + config, + } + } + } + + // 4. 下载测试 + if (testType === 'full' || testType === 'quick' || testType === 'concurrent' || testType === 'mixed') { + console.log('[S3 Speed Test] 4. Testing downloads...') + const testFileUrls = await getTestFileUrls(20) + + if (testFileUrls.length > 0) { + // 公网下载测试 + const publicDownload = await testDownloadSpeed(testFileUrls[0]) + result.download = { + public: { + url: testFileUrls[0], + ...publicDownload, + speedMBps: publicDownload.speed / 1024, + }, + } + + // 内网下载测试 + if (isIntranet && process.env.SERVER_IP) { + try { + const url = new URL(testFileUrls[0]) + const intranetUrl = `http://${process.env.SERVER_IP}:${process.env.MINIO_API_PORT || '9000'}${url.pathname}` + const intranetDownload = await testDownloadSpeed(intranetUrl) + result.download.intranet = { + url: intranetUrl, + ...intranetDownload, + speedMBps: intranetDownload.speed / 1024, + speedup: publicDownload.speed > 0 ? (intranetDownload.speed / publicDownload.speed) : 0, + } + } catch (error) { + console.warn('[S3 Speed Test] Cannot test intranet download') + } + } + + // 并发下载测试 + if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') { + console.log('[S3 Speed Test] 5. Testing concurrent downloads...') + const concurrentConfigs: Array<{ concurrency: number; count: number }> = testType === 'quick' + ? [{ concurrency: 5, count: 20 }] + : [ + { concurrency: 10, count: 50 }, + { concurrency: 20, count: 100 }, + { concurrency: 50, count: 200 }, + ] + + result.concurrentDownload = {} + for (const config of concurrentConfigs) { + const test = await testConcurrentDownloads(config.concurrency, testFileUrls, config.count) + result.concurrentDownload[`${config.concurrency}并发_${config.count}次`] = { + ...test, + config, + } + } + } + } + } + + // 6. 混合负载测试 + if (testType === 'full' || testType === 'mixed') { + console.log('[S3 Speed Test] 6. Testing mixed load (upload + download)...') + const testFileUrls = await getTestFileUrls(10) + if (testFileUrls.length > 0) { + const mixedTest = await testMixedLoad( + concurrency, + concurrency, + 100, // 100KB上传 + testFileUrls, + 30000 // 30秒 + ) + result.mixedLoad = mixedTest + } + } + + // 7. 压力测试(高并发) + if (testType === 'full') { + console.log('[S3 Speed Test] 7. Testing stress (high concurrency)...') + const stressTest = await testConcurrentUploads(100, 50, 500) // 100并发,50KB,500次 + result.stressTest = stressTest + } + + // 8. 最终清理:确保所有测试文件都被删除 + console.log('[S3 Speed Test] 8. Cleaning up all test files...') + const cleanupResult = await cleanupAllTestFiles() + result.cleanup = cleanupResult + console.log(`[S3 Speed Test] Cleanup: ${cleanupResult.deleted} deleted, ${cleanupResult.failed} failed`) + + console.log('[S3 Speed Test] All tests completed') + + return NextResponse.json(result) + } catch (error: any) { + console.error('[S3 Speed Test] Error:', error) + return NextResponse.json( + { + success: false, + error: error.message || '测试失败', + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, + }, + { status: 500 } + ) + } +} diff --git a/app/api/upload/s3/route.ts b/app/api/upload/s3/route.ts index d6622c3d..75cac40b 100644 --- a/app/api/upload/s3/route.ts +++ b/app/api/upload/s3/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { uploadToS3 } from '@/lib/s3'; +import { compressImage } from '@/lib/image-processor'; // POST - 上传文件到 S3 export async function POST(request: NextRequest) { @@ -19,31 +20,87 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '缺少文件' }, { status: 400 }); } - // 生成文件路径(使用更精确的时间戳和随机数,避免文件名冲突) - const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).substring(2, 9); // 7位随机字符串 - const ext = file.name.split('.').pop(); - const fileName = `${type}-${timestamp}-${randomSuffix}.${ext}`; - // 注意:s3Path 不应该包含 S3_FILE_PATH 前缀,因为 uploadToS3 会自动添加 - const s3Path = `product-package/${fileName}`; + // 文件大小检查(参考实拍图片类型的处理逻辑,防止超大文件造成性能下降) + if (file.size > 50 * 1024 * 1024) { + return NextResponse.json( + { error: `图片过大,请使用小于50MB的图片` }, + { status: 400 } + ); + } - // 上传到 S3 + // 根据类型设置压缩参数 + let maxWidth: number; + let maxHeight: number; + let maxSizeKB: number; + + switch (type) { + case 'main': + // 主图:最大宽度1440px + maxWidth = 1440; + maxHeight = 2400; // 高度限制较宽松,主要是限制宽度 + maxSizeKB = 500; // 主图允许稍大的文件大小 + break; + case 'sku': + // SKU图:最大宽度800px + maxWidth = 800; + maxHeight = 1600; + maxSizeKB = 300; + break; + case 'detail': + // 详情图:最大宽度1200px + maxWidth = 1200; + maxHeight = 2400; + maxSizeKB = 500; + break; + default: + // 默认使用详情图的参数 + maxWidth = 1200; + maxHeight = 2400; + maxSizeKB = 500; + } + + // 读取文件buffer const buffer = Buffer.from(await file.arrayBuffer()); - // 添加文件内容验证日志(计算文件哈希,用于验证文件是否不同) - const fileHashStart = buffer.slice(0, Math.min(16, buffer.length)).toString('hex'); - const fileHashEnd = buffer.slice(Math.max(0, buffer.length - 16)).toString('hex'); - const fileSize = buffer.length; - console.log(`[S3 Upload] ========== Uploading file ==========`); + console.log(`[S3 Upload] ========== Processing file ==========`); console.log(`[S3 Upload] Type: ${type}`); console.log(`[S3 Upload] Original fileName: ${file.name}`); + console.log(`[S3 Upload] Original file size: ${buffer.length} bytes`); + console.log(`[S3 Upload] Compression params: maxWidth=${maxWidth}, maxHeight=${maxHeight}, maxSizeKB=${maxSizeKB}`); + + // 压缩图片(参考实拍图片类型的处理逻辑) + let processedBuffer: Buffer; + try { + processedBuffer = await compressImage(buffer, { + maxWidth, + maxHeight, + maxSizeKB, + format: 'webp' + }); + console.log(`[S3 Upload] Compressed file size: ${processedBuffer.length} bytes`); + } catch (compressError) { + console.error('[S3 Upload] Image compression failed:', compressError); + // 压缩失败时,如果原文件不太大(小于10MB),可以尝试直接上传 + if (buffer.length < 10 * 1024 * 1024) { + console.warn('[S3 Upload] Compression failed, using original file (size < 10MB)'); + processedBuffer = buffer; + } else { + throw new Error('图片压缩失败,请使用较小的图片'); + } + } + + // 生成文件路径(压缩后使用webp格式) + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 9); // 7位随机字符串 + const fileName = `${type}-${timestamp}-${randomSuffix}.webp`; + // 注意:s3Path 不应该包含 S3_FILE_PATH 前缀,因为 uploadToS3 会自动添加 + const s3Path = `product-package/${fileName}`; + console.log(`[S3 Upload] Generated fileName: ${fileName}`); - console.log(`[S3 Upload] File size: ${fileSize} bytes`); - console.log(`[S3 Upload] File hash (first 16 bytes): ${fileHashStart}`); - console.log(`[S3 Upload] File hash (last 16 bytes): ${fileHashEnd}`); console.log(`[S3 Upload] S3 path: ${s3Path}`); - const url = await uploadToS3(buffer, s3Path, file.type); + // 上传到 S3(使用webp格式) + const url = await uploadToS3(processedBuffer, s3Path, 'image/webp'); console.log(`[S3 Upload] Uploaded successfully: url=${url}`); console.log(`[S3 Upload] ====================================`); diff --git a/app/api/uploads/[...path]/route.ts b/app/api/uploads/[...path]/route.ts index 49b8ebcf..9e4a3dd8 100644 --- a/app/api/uploads/[...path]/route.ts +++ b/app/api/uploads/[...path]/route.ts @@ -96,5 +96,37 @@ export async function HEAD( return new Response(null, { status: 200 }); } - return new Response(null, { status: 404 }); + const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'; + const relativePath = path.join(...pathSegments); + const filePath = path.resolve(uploadDir, relativePath); + const baseDir = path.resolve(uploadDir); + + // 安全检查:防止路径遍历攻击 + if (!filePath.startsWith(baseDir)) { + return new Response(null, { status: 403 }); + } + + try { + const stats = await fs.promises.stat(filePath); + + if (!stats.isFile()) { + return new Response(null, { status: 404 }); + } + + const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'; + + return new Response(null, { + status: 200, + headers: { + 'Content-Length': String(stats.size), + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch (error: any) { + if (error.code === 'ENOENT') { + return new Response(null, { status: 404 }); + } + return new Response(null, { status: 500 }); + } } diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index 5b388342..9bc2cb02 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { signIn } from 'next-auth/react' import Image from 'next/image' +import { parseApiError, parseFetchError } from '@/lib/error-handler' interface Department { id: string @@ -149,7 +150,8 @@ function CompleteProfileContent() { } } catch (error: any) { console.error('提交失败:', error) - setError(error.message || '提交失败,请稍后再试') + const { message } = parseApiError(error) + setError(message) } finally { setLoading(false) } diff --git a/app/dingtalk-callback/page.tsx b/app/dingtalk-callback/page.tsx index f9e1e1fc..1f5c1d1b 100644 --- a/app/dingtalk-callback/page.tsx +++ b/app/dingtalk-callback/page.tsx @@ -52,8 +52,18 @@ function DingtalkCallbackContent() { // 跳转到信息完善页面 const userInfoParam = encodeURIComponent(JSON.stringify(data.user)) setTimeout(() => { - if (window.parent !== window) { - // 在 iframe 中,通知父窗口跳转 + if (window.opener) { + // 新窗口模式:通知打开窗口的父页面 + window.opener.postMessage( + { + type: 'dingtalk_need_complete_profile', + userInfo: data.user, + }, + '*' + ) + window.close() + } else if (window.parent !== window) { + // iframe模式:通知父窗口跳转(兼容旧代码) window.parent.postMessage( { type: 'dingtalk_need_complete_profile', @@ -74,8 +84,19 @@ function DingtalkCallbackContent() { // 检查来源:优先使用state中的origin,其次使用referrer const isFromIntranet = originUrl && originUrl.includes('192.168.') - // 通知父窗口登录成功(如果在 iframe 中) - if (window.parent !== window) { + // 通知父窗口登录成功(如果在 iframe 或新窗口中) + if (window.opener) { + // 新窗口模式:通知打开窗口的父页面 + window.opener.postMessage( + { + type: 'dingtalk_login_success', + userInfo: data.user, + originUrl: originUrl // 携带原始地址 + }, + '*' + ) + } else if (window.parent !== window) { + // iframe模式:通知父窗口(兼容旧代码) window.parent.postMessage( { type: 'dingtalk_login_success', @@ -116,7 +137,12 @@ function DingtalkCallbackContent() { // 3秒后关闭或跳转 setTimeout(() => { - if (window.parent !== window) { + if (window.opener) { + // 新窗口模式:通知打开窗口的父页面 + window.opener.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*') + window.close() + } else if (window.parent !== window) { + // iframe模式:通知父窗口(兼容旧代码) window.parent.postMessage({ type: 'dingtalk_login_error', message: error.message }, '*') } else { router.push('/login') diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 657aae09..40db30a4 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -45,6 +45,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const materialTypes = [ '3D场景', '详情页', + '详情模板', '材质', '模特', '实拍图片', @@ -120,7 +121,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setLoading(false) } catch (error: any) { console.error('获取素材失败:', error) - setError(error.message || '获取素材信息失败') + const { parseApiError } = await import('@/lib/error-handler') + const { message } = parseApiError(error) + setError(message) setLoading(false) } } diff --git a/app/login/page.tsx b/app/login/page.tsx index 20caa8ee..b9b4e747 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -5,6 +5,8 @@ import { signIn } from 'next-auth/react' import { useRouter } from 'next/navigation' import Image from 'next/image' import Link from 'next/link' +import { ensureHttpsUrl } from '@/lib/url-rewrite' +import { parseApiError } from '@/lib/error-handler' export default function LoginPage() { const router = useRouter() @@ -12,7 +14,7 @@ export default function LoginPage() { const [password, setPassword] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') - const [showDingtalkModal, setShowDingtalkModal] = useState(false) + // 注意:钉钉登录现在使用新窗口模式,不再需要iframe弹窗 // 系统设置 const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') @@ -28,11 +30,16 @@ export default function LoginPage() { const response = await fetch('/api/system-settings') if (response.ok) { const settings = await response.json() - setLoginLogoUrl(settings.loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + // 确保 logo URL 使用 HTTPS,避免混合内容问题 + const logoUrl = settings.loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' + setLoginLogoUrl(ensureHttpsUrl(logoUrl) || logoUrl) setSiteName(settings.siteName || '由壹点3D素材') } } catch (error) { console.error('加载系统设置失败:', error) + // 即使加载失败,也确保默认 URL 使用 HTTPS + const defaultUrl = 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' + setLoginLogoUrl(ensureHttpsUrl(defaultUrl) || defaultUrl) } } loadSettings() @@ -40,15 +47,7 @@ export default function LoginPage() { // 页面加载时识别访问环境 useEffect(() => { - const isPublicDomain = window.location.hostname === '3d.yydd.ai' || window.location.hostname.includes('yydd.ai') - const isIntranet = window.location.hostname.includes('192.168.') - - if (isPublicDomain) { - console.log('🌐 检测到公网访问 (' + window.location.hostname + ')') - } else if (isIntranet) { - console.log('🏠 检测到内网访问 (' + window.location.hostname + ')') - } - + // 不输出hostname信息,避免在浏览器控制台暴露内网信息 // 注意:内网检测已改为后端实现(/api/network-status) // 登录成功后会在首页自动调用后端API判断是否需要显示切换提示 }, []) @@ -97,7 +96,8 @@ export default function LoginPage() { } } catch (error) { console.error('自动登录失败:', error) - setError('登录失败,请重试') + const { message } = parseApiError(error) + setError(message) } finally { setLoading(false) } @@ -108,25 +108,52 @@ export default function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + console.log('🔐 开始登录流程...', { email, hasPassword: !!password }) + + // 验证输入 + if (!email || !password) { + setError('请输入邮箱和密码') + return + } + setError('') setLoading(true) try { + console.log('📤 调用 signIn...') const result = await signIn('credentials', { - email, + email: email.trim(), password, redirect: false, }) + console.log('📥 signIn 返回结果:', result) + + // 处理结果 if (result?.error) { - setError(result.error) + console.error('❌ 登录失败:', result.error) + setError(result.error === 'CredentialsSignin' ? '邮箱或密码错误' : result.error) + } else if (result?.ok !== false) { + // result.ok 为 true 或 undefined 都视为成功 + console.log('✅ 登录成功,等待 cookie 设置完成...') + // 等待一小段时间,确保 NextAuth 的 cookie 已经设置完成 + // 这样可以避免 middleware 检测不到 token 而重定向回登录页 + // 增加延迟时间,确保 cookie 已经写入浏览器 + await new Promise(resolve => setTimeout(resolve, 300)) + console.log('🔄 开始跳转到首页...') + // 使用 window.location.href 强制跳转,确保跳转生效 + // 使用完整的 URL 可以确保 cookie 正确传递 + window.location.href = window.location.origin + '/' + // 注意:跳转后不需要 setLoading(false),因为页面会刷新 + return } else { - router.push('/') - router.refresh() + console.warn('⚠️ signIn 返回了意外的结果:', result) + setError('登录失败,请稍后再试') } - } catch (error) { - console.error('Login error:', error) - setError('登录失败,请稍后再试') + } catch (error: any) { + console.error('❌ 登录异常:', error) + const { message } = parseApiError(error) + setError(message) } finally { setLoading(false) } @@ -148,21 +175,34 @@ export default function LoginPage() { `&state=${encodeURIComponent(stateData)}` + `&prompt=consent` - // 统一使用iframe模式(内网和外网都使用统一域名,通过DNS解析区分) - setShowDingtalkModal(true) - setTimeout(() => { - const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement - if (iframe) { - iframe.src = dingtalkAuthUrl - } - }, 100) - } - - // 监听来自 iframe 的消息 - useEffect(() => { + // 使用新窗口打开钉钉登录,避免iframe的cookie限制问题 + // 新窗口可以正常访问cookie,解决"授权失败"的问题 + const width = 500 + const height = 600 + const left = (window.screen.width - width) / 2 + const top = (window.screen.height - height) / 2 + + const popup = window.open( + dingtalkAuthUrl, + '钉钉登录', + `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` + ) + + if (!popup) { + setError('无法打开登录窗口,请检查浏览器弹窗设置') + return + } + + // 监听新窗口的消息(从回调页面发送) const handleMessage = async (event: MessageEvent) => { + // 只处理来自钉钉回调页面的消息 + if (event.origin !== 'https://3d.yydd.ai' && !event.origin.includes('192.168.')) { + return + } + if (event.data && event.data.type === 'dingtalk_login_success') { - setShowDingtalkModal(false) + popup?.close() + window.removeEventListener('message', handleMessage) // 创建 NextAuth session try { @@ -175,6 +215,8 @@ export default function LoginPage() { if (result?.error) { setError('登录失败,请重试') } else { + // 短暂延迟,避免浏览器扩展脚本在处理事件时出错 + await new Promise(resolve => setTimeout(resolve, 100)) // 登录成功,智能跳转 const originUrl = event.data.originUrl if (originUrl && originUrl.includes('192.168.')) { @@ -191,22 +233,35 @@ export default function LoginPage() { } } catch (error) { console.error('创建session失败:', error) - setError('登录失败,请重试') + const { message } = parseApiError(error) + setError(message) } } else if (event.data && event.data.type === 'dingtalk_need_complete_profile') { - setShowDingtalkModal(false) + popup?.close() + window.removeEventListener('message', handleMessage) // 跳转到信息完善页面 const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo)) router.push(`/complete-profile?userInfo=${userInfoParam}`) } else if (event.data && event.data.type === 'dingtalk_login_error') { - setShowDingtalkModal(false) + popup?.close() + window.removeEventListener('message', handleMessage) setError(event.data.message || '钉钉登录失败') } } - + window.addEventListener('message', handleMessage) - return () => window.removeEventListener('message', handleMessage) - }, [router]) + + // 检查新窗口是否被关闭(用户手动关闭) + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed) + window.removeEventListener('message', handleMessage) + } + }, 1000) + } + + // 注意:钉钉登录现在使用新窗口模式,消息监听在 handleDingtalkLogin 中处理 + // 这样可以避免iframe的cookie限制问题 return (
@@ -311,37 +366,6 @@ export default function LoginPage() {
- {/* 钉钉登录 iframe 弹窗 */} - {showDingtalkModal && ( -
setShowDingtalkModal(false)} - > -
e.stopPropagation()} - > -
-

钉钉扫码登录

- -
-
-