From 482447fe40107682fe189c2b48671e8b1d3a5a69 Mon Sep 17 00:00:00 2001 From: chenpei Date: Sat, 14 Mar 2026 10:36:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=BD=93=E5=89=8D=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E6=BA=90=E7=A0=81=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=99=BD=E5=BA=95=E5=9B=BE=E4=B8=8A=E4=BC=A0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 6 +- Dockerfile | 4 + app/admin/settings/page.tsx | 8 +- app/api/admin/materials/batch-delete/route.ts | 24 +- app/api/external/download/route.ts | 163 +++- app/api/external/product-assets/route.ts | 9 +- .../standard-model-white-background/route.ts | 232 ++++++ app/api/favicon/route.ts | 2 +- app/api/materials/[id]/assets/route.ts | 72 +- .../[id]/skus/[skuId]/download/route.ts | 209 ++++- app/api/materials/[id]/skus/[skuId]/route.ts | 181 +++-- .../[id]/skus/batch-download/route.ts | 182 ++++- app/api/materials/[id]/skus/route.ts | 173 +++- app/api/materials/[id]/skus/upload/route.ts | 4 + .../materials/[id]/white-background/route.ts | 311 ++++++-- app/api/materials/restore/route.ts | 115 ++- app/api/materials/route.ts | 17 +- .../upload/3d-original/complete/route.ts | 10 +- .../upload/3d-original/init/route.ts | 20 +- .../upload/ai-original/complete/route.ts | 10 +- .../upload/ai-original/init/route.ts | 14 +- app/api/materials/upload/chunk/route.ts | 253 ++++-- app/api/materials/upload/cleanup/route.ts | 80 +- .../upload/product-package/complete/route.ts | 4 + .../upload/product-package/init/route.ts | 51 +- .../upload/real-photo/complete/route.ts | 10 +- .../materials/upload/real-photo/init/route.ts | 14 +- app/api/materials/upload/route.ts | 13 +- .../upload/task-page/complete/route.ts | 10 +- .../materials/upload/task-page/init/route.ts | 14 +- .../upload/video-screenshot/complete/route.ts | 10 +- .../upload/video-screenshot/init/route.ts | 14 +- app/api/system-settings/route.ts | 8 +- app/api/upload/preview/route.ts | 4 + app/complete-profile/page.tsx | 2 +- app/favicon.ico/route.ts | 9 + app/layout.tsx | 7 +- app/login/page.tsx | 18 +- app/page.tsx | 81 +- app/register/page.tsx | 2 +- app/share/[id]/page.tsx | 199 ++++- components/Header.tsx | 13 +- components/SkuEditModal.tsx | 753 ++++++++++++++++-- components/SkuList.tsx | 188 +++-- components/UploadModal.tsx | 38 + components/upload/AIOriginalUploader.tsx | 1 + components/upload/RealPhotoUploader.tsx | 1 + .../upload/StandardModelSkuZipUpload.tsx | 170 ++++ .../StandardModelWhiteBackgroundUpload.tsx | 322 ++++++-- components/upload/TaskPageUploader.tsx | 1 + components/upload/ThreeDOriginalUploader.tsx | 1 + components/upload/VideoScreenshotUploader.tsx | 1 + components/upload/file-upload-handlers.ts | 3 +- docker-compose.shadow.yml | 1 + docker-compose.yml | 4 +- docs/AI生图白底图接口文档.md | 249 ++++++ docs/NETWORK_TOPOLOGY.md | 310 ++++--- docs/外部接口文档.md | 138 +++- lib/__tests__/auth-cookie.test.ts | 45 ++ .../material-cleanup-standard-model.test.ts | 9 +- .../standard-model-sku-uniqueness.test.ts | 31 + .../standard-model-soft-delete-sync.test.ts | 40 + .../standard-model-white-background.test.ts | 195 ++++- lib/auth-cookie.ts | 140 ++++ lib/auth.ts | 16 +- lib/chunk-upload.ts | 14 +- lib/download-token.ts | 12 +- lib/file-cleanup.ts | 105 +++ lib/material-cleanup.ts | 21 +- lib/network-detection.ts | 1 + lib/product-package.ts | 140 +++- lib/public-app-url.ts | 51 ++ lib/standard-model-white-background.ts | 343 +++++++- lib/url-rewrite.ts | 4 +- middleware.ts | 22 +- 75 files changed, 4912 insertions(+), 1040 deletions(-) create mode 100644 app/api/external/standard-model-white-background/route.ts create mode 100644 app/favicon.ico/route.ts create mode 100644 components/upload/StandardModelSkuZipUpload.tsx create mode 100644 docs/AI生图白底图接口文档.md create mode 100644 lib/__tests__/auth-cookie.test.ts create mode 100644 lib/__tests__/standard-model-sku-uniqueness.test.ts create mode 100644 lib/__tests__/standard-model-soft-delete-sync.test.ts create mode 100644 lib/auth-cookie.ts create mode 100644 lib/public-app-url.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1d9e071..4442a32 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,11 @@ "Bash(powershell -Command \"[System.IO.File]::ReadAllBytes\\(''D:\\\\AI Code\\\\gongxiangp2\\\\deploy-remote-build.ps1''\\)[0..10] -join '',''\")", "Bash(powershell:*)", "Bash(cmd.exe /c \"cd /d D:\\\\AI Code\\\\gongxiangp2 && powershell -ExecutionPolicy Bypass -File analyze-braces.ps1\")", - "Bash(Select-Object -Last 30)" + "Bash(Select-Object -Last 30)", + "Bash(ping:*)", + "Bash(Test-NetConnection:*)", + "Bash(Select-Object ComputerName, RemotePort, TcpTestSucceeded)", + "Bash(nc:*)" ] } } diff --git a/Dockerfile b/Dockerfile index a53e6c1..8c44d00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,10 @@ WORKDIR /app # 复制 package.json(利用 Docker 层缓存) COPY package.json package-lock.json* ./ +# canvas 在项目里是可选依赖,且服务端代码已做降级处理。 +# 构建机当前卡在 canvas 的 node-pre-gyp 安装阶段,因此这里只在镜像构建时跳过它。 +RUN node -e "const fs=require('fs'); const pkg=require('./package.json'); if (pkg.optionalDependencies && pkg.optionalDependencies.canvas) { delete pkg.optionalDependencies.canvas; fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\\n'); }" + # 安装依赖(使用淘宝镜像 + BuildKit缓存) RUN --mount=type=cache,target=/root/.npm \ echo "Installing dependencies (using cache if available)..." && \ diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 67a4371..e6fccc7 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -231,13 +231,13 @@ export default function SystemSettings() { } const handleHeaderLogoReset = () => { - setHeaderLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') - setHeaderLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + setHeaderLogoPreview('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + setHeaderLogoUrl('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') } const handleLoginLogoReset = () => { - setLoginLogoPreview('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') - setLoginLogoUrl('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + setLoginLogoPreview('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + setLoginLogoUrl('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') } if (status === 'loading' || loading) { diff --git a/app/api/admin/materials/batch-delete/route.ts b/app/api/admin/materials/batch-delete/route.ts index 3e3f709..937582d 100644 --- a/app/api/admin/materials/batch-delete/route.ts +++ b/app/api/admin/materials/batch-delete/route.ts @@ -174,19 +174,25 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 }) } - const now = Date.now() const deletedAt = new Date() - await Promise.all(materials.map((m, index) => - prisma.material.update({ - where: { id: m.id }, + await prisma.$transaction([ + prisma.material.updateMany({ + where: { id: { in: materials.map((m) => m.id) } }, data: { deletedAt, - // 如果有任务号,加上删除时间戳后缀使其作废(防止重复) - ...(m.taskNo ? { taskNo: `${m.taskNo}_DELETED_${now}_${index}` } : {}) - } - }) - )) + }, + }), + prisma.materialSku.updateMany({ + where: { + materialId: { in: materials.map((m) => m.id) }, + deletedAt: null, + }, + data: { + deletedAt, + }, + }), + ]) await Promise.all( materials.map((material) => diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 40fc335..a269f20 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -17,6 +17,13 @@ import { access, stat } from 'fs/promises'; import { lookup, extension } from 'mime-types'; import JSZip from 'jszip'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + hasAnyWhiteBackgroundAssetGroup, + parseStandardModelWhiteBgAssetType, + STANDARD_MODEL_WHITE_BG_VIEWS, +} from '@/lib/standard-model-white-background'; /** * GET - 下载文件 @@ -39,7 +46,7 @@ export async function GET(request: NextRequest) { return errorResponse(API_ERROR.INVALID_TOKEN); } - const { materialId, downloadType, assetId } = payload; + const { materialId, downloadType, assetId, skuId } = payload; try { // 查询素材 @@ -54,12 +61,19 @@ export async function GET(request: NextRequest) { return errorResponse(API_ERROR.TASK_NOT_FOUND); } + if ( + material.materialType === '标准模型' && + (downloadType === 'white-background-single' || downloadType === 'white-background-zip') + ) { + return await downloadStandardModelWhiteBackground(material, downloadType, assetId, skuId); + } + // TODO: 权限检查(暂时保留接口,后续扩展) // 后续需要实现:根据 API Key 或用户权限检查下载权限 // 参考:/api/materials/download 中的权限检查逻辑 // 任务类型素材:使用assets逻辑 - const isTaskMaterial = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(material.materialType); + const isTaskMaterial = ['任务-详情', '任务-页面', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(material.materialType); if (isTaskMaterial) { console.log(`[external/download] ========== 任务类型素材下载 ==========`); @@ -204,6 +218,92 @@ export async function GET(request: NextRequest) { } } +async function downloadStandardModelWhiteBackground( + material: any, + downloadType: 'white-background-single' | 'white-background-zip', + assetId?: string, + skuId?: string, +) { + if (downloadType === 'white-background-single') { + if (!assetId) { + return NextResponse.json( + { success: false, error: { code: 'INVALID_TOKEN', message: '白底图下载令牌无效' } }, + { status: 400 } + ); + } + + const asset = (material.assets || []).find((item: any) => item.id === assetId); + const parsedType = parseStandardModelWhiteBgAssetType(asset?.assetType || ''); + if (!asset || !parsedType || parsedType.kind === 'zip') { + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } }, + { status: 404 } + ); + } + + return downloadSingleFile(asset); + } + + if (!skuId) { + return NextResponse.json( + { success: false, error: { code: 'INVALID_TOKEN', message: '白底图下载令牌无效' } }, + { status: 400 } + ); + } + + const sku = await prisma.materialSku.findFirst({ + where: { + id: skuId, + materialId: material.id, + deletedAt: null, + }, + select: { + id: true, + skuName: true, + }, + }); + + if (!sku) { + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: 'SKU 不存在' } }, + { status: 404 } + ); + } + + const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(material.assets || []).get(sku.id); + if (whiteBackgroundGroup && hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) { + const zipBuffer = await buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup); + if (zipBuffer.length === 0) { + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } }, + { status: 404 } + ); + } + const fileName = getWhiteBackgroundZipFileName(sku.skuName); + + return new NextResponse(zipBuffer as any, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Length': zipBuffer.length.toString(), + }, + }); + } + + const fallbackZipAsset = whiteBackgroundGroup?.zipAssets?.[0]; + if (fallbackZipAsset) { + return downloadSingleFile({ + ...fallbackZipAsset, + fileName: getWhiteBackgroundZipFileName(sku.skuName), + }); + } + + return NextResponse.json( + { success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } }, + { status: 404 } + ); +} + /** * 根据下载类型获取资产列表 * 排除预览类型资产 @@ -311,6 +411,65 @@ async function downloadSingleFile(asset: any) { ); } +async function buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup: { + fixed: Record; + other: any[]; +}) { + const zip = new JSZip(); + let addedCount = 0 + + for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) { + const asset = whiteBackgroundGroup.fixed[view.key]; + if (!asset?.fileUrl) { + continue; + } + + const fileBuffer = await readExternalAssetBuffer(asset.fileUrl); + const ext = path.extname(asset.fileName || asset.fileUrl) || '.jpg'; + const fileName = asset.fileName || `${view.label}${ext}`; + zip.file(fileName, fileBuffer); + addedCount += 1 + } + + for (const asset of whiteBackgroundGroup.other || []) { + if (!asset?.fileUrl) { + continue; + } + + const fileBuffer = await readExternalAssetBuffer(asset.fileUrl); + zip.file(`other/${asset.fileName || path.basename(asset.fileUrl)}`, fileBuffer); + addedCount += 1 + } + + if (addedCount === 0) { + return Buffer.alloc(0); + } + + return zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); +} + +async function readExternalAssetBuffer(fileUrl: string): Promise { + if (isHttpUrl(fileUrl)) { + const response = await fetch(fileUrl); + if (!response.ok) { + throw new Error(`读取资源失败: ${response.status}`); + } + + return Buffer.from(await response.arrayBuffer()); + } + + const localPath = resolveAssetLocalPath(fileUrl); + if (!localPath) { + throw new Error('资源路径无效'); + } + + return fs.promises.readFile(localPath); +} + /** * 打包下载为 ZIP */ diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index 21ee8f0..5667971 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -25,12 +25,11 @@ import { import { generateDownloadUrl } from '@/lib/download-token'; import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { getPublicAppOrigin } from '@/lib/public-app-url'; import path from 'path'; -// 获取基础 URL - 外部API始终返回公网域名 -function getBaseUrl(request: NextRequest): string { - // 外部API固定返回公网域名,确保任务平台可以访问 - return 'https://3d.yydd.ai'; +function getBaseUrl(): string { + return getPublicAppOrigin(); } /** @@ -123,7 +122,7 @@ export async function GET(request: NextRequest) { }); } - const baseUrl = getBaseUrl(request); + const baseUrl = getBaseUrl(); console.log('[external/product-assets] Base URL:', baseUrl); console.log('[external/product-assets] Material type:', material.materialType); diff --git a/app/api/external/standard-model-white-background/route.ts b/app/api/external/standard-model-white-background/route.ts new file mode 100644 index 0000000..9dcd4f0 --- /dev/null +++ b/app/api/external/standard-model-white-background/route.ts @@ -0,0 +1,232 @@ +import path from 'path' +import { NextRequest, NextResponse } from 'next/server' +import { + getApiKeyFromRequest, + getClientIp, + logApiCall, + successResponse, + validateApiKey, +} from '@/lib/external-api' +import { generateDownloadUrl } from '@/lib/download-token' +import { prisma } from '@/lib/prisma' +import { + countWhiteBackgroundAssets, + countWhiteBackgroundFixedViews, + countWhiteBackgroundOtherImages, + getMissingWhiteBackgroundViewsFromAssetGroup, + hasAnyWhiteBackgroundAssetGroup, + getWhiteBackgroundAssetWhereForSku, + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + hasCompleteWhiteBackgroundAssetGroup, + STANDARD_MODEL_WHITE_BG_VIEWS, + type StandardModelWhiteBgKey, +} from '@/lib/standard-model-white-background' +import { getPublicAppOrigin } from '@/lib/public-app-url' + +function normalizeSkuName(value: string): string { + return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() +} + +function getBaseUrl(): string { + return getPublicAppOrigin() +} + +function buildErrorResponse(status: number, code: string, message: string) { + return NextResponse.json( + { + success: false, + error: { + code, + message, + }, + }, + { status }, + ) +} + +export async function GET(request: NextRequest) { + const startTime = Date.now() + const apiKey = getApiKeyFromRequest(request) + const keyRecord = await validateApiKey(apiKey) + + if (!keyRecord) { + return buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失') + } + + const sku = request.nextUrl.searchParams.get('sku') || '' + const normalizedSkuName = normalizeSkuName(sku) + const logBase = { + apiKeyId: keyRecord.id, + endpoint: '/api/external/standard-model-white-background', + method: 'GET' as const, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + } + + if (!normalizedSkuName) { + await logApiCall({ + ...logBase, + statusCode: 400, + duration: Date.now() - startTime, + }) + return buildErrorResponse(400, 'INVALID_SKU', 'SKU 参数不能为空') + } + + try { + const matchedSkus = await prisma.materialSku.findMany({ + where: { + deletedAt: null, + skuName: { + equals: normalizedSkuName, + mode: 'insensitive', + }, + material: { + is: { + materialType: '标准模型', + deletedAt: null, + }, + }, + }, + select: { + id: true, + skuName: true, + materialId: true, + material: { + select: { + id: true, + productModel: true, + deletedAt: true, + }, + }, + }, + take: 2, + }) + + if (matchedSkus.length === 0) { + await logApiCall({ + ...logBase, + statusCode: 404, + duration: Date.now() - startTime, + }) + return buildErrorResponse(404, 'SKU_NOT_FOUND', '未找到对应的标准模型 SKU') + } + + if (matchedSkus.length > 1) { + await logApiCall({ + ...logBase, + statusCode: 409, + duration: Date.now() - startTime, + }) + return buildErrorResponse(409, 'SKU_NOT_UNIQUE', '该 SKU 存在重复数据,暂无法对外提供白底图') + } + + const matchedSku = matchedSkus[0] + const whiteBackgroundAssets = await prisma.materialAsset.findMany({ + where: { + materialId: matchedSku.materialId, + ...getWhiteBackgroundAssetWhereForSku(matchedSku.id), + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }) + + const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets).get(matchedSku.id) + if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) { + await logApiCall({ + ...logBase, + statusCode: 404, + duration: Date.now() - startTime, + }) + return buildErrorResponse(404, 'WHITE_BACKGROUND_NOT_FOUND', '该 SKU 暂无白底图') + } + + const baseUrl = getBaseUrl() + const fixed = STANDARD_MODEL_WHITE_BG_VIEWS + .map((view) => { + const asset = whiteBackgroundGroup.fixed[view.key] + if (!asset) { + return null + } + const fileName = asset.fileName || `${view.label}${path.extname(asset.fileUrl || '') || '.jpg'}` + + return { + key: view.key, + label: view.label, + fileName, + fileSize: Number(asset.fileSize || 0), + downloadUrl: generateDownloadUrl(baseUrl, { + materialId: matchedSku.materialId, + downloadType: 'white-background-single', + assetId: asset.id, + }), + } + }) + .filter((item): item is { + key: StandardModelWhiteBgKey + label: string + fileName: string + fileSize: number + downloadUrl: string + } => Boolean(item)) + + const other = (whiteBackgroundGroup.other || []).map((asset, index) => ({ + key: `other-${index + 1}`, + label: `其他图片${index + 1}`, + fileName: asset.fileName || path.basename(asset.fileUrl || 'other-image'), + fileSize: Number(asset.fileSize || 0), + downloadUrl: generateDownloadUrl(baseUrl, { + materialId: matchedSku.materialId, + downloadType: 'white-background-single', + assetId: asset.id, + }), + })) + + await logApiCall({ + ...logBase, + statusCode: 200, + duration: Date.now() - startTime, + }) + + return successResponse({ + sku: matchedSku.skuName, + materialId: matchedSku.materialId, + productModel: matchedSku.material.productModel, + whiteBackgroundUploaded: true, + whiteBackgroundComplete: hasCompleteWhiteBackgroundAssetGroup(whiteBackgroundGroup), + whiteBackgroundFixedCount: countWhiteBackgroundFixedViews(whiteBackgroundGroup), + whiteBackgroundOtherCount: countWhiteBackgroundOtherImages(whiteBackgroundGroup), + whiteBackgroundCount: countWhiteBackgroundAssets(whiteBackgroundGroup), + missingViews: getMissingWhiteBackgroundViewsFromAssetGroup(whiteBackgroundGroup).map((view) => ({ + key: view.key, + label: view.label, + })), + whiteBackground: { + fixed, + other, + }, + downloadUrls: { + zip: generateDownloadUrl(baseUrl, { + materialId: matchedSku.materialId, + downloadType: 'white-background-zip', + skuId: matchedSku.id, + }), + }, + }) + } catch (error) { + console.error('[external/standard-model-white-background] error:', error) + await logApiCall({ + ...logBase, + statusCode: 500, + duration: Date.now() - startTime, + }) + return buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误') + } +} diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 655d917..3bdecf9 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -15,7 +15,7 @@ export async function GET(request: NextRequest) { } }) - const logoUrl = settings?.headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' + const logoUrl = settings?.headerLogoUrl || 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' // 根据用户网络位置重写 URL(内网用户使用内网地址) const finalUrl = rewriteStorageUrlForClient(logoUrl, request) || logoUrl diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index 72236fa..5d67237 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -9,8 +9,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; -import { getAssetsByMaterialId, addAsset, deleteAsset } from '@/lib/product-package'; +import { getAssetsByMaterialId, addAsset } from '@/lib/product-package'; +import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'; import { createMaterialLog } from '@/lib/material-log'; +import { normalizeDisplayImages } from '@/lib/material-cleanup'; /** * GET - 获取资产列表 @@ -33,7 +35,15 @@ export async function GET( // 验证素材是否存在 const material = await prisma.material.findUnique({ where: { id: materialId, deletedAt: null }, - select: { id: true, userId: true }, + select: { + id: true, + userId: true, + previewUrl: true, + zipFilePath: true, + zipFileName: true, + zipFileSize: true, + displayImages: true, + }, }); if (!material) { @@ -222,8 +232,53 @@ export async function DELETE( ); } - // 删除资产 - await deleteAsset(assetId); + const previewAssetTypes = ['preview', 'main-preview', 'sku-preview', 'detail-preview']; + const currentDisplayImages = normalizeDisplayImages((material as any).displayImages); + const displayImages = currentDisplayImages.filter((url) => url !== asset.fileUrl); + const materialUpdateData: Record = {}; + + if ((material as any).previewUrl === asset.fileUrl) { + const nextPreviewAsset = await prisma.materialAsset.findFirst({ + where: { + materialId, + id: { not: assetId }, + assetType: { in: previewAssetTypes }, + }, + orderBy: [ + { sortOrder: 'asc' }, + { createdAt: 'asc' }, + ], + select: { fileUrl: true }, + }); + materialUpdateData.previewUrl = nextPreviewAsset?.fileUrl || ''; + } + + if ((material as any).zipFilePath === asset.fileUrl) { + materialUpdateData.zipFilePath = ''; + materialUpdateData.zipFileName = ''; + materialUpdateData.zipFileSize = BigInt(0); + } + + if (displayImages.length !== currentDisplayImages.length) { + materialUpdateData.displayImages = displayImages; + } + + await prisma.$transaction(async (tx) => { + await tx.materialAsset.delete({ + where: { id: assetId }, + }); + + if (Object.keys(materialUpdateData).length > 0) { + await tx.material.update({ + where: { id: materialId }, + data: materialUpdateData as any, + }); + } + }); + + const cleanupResult = await deleteFileIfUnreferenced(asset.fileUrl, { + excludeAssetIds: [assetId], + }); await createMaterialLog({ materialId, @@ -234,6 +289,10 @@ export async function DELETE( oldValue: null, newValue: 'DELETE', }, + fileCleanup: { + oldValue: null, + newValue: cleanupResult.skipped ? 'skipped' : cleanupResult.success ? 'deleted' : 'failed', + }, assetId: { oldValue: asset.id, newValue: asset.id, @@ -256,6 +315,11 @@ export async function DELETE( return NextResponse.json({ success: true, message: '删除成功', + details: { + fileDeleted: !cleanupResult.skipped && cleanupResult.success, + fileSkipped: !!cleanupResult.skipped, + fileCleanupError: cleanupResult.success ? null : cleanupResult.error || null, + }, }); } catch (error) { console.error('[materials/assets] DELETE error:', error); diff --git a/app/api/materials/[id]/skus/[skuId]/download/route.ts b/app/api/materials/[id]/skus/[skuId]/download/route.ts index 25567c2..fc2d69e 100644 --- a/app/api/materials/[id]/skus/[skuId]/download/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/download/route.ts @@ -1,16 +1,75 @@ 'use server' +import { promises as fs } from 'fs' +import { createReadStream, existsSync } from 'fs' +import { stat } from 'fs/promises' +import path from 'path' +import JSZip from 'jszip' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { createReadStream, existsSync } from 'fs' -import { stat } from 'fs/promises' -import path from 'path' import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' +import { + hasAnyWhiteBackgroundAssetGroup, + getWhiteBackgroundAssetWhereForSku, + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + type GroupedWhiteBackgroundAssets, + STANDARD_MODEL_WHITE_BG_VIEWS, +} from '@/lib/standard-model-white-background' + +type DownloadType = 'model' | 'white-background' + +type WhiteBackgroundAssetRecord = { + assetType: string + fileUrl: string + fileName: string | null + sortOrder: number +} + +function resolveModelDownloadFileName( + skuName: string, + rawFileName?: string | null, + rawFilePath?: string | null, +): string { + const ext = path.extname(rawFileName || rawFilePath || '') || '.zip' + return `${skuName}${ext}` +} + +function resolveLocalFilePath(fileUrl: string): string { + if (path.isAbsolute(fileUrl)) { + return fileUrl + } + return path.join(process.cwd(), fileUrl) +} + +function getWhiteBackgroundZipEntries(group: GroupedWhiteBackgroundAssets) { + const fixedEntries = STANDARD_MODEL_WHITE_BG_VIEWS + .map((view) => { + const asset = group.fixed[view.key] + if (!asset) { + return null + } + return { + fileName: asset.fileName || `${view.label}${path.extname(asset.fileUrl) || '.jpg'}`, + fileUrl: asset.fileUrl, + } + }) + .filter((entry): entry is { fileName: string; fileUrl: string } => Boolean(entry)) + + const otherEntries = group.other.map((asset) => ({ + fileName: asset.fileName || path.basename(asset.fileUrl), + fileUrl: asset.fileUrl, + directory: 'other', + })) + + return { + fixedEntries, + otherEntries, + } +} -// 下载单个 SKU 的压缩包 -// ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24) export async function GET( request: NextRequest, { params }: { params: { id: string; skuId: string } } @@ -18,17 +77,18 @@ export async function GET( try { const { searchParams } = new URL(request.url) const validateOnly = searchParams.get('validate') === '1' + const downloadType: DownloadType = + searchParams.get('downloadType') === 'white-background' ? 'white-background' : 'model' const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } - // 检查用户下载权限 const userId = (session.user as any).id const user = await prisma.user.findUnique({ where: { id: userId }, - select: { canDownload: true } + select: { canDownload: true }, }) if (!user?.canDownload) { @@ -38,41 +98,134 @@ export async function GET( const { skuId } = params const materialId = params.id - // 获取 SKU 信息 const sku = await prisma.materialSku.findFirst({ where: { id: skuId, materialId, deletedAt: null, - } + }, + select: { + id: true, + skuName: true, + filePath: true, + fileName: true, + }, }) if (!sku) { return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) } - if (!sku.filePath) { - return NextResponse.json({ error: 'SKU 没有可下载的文件' }, { status: 404 }) + if (downloadType === 'white-background') { + const whiteBackgroundAssets = await prisma.materialAsset.findMany({ + where: { + materialId, + ...getWhiteBackgroundAssetWhereForSku(skuId), + }, + select: { + assetType: true, + fileUrl: true, + fileName: true, + sortOrder: true, + }, + }) + + const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku( + whiteBackgroundAssets, + ).get(skuId) + + if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) { + return NextResponse.json({ error: '该 SKU 暂无可下载的白底图' }, { status: 404 }) + } + + const { fixedEntries, otherEntries } = getWhiteBackgroundZipEntries(whiteBackgroundGroup) + if (fixedEntries.length === 0 && otherEntries.length === 0) { + return NextResponse.json({ error: '该 SKU 暂无可下载的白底图' }, { status: 404 }) + } + const allEntries = [...fixedEntries, ...otherEntries] + const downloadFileName = getWhiteBackgroundZipFileName(sku.skuName) + + let totalSize = 0 + for (const entry of allEntries) { + const absolutePath = resolveLocalFilePath(entry.fileUrl) + if (!existsSync(absolutePath)) { + console.error(`[SKU Download] 白底图文件不存在: ${absolutePath}`) + return NextResponse.json({ error: '白底图文件不存在' }, { status: 404 }) + } + totalSize += (await stat(absolutePath)).size + } + + if (validateOnly) { + return NextResponse.json({ + success: true, + fileName: downloadFileName, + fileSize: totalSize, + contentType: 'application/zip', + }) + } + + const zip = new JSZip() + for (const entry of fixedEntries) { + const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl)) + zip.file(entry.fileName, fileBuffer) + } + for (const entry of otherEntries) { + const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl)) + zip.file(`other/${entry.fileName}`, fileBuffer) + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }) + + recordSkuDownloadLog(materialId, userId).catch((error) => { + console.error('[SKU Download] 记录下载日志失败:', error) + }) + + return new NextResponse(zipBuffer as any, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(downloadFileName)}"`, + 'Content-Length': zipBuffer.length.toString(), + }, + }) } - // 构建文件路径 - const filePath = path.join(process.cwd(), 'uploads', sku.filePath) + if (!sku.filePath) { + return NextResponse.json({ error: 'SKU 没有可下载的模型文件' }, { status: 404 }) + } + + const filePath = path.join(process.cwd(), 'uploads', sku.filePath) + const fileName = resolveModelDownloadFileName( + sku.skuName, + sku.fileName || null, + sku.filePath || null, + ) - // 检查文件是否存在 if (!existsSync(filePath)) { - console.error(`SKU 文件不存在: ${filePath}`) + console.error(`[SKU Download] 文件不存在: ${filePath}`) return NextResponse.json({ error: '文件不存在' }, { status: 404 }) } - // 使用流式传输,避免将整个文件加载到内存中(修复 R24) + const fileStats = await stat(filePath) + + if (validateOnly) { + return NextResponse.json({ + success: true, + fileName, + fileSize: fileStats.size, + contentType: 'application/zip', + }) + } + const fileStream = createReadStream(filePath) - // N6修复:确保文件流在任何情况下都能正确清理 const stream = new ReadableStream({ start(controller) { fileStream.on('data', (chunk) => controller.enqueue(chunk)) fileStream.on('end', () => controller.close()) fileStream.on('error', (err) => { - // 错误时也要销毁文件流,防止文件描述符泄漏 fileStream.destroy() controller.error(err) }) @@ -82,33 +235,19 @@ export async function GET( }, }) - const fileName = `${sku.skuName}.zip` - const fileStats = await stat(filePath) - - if (validateOnly) { - fileStream.destroy() - return NextResponse.json({ - success: true, - fileName, - fileSize: fileStats.size, - contentType: 'application/zip', - }) - } - recordSkuDownloadLog(materialId, userId).catch((error) => { console.error('[SKU Download] 记录下载日志失败:', error) }) - // 返回文件流 return new NextResponse(stream, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, - 'Content-Length': fileStats.size.toString() - } + 'Content-Length': fileStats.size.toString(), + }, }) } catch (error) { - console.error('下载 SKU 失败:', error) + console.error('[SKU Download] 下载失败:', error) return NextResponse.json({ error: '下载失败' }, { status: 500 }) } } diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts index 9991c17..6a0340a 100644 --- a/app/api/materials/[id]/skus/[skuId]/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -4,11 +4,37 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import fs from 'fs' -import path from 'path' import { createMaterialLog } from '@/lib/material-log' +import { deleteFileIfUnreferenced, deleteLocalFile } from '@/lib/file-cleanup' +import { + getWhiteBackgroundAssetWhereForSku, + getWhiteBackgroundRelativeDirectory, +} from '@/lib/standard-model-white-background' + +function normalizeSkuName(value: string): string { + return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() +} + +async function findDuplicateStandardModelSkuName(skuName: string, excludeSkuId?: string) { + return prisma.materialSku.findFirst({ + where: { + deletedAt: null, + skuName: { + equals: skuName, + mode: 'insensitive', + }, + ...(excludeSkuId ? { NOT: { id: excludeSkuId } } : {}), + material: { + is: { + materialType: '标准模型', + deletedAt: null, + }, + }, + }, + select: { id: true }, + }) +} -// 获取单个 SKU 详情 export async function GET( request: NextRequest, { params }: { params: { id: string; skuId: string } } @@ -17,17 +43,16 @@ export async function GET( const { skuId } = params const sku = await prisma.materialSku.findFirst({ - where: { + where: { id: skuId, - deletedAt: null // 只获取未删除的SKU - } + deletedAt: null, + }, }) if (!sku) { return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) } - // 返回时统一字段命名(前端使用zipFilePath等) return NextResponse.json({ id: sku.id, materialId: sku.materialId, @@ -38,7 +63,7 @@ export async function GET( zipFileSize: sku.fileSize.toString(), sortOrder: sku.sortOrder, createdAt: sku.createdAt, - updatedAt: sku.updatedAt + updatedAt: sku.updatedAt, }) } catch (error) { console.error('获取 SKU 详情失败:', error) @@ -46,7 +71,6 @@ export async function GET( } } -// 更新 SKU export async function PUT( request: NextRequest, { params }: { params: { id: string; skuId: string } } @@ -59,30 +83,25 @@ export async function PUT( const { skuId } = params - // 检查 SKU 是否存在,并获取关联的素材信息用于权限检查 - const existingSku = await prisma.materialSku.findFirst({ - where: { - id: skuId, - deletedAt: null // 只获取未删除的SKU - }, + const existingSku = await prisma.materialSku.findUnique({ + where: { id: skuId }, include: { material: { select: { - userId: true // 获取素材的上传者ID - } - } - } + userId: true, + }, + }, + }, }) if (!existingSku) { return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) } - // 检查权限:只有素材上传者或管理员可以编辑 SKU - const userId = (session.user as any).id; + const userId = (session.user as any).id const user = await prisma.user.findUnique({ where: { id: userId }, - select: { role: true } + select: { role: true }, }) if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') { @@ -91,10 +110,21 @@ export async function PUT( const body = await request.json() const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize, sortOrder } = body + const normalizedSkuName = skuName !== undefined ? normalizeSkuName(skuName) : undefined + + if (normalizedSkuName !== undefined && !normalizedSkuName) { + return NextResponse.json({ error: 'SKU 名称不能为空' }, { status: 400 }) + } + + if (normalizedSkuName !== undefined && normalizedSkuName !== existingSku.skuName) { + const duplicateSku = await findDuplicateStandardModelSkuName(normalizedSkuName, skuId) + if (duplicateSku) { + return NextResponse.json({ error: '所有标准模型下 SKU 名称不能重复' }, { status: 409 }) + } + } - // 构建更新数据(前端传的是zipFilePath等,需要映射到数据库字段) const updateData: any = {} - if (skuName !== undefined) updateData.skuName = skuName + if (normalizedSkuName !== undefined) updateData.skuName = normalizedSkuName if (previewUrl !== undefined) updateData.previewUrl = previewUrl || null if (zipFileName !== undefined) updateData.fileName = zipFileName || null if (zipFilePath !== undefined) updateData.filePath = zipFilePath @@ -103,7 +133,7 @@ export async function PUT( const updatedSku = await prisma.materialSku.update({ where: { id: skuId }, - data: updateData + data: updateData, }) const changes: Record = { @@ -164,7 +194,6 @@ export async function PUT( }) } - // 返回时统一字段命名 return NextResponse.json({ id: updatedSku.id, materialId: updatedSku.materialId, @@ -175,7 +204,7 @@ export async function PUT( zipFileSize: updatedSku.fileSize.toString(), sortOrder: updatedSku.sortOrder, createdAt: updatedSku.createdAt, - updatedAt: updatedSku.updatedAt + updatedAt: updatedSku.updatedAt, }) } catch (error) { console.error('更新 SKU 失败:', error) @@ -183,7 +212,6 @@ export async function PUT( } } -// 删除 SKU export async function DELETE( request: NextRequest, { params }: { params: { id: string; skuId: string } } @@ -196,43 +224,82 @@ export async function DELETE( const { skuId } = params - // 检查 SKU 是否存在,并获取关联的素材信息用于权限检查 const existingSku = await prisma.materialSku.findUnique({ where: { id: skuId }, include: { material: { select: { - userId: true // 获取素材的上传者ID - } - } - } + userId: true, + }, + }, + }, }) if (!existingSku) { return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) } - // 检查权限:只有素材上传者或管理员可以删除 SKU - const userId = (session.user as any).id; + const userId = (session.user as any).id const user = await prisma.user.findUnique({ where: { id: userId }, - select: { role: true } + select: { role: true }, }) if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') { return NextResponse.json({ error: '没有权限删除此 SKU' }, { status: 403 }) } - // 软删除:只标记删除时间,不删除文件(文件保留在服务器,可通过回收站恢复) - const deletedAt = new Date() - - await prisma.materialSku.update({ - where: { id: skuId }, - data: { - deletedAt - } as any + const whiteBgAssets = await prisma.materialAsset.findMany({ + where: { + materialId: existingSku.materialId, + ...getWhiteBackgroundAssetWhereForSku(skuId), + }, + select: { + id: true, + fileUrl: true, + }, }) + await prisma.$transaction(async (tx) => { + if (whiteBgAssets.length > 0) { + await tx.materialAsset.deleteMany({ + where: { + materialId: existingSku.materialId, + ...getWhiteBackgroundAssetWhereForSku(skuId), + }, + }) + } + + await tx.materialSku.delete({ + where: { id: skuId }, + }) + }) + + const cleanupTargets = [ + existingSku.filePath, + (existingSku as any).previewUrl || '', + ...whiteBgAssets.map((asset) => asset.fileUrl || ''), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + + const cleanupResults = [] + for (const fileUrl of cleanupTargets) { + cleanupResults.push( + await deleteFileIfUnreferenced(fileUrl, { + excludeSkuIds: [skuId], + excludeAssetIds: whiteBgAssets.map((asset) => asset.id), + }) + ) + } + + const whiteBackgroundDirectory = getWhiteBackgroundRelativeDirectory(existingSku.materialId, skuId) + const whiteBackgroundDirectoryResult = await deleteLocalFile(whiteBackgroundDirectory) + + const deletedFileCount = cleanupResults.filter((item) => item.success && !item.skipped).length + const skippedFileCount = cleanupResults.filter((item) => item.skipped).length + const failedFileCount = + cleanupResults.filter((item) => !item.success).length + + (whiteBackgroundDirectoryResult.success ? 0 : 1) + await createMaterialLog({ materialId: existingSku.materialId, userId, @@ -250,14 +317,32 @@ export async function DELETE( oldValue: existingSku.skuName, newValue: existingSku.skuName, }, - deletedAt: { - oldValue: existingSku.deletedAt ? existingSku.deletedAt.toISOString() : null, - newValue: deletedAt.toISOString(), + deleteMode: { + oldValue: 'active', + newValue: 'permanent', + }, + fileCleanup: { + oldValue: null, + newValue: { + deleted: deletedFileCount, + skipped: skippedFileCount, + failed: failedFileCount, + }, }, }, }) - return NextResponse.json({ success: true, message: 'SKU 已删除' }) + return NextResponse.json({ + success: true, + message: 'SKU 已删除', + details: { + deletedFiles: deletedFileCount, + skippedFiles: skippedFileCount, + failedFiles: failedFileCount, + deletedWhiteBackgroundAssets: whiteBgAssets.length, + deletedWhiteBackgroundDirectory: whiteBackgroundDirectoryResult.success, + }, + }) } catch (error) { console.error('删除 SKU 失败:', error) return NextResponse.json({ error: '删除 SKU 失败' }, { status: 500 }) diff --git a/app/api/materials/[id]/skus/batch-download/route.ts b/app/api/materials/[id]/skus/batch-download/route.ts index 65d9a5f..e65e3cd 100644 --- a/app/api/materials/[id]/skus/batch-download/route.ts +++ b/app/api/materials/[id]/skus/batch-download/route.ts @@ -1,12 +1,70 @@ +import { promises as fs } from 'fs' +import path from 'path' +import JSZip from 'jszip' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import path from 'path' -import JSZip from 'jszip' import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' +import { + getWhiteBackgroundAssetWhereForSku, + groupWhiteBackgroundAssetsBySku, + hasAnyWhiteBackgroundAssetGroup, + STANDARD_MODEL_WHITE_BG_VIEWS, + type GroupedWhiteBackgroundAssets, +} from '@/lib/standard-model-white-background' + +type DownloadType = 'model' | 'white-background' + +type WhiteBackgroundAssetRecord = { + assetType: string + fileUrl: string + fileName: string | null + sortOrder: number +} + +function resolveModelDownloadFileName( + skuName: string, + rawFileName?: string | null, + rawFilePath?: string | null, +): string { + const ext = path.extname(rawFileName || rawFilePath || '') || '.zip' + return `${skuName}${ext}` +} + +function resolveLocalFilePath(fileUrl: string): string { + if (path.isAbsolute(fileUrl)) { + return fileUrl + } + return path.join(process.cwd(), fileUrl) +} + +function getWhiteBackgroundZipEntries(group: GroupedWhiteBackgroundAssets) { + const fixedEntries = STANDARD_MODEL_WHITE_BG_VIEWS + .map((view) => { + const asset = group.fixed[view.key] + if (!asset) { + return null + } + return { + fileName: asset.fileName || `${view.label}${path.extname(asset.fileUrl) || '.jpg'}`, + fileUrl: asset.fileUrl, + } + }) + .filter((entry): entry is { fileName: string; fileUrl: string } => Boolean(entry)) + + const otherEntries = group.other.map((asset) => ({ + fileName: asset.fileName || path.basename(asset.fileUrl), + fileUrl: asset.fileUrl, + directory: 'other', + })) + + return { + fixedEntries, + otherEntries, + } +} -// SKU批量下载API export async function POST( request: NextRequest, { params }: { params: { id: string } } @@ -18,7 +76,6 @@ export async function POST( } const userId = (session.user as any).id - const user = await prisma.user.findUnique({ where: { id: userId }, select: { canDownload: true }, @@ -30,16 +87,16 @@ export async function POST( const materialId = params.id const body = await request.json() - const { skuIds } = body + const skuIds = Array.isArray(body?.skuIds) ? body.skuIds : [] + const downloadType: DownloadType = body?.downloadType === 'white-background' ? 'white-background' : 'model' - if (!skuIds || !Array.isArray(skuIds) || skuIds.length === 0) { - return NextResponse.json({ error: '请选择要下载的SKU' }, { status: 400 }) + if (skuIds.length === 0) { + return NextResponse.json({ error: '请选择要下载的 SKU' }, { status: 400 }) } - // 检查素材是否存在(排除已删除的素材) const material = await prisma.material.findFirst({ where: { id: materialId, deletedAt: null }, - select: { id: true, productModel: true, materialType: true } + select: { id: true, productModel: true, materialType: true }, }) if (!material) { @@ -47,69 +104,113 @@ export async function POST( } if (material.materialType !== '标准模型') { - return NextResponse.json({ error: '只有标准模型类型才支持SKU批量下载' }, { status: 400 }) + return NextResponse.json({ error: '只有标准模型类型才支持 SKU 批量下载' }, { status: 400 }) } - // 获取选中的SKU列表 const skus = await prisma.materialSku.findMany({ where: { id: { in: skuIds }, materialId, - deletedAt: null // 只获取未删除的SKU + deletedAt: null, }, select: { id: true, skuName: true, filePath: true, - fileName: true - } + fileName: true, + }, + orderBy: { sortOrder: 'asc' }, }) if (skus.length === 0) { - return NextResponse.json({ error: '没有找到可下载的SKU' }, { status: 404 }) + return NextResponse.json({ error: '没有找到可下载的 SKU' }, { status: 404 }) } - // 创建ZIP文件 const zip = new JSZip() let addedCount = 0 - const fs = await import('fs/promises') - - for (const sku of skus) { - try { - const filePath = path.join(process.cwd(), 'uploads', sku.filePath) - - // 检查文件是否存在 - try { - await fs.access(filePath) - } catch { - console.warn(`SKU文件不存在: ${filePath}`) + if (downloadType === 'white-background') { + const whiteBackgroundAssets = await prisma.materialAsset.findMany({ + where: { + materialId, + OR: skuIds.flatMap((skuId: string) => getWhiteBackgroundAssetWhereForSku(skuId).OR), + }, + select: { + assetType: true, + fileUrl: true, + fileName: true, + sortOrder: true, + }, + }) + + const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku( + whiteBackgroundAssets, + ) + + for (const sku of skus) { + const whiteBackgroundGroup = whiteBackgroundMap.get(sku.id) + if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) { continue } - // 读取文件并添加到ZIP - const fileBuffer = await fs.readFile(filePath) - const fileName = sku.fileName || `${sku.skuName}.zip` - zip.file(fileName, fileBuffer) - addedCount++ - } catch (error) { - console.error(`添加SKU文件失败: ${sku.id}`, error) - // 继续处理其他文件 + const { fixedEntries, otherEntries } = getWhiteBackgroundZipEntries(whiteBackgroundGroup) + if (fixedEntries.length === 0 && otherEntries.length === 0) { + continue + } + + try { + for (const entry of fixedEntries) { + const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl)) + zip.file(`${sku.skuName}/${entry.fileName}`, fileBuffer) + } + + for (const entry of otherEntries) { + const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl)) + zip.file(`${sku.skuName}/other/${entry.fileName}`, fileBuffer) + } + + addedCount++ + } catch (error) { + console.error(`[SKU Batch Download] 添加白底图失败: ${sku.id}`, error) + } + } + } else { + for (const sku of skus) { + try { + if (!sku.filePath) { + continue + } + + const filePath = path.join(process.cwd(), 'uploads', sku.filePath) + const fileName = resolveModelDownloadFileName( + sku.skuName, + sku.fileName || null, + sku.filePath || null, + ) + + const fileBuffer = await fs.readFile(filePath) + zip.file(fileName, fileBuffer) + addedCount++ + } catch (error) { + console.error(`[SKU Batch Download] 添加模型失败: ${sku.id}`, error) + } } } if (addedCount === 0) { - return NextResponse.json({ error: '没有可下载的文件' }, { status: 404 }) + return NextResponse.json( + { error: downloadType === 'white-background' ? '没有可下载的白底图文件' : '没有可下载的模型文件' }, + { status: 404 } + ) } - // 生成ZIP文件 const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', - compressionOptions: { level: 6 } + compressionOptions: { level: 6 }, }) - const filename = `${material.productModel}_SKU批量下载_${Date.now()}.zip` + const filename = `${material.productModel}_${downloadType === 'white-background' ? 'SKU白底图' : 'SKU模型'}_${Date.now()}.zip` recordBatchDownloadLog(materialId, userId).catch((error) => { console.error('[SKU Batch Download] 记录下载日志失败:', error) @@ -123,7 +224,7 @@ export async function POST( }, }) } catch (error) { - console.error('SKU批量下载失败:', error) + console.error('[SKU Batch Download] 下载失败:', error) return NextResponse.json( { error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') }, { status: 500 } @@ -151,4 +252,3 @@ async function recordBatchDownloadLog(materialId: string, userId: string) { }), ]) } - diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts index ee62597..c21c6c3 100644 --- a/app/api/materials/[id]/skus/route.ts +++ b/app/api/materials/[id]/skus/route.ts @@ -6,52 +6,140 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' +import { + countWhiteBackgroundAssets, + countWhiteBackgroundFixedViews, + countWhiteBackgroundOtherImages, + getWhiteBackgroundAssetWhereForSku, + groupWhiteBackgroundAssetsBySku, + hasAnyWhiteBackgroundAssetGroup, + hasCompleteWhiteBackgroundAssetGroup, +} from '@/lib/standard-model-white-background' + +function normalizeSkuName(value: string): string { + return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() +} + +async function findDuplicateStandardModelSkuName(skuName: string, excludeSkuId?: string) { + return prisma.materialSku.findFirst({ + where: { + deletedAt: null, + skuName: { + equals: skuName, + mode: 'insensitive', + }, + ...(excludeSkuId ? { NOT: { id: excludeSkuId } } : {}), + material: { + is: { + materialType: '标准模型', + deletedAt: null, + }, + }, + }, + select: { + id: true, + materialId: true, + }, + }) +} -// 获取素材的所有 SKU 列表 export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { try { const materialId = params.id + const { searchParams } = new URL(request.url) + const validateSkuName = searchParams.get('validateSkuName') + const excludeSkuId = searchParams.get('excludeSkuId') || '' - // 检查素材是否存在 const material = await prisma.material.findUnique({ where: { id: materialId }, - select: { id: true, materialType: true } + select: { id: true, materialType: true }, }) if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } - // 获取 SKU 列表,按排序顺序(排除已删除的) + if (validateSkuName !== null) { + const normalizedSkuName = normalizeSkuName(validateSkuName) + if (!normalizedSkuName) { + return NextResponse.json({ valid: false, error: 'SKU 名称不能为空' }, { status: 400 }) + } + + const duplicateSku = await findDuplicateStandardModelSkuName( + normalizedSkuName, + excludeSkuId || undefined, + ) + + return NextResponse.json({ + valid: !duplicateSku, + normalizedSkuName, + error: duplicateSku ? '所有标准模型下 SKU 名称不能重复' : null, + }) + } + const skus = await prisma.materialSku.findMany({ - where: { + where: { materialId, - deletedAt: null // 只获取未删除的SKU + deletedAt: null, }, - orderBy: { sortOrder: 'asc' } + orderBy: { sortOrder: 'asc' }, }) - // 转换 BigInt 为字符串,并统一字段命名(前端使用zipFilePath等) - // 使用智能 URL 重写(根据用户网络位置选择内网或外网地址) - const skusWithStringSize = skus.map(sku => ({ - id: sku.id, - materialId: sku.materialId, - skuName: sku.skuName, - previewUrl: rewriteStorageUrlForClient((sku as any).previewUrl || '', request), - zipFileName: (sku as any).fileName || '', - zipFilePath: sku.filePath, // 统一使用zipFilePath(本地文件路径,不需要重写) - zipFileSize: sku.fileSize.toString(), - sortOrder: sku.sortOrder, - createdAt: sku.createdAt, - updatedAt: sku.updatedAt - })) + const whiteBackgroundAssets = skus.length > 0 + ? await prisma.materialAsset.findMany({ + where: { + materialId, + OR: skus.flatMap((sku) => getWhiteBackgroundAssetWhereForSku(sku.id).OR), + }, + select: { + id: true, + assetType: true, + fileName: true, + fileUrl: true, + sortOrder: true, + }, + }) + : [] + + const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets) return NextResponse.json({ - skus: skusWithStringSize, - total: skus.length + skus: skus.map((sku) => { + const whiteBackgroundGroup = whiteBackgroundMap.get(sku.id) + const whiteBackgroundUploaded = hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup) + const whiteBackgroundComplete = hasCompleteWhiteBackgroundAssetGroup(whiteBackgroundGroup) + const whiteBackgroundFixedCount = countWhiteBackgroundFixedViews(whiteBackgroundGroup) + const whiteBackgroundOtherCount = countWhiteBackgroundOtherImages(whiteBackgroundGroup) + const whiteBackgroundCount = countWhiteBackgroundAssets(whiteBackgroundGroup) + const previewAsset = + Object.values(whiteBackgroundGroup?.fixed || {}).find(Boolean) || + whiteBackgroundGroup?.other?.[0] || + whiteBackgroundGroup?.zipAssets?.[0] + + return { + id: sku.id, + materialId: sku.materialId, + skuName: sku.skuName, + previewUrl: rewriteStorageUrlForClient((sku as any).previewUrl || '', request), + zipFileName: (sku as any).fileName || '', + zipFilePath: sku.filePath, + zipFileSize: sku.fileSize.toString(), + sortOrder: sku.sortOrder, + createdAt: sku.createdAt, + updatedAt: sku.updatedAt, + whiteBackgroundUploaded, + whiteBackgroundComplete, + whiteBackgroundFixedCount, + whiteBackgroundOtherCount, + whiteBackgroundCount, + whiteBackgroundFileName: whiteBackgroundUploaded ? `${sku.skuName}_白底图.zip` : '', + whiteBackgroundFileUrl: previewAsset?.fileUrl || '', + } + }), + total: skus.length, }) } catch (error) { console.error('获取 SKU 列表失败:', error) @@ -59,7 +147,6 @@ export async function GET( } } -// 添加新的 SKU export async function POST( request: NextRequest, { params }: { params: { id: string } } @@ -71,13 +158,11 @@ export async function POST( } const userId = (session.user as any).id - const materialId = params.id - // 检查素材是否存在且是标准模型类型 const material = await prisma.material.findUnique({ where: { id: materialId }, - select: { id: true, materialType: true } + select: { id: true, materialType: true }, }) if (!material) { @@ -90,31 +175,34 @@ export async function POST( const body = await request.json() const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize } = body + const normalizedSkuName = normalizeSkuName(skuName) - // 验证必填字段 - if (!skuName || !zipFilePath) { - return NextResponse.json({ error: '缺少必填字段:skuName 和 zipFilePath' }, { status: 400 }) + if (!normalizedSkuName || !previewUrl) { + return NextResponse.json({ error: '缺少必填字段:skuName 和 previewUrl' }, { status: 400 }) + } + + const duplicateSku = await findDuplicateStandardModelSkuName(normalizedSkuName) + if (duplicateSku) { + return NextResponse.json({ error: '所有标准模型下 SKU 名称不能重复' }, { status: 409 }) } - // 获取当前最大排序值 const maxSortOrder = await prisma.materialSku.aggregate({ where: { materialId }, - _max: { sortOrder: true } + _max: { sortOrder: true }, }) const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1 - // 创建 SKU const sku = await prisma.materialSku.create({ data: { materialId, - skuName, + skuName: normalizedSkuName, previewUrl: previewUrl || null, - filePath: zipFilePath, // 前端传的是zipFilePath,存储到filePath + filePath: zipFilePath || '', fileName: zipFileName || null, fileSize: BigInt(zipFileSize || 0), - sortOrder: newSortOrder - } as any // 临时类型断言,因为 Prisma Client 可能还未完全更新 + sortOrder: newSortOrder, + } as any, }) await createMaterialLog({ @@ -145,27 +233,24 @@ export async function POST( }, }) - // 返回时转换为前端期望的字段名 return NextResponse.json({ id: sku.id, materialId: sku.materialId, skuName: sku.skuName, previewUrl: (sku as any).previewUrl || '', zipFileName: (sku as any).fileName || '', - zipFilePath: sku.filePath, // 返回时使用zipFilePath + zipFilePath: sku.filePath, zipFileSize: sku.fileSize.toString(), sortOrder: sku.sortOrder, createdAt: sku.createdAt, - updatedAt: sku.updatedAt + updatedAt: sku.updatedAt, }, { status: 201 }) } catch (error) { console.error('添加 SKU 失败:', error) const errorMessage = error instanceof Error ? error.message : String(error) - const errorStack = error instanceof Error ? error.stack : undefined - console.error('错误详情:', { errorMessage, errorStack, error }) - return NextResponse.json({ + return NextResponse.json({ error: '添加 SKU 失败', - details: process.env.NODE_ENV === 'development' ? errorMessage : undefined + details: process.env.NODE_ENV === 'development' ? errorMessage : undefined, }, { status: 500 }) } } diff --git a/app/api/materials/[id]/skus/upload/route.ts b/app/api/materials/[id]/skus/upload/route.ts index fbd0e14..f40e1ff 100644 --- a/app/api/materials/[id]/skus/upload/route.ts +++ b/app/api/materials/[id]/skus/upload/route.ts @@ -6,6 +6,10 @@ import { writeFile, mkdir } from 'fs/promises' import path from 'path' import { existsSync } from 'fs' +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // SKU文件上传API export async function POST( request: NextRequest, diff --git a/app/api/materials/[id]/white-background/route.ts b/app/api/materials/[id]/white-background/route.ts index a7c6e57..f87f6f4 100644 --- a/app/api/materials/[id]/white-background/route.ts +++ b/app/api/materials/[id]/white-background/route.ts @@ -1,31 +1,55 @@ +import { randomBytes } from 'crypto' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { promises as fs } from 'fs' import path from 'path' -import { randomBytes } from 'crypto' -import JSZip from 'jszip' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sanitizeFileName } from '@/lib/image-processor' +import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { + buildWhiteBackgroundFixedFileName, + countWhiteBackgroundFixedViews, + getAllWhiteBackgroundAssetTypesForSku, + getNextWhiteBackgroundOtherIndex, + getWhiteBackgroundAssetWhereForSku, + getWhiteBackgroundOtherAssetType, + getWhiteBackgroundRelativeDirectory, + getWhiteBackgroundRelativeFilePath, + getWhiteBackgroundViewAssetType, + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + hasCompleteWhiteBackgroundAssetGroup, + normalizeWhiteBackgroundSkuName, + STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, STANDARD_MODEL_WHITE_BG_VIEWS, - getMissingWhiteBackgroundViews, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' import { createMaterialLog } from '@/lib/material-log' export const runtime = 'nodejs' -function generateUniqueFilename(prefix: string, ext: string): string { - const timestamp = Date.now() - const random = randomBytes(8).toString('hex') - return `${prefix}-${timestamp}-${random}${ext}` +function buildUniqueDiskFileName(prefix: string, ext: string): string { + return `${prefix}-${Date.now()}-${randomBytes(4).toString('hex')}${ext}` +} + +function resolveSafeOtherBaseName(file: File, index: number): string { + const ext = path.extname(file.name) + const originalBaseName = path.basename(file.name, ext) + return ( + sanitizeFileName(originalBaseName || `other-${index}`) + .replace(/\s+/g, '-') + .toLowerCase() || `other-${index}` + ) } export async function POST( request: NextRequest, { params }: { params: { id: string } }, ) { + const writtenFilePaths: string[] = [] + let assetsPersisted = false + try { const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { @@ -37,7 +61,13 @@ export async function POST( const material = await prisma.material.findUnique({ where: { id: materialId }, - select: { id: true, materialType: true, userId: true }, + select: { + id: true, + materialType: true, + userId: true, + productCategoryName: true, + categoryName: true, + }, }) if (!material) { @@ -77,115 +107,220 @@ export async function POST( if (!sku) { return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 }) } + const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => { acc[view.key] = formData.get(view.fieldName) as File | null return acc }, {} as Partial>) - const missingViews = getMissingWhiteBackgroundViews(whiteBackgroundImages) - if (missingViews.length > 0) { - const missingLabels = STANDARD_MODEL_WHITE_BG_VIEWS - .filter((view) => missingViews.includes(view.key)) - .map((view) => view.label) + const otherImages = formData + .getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME) + .filter((file): file is File => { + return ( + !!file && + typeof (file as File).name === 'string' && + typeof (file as File).arrayBuffer === 'function' && + Number((file as File).size || 0) > 0 + ) + }) + + const uploadedFixedViews = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBackgroundImages[view.key]) + if (uploadedFixedViews.length === 0 && otherImages.length === 0) { return NextResponse.json( - { error: `白底图需一次上传完整6张,缺少:${missingLabels.join('、')}` }, + { error: '请至少上传 1 张白底图' }, { status: 400 }, ) } - const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId, 'white-background') - await fs.mkdir(uploadDir, { recursive: true }) - - const zip = new JSZip() - for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) { - const imageFile = whiteBackgroundImages[view.key] - if (!imageFile) { - throw new Error(`白底图缺失:${view.label}`) - } - - const ext = path.extname(imageFile.name) || '.jpg' - const normalizedName = sanitizeFileName(view.label).replace(/\s+/g, '-').toLowerCase() - const targetName = `${normalizedName}${ext}` - const imageBuffer = Buffer.from(await imageFile.arrayBuffer()) - zip.file(targetName, imageBuffer) - } - - const zipBuffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }) - - const safeSkuPrefix = sanitizeFileName(skuName || sku.skuName || 'standard-model') - const whiteBgZipName = generateUniqueFilename(`${safeSkuPrefix}-white-bg`, '.zip') - const absolutePath = path.join(uploadDir, whiteBgZipName) - const relativePath = path.join('uploads', 'skus', materialId, 'white-background', whiteBgZipName) - - await fs.writeFile(absolutePath, zipBuffer) - - const skuAssetType = `standard-model-white-bg-zip:${skuId}` - const existingAssets = await prisma.materialAsset.findMany({ where: { materialId, - assetType: skuAssetType, + ...getWhiteBackgroundAssetWhereForSku(skuId), + }, + select: { + id: true, + fileUrl: true, + assetType: true, + sortOrder: true, }, - select: { id: true, fileUrl: true }, }) - for (const asset of existingAssets) { - if (!asset.fileUrl || asset.fileUrl.startsWith('http://') || asset.fileUrl.startsWith('https://')) { + const existingWhiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(existingAssets).get(skuId) + const nextOtherIndex = getNextWhiteBackgroundOtherIndex(existingWhiteBackgroundGroup) + const assetTypesToReplace = new Set(getAllWhiteBackgroundAssetTypesForSku(skuId)) + const normalizedSkuName = normalizeWhiteBackgroundSkuName(skuName || sku.skuName) + const whiteBackgroundCategoryName = + String(material.productCategoryName || '').trim() || + String(material.categoryName || '').trim() + + const uploadDir = path.join( + process.cwd(), + ...getWhiteBackgroundRelativeDirectory(materialId, skuId).split('/'), + ) + + await fs.mkdir(path.join(uploadDir, 'other'), { recursive: true }) + + const assetsToCreate: Array<{ + materialId: string + assetType: string + fileUrl: string + fileName: string + fileSize: bigint + sortOrder: number + }> = [] + + for (let index = 0; index < STANDARD_MODEL_WHITE_BG_VIEWS.length; index++) { + const view = STANDARD_MODEL_WHITE_BG_VIEWS[index] + const imageFile = whiteBackgroundImages[view.key] + if (!imageFile) { continue } - const oldPath = path.isAbsolute(asset.fileUrl) - ? asset.fileUrl - : path.join(process.cwd(), asset.fileUrl) - try { - await fs.unlink(oldPath) - } catch { - // 忽略旧文件不存在等清理异常 - } - } - if (existingAssets.length > 0) { - await prisma.materialAsset.deleteMany({ - where: { - materialId, - assetType: skuAssetType, - }, + const ext = (path.extname(imageFile.name) || '.jpg').toLowerCase() + const displayFileName = buildWhiteBackgroundFixedFileName( + normalizedSkuName, + whiteBackgroundCategoryName, + view.label, + ext, + ) + const diskFileName = displayFileName + const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) + const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) + const imageBuffer = Buffer.from(await imageFile.arrayBuffer()) + assetTypesToReplace.add(getWhiteBackgroundViewAssetType(skuId, view.key)) + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }) + await fs.writeFile(absolutePath, imageBuffer) + writtenFilePaths.push(relativePath) + + assetsToCreate.push({ + materialId, + assetType: getWhiteBackgroundViewAssetType(skuId, view.key), + fileUrl: relativePath, + fileName: displayFileName, + fileSize: BigInt(imageBuffer.length), + sortOrder: index, }) } - await prisma.materialAsset.create({ - data: { - materialId, - assetType: skuAssetType, - fileUrl: relativePath, - fileName: whiteBgZipName, - fileSize: BigInt(zipBuffer.length), - sortOrder: 0, - }, - }) + for (let index = 0; index < otherImages.length; index++) { + const imageFile = otherImages[index] + const ext = (path.extname(imageFile.name) || '.jpg').toLowerCase() + const otherIndex = nextOtherIndex + index + const safeBaseName = resolveSafeOtherBaseName(imageFile, otherIndex) + const displayFileName = `${String(otherIndex).padStart(2, '0')}-${safeBaseName}${ext}` + const diskFileName = `other/${buildUniqueDiskFileName(displayFileName.replace(ext, ''), ext)}` + const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) + const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) + const imageBuffer = Buffer.from(await imageFile.arrayBuffer()) - await createMaterialLog({ - materialId, - userId, - action: 'UPDATE', - changes: { - whiteBackgroundZip: { - oldValue: existingAssets.length > 0 ? 'replaced' : null, - newValue: whiteBgZipName, - }, - }, + await fs.mkdir(path.dirname(absolutePath), { recursive: true }) + await fs.writeFile(absolutePath, imageBuffer) + writtenFilePaths.push(relativePath) + + assetsToCreate.push({ + materialId, + assetType: getWhiteBackgroundOtherAssetType(skuId, otherIndex), + fileUrl: relativePath, + fileName: displayFileName, + fileSize: BigInt(imageBuffer.length), + sortOrder: 1000 + otherIndex - 1, + }) + } + + const assetTypeListToReplace = Array.from(assetTypesToReplace) + const replacedAssets = existingAssets.filter((asset) => assetTypeListToReplace.includes(asset.assetType)) + + const nextFixed: Partial> = { + ...(existingWhiteBackgroundGroup?.fixed || {}), + } + uploadedFixedViews.forEach((view) => { + nextFixed[view.key] = { + assetType: getWhiteBackgroundViewAssetType(skuId, view.key), + } }) + const nextFixedCount = countWhiteBackgroundFixedViews({ fixed: nextFixed }) + const nextOtherCount = (existingWhiteBackgroundGroup?.other.length || 0) + otherImages.length + const whiteBackgroundCount = nextFixedCount + nextOtherCount + const isComplete = + hasCompleteWhiteBackgroundAssetGroup({ fixed: nextFixed }) && nextFixedCount === STANDARD_MODEL_WHITE_BG_VIEWS.length + + await prisma.$transaction(async (tx) => { + if (assetTypeListToReplace.length > 0) { + await tx.materialAsset.deleteMany({ + where: { + materialId, + assetType: { + in: assetTypeListToReplace, + }, + }, + }) + } + + if (assetsToCreate.length > 0) { + await tx.materialAsset.createMany({ + data: assetsToCreate, + }) + } + }) + assetsPersisted = true + + for (const asset of replacedAssets) { + if (!asset.fileUrl) { + continue + } + await deleteFileIfUnreferenced(asset.fileUrl) + } + + try { + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + whiteBackgroundStorageMode: { + oldValue: existingAssets.length > 0 ? 'replace' : null, + newValue: 'per-image-assets', + }, + whiteBackgroundSummary: { + oldValue: null, + newValue: { + uploadedFixedCount: uploadedFixedViews.length, + fixedCount: nextFixedCount, + otherCount: nextOtherCount, + skuId, + skuName: normalizedSkuName, + zipFileName: getWhiteBackgroundZipFileName(normalizedSkuName), + }, + }, + }, + }) + } catch (logError) { + console.error('记录白底图上传日志失败:', logError) + } return NextResponse.json({ success: true, - fileName: whiteBgZipName, - filePath: relativePath.replace(/\\/g, '/'), - fileSize: zipBuffer.length, + whiteBackgroundUploaded: whiteBackgroundCount > 0, + uploadedFixedCount: uploadedFixedViews.length, + fixedCount: nextFixedCount, + otherCount: nextOtherCount, + whiteBackgroundCount, + isComplete, + downloadFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }) } catch (error) { + if (!assetsPersisted) { + for (const relativePath of writtenFilePaths) { + const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) + try { + await fs.unlink(absolutePath) + } catch { + // ignore cleanup failure + } + } + } + console.error('上传白底图失败:', error) return NextResponse.json({ error: '上传白底图失败' }, { status: 500 }) } diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts index 440edd6..729e125 100644 --- a/app/api/materials/restore/route.ts +++ b/app/api/materials/restore/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { createMaterialLog } from '@/lib/material-log'; +import { getOriginalTaskNo } from '@/lib/product-package'; // POST /api/materials/restore - 恢复已删除的素材 export async function POST(request: NextRequest) { @@ -30,14 +31,108 @@ export async function POST(request: NextRequest) { if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } + + const restoredTaskNo = getOriginalTaskNo((material as any).taskNo); + if (restoredTaskNo && restoredTaskNo !== (material as any).taskNo) { + const existingMaterial = await prisma.material.findFirst({ + where: { + taskNo: restoredTaskNo, + deletedAt: null, + NOT: { id }, + } as any, + select: { + id: true, + productModel: true, + materialType: true, + }, + }); + + if (existingMaterial) { + return NextResponse.json( + { error: `恢复失败:原任务号 ${restoredTaskNo} 已被素材 ${existingMaterial.productModel}(${existingMaterial.materialType})占用` }, + { status: 409 } + ); + } + } + + if (material.materialType === '标准模型') { + const materialSkus = await prisma.materialSku.findMany({ + where: { materialId: id }, + select: { + skuName: true, + }, + }); + + const skuNames = Array.from( + new Set( + materialSkus + .map((sku) => String(sku.skuName || '').trim().toUpperCase()) + .filter(Boolean) + ) + ); + + if (skuNames.length > 0) { + const conflictingSkus = await prisma.materialSku.findMany({ + where: { + deletedAt: null, + skuName: { + in: skuNames, + mode: 'insensitive', + }, + materialId: { + not: id, + }, + material: { + is: { + materialType: '标准模型', + deletedAt: null, + }, + }, + }, + select: { + skuName: true, + material: { + select: { + productModel: true, + }, + }, + }, + take: 5, + }); + + if (conflictingSkus.length > 0) { + const conflictSummary = conflictingSkus + .map((item) => `${item.skuName}(${item.material.productModel})`) + .join('、'); + + return NextResponse.json( + { error: `恢复失败:以下 SKU 已被其他标准模型占用:${conflictSummary}` }, + { status: 409 } + ); + } + } + } // 恢复素材(将 deletedAt 设为 null) - await prisma.material.update({ - where: { id }, - data: { - deletedAt: null, - }, - }); + await prisma.$transaction([ + prisma.material.update({ + where: { id }, + data: { + deletedAt: null, + ...(restoredTaskNo && restoredTaskNo !== (material as any).taskNo + ? { taskNo: restoredTaskNo } + : {}), + }, + }), + prisma.materialSku.updateMany({ + where: { + materialId: id, + }, + data: { + deletedAt: null, + }, + }), + ]); // 记录操作日志 const userId = (session.user as any).id; @@ -50,6 +145,14 @@ export async function POST(request: NextRequest) { oldValue: material.deletedAt?.toISOString() || null, newValue: null, }, + ...(restoredTaskNo && restoredTaskNo !== (material as any).taskNo + ? { + taskNo: { + oldValue: (material as any).taskNo || null, + newValue: restoredTaskNo, + }, + } + : {}), }, }); diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 5b3c6ca..48d09b3 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -991,10 +991,19 @@ export async function DELETE(request: NextRequest) { // 注意:软删除时,文件仍然保留,以便恢复时可以重新使用 // 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时) - await prisma.material.update({ - where: { id }, - data: { deletedAt }, - }); + await prisma.$transaction([ + prisma.material.update({ + where: { id }, + data: { deletedAt }, + }), + prisma.materialSku.updateMany({ + where: { + materialId: id, + deletedAt: null, + }, + data: { deletedAt }, + }), + ]); // 记录日志 await createMaterialLog({ diff --git a/app/api/materials/upload/3d-original/complete/route.ts b/app/api/materials/upload/3d-original/complete/route.ts index f02f4b9..58f3df4 100644 --- a/app/api/materials/upload/3d-original/complete/route.ts +++ b/app/api/materials/upload/3d-original/complete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { assignTaskNoOnComplete } from '@/lib/product-package'; import { createZipFromFiles, generatePreviewImages, @@ -11,6 +12,10 @@ import { } from '@/lib/task-material-processor'; import { createMaterialLog } from '@/lib/material-log'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // POST - 完成3D原图上传 export async function POST(request: NextRequest) { let materialId: string | null = null; @@ -26,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets } = body; + const { materialId: matId, assets, taskNo } = body; materialId = matId; @@ -117,6 +122,9 @@ export async function POST(request: NextRequest) { // 步骤4: 保存资产记录到数据库 await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + // 步骤4.5: 仅在素材成功完成后写入任务号 + await assignTaskNoOnComplete(materialId!, taskNo); + // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/3d-original/init/route.ts b/app/api/materials/upload/3d-original/init/route.ts index 189786f..dc87839 100644 --- a/app/api/materials/upload/3d-original/init/route.ts +++ b/app/api/materials/upload/3d-original/init/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; // POST - 初始化3D原图素材记录 export async function POST(request: NextRequest) { @@ -24,20 +25,15 @@ export async function POST(request: NextRequest) { // 验证任务号格式(如果提供) if (taskNo) { - const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; - if (!taskNoRegex.test(taskNo)) { + if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误' }, { status: 400 } ); } - // 检查任务号是否已存在(排除已删除的素材) const existingMaterial = await prisma.material.findFirst({ - where: { - taskNo, - deletedAt: null, // 只检查未删除的素材 - }, + where: { taskNo, deletedAt: null }, select: { id: true, productModel: true, materialType: true }, }); @@ -47,6 +43,15 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log(`[3D Original Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条`); + } + } catch (cleanupError) { + console.error('[3D Original Init] 清理旧软删素材失败:', cleanupError); + } } // 获取用户信息 @@ -77,7 +82,6 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // 3D原图不需要渲染器 - taskNo: taskNo || null, // 任务号(可选) }, }); diff --git a/app/api/materials/upload/ai-original/complete/route.ts b/app/api/materials/upload/ai-original/complete/route.ts index e98a72e..6ba1c94 100644 --- a/app/api/materials/upload/ai-original/complete/route.ts +++ b/app/api/materials/upload/ai-original/complete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { assignTaskNoOnComplete } from '@/lib/product-package'; import { createZipFromFiles, generatePreviewImages, @@ -11,6 +12,10 @@ import { } from '@/lib/task-material-processor'; import { createMaterialLog } from '@/lib/material-log'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // POST - 完成AI原图上传 export async function POST(request: NextRequest) { let materialId: string | null = null; @@ -26,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets } = body; + const { materialId: matId, assets, taskNo } = body; materialId = matId; @@ -117,6 +122,9 @@ export async function POST(request: NextRequest) { // 步骤4: 保存资产记录到数据库 await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + // 步骤4.5: 仅在素材成功完成后写入任务号 + await assignTaskNoOnComplete(materialId!, taskNo); + // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/ai-original/init/route.ts b/app/api/materials/upload/ai-original/init/route.ts index 4fb36f2..410bc9b 100644 --- a/app/api/materials/upload/ai-original/init/route.ts +++ b/app/api/materials/upload/ai-original/init/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; // POST - 初始化AI原图素材记录 export async function POST(request: NextRequest) { @@ -24,8 +25,7 @@ export async function POST(request: NextRequest) { // 验证任务号格式(如果提供) if (taskNo) { - const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; - if (!taskNoRegex.test(taskNo)) { + if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误' }, { status: 400 } @@ -47,6 +47,15 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log(`[AI Original Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条`); + } + } catch (cleanupError) { + console.error('[AI Original Init] 清理旧软删素材失败:', cleanupError); + } } // 获取用户信息 @@ -77,7 +86,6 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // AI原图不需要渲染器 - taskNo: taskNo || null, // 任务号(可选) }, }); diff --git a/app/api/materials/upload/chunk/route.ts b/app/api/materials/upload/chunk/route.ts index 31be1d4..a1b9a38 100644 --- a/app/api/materials/upload/chunk/route.ts +++ b/app/api/materials/upload/chunk/route.ts @@ -1,127 +1,224 @@ +import { once } from 'events' +import { createWriteStream } from 'fs' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions' -import { writeFile, mkdir } from 'fs/promises' -import path from 'path' -// Configure route to handle large files export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const maxDuration = 10800 // 3 hours timeout +export const maxDuration = 10800 + +interface ChunkRequestPayload { + uploadId: string + chunkIndex: number + fileName: string + chunkSizeHint: number + mode: 'raw' | 'multipart' + chunkFile?: File | null +} export async function POST(req: NextRequest) { const requestStartTime = Date.now() console.log(`[UploadChunk] 📥 收到请求 at ${new Date().toISOString()}`) - + try { - // 性能监控:认证 - const t1 = Date.now() - console.log(`[UploadChunk] 🔐 开始认证...`) + const authStartedAt = Date.now() const session = await getServerSession(authOptions) - const authTime = Date.now() - t1 + const authTime = Date.now() - authStartedAt console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`) - + if (!session) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } - // 性能监控:解析FormData - const t2 = Date.now() - console.log(`[UploadChunk] 📦 开始解析FormData...`) - const formData = await req.formData() - const formDataTime = Date.now() - t2 - console.log(`[UploadChunk] 📦 FormData解析完成: ${formDataTime}ms`) - - const uploadId = formData.get('uploadId') as string - const chunkIndex = parseInt(formData.get('chunkIndex') as string) - const chunk = formData.get('chunk') as File - const fileName = formData.get('fileName') as string - - console.log(`[UploadChunk] 📊 Chunk ${chunkIndex}, 大小: ${(chunk.size / 1024 / 1024).toFixed(2)}MB`) + const payloadStartedAt = Date.now() + const payload = await resolveChunkPayload(req) + const payloadResolveTime = Date.now() - payloadStartedAt + console.log( + `[UploadChunk] 📦 请求参数解析完成: ${payloadResolveTime}ms, mode=${payload.mode}, chunkIndex=${payload.chunkIndex}`, + ) - if (!uploadId || isNaN(chunkIndex) || !chunk) { - console.error(`[UploadChunk] ❌ 参数错误: uploadId="${uploadId}", chunkIndex=${chunkIndex}, hasChunk=${!!chunk}`) + if (!payload.uploadId || Number.isNaN(payload.chunkIndex)) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) } - // 性能监控:获取会话 - const t3 = Date.now() - console.log(`[UploadChunk] 🔍 开始获取会话: ${uploadId}`) - const sessionData = await getUploadSession(uploadId) - const getSessionTime = Date.now() - t3 + const sessionStartedAt = Date.now() + const sessionData = await getUploadSession(payload.uploadId) + const getSessionTime = Date.now() - sessionStartedAt console.log(`[UploadChunk] 🔍 获取会话完成: ${getSessionTime}ms, 会话存在: ${!!sessionData}`) - + if (!sessionData) { - console.log(`[UploadChunk] ❌ 会话不存在: ${uploadId}`) + console.log(`[UploadChunk] ❌ 会话不存在: ${payload.uploadId}`) return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) } - // 验证用户 if (sessionData.userId !== (session.user as any).id) { return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) } - // 验证chunkIndex范围 - if (chunkIndex < 0 || chunkIndex >= sessionData.totalChunks) { - console.error(`[UploadChunk] ❌ chunkIndex超出范围: ${chunkIndex}, 总数: ${sessionData.totalChunks}`) - return NextResponse.json({ error: `chunkIndex超出范围: ${chunkIndex}/${sessionData.totalChunks}` }, { status: 400 }) + if (payload.chunkIndex < 0 || payload.chunkIndex >= sessionData.totalChunks) { + console.error( + `[UploadChunk] ❌ chunkIndex超出范围: ${payload.chunkIndex}, 总数: ${sessionData.totalChunks}`, + ) + return NextResponse.json( + { error: `chunkIndex超出范围: ${payload.chunkIndex}/${sessionData.totalChunks}` }, + { status: 400 }, + ) } - // 性能监控:准备目录 - const t4 = Date.now() - const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId) + const mkdirStartedAt = Date.now() + const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', payload.uploadId) await mkdir(tempDir, { recursive: true }) - const mkdirTime = Date.now() - t4 - - // 性能监控:转换Buffer - const t5 = Date.now() - const chunkFilePath = path.join(tempDir, `chunk-${chunkIndex}`) - const chunkBuffer = Buffer.from(await chunk.arrayBuffer()) - const bufferTime = Date.now() - t5 - - // 性能监控:写入文件 - const t6 = Date.now() - await writeFile(chunkFilePath, chunkBuffer) - const writeFileTime = Date.now() - t6 + const mkdirTime = Date.now() - mkdirStartedAt - // 性能监控:更新会话 - const t7 = Date.now() - await updateChunkWithStats(uploadId, chunkIndex, chunkFilePath, chunk.size) - const updateChunkTime = Date.now() - t7 + const chunkFilePath = path.join(tempDir, `chunk-${payload.chunkIndex}`) + const writeStartedAt = Date.now() + const writtenBytes = + payload.mode === 'raw' + ? await writeRawRequestBodyToFile(req, chunkFilePath) + : await writeMultipartChunkToFile(payload.chunkFile, chunkFilePath) + const writeTime = Date.now() - writeStartedAt + + const statsStartedAt = Date.now() + await updateChunkWithStats(payload.uploadId, payload.chunkIndex, chunkFilePath, writtenBytes) + const updateChunkTime = Date.now() - statsStartedAt - // 计算总耗时 const totalTime = Date.now() - requestStartTime - - // 输出详细的性能分析 - console.log(`[UploadChunk] ⏱️ Chunk ${chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, { - 认证: `${authTime}ms (${(authTime/totalTime*100).toFixed(1)}%)`, - FormData解析: `${formDataTime}ms (${(formDataTime/totalTime*100).toFixed(1)}%)`, - 获取会话: `${getSessionTime}ms (${(getSessionTime/totalTime*100).toFixed(1)}%)`, - 创建目录: `${mkdirTime}ms (${(mkdirTime/totalTime*100).toFixed(1)}%)`, - 转换Buffer: `${bufferTime}ms (${(bufferTime/totalTime*100).toFixed(1)}%)`, - 写入文件: `${writeFileTime}ms (${(writeFileTime/totalTime*100).toFixed(1)}%)`, - 更新会话: `${updateChunkTime}ms (${(updateChunkTime/totalTime*100).toFixed(1)}%)`, - 大小: `${(chunk.size / 1024 / 1024).toFixed(2)}MB` + console.log(`[UploadChunk] ⏱️ Chunk ${payload.chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, { + 认证: `${authTime}ms (${percent(authTime, totalTime)}%)`, + 请求参数解析: `${payloadResolveTime}ms (${percent(payloadResolveTime, totalTime)}%)`, + 获取会话: `${getSessionTime}ms (${percent(getSessionTime, totalTime)}%)`, + 创建目录: `${mkdirTime}ms (${percent(mkdirTime, totalTime)}%)`, + 流式写盘: `${writeTime}ms (${percent(writeTime, totalTime)}%)`, + 更新会话: `${updateChunkTime}ms (${percent(updateChunkTime, totalTime)}%)`, + 大小: `${(writtenBytes / 1024 / 1024).toFixed(2)}MB`, + 模式: payload.mode, }) - - // 如果FormData解析时间占比超过80%,说明是网络接收慢 - if (formDataTime / totalTime > 0.8) { - console.log(`[UploadChunk] ⚠️ 警告: FormData解析占比${(formDataTime/totalTime*100).toFixed(1)}%,可能是网络接收慢!`) + + if (writeTime / totalTime > 0.8) { + console.log( + `[UploadChunk] ⚠️ 流式写盘阶段占比 ${percent(writeTime, totalTime)}%,当前主要瓶颈仍在网络接收`, + ) } - return NextResponse.json({ + return NextResponse.json({ success: true, uploadedChunks: sessionData.chunks.size + 1, totalChunks: sessionData.totalChunks, - serverProcessTime: totalTime, // 返回给前端 + serverProcessTime: totalTime, + receivedBytes: writtenBytes, + mode: payload.mode, }) } catch (error) { console.error('[UploadChunk] Error:', error) - return NextResponse.json( - { error: '上传块失败' }, - { status: 500 } - ) + return NextResponse.json({ error: '上传块失败' }, { status: 500 }) } } +async function resolveChunkPayload(req: NextRequest): Promise { + const contentType = (req.headers.get('content-type') || '').toLowerCase() + + if (!contentType.includes('multipart/form-data')) { + const uploadId = (req.headers.get('x-upload-id') || req.nextUrl.searchParams.get('uploadId') || '').trim() + const chunkIndexRaw = + req.headers.get('x-chunk-index') || req.nextUrl.searchParams.get('chunkIndex') || '' + const fileNameRaw = + req.headers.get('x-file-name') || req.nextUrl.searchParams.get('fileName') || '' + const chunkSizeHint = Number( + req.headers.get('x-chunk-size') || req.headers.get('content-length') || 0, + ) + + return { + uploadId, + chunkIndex: Number.parseInt(chunkIndexRaw, 10), + fileName: safeDecodeURIComponent(fileNameRaw), + chunkSizeHint: Number.isFinite(chunkSizeHint) ? chunkSizeHint : 0, + mode: 'raw', + chunkFile: null, + } + } + + const formData = await req.formData() + const uploadId = (formData.get('uploadId') as string) || '' + const chunkIndex = Number.parseInt((formData.get('chunkIndex') as string) || '', 10) + const fileName = (formData.get('fileName') as string) || '' + const chunk = formData.get('chunk') as File | null + + return { + uploadId: uploadId.trim(), + chunkIndex, + fileName, + chunkSizeHint: Number(chunk?.size || 0), + mode: 'multipart', + chunkFile: chunk, + } +} + +async function writeRawRequestBodyToFile(req: NextRequest, chunkFilePath: string): Promise { + if (!req.body) { + throw new Error('请求体为空') + } + + const reader = req.body.getReader() + const fileStream = createWriteStream(chunkFilePath) + let writtenBytes = 0 + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + if (!value || value.length === 0) { + continue + } + + writtenBytes += value.length + if (!fileStream.write(Buffer.from(value))) { + await once(fileStream, 'drain') + } + } + } catch (error) { + fileStream.destroy() + throw error + } finally { + reader.releaseLock() + } + + fileStream.end() + await once(fileStream, 'close') + return writtenBytes +} + +async function writeMultipartChunkToFile(chunk: File | null | undefined, chunkFilePath: string): Promise { + if (!chunk) { + throw new Error('缺少 chunk 文件') + } + + const buffer = Buffer.from(await chunk.arrayBuffer()) + await writeFile(chunkFilePath, buffer) + return buffer.length +} + +function safeDecodeURIComponent(value: string): string { + if (!value) { + return '' + } + + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function percent(part: number, total: number): string { + if (total <= 0) { + return '0.0' + } + return ((part / total) * 100).toFixed(1) +} diff --git a/app/api/materials/upload/cleanup/route.ts b/app/api/materials/upload/cleanup/route.ts index 08b0d4d..e5ca123 100644 --- a/app/api/materials/upload/cleanup/route.ts +++ b/app/api/materials/upload/cleanup/route.ts @@ -219,22 +219,64 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } - const { uploadId } = await req.json() + const { uploadId, existingFilePath } = await req.json() - if (!uploadId) { - return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 }) + if (!uploadId && !existingFilePath) { + return NextResponse.json({ error: '缺少 uploadId 或 existingFilePath' }, { status: 400 }) } - // 验证会话归属 - const uploadSession = await getUploadSession(uploadId) - if (uploadSession && uploadSession.userId !== (session.user as any).id) { - return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 }) + if (uploadId) { + // 验证会话归属 + const uploadSession = await getUploadSession(uploadId) + if (uploadSession && uploadSession.userId !== (session.user as any).id) { + return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 }) + } } const uploadDir = process.env.UPLOAD_DIR || './uploads' - const result = await cleanupUploadSession(uploadId, uploadDir) + let deletedFiles = 0 + let freedSpace = 0 - if (result.success) { + if (uploadId) { + const result = await cleanupUploadSession(uploadId, uploadDir) + if (!result.success) { + return NextResponse.json( + { error: result.error || '清理失败' }, + { status: 500 } + ) + } + deletedFiles += result.deletedFiles + freedSpace += result.freedSpace + } + + if (existingFilePath) { + const assembledRoot = path.resolve(path.join(uploadDir, 'temp')) + const targetPath = path.resolve(existingFilePath) + const isUnderTemp = targetPath.startsWith(assembledRoot + path.sep) || targetPath === assembledRoot + const isMergedFile = path.basename(targetPath).startsWith('merged-') + + if (!isUnderTemp || !isMergedFile) { + return NextResponse.json({ error: '非法的 existingFilePath' }, { status: 400 }) + } + + try { + const stat = await fs.stat(targetPath) + if (stat.isFile()) { + await fs.unlink(targetPath) + deletedFiles += 1 + freedSpace += stat.size + } + } catch (error: any) { + if (error?.code !== 'ENOENT') { + return NextResponse.json( + { error: error.message || '清理临时文件失败' }, + { status: 500 } + ) + } + } + } + + if (uploadId) { // 删除上传会话 try { const { deleteUploadSession } = await import('@/lib/upload-sessions') @@ -242,19 +284,14 @@ export async function POST(req: NextRequest) { } catch (err) { console.warn('[Cleanup] 删除上传会话失败:', err) } - - return NextResponse.json({ - message: '清理成功', - deletedFiles: result.deletedFiles, - freedSpace: result.freedSpace, - freedSpaceFormatted: formatBytes(result.freedSpace) - }) - } else { - return NextResponse.json( - { error: result.error || '清理失败' }, - { status: 500 } - ) } + + return NextResponse.json({ + message: '清理成功', + deletedFiles, + freedSpace, + freedSpaceFormatted: formatBytes(freedSpace) + }) } catch (error: any) { console.error('[Cleanup] Error:', error) return NextResponse.json( @@ -271,4 +308,3 @@ function formatBytes(bytes: number): string { const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] } - diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index da2238b..9900e0f 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -21,6 +21,10 @@ import { createMaterialLog } from '@/lib/material-log'; import path from 'path'; import { mkdir, writeFile, readFile } from 'fs/promises'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + interface AssetUpload { uploadId?: string; // 可选:分片上传的 uploadId fileUrl?: string; // 可选:直接上传到 S3 的 URL diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index 8e71fce..5f15b1b 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -7,10 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; -import { validateTaskNo, isTaskNoExists, TASK_STATUS } from '@/lib/product-package'; -import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; -import { deleteFiles } from '@/lib/file-cleanup'; -import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; +import { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } from '@/lib/product-package'; export async function POST(request: NextRequest) { try { @@ -53,44 +50,16 @@ export async function POST(request: NextRequest) { ); } - // 检查是否有已删除的素材使用该任务号,如果有则永久删除 - 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 (filesToDelete.length > 0) { - const result = await deleteFiles(filesToDelete); - console.log(`[Init] 已删除旧素材文件: ${result.deletedCount} 个,失败: ${result.failedCount} 个`); - } - - // 删除关联记录 - await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType); - - // 确保下载日志表已应用“删除素材后保留日志”策略 - await ensureMaterialDownloadLogsTable(); - - // 从数据库永久删除 - await prisma.material.delete({ - where: { id: deletedMaterial.id }, - }); - - console.log(`[Init] 已永久删除旧素材 ${deletedMaterial.id},任务号 ${taskNo} 现在可以重新使用`); - } catch (cleanupError) { - console.error(`[Init] 清理旧素材失败:`, cleanupError); - // 即使清理失败,也尝试创建新素材(数据库唯一约束可能会阻止) + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log( + `[Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条,删除文件 ${cleanupResult.deletedFiles} 个,失败 ${cleanupResult.failedFiles} 个` + ); } + } catch (cleanupError) { + console.error(`[Init] 清理旧素材失败:`, cleanupError); + // 保持旧行为:清理失败时仅记录,不阻断新建流程 } } diff --git a/app/api/materials/upload/real-photo/complete/route.ts b/app/api/materials/upload/real-photo/complete/route.ts index f8f49f5..615ced2 100644 --- a/app/api/materials/upload/real-photo/complete/route.ts +++ b/app/api/materials/upload/real-photo/complete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { assignTaskNoOnComplete } from '@/lib/product-package'; import { createZipFromFiles, generatePreviewImages, @@ -11,6 +12,10 @@ import { } from '@/lib/task-material-processor'; import { createMaterialLog } from '@/lib/material-log'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // POST - 完成实拍原图上传 export async function POST(request: NextRequest) { let materialId: string | null = null; @@ -26,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets } = body; + const { materialId: matId, assets, taskNo } = body; materialId = matId; @@ -117,6 +122,9 @@ export async function POST(request: NextRequest) { // 步骤4: 保存资产记录到数据库 await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + // 步骤4.5: 仅在素材成功完成后写入任务号 + await assignTaskNoOnComplete(materialId!, taskNo); + // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/real-photo/init/route.ts b/app/api/materials/upload/real-photo/init/route.ts index 401f518..dd5c1f6 100644 --- a/app/api/materials/upload/real-photo/init/route.ts +++ b/app/api/materials/upload/real-photo/init/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; // POST - 初始化实拍原图素材记录 export async function POST(request: NextRequest) { @@ -24,8 +25,7 @@ export async function POST(request: NextRequest) { // 验证任务号格式(如果提供) if (taskNo) { - const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; - if (!taskNoRegex.test(taskNo)) { + if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误' }, { status: 400 } @@ -47,6 +47,15 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log(`[Real Photo Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条`); + } + } catch (cleanupError) { + console.error('[Real Photo Init] 清理旧软删素材失败:', cleanupError); + } } // 获取用户信息 @@ -77,7 +86,6 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // 实拍原图不需要渲染器 - taskNo: taskNo || null, // 任务号(可选) }, }); diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index fe26cf8..7dbef7f 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -241,7 +241,7 @@ export async function POST(request: NextRequest) { // 任务号验证(可选,如果提供则验证格式和唯一性) if (taskNo && taskNo.trim()) { - const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); + const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = await import('@/lib/product-package'); if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' }, @@ -256,6 +256,17 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log( + `[Upload] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条,删除文件 ${cleanupResult.deletedFiles} 个,失败 ${cleanupResult.failedFiles} 个`, + ); + } + } catch (cleanupError) { + console.error('[Upload] 清理旧软删素材失败:', cleanupError); + } } if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { diff --git a/app/api/materials/upload/task-page/complete/route.ts b/app/api/materials/upload/task-page/complete/route.ts index 2cfffa5..47510b3 100644 --- a/app/api/materials/upload/task-page/complete/route.ts +++ b/app/api/materials/upload/task-page/complete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { assignTaskNoOnComplete } from '@/lib/product-package'; import { createZipFromFiles, generatePreviewImages, @@ -11,6 +12,10 @@ import { } from '@/lib/task-material-processor'; import { createMaterialLog } from '@/lib/material-log'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // POST - 完成任务-页面上传 export async function POST(request: NextRequest) { let materialId: string | null = null; @@ -26,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets } = body; + const { materialId: matId, assets, taskNo } = body; materialId = matId; @@ -117,6 +122,9 @@ export async function POST(request: NextRequest) { // 步骤4: 保存资产记录到数据库 await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + // 步骤4.5: 仅在素材成功完成后写入任务号 + await assignTaskNoOnComplete(materialId!, taskNo); + // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/task-page/init/route.ts b/app/api/materials/upload/task-page/init/route.ts index b178f0b..b66f7dd 100644 --- a/app/api/materials/upload/task-page/init/route.ts +++ b/app/api/materials/upload/task-page/init/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; // POST - 初始化任务-页面素材记录 export async function POST(request: NextRequest) { @@ -24,8 +25,7 @@ export async function POST(request: NextRequest) { // 验证任务号格式(如果提供) if (taskNo) { - const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; - if (!taskNoRegex.test(taskNo)) { + if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误' }, { status: 400 } @@ -47,6 +47,15 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log(`[Task Page Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条`); + } + } catch (cleanupError) { + console.error('[Task Page Init] 清理旧软删素材失败:', cleanupError); + } } // 获取用户信息 @@ -77,7 +86,6 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // 任务-页面不需要渲染器 - taskNo: taskNo || null, // 任务号(可选) }, }); diff --git a/app/api/materials/upload/video-screenshot/complete/route.ts b/app/api/materials/upload/video-screenshot/complete/route.ts index 4a92f0b..2ffcc96 100644 --- a/app/api/materials/upload/video-screenshot/complete/route.ts +++ b/app/api/materials/upload/video-screenshot/complete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { assignTaskNoOnComplete } from '@/lib/product-package'; import { createZipFromFiles, generatePreviewImages, @@ -11,6 +12,10 @@ import { } from '@/lib/task-material-processor'; import { createMaterialLog } from '@/lib/material-log'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // POST - 完成视频截图上传 export async function POST(request: NextRequest) { let materialId: string | null = null; @@ -26,7 +31,7 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { materialId: matId, assets } = body; + const { materialId: matId, assets, taskNo } = body; materialId = matId; @@ -117,6 +122,9 @@ export async function POST(request: NextRequest) { // 步骤4: 保存资产记录到数据库 await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + // 步骤4.5: 仅在素材成功完成后写入任务号 + await assignTaskNoOnComplete(materialId!, taskNo); + // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); diff --git a/app/api/materials/upload/video-screenshot/init/route.ts b/app/api/materials/upload/video-screenshot/init/route.ts index b4f9913..e1e2047 100644 --- a/app/api/materials/upload/video-screenshot/init/route.ts +++ b/app/api/materials/upload/video-screenshot/init/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'; // POST - 初始化视频截图素材记录 export async function POST(request: NextRequest) { @@ -24,8 +25,7 @@ export async function POST(request: NextRequest) { // 验证任务号格式(如果提供) if (taskNo) { - const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; - if (!taskNoRegex.test(taskNo)) { + if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误' }, { status: 400 } @@ -47,6 +47,15 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + + try { + const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo); + if (cleanupResult.deletedMaterials > 0) { + console.log(`[Video Screenshot Init] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条`); + } + } catch (cleanupError) { + console.error('[Video Screenshot Init] 清理旧软删素材失败:', cleanupError); + } } // 获取用户信息 @@ -77,7 +86,6 @@ export async function POST(request: NextRequest) { zipFilePath: '', // 临时空值,完成上传时更新 zipFileName: '', // 临时空值,完成上传时更新 renderer: '', // 视频截图不需要渲染器 - taskNo: taskNo || null, // 任务号(可选) }, }); diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 110a90b..3fe14d5 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -16,8 +16,8 @@ export async function GET() { settings = await prisma.systemSettings.create({ data: { headerBgColor: '#1f2937', - headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', - loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', + headerLogoUrl: 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', + loginLogoUrl: 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', siteName: '由壹点3D素材', }, }); @@ -104,8 +104,8 @@ export async function PUT(request: NextRequest) { settings = await prisma.systemSettings.create({ data: { headerBgColor: headerBgColor || '#1f2937', - headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', - loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', + headerLogoUrl: headerLogoUrl || 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', + loginLogoUrl: loginLogoUrl || 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', siteName: siteName || '由壹点3D素材', }, }); diff --git a/app/api/upload/preview/route.ts b/app/api/upload/preview/route.ts index 760f4a9..ddad436 100644 --- a/app/api/upload/preview/route.ts +++ b/app/api/upload/preview/route.ts @@ -6,6 +6,10 @@ import { uploadToS3 } from '@/lib/s3'; import { compressImage, sanitizeFileName } from '@/lib/image-processor'; import { randomBytes } from 'crypto'; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 + // 生成唯一文件名 function generateUniqueFilename(prefix: string, ext: string): string { const timestamp = Date.now(); diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index 9bc2cb0..487af90 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -23,7 +23,7 @@ function CompleteProfileContent() { const [userInfo, setUserInfo] = useState(null) // 系统设置 - const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + const [loginLogoUrl, setLoginLogoUrl] = useState('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') const [siteName, setSiteName] = useState('由壹点3D素材') useEffect(() => { diff --git a/app/favicon.ico/route.ts b/app/favicon.ico/route.ts new file mode 100644 index 0000000..fb78c80 --- /dev/null +++ b/app/favicon.ico/route.ts @@ -0,0 +1,9 @@ +import { NextRequest } from 'next/server' +import { GET as getApiFavicon } from '../api/favicon/route' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + return getApiFavicon(request) +} diff --git a/app/layout.tsx b/app/layout.tsx index 2aa22e1..67e2251 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,9 +25,9 @@ export async function generateMetadata(): Promise { title: settings?.siteName || '由壹点3D素材', description: '3D素材分享平台', icons: { - icon: '/favicon.ico', - shortcut: '/favicon.ico', - apple: '/favicon.ico', + icon: '/api/favicon', + shortcut: '/api/favicon', + apple: '/api/favicon', }, } } @@ -56,4 +56,3 @@ export default function RootLayout({ ) } - diff --git a/app/login/page.tsx b/app/login/page.tsx index b9b4e74..2102c89 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,6 +7,7 @@ import Image from 'next/image' import Link from 'next/link' import { ensureHttpsUrl } from '@/lib/url-rewrite' import { parseApiError } from '@/lib/error-handler' +import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url' export default function LoginPage() { const router = useRouter() @@ -17,11 +18,15 @@ export default function LoginPage() { // 注意:钉钉登录现在使用新窗口模式,不再需要iframe弹窗 // 系统设置 - const [loginLogoUrl, setLoginLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') + const [loginLogoUrl, setLoginLogoUrl] = useState('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png') const [siteName, setSiteName] = useState('由壹点3D素材') // 钉钉配置(从环境变量或配置文件获取) const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f' + const publicAppOrigin = getPublicAppOrigin() + const allowedMessageOrigins = getAllowedPublicAppOrigins() + const dingtalkCallbackOrigin = + process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin // 加载系统设置 useEffect(() => { @@ -31,14 +36,14 @@ export default function LoginPage() { if (response.ok) { const settings = await response.json() // 确保 logo URL 使用 HTTPS,避免混合内容问题 - const logoUrl = settings.loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' + const logoUrl = settings.loginLogoUrl || 'https://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' + const defaultUrl = 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png' setLoginLogoUrl(ensureHttpsUrl(defaultUrl) || defaultUrl) } } @@ -160,8 +165,7 @@ export default function LoginPage() { } const handleDingtalkLogin = () => { - // 统一使用公网域名作为回调地址(内网用户通过DNS解析到内网IP,但仍使用统一域名) - const redirectUrl = `https://3d.yydd.ai/dingtalk-callback` + const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback` // 在state中携带原始访问地址信息(用于登录后跳转) const currentOrigin = window.location.origin @@ -195,8 +199,7 @@ export default function LoginPage() { // 监听新窗口的消息(从回调页面发送) const handleMessage = async (event: MessageEvent) => { - // 只处理来自钉钉回调页面的消息 - if (event.origin !== 'https://3d.yydd.ai' && !event.origin.includes('192.168.')) { + if (!allowedMessageOrigins.includes(event.origin) && !event.origin.includes('192.168.')) { return } @@ -370,4 +373,3 @@ export default function LoginPage() { ) } - diff --git a/app/page.tsx b/app/page.tsx index bf4094a..2ad1d35 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,9 +13,26 @@ import { useToast } from '@/hooks/useToast' import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' +import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' +const PUBLIC_APP_ORIGIN = getPublicAppOrigin() +const PUBLIC_DOMAIN = (() => { + try { + return new URL(PUBLIC_APP_ORIGIN).hostname + } catch { + return 'sc.jiansun.vip' + } +})() +const KNOWN_PUBLIC_HOSTS = getAllowedPublicAppOrigins().map((origin) => { + try { + return new URL(origin).hostname + } catch { + return origin + } +}) + interface Material { id: string userId: string @@ -232,9 +249,8 @@ export default function Home() { console.warn('[网络检测] 当前在内网地址但无法访问,可能用户已切换到公网环境,自动回退到公网域名') // 重定向到公网域名 - const publicDomain = '3d.yydd.ai' // 公网域名 const currentPath = window.location.pathname + window.location.search - window.location.replace(`https://${publicDomain}${currentPath}`) + window.location.replace(`https://${PUBLIC_DOMAIN}${currentPath}`) return } @@ -254,7 +270,7 @@ export default function Home() { // 注意:每次访问公网域名时都重新检测,不依赖 sessionStorage // 这样可以确保内网用户在清除缓存后仍能正常跳转 const hasRedirected = sessionStorage.getItem('intranet-auto-redirect') - if (hasRedirected && window.location.hostname === '3d.yydd.ai') { + if (hasRedirected && KNOWN_PUBLIC_HOSTS.includes(window.location.hostname)) { // 如果已经跳过,且当前访问的是公网域名,清除标记并重新检测 console.debug('[内网切换] 检测到已跳过标记,但访问的是公网域名,重新检测...') sessionStorage.removeItem('intranet-auto-redirect') @@ -267,36 +283,35 @@ export default function Home() { // 检查当前协议 const isHttps = window.location.protocol === 'https:' - // 统一检测逻辑:无论HTTPS还是HTTP,都先尝试直接访问内网地址 - // 如果能访问内网地址,说明用户在内网,直接跳转 + // 公网 HTTPS 页面不能主动探测 http:// 内网地址,否则会触发 mixed content + // 因此仅在 HTTP 场景下尝试直连内网地址;HTTPS 场景统一走后端 /api/network-status let canAccessIntranet = false try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败 - - // 尝试直接访问内网地址(通过fetch) - const intranetUrl = `http://192.168.58.20:4320/api/network-status` - const intranetResponse = await fetch(intranetUrl, { - method: 'GET', - cache: 'no-store', - mode: 'cors', // 使用CORS模式 - credentials: 'omit', // 不发送cookies,避免CORS问题 - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (intranetResponse.ok) { - const intranetData = await intranetResponse.json() - const hostname = intranetData.hostname || '' + if (!isHttps) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败 - // 关键判断:如果返回的hostname是192.168.58.20,说明客户端能直接访问内网地址 - const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true + const intranetUrl = `http://192.168.58.20:4320/api/network-status` + const intranetResponse = await fetch(intranetUrl, { + method: 'GET', + cache: 'no-store', + mode: 'cors', + credentials: 'omit', + signal: controller.signal + }) - if (isTargetServer) { - canAccessIntranet = true - console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户') + clearTimeout(timeoutId) + + if (intranetResponse.ok) { + const intranetData = await intranetResponse.json() + const hostname = intranetData.hostname || '' + const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true + + if (isTargetServer) { + canAccessIntranet = true + console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户') + } } } } catch (error) { @@ -335,14 +350,10 @@ export default function Home() { if (isIntranet) { canAccessIntranet = true console.debug('[内网切换] 后端API检测:判定为内网用户(isIntranet:', isIntranet, ')') - } else { - // 即使clientIp是公网IP,也可能是通过代理访问的内网用户 - // 在这种情况下,如果直接访问内网地址失败(可能是混合内容阻止), - // 我们可以尝试通过创建一个隐藏的iframe来检测是否能够访问内网地址 + } else if (!isHttps) { + // 仅 HTTP 场景下继续尝试 iframe 探测,避免 HTTPS 公网页面触发 mixed content console.debug('[内网切换] 后端API检测:clientIp为公网IP(', clientIp, '),可能是代理IP,尝试其他检测方式') - // 尝试通过创建隐藏的iframe来检测是否能够访问内网地址 - // 这可以绕过混合内容的限制 const testIframe = document.createElement('iframe') testIframe.style.display = 'none' testIframe.src = `http://192.168.58.20:4320/api/network-status` @@ -1579,7 +1590,7 @@ export default function Home() { )} - {/* 内网切换提示已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网 */} + {/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */} diff --git a/app/register/page.tsx b/app/register/page.tsx index 6796e7f..27d38e4 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -82,7 +82,7 @@ export default function RegisterPage() {
由壹点3D素材 } @@ -85,6 +91,8 @@ export default function SharePage() { const [audioProgress, setAudioProgress] = useState(0) // 0-100 // SKU选中状态(用于标准模型类型的批量下载) const [selectedSkuIds, setSelectedSkuIds] = useState>(new Set()) + const [downloadingBatchType, setDownloadingBatchType] = useState<'model' | 'white-background' | null>(null) + const [skuListRefreshKey, setSkuListRefreshKey] = useState(0) useEffect(() => { // 分享页面允许未登录访问,立即开始加载,不等待session状态 @@ -377,21 +385,49 @@ export default function SharePage() { }, []) // 批量下载选中的SKU(标准模型类型) - const handleBatchDownloadSkus = async () => { + const handleBatchDownloadSkus = async (downloadType: 'model' | 'white-background' = 'model') => { if (!material || selectedSkuIds.size === 0) { showToast('请先选择要下载的SKU', { variant: 'error' }) return } + let targetSkuIds = Array.from(selectedSkuIds) + if (downloadType === 'white-background') { + const availableWhiteBgSkuIds = (material.skus || []) + .filter((sku: any) => (sku?.whiteBackgroundCount || 0) > 0 || sku?.whiteBackgroundUploaded) + .map((sku: any) => sku.id) + targetSkuIds = targetSkuIds.filter((skuId) => availableWhiteBgSkuIds.includes(skuId)) + + if (targetSkuIds.length === 0) { + showToast('选中的 SKU 中没有可下载的白底图', { variant: 'error' }) + return + } + } else { + const availableModelSkuIds = (material.skus || []) + .filter((sku: any) => sku?.zipFilePath && String(sku.zipFilePath).trim().length > 0) + .map((sku: any) => sku.id) + targetSkuIds = targetSkuIds.filter((skuId) => availableModelSkuIds.includes(skuId)) + + if (targetSkuIds.length === 0) { + showToast('选中的 SKU 中没有可下载的模型文件', { variant: 'error' }) + return + } + } + setDownloading(true) - setDownloadProgress(0) + setDownloadingBatchType(downloadType) + setDownloadProgress(15) + if (downloadType === 'white-background') { + showToast('正在打包白底图,请稍候', { variant: 'success' }) + } try { const response = await fetch(`/api/materials/${materialId}/skus/batch-download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - skuIds: Array.from(selectedSkuIds) + skuIds: targetSkuIds, + downloadType, }) }) @@ -400,19 +436,22 @@ export default function SharePage() { const { message } = parseDownloadError(error) showToast(message, { variant: 'error' }) setDownloading(false) + setDownloadingBatchType(null) setDownloadProgress(0) return } // 获取ZIP文件并下载 + setDownloadProgress(70) const blob = await response.blob() + setDownloadProgress(95) const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url // 从Content-Disposition获取文件名 const contentDisposition = response.headers.get('Content-Disposition') - let filename = `SKU批量下载_${Date.now()}.zip` + let filename = `SKU_${downloadType === 'white-background' ? 'white-background' : 'model'}_${Date.now()}.zip` if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) if (filenameMatch && filenameMatch[1]) { @@ -429,7 +468,7 @@ export default function SharePage() { window.URL.revokeObjectURL(url) }, 100) - showToast(`成功下载 ${selectedSkuIds.size} 个SKU`, { variant: 'success' }) + showToast(`成功下载 ${targetSkuIds.length} 个SKU${downloadType === 'white-background' ? '白底图' : '模型'}`, { variant: 'success' }) setSelectedSkuIds(new Set()) // 清空选择 } catch (error) { console.error('批量下载 SKU 失败:', error) @@ -437,6 +476,7 @@ export default function SharePage() { showToast(message, { variant: 'error' }) } finally { setDownloading(false) + setDownloadingBatchType(null) setDownloadProgress(0) } } @@ -447,7 +487,7 @@ export default function SharePage() { } if (material.materialType === '标准模型' && selectedSkuIds.size > 0) { - void handleBatchDownloadSkus() + void handleBatchDownloadSkus('model') return } @@ -507,6 +547,7 @@ export default function SharePage() { // 获取映射后的素材类型 const mappedMaterialType = mapMaterialType(material.materialType) + const isTaskPageType = mappedMaterialType === '任务-页面' // 检查是否为3D原图/AI原图/实拍原图/视频截图类型(带"任务-"前缀) const is3DOriginalType = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(mappedMaterialType) @@ -548,6 +589,38 @@ export default function SharePage() { onBack={() => router.back()} />
+ ) : isTaskPageType ? ( +
+ { + try { + let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}` + if (assetId) { + directDownloadUrl += `&assetId=${assetId}` + } + await validateAndTriggerDownload(directDownloadUrl) + return Promise.resolve() + } catch (error) { + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + return Promise.reject(error) + } + }} + onBack={() => router.back()} + /> +
) : is3DOriginalType ? ( /* 3D原图/AI原图/实拍原图/视频截图类型:使用专用详情组件 */
@@ -901,6 +974,7 @@ export default function SharePage() { canEdit={!!(session?.user && ((session.user as any)?.id === material.userId || (session.user as any)?.role === 'ADMIN'))} onEditClick={() => setShowSkuEditModal(true)} onSelectionChange={setSelectedSkuIds} + refreshKey={skuListRefreshKey} /> ) : ( /* 其他类型:显示素材信息 */ @@ -938,36 +1012,92 @@ export default function SharePage() { {/* 操作按钮 - 统一样式 */}
- + +
+ ) : ( + - + )} + + )} + {/* 编辑 */} @@ -756,41 +1232,106 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S />
- {/* 操作按钮 */} - -
)} + {activeWhiteBgSku && ( +
+
+
+

+ 白底图管理 +

+

+ 当前 SKU:{activeWhiteBgSku.skuName} +

+
+

+ 固定角度支持分次补传,文件会按 SKU + 品类 + 视角 自动命名。 +

+ {whiteBgUploading && ( +
+
+ 上传进度 {whiteBgUploadProgress}% + {whiteBgUploadSpeed > 0 ? formatSpeed(whiteBgUploadSpeed) : '计算中...'} +
+
+
+
+
+ )} +
+ + ({ + id: image.id, + url: image.previewUrl, + name: image.name, + }))} + uploading={whiteBgUploading} + error={whiteBgError} + onSelectImage={handleSelectWhiteBackgroundImage} + onRemoveImage={handleRemoveWhiteBackgroundImage} + onSelectOtherImages={handleSelectWhiteBackgroundOthers} + onRemoveOtherImage={handleRemoveWhiteBackgroundOther} + /> +
+ )} + + {activeModelSku && ( +
+
+
+

+ 模型文件管理 +

+

+ 当前 SKU:{activeModelSku.skuName} +

+
+

+ 底部按钮区会显示“保存模型”,保存成功后会自动收起。 +

+ {modelUploading && ( +
+
+ 上传进度 {modelUploadProgress}% + {modelUploadProgress < 100 ? '上传中...' : '即将保存...'} +
+
+
+
+
+ )} +
+ + { + setModelUploadError('') + setModelZipFile(file) + }} + onRemoveFile={() => { + setModelZipFile(null) + setModelUploadError('') + }} + /> +
+ )} + {/* SKU 列表(支持拖拽排序) */} {skus.length === 0 ? (
@@ -837,13 +1378,101 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
{/* 底部按钮 */} -
- +
+ {showAddForm ? ( + <> + + + + ) : activeWhiteBgSku ? ( + <> + + + + ) : activeModelSku ? ( + <> + + + + ) : ( + + )}
diff --git a/components/SkuList.tsx b/components/SkuList.tsx index 68af15e..9252de1 100644 --- a/components/SkuList.tsx +++ b/components/SkuList.tsx @@ -7,7 +7,6 @@ import ToastBubble from './ToastBubble' import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' -// SKU 数据类型 interface Sku { id: string skuName: string @@ -23,28 +22,33 @@ interface Sku { username: string realName?: string } + whiteBackgroundUploaded?: boolean + whiteBackgroundComplete?: boolean + whiteBackgroundFixedCount?: number + whiteBackgroundOtherCount?: number + whiteBackgroundCount?: number } interface SkuListProps { materialId: string - canEdit: boolean // 是否可以编辑(上传者或管理员) - onEditClick: () => void // 点击编辑按钮的回调 - onSelectionChange?: (selectedIds: Set) => void // 选中状态变化回调 + canEdit: boolean + onEditClick: () => void + onSelectionChange?: (selectedIds: Set) => void + refreshKey?: number } -export default function SkuList({ materialId, canEdit, onEditClick, onSelectionChange }: SkuListProps) { +export default function SkuList({ materialId, canEdit, onEditClick, onSelectionChange, refreshKey = 0 }: SkuListProps) { const [skus, setSkus] = useState([]) const [loading, setLoading] = useState(true) const { toast, showToast } = useToast() const [selectedSkuIds, setSelectedSkuIds] = useState>(new Set()) const [downloadingSkuId, setDownloadingSkuId] = useState(null) - // 移除批量下载状态,由父组件处理 - const [previewImage, setPreviewImage] = useState(null) // 大图预览 + const [downloadingWhiteBgSkuId, setDownloadingWhiteBgSkuId] = useState(null) + const [previewImage, setPreviewImage] = useState(null) - // 加载 SKU 列表 useEffect(() => { fetchSkus() - }, [materialId]) + }, [materialId, refreshKey]) const fetchSkus = async () => { try { @@ -61,25 +65,14 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC } } - // 格式化文件大小 - const formatFileSize = (bytes: string): string => { - const size = parseInt(bytes) - if (size === 0 || isNaN(size)) return '未知' - const units = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(size) / Math.log(1024)) - return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` - } - - // 全选/取消全选 const handleSelectAll = () => { - const newSelected = selectedSkuIds.size === skus.length + const newSelected = selectedSkuIds.size === skus.length ? new Set() - : new Set(skus.map(sku => sku.id)) + : new Set(skus.map((sku) => sku.id)) setSelectedSkuIds(newSelected) onSelectionChange?.(newSelected) } - // 单选/取消单选 const handleSelectSku = (skuId: string) => { const newSelected = new Set(selectedSkuIds) if (newSelected.has(skuId)) { @@ -91,15 +84,18 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC onSelectionChange?.(newSelected) } - // 下载单个 SKU const handleDownloadSku = async (sku: Sku) => { + if (!sku.zipFilePath || !sku.zipFilePath.trim()) { + showToast('该 SKU 暂无模型压缩包', { variant: 'error' }) + return + } + try { setDownloadingSkuId(sku.id) - const directDownloadUrl = `/api/materials/${materialId}/skus/${sku.id}/download` await validateAndTriggerDownload(directDownloadUrl) } catch (error) { - console.error('下载 SKU 失败:', error) + console.error('下载 SKU 模型失败:', error) const { message } = parseDownloadError(error) showToast(message, { variant: 'error' }) } finally { @@ -107,15 +103,62 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC } } - // 批量下载功能已移至父组件 + const handleDownloadWhiteBackground = async (sku: Sku) => { + if (!sku.whiteBackgroundUploaded) { + showToast('该 SKU 暂无白底图', { variant: 'error' }) + return + } + try { + setDownloadingWhiteBgSkuId(sku.id) + showToast('正在打包白底图,请稍候', { variant: 'success', duration: 1800 }) + const directDownloadUrl = `/api/materials/${materialId}/skus/${sku.id}/download?downloadType=white-background` + const response = await fetch(directDownloadUrl, { + method: 'GET', + cache: 'no-store', + credentials: 'include', + }) + + if (!response.ok) { + let errorData: unknown = null + try { + errorData = await response.json() + } catch {} + throw errorData || { error: '下载 SKU 白底图失败' } + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + + const contentDisposition = response.headers.get('Content-Disposition') + let fileName = `${sku.skuName}_白底图.zip` + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) + if (filenameMatch && filenameMatch[1]) { + fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) + } + } + + link.download = fileName + document.body.appendChild(link) + link.click() + setTimeout(() => { + link.remove() + window.URL.revokeObjectURL(url) + }, 100) + } catch (error) { + console.error('下载 SKU 白底图失败:', error) + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + } finally { + setDownloadingWhiteBgSkuId(null) + } + } if (loading) { - return ( -
- 加载中... -
- ) + return
加载中...
} if (skus.length === 0) { @@ -125,7 +168,7 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC {canEdit && (
- {/* 全选栏 */}
{selectedSkuIds.size > 0 && ( - - 已选 {selectedSkuIds.size} 个 - + 已选 {selectedSkuIds.size} 个 )}
- {/* SKU 列表(可滚动区域) */} -
- {skus.length === 0 ? ( -
- - - -

暂无 SKU

- {canEdit && ( -

点击上方“编辑”按钮添加

- )} -
- ) : ( - skus.map(sku => ( +
+ {skus.map((sku) => (
- {/* 复选框 */} - {/* 预览图(点击放大) */}
setPreviewImage(sku.previewUrl)} @@ -219,32 +239,36 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC />
- {/* SKU 信息 */}
-

- {sku.skuName} -

-

- {formatFileSize(sku.zipFileSize)} +

{sku.skuName}

+

+ 模型:{sku.zipFilePath && sku.zipFilePath.trim() ? '已上传' : '未上传'} + · + 白底图:{sku.whiteBackgroundUploaded ? `已上传 ${sku.whiteBackgroundCount || 0} 张` : '未上传'} + {sku.whiteBackgroundUploaded && sku.whiteBackgroundComplete === false ? '(未补齐)' : ''}

- {/* 下载按钮 */} - +
+ + +
- )) - )} + ))}
- {/* 批量下载按钮已移至父组件统一处理 */} - - {/* 大图预览弹窗 */} {previewImage && (
)} + + {toast && toast.show && }
) } diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index b430c6f..419ef86 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -148,6 +148,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const xhrRef = useRef(null) const singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志 const singleUploadXhrsRef = useRef>(new Set()) // 单文件上传的所有活跃XHR + const singleUploadServerStateRef = useRef<{ uploadId?: string; existingFilePath?: string } | null>(null) const batchFileInputRef = useRef(null) // 批量文件选择input // 加载当前用户可见的素材类型 @@ -1429,6 +1430,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setIsProcessing(false) singleUploadCancelRef.current.cancelled = false singleUploadXhrsRef.current.clear() + singleUploadServerStateRef.current = null try { // 大文件走分片,小文件继续直传 @@ -1461,6 +1463,13 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { }, }) + singleUploadServerStateRef.current = chunkResult + ? { + uploadId: chunkResult.uploadId, + existingFilePath: chunkResult.existingFilePath, + } + : null + // 如果已取消,不继续后续操作 if (singleUploadCancelRef.current.cancelled) { return @@ -1496,6 +1505,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { onError: (error) => setError(error), onSuccess: () => { setUploading(false) + singleUploadServerStateRef.current = null onSuccess() onClose() }, @@ -1523,6 +1533,19 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { } catch (cleanupError) { console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError) } + + try { + const cleanupPayload = singleUploadServerStateRef.current + if (cleanupPayload?.uploadId || cleanupPayload?.existingFilePath) { + await fetch('/api/materials/upload/cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cleanupPayload), + }) + } + } catch (cleanupError) { + console.warn('[UploadModal] 清理服务端临时文件失败(忽略):', cleanupError) + } } setError((error as Error)?.message || '上传失败,请稍后再试') @@ -1569,8 +1592,23 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { } catch (cleanupError) { console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError) } + + try { + const cleanupPayload = singleUploadServerStateRef.current + if (cleanupPayload?.uploadId || cleanupPayload?.existingFilePath) { + await fetch('/api/materials/upload/cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cleanupPayload), + }) + console.log('[UploadModal] 取消上传,已请求清理服务端临时文件') + } + } catch (cleanupError) { + console.warn('[UploadModal] 清理服务端临时文件失败(忽略):', cleanupError) + } } + singleUploadServerStateRef.current = null setUploading(false) setError('上传已取消') } else { diff --git a/components/upload/AIOriginalUploader.tsx b/components/upload/AIOriginalUploader.tsx index 89e59dd..0e82318 100644 --- a/components/upload/AIOriginalUploader.tsx +++ b/components/upload/AIOriginalUploader.tsx @@ -284,6 +284,7 @@ export default function AIOriginalUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, + taskNo: taskNo || undefined, assets: { preview: [], // 不传预览图,后端自动生成 main: mainAssets.map((m, index) => ({ diff --git a/components/upload/RealPhotoUploader.tsx b/components/upload/RealPhotoUploader.tsx index 97c81b1..b4d6f2f 100644 --- a/components/upload/RealPhotoUploader.tsx +++ b/components/upload/RealPhotoUploader.tsx @@ -283,6 +283,7 @@ export default function RealPhotoUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, + taskNo: taskNo || undefined, assets: { preview: [], main: mainAssets.map((m, index) => ({ diff --git a/components/upload/StandardModelSkuZipUpload.tsx b/components/upload/StandardModelSkuZipUpload.tsx new file mode 100644 index 0000000..3fe218b --- /dev/null +++ b/components/upload/StandardModelSkuZipUpload.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState } from 'react' + +interface StandardModelSkuZipUploadProps { + selectedFile: File | null + existingFileName?: string + existingFileSize?: string | number + uploading?: boolean + error?: string + onSelectFile: (file: File) => void + onRemoveFile: () => void +} + +function formatFileSize(bytes: string | number | undefined): string { + const size = typeof bytes === 'string' ? parseInt(bytes, 10) : Number(bytes || 0) + if (!size || Number.isNaN(size)) { + return '未知' + } + + const units = ['B', 'KB', 'MB', 'GB'] + const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1) + return `${(size / Math.pow(1024, index)).toFixed(2)} ${units[index]}` +} + +function isSupportedZipFile(file: File): boolean { + const lowerName = file.name.toLowerCase() + return ['.zip', '.rar', '.7z'].some((ext) => lowerName.endsWith(ext)) +} + +export default function StandardModelSkuZipUpload({ + selectedFile, + existingFileName = '', + existingFileSize, + uploading = false, + error, + onSelectFile, + onRemoveFile, +}: StandardModelSkuZipUploadProps) { + const [isDragging, setIsDragging] = useState(false) + + const hasExistingFile = !!existingFileName.trim() + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsDragging(false) + + const file = event.dataTransfer.files?.[0] + if (!file || !isSupportedZipFile(file)) { + return + } + + onSelectFile(file) + } + + return ( +
+
+ + 模型压缩包 {selectedFile ? '已选择' : hasExistingFile ? '可重传' : '待上传'} + + {uploading && ( + + 上传处理中 + + )} +
+ +
+ {selectedFile + ? `待上传:${selectedFile.name}(${formatFileSize(selectedFile.size)})` + : hasExistingFile + ? `当前模型:${existingFileName}(${formatFileSize(existingFileSize)})` + : '当前还没有模型压缩包,请先选择要上传的文件。'} +
+ + {error && ( +
+ {error} +
+ )} + +
+ { + const file = event.target.files?.[0] + if (file && isSupportedZipFile(file)) { + onSelectFile(file) + } + event.currentTarget.value = '' + }} + /> + + {/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */} + +
+
+ ) +} diff --git a/components/upload/StandardModelWhiteBackgroundUpload.tsx b/components/upload/StandardModelWhiteBackgroundUpload.tsx index 3f7092e..1aae447 100644 --- a/components/upload/StandardModelWhiteBackgroundUpload.tsx +++ b/components/upload/StandardModelWhiteBackgroundUpload.tsx @@ -1,24 +1,54 @@ 'use client' -import { useState } from 'react' +import { useMemo, useState } from 'react' import Image from 'next/image' import { STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' +interface OtherImagePreview { + id: string + url: string + name: string +} + interface StandardModelWhiteBackgroundUploadProps { imageUrls: Partial> + otherImages: OtherImagePreview[] + uploading?: boolean + error?: string onSelectImage: (key: StandardModelWhiteBgKey, file: File) => void onRemoveImage: (key: StandardModelWhiteBgKey) => void + onSelectOtherImages: (files: FileList | File[]) => void + onRemoveOtherImage: (id: string) => void } export default function StandardModelWhiteBackgroundUpload({ imageUrls, + otherImages, + uploading = false, + error, onSelectImage, onRemoveImage, + onSelectOtherImages, + onRemoveOtherImage, }: StandardModelWhiteBackgroundUploadProps) { const [draggingKey, setDraggingKey] = useState(null) + const [isOtherDragging, setIsOtherDragging] = useState(false) + + const uploadedRequiredCount = useMemo( + () => STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!imageUrls[view.key]).length, + [imageUrls], + ) + + const missingLabels = useMemo( + () => + STANDARD_MODEL_WHITE_BG_VIEWS + .filter((view) => !imageUrls[view.key]) + .map((view) => view.label), + [imageUrls], + ) const handleDragEnter = (event: React.DragEvent, key: StandardModelWhiteBgKey) => { event.preventDefault() @@ -58,96 +88,236 @@ export default function StandardModelWhiteBackgroundUpload({ onSelectImage(key, file) } - return ( -
-

- 产品白底图(6张) * -

-
- {STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => { - const inputId = `white-bg-${view.key}` - const imageUrl = imageUrls[view.key] - const isDragging = draggingKey === view.key + const handleOtherDrop = (event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + setIsOtherDragging(false) - return ( -
-

{view.label}

- { - const file = event.target.files?.[0] - if (file) { - onSelectImage(view.key, file) - } - event.currentTarget.value = '' - }} - /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要处理拖拽上传事件 */} -
handleDragEnter(event, view.key)} - onDragLeave={(event) => handleDragLeave(event, view.key)} - onDragOver={handleDragOver} - onDrop={(event) => handleDrop(event, view.key)} - > - {imageUrl ? ( -
-
+ const files = event.dataTransfer.files + if (!files || files.length === 0) { + return + } + + onSelectOtherImages(files) + } + + return ( +
+
+ + 固定角度 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length} + + + 可分次补传 + + {otherImages.length > 0 && ( + + 其他图片 {otherImages.length} 张 + + )} + {uploading && ( + + 上传处理中 + + )} +
+ +
+ {missingLabels.length > 0 + ? `未上传:${missingLabels.join('、')}(可先保存,后续再补)` + : '8 张固定角度已齐,可直接保存。'} +
+ + {error && ( +
+ {error} +
+ )} + +
+
+

固定角度

+ 可选,建议补齐 8 张 +
+ +
+ {STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => { + const inputId = `white-bg-${view.key}` + const imageUrl = imageUrls[view.key] + const isDragging = draggingKey === view.key + + return ( +
+
+

{view.label}

+ + 可选 + +
+ + { + const file = event.target.files?.[0] + if (file) { + onSelectImage(view.key, file) + } + event.currentTarget.value = '' + }} + /> + + {/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */} + +
+ ) + })} +
+
+ +
+
+

其他图片

+ 可选,多张 +
+ + { + if (event.target.files && event.target.files.length > 0) { + onSelectOtherImages(event.target.files) + } + event.currentTarget.value = '' + }} + /> + + {/* biome-ignore lint/a11y/noStaticElementInteractions: 其他图片区需要拖拽上传 */} +
-

- 白底图需一次上传完整6张,系统会自动打包为白底图压缩包用于下载。 -

-

- 💡 每个角度支持拖拽上传,拖入后会自动替换对应位置图片。 -

) } diff --git a/components/upload/TaskPageUploader.tsx b/components/upload/TaskPageUploader.tsx index 2e1f872..8a8c491 100644 --- a/components/upload/TaskPageUploader.tsx +++ b/components/upload/TaskPageUploader.tsx @@ -260,6 +260,7 @@ export default function TaskPageUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, + taskNo: taskNo || undefined, assets: { main: previewAssets.map((m, index) => ({ uploadId: m.uploadId, diff --git a/components/upload/ThreeDOriginalUploader.tsx b/components/upload/ThreeDOriginalUploader.tsx index a1add2a..5eaa5de 100644 --- a/components/upload/ThreeDOriginalUploader.tsx +++ b/components/upload/ThreeDOriginalUploader.tsx @@ -284,6 +284,7 @@ export default function ThreeDOriginalUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, + taskNo: taskNo || undefined, assets: { preview: [], // 不传预览图,后端自动生成 main: mainAssets.map((m, index) => ({ diff --git a/components/upload/VideoScreenshotUploader.tsx b/components/upload/VideoScreenshotUploader.tsx index 9ad84c0..664d799 100644 --- a/components/upload/VideoScreenshotUploader.tsx +++ b/components/upload/VideoScreenshotUploader.tsx @@ -290,6 +290,7 @@ export default function VideoScreenshotUploader({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, + taskNo: taskNo || undefined, assets: { preview: [], // 不传预览图,后端自动生成 main: screenshotAssets.map((m, index) => ({ diff --git a/components/upload/file-upload-handlers.ts b/components/upload/file-upload-handlers.ts index 331591e..e2e6e60 100644 --- a/components/upload/file-upload-handlers.ts +++ b/components/upload/file-upload-handlers.ts @@ -101,7 +101,7 @@ export async function uploadSingleFile(params: { onStageChange?: (stage: string) => void isCancelled?: () => boolean // 取消检查回调 onActiveXhrChange?: (xhrs: Set) => void // 活跃XHR集合回调 -}): Promise<{ existingFilePath?: string; originalFileName?: string } | null> { +}): Promise<{ existingFilePath?: string; originalFileName?: string; uploadId?: string } | null> { const { zipFile, previewImages, @@ -151,6 +151,7 @@ export async function uploadSingleFile(params: { return { existingFilePath: result?.existingFilePath, originalFileName: zipFile.name, + uploadId: result?.uploadId, } } diff --git a/docker-compose.shadow.yml b/docker-compose.shadow.yml index 6b8161d..ea17c87 100644 --- a/docker-compose.shadow.yml +++ b/docker-compose.shadow.yml @@ -66,6 +66,7 @@ services: - S3_SET_ACL=1 - S3_PREVIEW_URL_EXPIRE_IN=7200 - NEXT_PUBLIC_S3_FILE_PATH=由壹点AI/ + - APP_PUBLIC_URL=http://192.168.58.20:5320 # 影子系统使用独立的 URL,避免与正式系统登录态混淆 - NEXTAUTH_URL=http://192.168.58.20:5320 # 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽) diff --git a/docker-compose.yml b/docker-compose.yml index 9f1861f..fee402f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,9 @@ services: - S3_SET_ACL=1 - S3_PREVIEW_URL_EXPIRE_IN=7200 - NEXT_PUBLIC_S3_FILE_PATH=由壹点AI/ - - NEXTAUTH_URL=http://192.168.58.20:4320 + - APP_PUBLIC_URL=https://sc.jiansun.vip + - NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip + - NEXTAUTH_URL=https://sc.jiansun.vip # 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽) - SERVER_IP=192.168.58.20 - MINIO_API_PORT=9000 diff --git a/docs/AI生图白底图接口文档.md b/docs/AI生图白底图接口文档.md new file mode 100644 index 0000000..19a0a28 --- /dev/null +++ b/docs/AI生图白底图接口文档.md @@ -0,0 +1,249 @@ +# AI 生图平台白底图接口文档 + +> 用途:AI 生图平台输入 `SKU` 后,直接从素材库获取该 `SKU` 对应的白底图。 +> 当前主域名:`https://sc.jiansun.vip` +> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名 +> 资源域名:`https://s3.yydd.ai` + +## 1. 基础信息 + +- **接口基地址**:`https://sc.jiansun.vip/api/external/` +- **鉴权方式**:请求头 `X-API-Key` +- **返回格式**:`application/json` +- **适用范围**:仅适用于 `标准模型` 类型素材 + +## 2. 接口说明 + +### 2.1 按 SKU 查询白底图 + +**请求** + +```http +GET /api/external/standard-model-white-background?sku={SKU} +X-API-Key: your-api-key-here +``` + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `sku` | string | 是 | SKU 名称。接口按大小写不敏感查询。 | + +**请求头** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `X-API-Key` | string | 是 | 外部接口访问密钥 | + +## 3. 成功响应 + +**响应示例** + +```json +{ + "success": true, + "data": { + "sku": "text001452", + "materialId": "484bd047-c956-48cf-a0f4-401f696aef8d", + "productModel": "TEXT0014", + "whiteBackground": { + "fixed": [ + { + "key": "leftFront45", + "label": "左前45°", + "fileName": "左前45°.png", + "fileSize": 68, + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + }, + { + "key": "rightFront45", + "label": "右前45°", + "fileName": "右前45°.png", + "fileSize": 68, + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + ], + "other": [ + { + "key": "other", + "label": "其他图片", + "fileName": "01-other-1.png", + "fileSize": 68, + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + ] + }, + "downloadUrls": { + "zip": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + } +} +``` + +### 3.1 字段说明 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.sku` | string | 实际命中的 SKU 名称 | +| `data.materialId` | string | 素材 ID | +| `data.productModel` | string | 所属 SPU / 型号 | +| `data.whiteBackgroundUploaded` | boolean | 是否已有白底图 | +| `data.whiteBackgroundComplete` | boolean | 固定 8 张是否已补齐 | +| `data.whiteBackgroundFixedCount` | number | 当前已上传的固定角度图数量 | +| `data.whiteBackgroundOtherCount` | number | 当前已上传的 other 图片数量 | +| `data.whiteBackgroundCount` | number | 当前白底图总数 | +| `data.missingViews` | array | 当前仍缺少的固定角度图 | +| `data.whiteBackground.fixed` | array | 当前已有的固定角度白底图,按固定顺序返回已存在项 | +| `data.whiteBackground.other` | array | 其他图片,可为空数组 | +| `data.downloadUrls.zip` | string | 当前 SKU 白底图整包 ZIP 下载链接 | + +### 3.2 `fixed` 固定图顺序 + +固定图返回顺序固定为: + +1. `leftFront45`:左前45° +2. `rightFront45`:右前45° +3. `leftBack45`:左后45° +4. `rightBack45`:右后45° +5. `front`:正视图 +6. `back`:后视图 +7. `left`:左视图 +8. `right`:右视图 + +### 3.3 单张图片对象字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `key` | string | 固定图为固定角度 key;其他图固定为 `other` | +| `label` | string | 图片说明 | +| `fileName` | string | 文件名 | +| `fileSize` | number | 文件大小,单位字节 | +| `downloadUrl` | string | 单张图片下载链接 | + +## 4. 下载方式 + +### 4.1 下载单张白底图 + +直接请求响应里的 `downloadUrl` 即可: + +```http +GET https://sc.jiansun.vip/api/external/download?token=xxx +``` + +返回内容为对应图片文件,例如: + +- `image/png` +- `image/jpeg` + +### 4.2 下载整包白底图 ZIP + +直接请求: + +```http +GET https://sc.jiansun.vip/api/external/download?token=xxx +``` + +当 token 对应的是白底图 ZIP 时,返回: + +- `Content-Type: application/zip` +- 文件名:`{SKU}_白底图.zip` + +## 5. 错误响应 + +### 5.1 SKU 不存在 + +```json +{ + "success": false, + "error": { + "code": "SKU_NOT_FOUND", + "message": "未找到对应的标准模型 SKU" + } +} +``` + +### 5.2 SKU 数据重复 + +```json +{ + "success": false, + "error": { + "code": "SKU_NOT_UNIQUE", + "message": "该 SKU 存在重复数据,暂无法对外提供白底图" + } +} +``` + +### 5.3 白底图不存在 + +```json +{ + "success": false, + "error": { + "code": "WHITE_BACKGROUND_NOT_FOUND", + "message": "该 SKU 暂无白底图" + } +} +``` + +### 5.4 API Key 无效 + +```json +{ + "success": false, + "error": { + "code": "INVALID_API_KEY", + "message": "API Key 无效或缺失" + } +} +``` + +## 6. 接入建议 + +- **推荐主流程**:先查接口 → 拿到 `fixed` / `other` → 按需下载单张图 +- 如果业务需要一次性拉全量,也可以直接使用 `downloadUrls.zip` +- `sku` 查询支持大小写不敏感,但建议调用方统一按大写传参,便于排查 +- 接口不再要求固定 8 张必须补齐;只要该 SKU 已有任意白底图,就会返回当前已有图片 +- 下载链接为带 token 的受控链接,建议由服务端调用,不建议直接暴露到前端页面 + +## 7. cURL 示例 + +### 7.1 查询白底图 + +```bash +curl -X GET \ + "https://sc.jiansun.vip/api/external/standard-model-white-background?sku=TEXT001452" \ + -H "X-API-Key: your-api-key-here" +``` + +### 7.2 下载单张图片 + +```bash +curl -L \ + "https://sc.jiansun.vip/api/external/download?token=xxx" \ + -o left-front-45.png +``` + +### 7.3 下载整包 ZIP + +```bash +curl -L \ + "https://sc.jiansun.vip/api/external/download?token=xxx" \ + -o TEXT001452_白底图.zip +``` + +## 8. 当前联调结论 + +已验证: + +- 旧任务平台接口 `GET /api/external/product-assets` 正常 +- 旧下载接口 `GET /api/external/download?token=...` 正常 +- 新白底图接口 `GET /api/external/standard-model-white-background` 正常 +- 新白底图单图下载正常 + +如果后续还要继续开放更多素材接口,建议继续复用当前这套: + +- `X-API-Key` +- `/api/external/*` +- token 下载链接 + diff --git a/docs/NETWORK_TOPOLOGY.md b/docs/NETWORK_TOPOLOGY.md index 2077f80..b401633 100644 --- a/docs/NETWORK_TOPOLOGY.md +++ b/docs/NETWORK_TOPOLOGY.md @@ -1,222 +1,178 @@ # 网络拓扑文档 -> 最后更新:2026-03-04 +> 最后更新:2026-03-13 +> 本文档优先记录“当前现网已验证的对外行为”和“项目内实际配置”。南京入口机的内层转发细节,如需继续下钻,请以服务器现场配置为准。 ---- +## 当前结论 -## 🗺️ 网络架构概览(当前生效) +- 主站主域名:`sc.jiansun.vip -> 180.97.221.140` + - 当前主站入口已经切到南京服务器 +- 旧主站域名:`3d.yydd.ai -> 103.146.230.120` + - 现网行为是 `301` 跳转到 `https://sc.jiansun.vip/` +- 资源域名:`s3.yydd.ai -> 103.146.230.120` + - 当前仍作为公开 S3 / MinIO 资源域名 +- 项目运行配置已经切到新主域名: + - `APP_PUBLIC_URL=https://sc.jiansun.vip` + - `NEXTAUTH_URL=https://sc.jiansun.vip` +- 项目运行配置还没有切走旧资源域名: + - `S3_PUBLIC_DOMAIN=https://s3.yydd.ai/gongxiangpan` -``` - ┌───────────────────────────┐ - │ 公网用户 │ - └─────────────┬─────────────┘ - │ - ▼ - ┌────────────────────────────────────┐ - │ 香港入口服务器 103.118.245.6 │ - │ - Nginx: 80/443 │ - │ - FRPS: 7001 │ - │ - FRP vhost: 8080/8443 │ - │ - Dashboard: 7500 │ - └────────────────┬───────────────────┘ - │ FRP (token) - ▼ - ┌────────────────────────────────────┐ - │ 生产服务器 192.168.58.20 │ - │ - Next.js: 4320 (容器映射) │ - │ - MinIO: 9000 │ - │ - frpc-s3.service (主链路) │ - │ - frpc.service (南京备用,已停用) │ - └────────────────────────────────────┘ +## 当前对外链路 -备用(非主链路): -南京 FRP 180.97.221.140(7000/2320/2330)当前仅保留为历史备用线路。 +### 1. 主站访问 + +```text +公网用户 + -> DNS + -> sc.jiansun.vip + -> 180.97.221.140(南京入口机) + -> 当前站点入口 + -> 登录页 / 首页 / API ``` ---- +说明: -## 🌐 域名解析 +- 备案完成后,主站对外入口已经从旧方案切到南京服务器 +- 仓库内没有保存南京入口机完整 nginx / FRP 配置,因此这里只记录已验证的入口行为,不强行推断内层链路 -| 域名 | DNS 指向 | 实际服务 | 用途 | -|-----|---------|---------|------| -| 3d.yydd.ai | 香港 (103.118.245.6) | Next.js 应用(经香港入口转发) | 主站访问 | -| s3.yydd.ai | 香港 (103.118.245.6) | MinIO 存储桶(经香港入口转发) | 文件存储/下载 | +### 2. 旧主站域名兼容 -### DNS 管理说明 - -- DNS 托管在 Cloudflare(权威 NS:`holly.ns.cloudflare.com`、`simon.ns.cloudflare.com`) -- 域名变更需在 Cloudflare Zone(`yydd.ai`)内操作 -- 当前运维终端默认无 Cloudflare 管理权限时,需联系域名管理员执行切换 - ---- - -## 🔌 FRP 穿透配置 - -### 当前服务模型(systemd) - -| 节点 | 服务 | 配置文件 | 角色 | -|-----|------|---------|------| -| 生产 192.168.58.20 | `frpc-s3.service` | `/etc/frp/frpc-s3.toml` | 主链路(3d + s3 → 香港) | -| 生产 192.168.58.20 | `frpc.service` | `/etc/frp/frpc.toml` | 南京备用链路(当前停用) | -| 香港 103.118.245.6 | `frps-minio.service` | `/root/frp-minio/frps.toml` | FRPS 服务端 | - -> 说明:`frpc` 二进制会有工作线程,进程数可大于 1;以 `systemctl is-active` 与日志为准。 -> -> 运行备注:`frpc.service`(南京备用链路)当前已停用,不影响 `frpc-s3.service`(香港主链路)对外服务;如需回切可手动启用。 - -### 关键配置(脱敏示例) - -#### `/etc/frp/frpc-s3.toml`(生产,主链路) - -```toml -serverAddr = "103.118.245.6" -serverPort = 7001 - -auth.method = "token" -auth.token = "" - -[[proxies]] -name = "minio-s3-hk" -type = "http" -localIP = "192.168.58.20" -localPort = 9000 -customDomains = ["s3.yydd.ai"] - -[[proxies]] -name = "3d-main-hk" -type = "http" -localIP = "192.168.58.20" -localPort = 4320 -customDomains = ["3d.yydd.ai"] +```text +公网用户 + -> 3d.yydd.ai + -> 103.146.230.120 + -> nginx 301 + -> https://sc.jiansun.vip/ ``` -#### `/root/frp-minio/frps.toml`(香港,服务端) +说明: -```toml -bindPort = 7001 -vhostHTTPPort = 8080 -vhostHTTPSPort = 8443 +- `3d.yydd.ai` 现在主要承担旧域名兼容和跳转入口,不再作为默认主站域名使用 -[auth] -token = "" +### 3. 资源访问 -[webServer] -addr = "0.0.0.0" -port = 7500 -user = "admin" -password = "" +```text +公网用户 + -> s3.yydd.ai + -> 103.146.230.120 + -> MinIO / S3 资源服务 ``` ---- +说明: -## 🚪 端口映射(当前) +- 当前主站入口和资源入口不在同一公网 IP 上 +- 排障时必须把“主站域名链路”和“资源域名链路”分开判断 -### 生产服务器 (192.168.58.20) +## 已验证证据 -| 服务 | 端口 | 说明 | -|-----|------|------| -| Next.js 容器 | 4320 | 3D 主站应用入口(内网) | -| MinIO | 9000 | 对象存储服务 | -| PostgreSQL | 5432 | 内网数据库 | -| Redis | 6379 | 内网缓存 | +### 1. 公网 DNS(2026-03-13) -### 香港入口服务器 (103.118.245.6) +- `sc.jiansun.vip -> 180.97.221.140` +- `3d.yydd.ai -> 103.146.230.120` +- `s3.yydd.ai -> 103.146.230.120` -| 端口 | 服务 | 说明 | -|-----|------|------| -| 80 | Nginx | HTTP 入口(跳 HTTPS) | -| 443 | Nginx + Let’s Encrypt | 3d/s3 统一 HTTPS 入口 | -| 7001 | FRPS bindPort | FRP 控制通道 | -| 8080 | FRPS vhostHTTPPort | FRP HTTP 转发 | -| 8443 | FRPS vhostHTTPSPort | FRP HTTPS 转发 | -| 7500 | FRPS Dashboard | 运维面板(需加固来源) | +以上结论已用公网 DoH 实测确认: -### 南京 FRP 服务器 (180.97.221.140) +- `https://dns.google/resolve?name=sc.jiansun.vip&type=A` +- `https://dns.google/resolve?name=3d.yydd.ai&type=A` +- `https://dns.google/resolve?name=s3.yydd.ai&type=A` -| 端口 | 服务 | 说明 | -|-----|------|------| -| 7000 | FRP Server | 南京旧线路控制端口 | -| 2320 | 3d 旧代理口 | 历史主站链路(备用) | -| 2330 | s3 旧代理口 | 历史存储链路(备用) | +### 2. HTTP 行为(2026-03-13) ---- +- `curl -I https://sc.jiansun.vip` + - 返回 `307` 到 `/login`,继续访问可得到 `200 OK` + - 响应头包含 `X-Powered-By: Next.js` +- `curl -I https://3d.yydd.ai` + - 返回 `301 Moved Permanently` + - `Location: https://sc.jiansun.vip/` +- `curl -I https://s3.yydd.ai` + - 返回对象存储风格的 `400 Bad Request` -## 📊 服务访问路径(当前) +## 项目内对应配置 -### 3d.yydd.ai(主站) +### 1. 主域名配置 -``` -用户 → DNS(香港) → Nginx(香港:443) → FRPS(香港:8080/8443) → frpc-s3(生产) → Next.js(4320) -``` +- `docker-compose.yml` + - `APP_PUBLIC_URL=https://sc.jiansun.vip` + - `NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip` + - `NEXTAUTH_URL=https://sc.jiansun.vip` +- `lib/public-app-url.ts` + - 默认对外主域名是 `https://sc.jiansun.vip` + - 旧域名 `https://3d.yydd.ai` 保留在兼容列表中 -### s3.yydd.ai(存储) +### 2. 对外下载链接生成 -``` -用户 → DNS(香港) → Nginx(香港:443) → FRPS(香港:8080/8443) → frpc-s3(生产) → MinIO(9000) -``` +- `app/api/external/product-assets/route.ts` +- `app/api/external/standard-model-white-background/route.ts` ---- +这两个外部接口都会通过 `getPublicAppOrigin()` 生成对外下载链接,因此默认会落到 `sc.jiansun.vip`。 -## 🔧 故障排查(更新后) +### 3. 资源域名配置 -### 常用检查命令 +- `docker-compose.yml` + - `S3_PUBLIC_DOMAIN=https://s3.yydd.ai/gongxiangpan` +- `lib/url-rewrite.ts` + - 仍把 `s3.yydd.ai` 视为当前公网资源域名 + +### 4. 历史南京直连口痕迹 + +- `middleware.ts` 里仍保留 `http://180.97.221.140:2320` + +这说明: + +- 项目内还保留着南京旧 TCP 直连口的兼容痕迹 +- 但当前对外主站口径应以 `https://sc.jiansun.vip` 为准 + +## 口径变更说明 + +- `2026-03-09` 那版文档里把 `103.146.230.120` 写成“当前公网主入口”,这个口径现在已经失效 +- 当前正确口径应当是: + - `sc.jiansun.vip`:主站主域名,当前解析到南京 `180.97.221.140` + - `3d.yydd.ai`:旧主站域名,当前保留跳转 + - `s3.yydd.ai`:资源域名,当前仍走 `103.146.230.120` +- 也就是说,这次切换不是“所有域名都切到南京”,而是“主站域名切到南京,资源域名暂未切走” + +## 当前风险与待确认项 + +- 主站入口和资源入口分属不同公网 IP,网络问题不能混看 +- 仓库内未保存南京入口机完整的现场配置 + - 如果后续要继续追 `sc.jiansun.vip` 的内层代理路径,需要直接登录 `180.97.221.140` 核对 +- `103.146.230.120` 当前仍承担: + - `3d.yydd.ai` 的跳转入口 + - `s3.yydd.ai` 的资源入口 + +## 推荐验证命令 + +### 公网 DNS 验证 ```bash -# 1) 生产侧 FRP 主链路 -ssh root@192.168.58.20 "systemctl is-active frpc-s3 && tail -n 80 /tmp/frpc-s3.log" - -# 2) 香港侧 FRPS + Nginx -ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "systemctl is-active frps-minio nginx" -ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "ss -tlnp | grep -E ':7001 |:8080 |:8443 |:443 '" - -# 3) 端到端验证 -curl -sS -o /dev/null -w "3d:%{http_code} ssl=%{ssl_verify_result}\n" https://3d.yydd.ai -curl -sS -o /dev/null -w "s3:%{http_code} ssl=%{ssl_verify_result}\n" https://s3.yydd.ai +curl -sS "https://dns.google/resolve?name=sc.jiansun.vip&type=A" +curl -sS "https://dns.google/resolve?name=3d.yydd.ai&type=A" +curl -sS "https://dns.google/resolve?name=s3.yydd.ai&type=A" ``` -### 常见问题 - -| 问题 | 可能原因 | 解决方案 | -|-----|---------|---------| -| 3d.yydd.ai 502/超时 | 香港 `frps-minio` 未运行或端口冲突 | `systemctl status frps-minio`,排查 `7500` 占用并重启 | -| s3.yydd.ai 不可用 | `frpc-s3` 未连接香港 | 检查 `/tmp/frpc-s3.log` 是否 `login success` + `start proxy success` | -| HTTPS 证书报错 | Nginx 证书未覆盖双域名 | 检查 `/etc/letsencrypt/live/s3.yydd.ai/fullchain.pem` SAN | -| 访问慢/抖动 | 跨境链路波动 | 观察 `frpc-s3` 重连频率,必要时启用备用线路 | - -### 重启流程(systemd) +### HTTP 行为验证 ```bash -# 生产:重启香港主链路 frpc -ssh root@192.168.58.20 "systemctl restart frpc-s3 && systemctl status frpc-s3 --no-pager -l | sed -n '1,60p'" - -# 生产:启用南京备用链路(仅回切时) -ssh root@192.168.58.20 "systemctl enable --now frpc && systemctl status frpc --no-pager -l | sed -n '1,60p'" - -# 香港:重启 FRPS -ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "systemctl restart frps-minio && systemctl status frps-minio --no-pager -l | sed -n '1,60p'" - -# 香港:重载 Nginx(改证书/站点后) -ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "nginx -t && systemctl reload nginx" +curl -I https://sc.jiansun.vip +curl -I https://3d.yydd.ai +curl -I https://s3.yydd.ai ``` ---- +### 跳转结果验证 -## 🔐 服务器凭据与连接方式 +```bash +curl -I -L https://3d.yydd.ai +curl -I -L https://sc.jiansun.vip +``` -| 服务器 | IP | 推荐 SSH 方式 | 备注 | -|-------|-----|-------------|------| -| 生产服务器 | 192.168.58.20 | `ssh prod` | 主服务节点 | -| 香港入口 | 103.118.245.6 | `ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6` | 已切换为新密钥 | -| 南京 FRP | 180.97.221.140 | `plink -ssh root@180.97.221.140 -pw "密码"` | 备用链路 | -| 构建服务器 | 192.168.42.134 | `ssh build` | 镜像构建 | +## 当前建议 -> 详细密钥清单与服务器说明见 `CLAUDE.md`。 - ---- - -## 📝 变更历史 - -| 日期 | 变更内容 | 操作人 | -|-----|---------|-------| -| 2026-02-23 | 创建文档,记录初始网络架构 | Claude | -| 2026-03-04 | 3d 主链路切到香港,证书覆盖 3d+s3,文档改为 systemd 运维模型 | Codex | +- 对外沟通时,主站统一写 `https://sc.jiansun.vip` +- 旧域名 `https://3d.yydd.ai` 只作为兼容跳转说明,不再作为默认示例地址 +- 资源直链示例仍使用 `https://s3.yydd.ai` +- 后续如果资源域名也切换,需要单独再更新: + - `docker-compose.yml` + - `lib/url-rewrite.ts` + - `next.config.js` + - 网络诊断与对外接口文档 diff --git a/docs/外部接口文档.md b/docs/外部接口文档.md index fc2f7dc..90b86d2 100644 --- a/docs/外部接口文档.md +++ b/docs/外部接口文档.md @@ -1,6 +1,9 @@ # 素材平台 - 外部接口文档 > 本文档供外部任务系统对接使用 +> 当前主域名:`https://sc.jiansun.vip` +> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名 +> 资源域名:`https://s3.yydd.ai` ## 概述 @@ -8,7 +11,7 @@ ### 基础信息 -- **接口地址**: `https://3d.yydd.ai/api/external/` +- **接口地址**: `https://sc.jiansun.vip/api/external/` - **鉴权方式**: API Key(请求头 `X-API-Key`) - **数据格式**: JSON @@ -17,7 +20,7 @@ #### 获取 API Key 1. **登录素材平台管理后台** - - 访问:`https://3d.yydd.ai/admin/api-keys` + - 访问:`https://sc.jiansun.vip/admin/api-keys` - 需要**超级管理员**或**系统管理员**权限 2. **创建 API Key** @@ -109,14 +112,14 @@ GET /api/external/product-assets?taskNo={任务号} ```bash # cURL 示例 curl -X GET \ - 'https://3d.yydd.ai/api/external/product-assets?taskNo=TASK202601030184' \ - -H 'X-API-Key: b78af0ec58a078105f63aaca3256918f' + 'https://sc.jiansun.vip/api/external/product-assets?taskNo=TASK202601030184' \ + -H 'X-API-Key: your-api-key-here' ``` ```http GET /api/external/product-assets?taskNo=TASK202601030184 HTTP/1.1 -Host: 3d.yydd.ai -X-API-Key: b78af0ec58a078105f63aaca3256918f +Host: sc.jiansun.vip +X-API-Key: your-api-key-here ``` **响应示例(成功 - 产品全案)** @@ -137,7 +140,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f { "id": "asset-uuid-1", "url": "https://s3.yydd.ai/...", - "downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx", "fileName": "主图1.jpg", "fileSize": 1024000 } @@ -146,7 +149,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f { "id": "asset-uuid-2", "url": "https://s3.yydd.ai/...", - "downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx", "fileName": "SKU白色.jpg", "fileSize": 512000 } @@ -155,7 +158,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f { "id": "asset-uuid-3", "url": "https://s3.yydd.ai/...", - "downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx", "fileName": "详情1.jpg", "fileSize": 2048000, "sortOrder": 0 @@ -165,18 +168,18 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f { "id": "asset-uuid-5", "url": "uploads/product-package/xxx/psd/psd-xxx.psd", - "downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx", "fileName": "源文件1.psd", "fileSize": 52428800 } ] }, "downloadUrls": { - "all": "https://3d.yydd.ai/api/external/download?token=xxx", - "main": "https://3d.yydd.ai/api/external/download?token=xxx", - "sku": "https://3d.yydd.ai/api/external/download?token=xxx", - "detail": "https://3d.yydd.ai/api/external/download?token=xxx", - "psd": "https://3d.yydd.ai/api/external/download?token=xxx" + "all": "https://sc.jiansun.vip/api/external/download?token=xxx", + "main": "https://sc.jiansun.vip/api/external/download?token=xxx", + "sku": "https://sc.jiansun.vip/api/external/download?token=xxx", + "detail": "https://sc.jiansun.vip/api/external/download?token=xxx", + "psd": "https://sc.jiansun.vip/api/external/download?token=xxx" } } } @@ -199,13 +202,13 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f { "id": "material-uuid-1", "url": "https://s3.yydd.ai/...", - "downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx", + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx", "fileName": "material.zip", "fileSize": 52428800 } ], "downloadUrls": { - "all": "https://3d.yydd.ai/api/external/download?token=xxx" + "all": "https://sc.jiansun.vip/api/external/download?token=xxx" } } } @@ -296,18 +299,18 @@ GET /api/external/download?token={下载令牌} ```bash # cURL 示例 - 打包下载(全部) curl -X GET \ - 'https://3d.yydd.ai/api/external/download?token=xxx' \ + 'https://sc.jiansun.vip/api/external/download?token=xxx' \ -o 'assets.zip' # cURL 示例 - 下载单个PSD文件(使用PSD资源的downloadUrl) curl -X GET \ - 'https://3d.yydd.ai/api/external/download?token=xxx' \ + 'https://sc.jiansun.vip/api/external/download?token=xxx' \ -o 'source.psd' ``` ```http GET /api/external/download?token=xxx HTTP/1.1 -Host: 3d.yydd.ai +Host: sc.jiansun.vip ``` **响应** @@ -364,14 +367,14 @@ DELETE /api/external/product-assets?taskNo={任务号} ```bash # cURL 示例 curl -X DELETE \ - 'https://3d.yydd.ai/api/external/product-assets?taskNo=TASK202601030184' \ - -H 'X-API-Key: b78af0ec58a078105f63aaca3256918f' + 'https://sc.jiansun.vip/api/external/product-assets?taskNo=TASK202601030184' \ + -H 'X-API-Key: your-api-key-here' ``` ```http DELETE /api/external/product-assets?taskNo=TASK202601030184 HTTP/1.1 -Host: 3d.yydd.ai -X-API-Key: b78af0ec58a078105f63aaca3256918f +Host: sc.jiansun.vip +X-API-Key: your-api-key-here ``` **响应示例(成功)** @@ -389,6 +392,76 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f --- +### 4. 查询标准模型白底图 + +供 AI 生图平台通过 `SKU` 直接获取白底图。 + +**请求** + +``` +GET /api/external/standard-model-white-background?sku={SKU} +``` + +**请求头** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| X-API-Key | string | 是 | API 访问密钥 | + +**请求参数** + +| 参数 | 类型 | 必填 | 说明 | +|-----|------|-----|------| +| sku | string | 是 | SKU 号,按标准模型全局唯一规则查询 | + +**成功响应示例** + +```json +{ + "success": true, + "data": { + "sku": "SKU001", + "materialId": "material-uuid", + "productModel": "SPU001", + "whiteBackground": { + "fixed": [ + { + "key": "leftFront45", + "label": "左前45°", + "fileName": "左前45°.jpg", + "fileSize": 123456, + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + ], + "other": [ + { + "key": "other-1", + "label": "其他图片1", + "fileName": "01-detail.jpg", + "fileSize": 654321, + "downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + ] + }, + "downloadUrls": { + "zip": "https://sc.jiansun.vip/api/external/download?token=xxx" + } + } +} +``` + +**错误说明** + +| 状态码 | code | 说明 | +|------|------|------| +| 400 | INVALID_SKU | SKU 参数为空或非法 | +| 401 | INVALID_API_KEY | API Key 无效 | +| 404 | SKU_NOT_FOUND | 未找到对应标准模型 SKU | +| 409 | SKU_NOT_UNIQUE | SKU 重复,暂不能对外提供 | +| 404 | WHITE_BACKGROUND_NOT_FOUND | 该 SKU 暂无白底图 | + +--- + ## 错误码 | 错误码 | HTTP状态码 | 说明 | @@ -399,6 +472,10 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f | TOKEN_EXPIRED | 401 | 下载令牌已过期(有效期24小时) | | INVALID_TOKEN | 401 | 下载令牌无效 | | INVALID_TASK_NO | 400 | 任务号格式错误 | +| INVALID_SKU | 400 | SKU 参数为空或非法 | +| SKU_NOT_FOUND | 404 | 未找到对应标准模型 SKU | +| SKU_NOT_UNIQUE | 409 | SKU 存在重复数据 | +| WHITE_BACKGROUND_NOT_FOUND | 404 | 该 SKU 暂无白底图 | | SERVER_ERROR | 500 | 服务器内部错误 | --- @@ -452,8 +529,8 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f ```javascript const axios = require('axios'); -const API_BASE = 'https://3d.yydd.ai/api/external'; -const API_KEY = 'b78af0ec58a078105f63aaca3256918f'; +const API_BASE = 'https://sc.jiansun.vip/api/external'; +const API_KEY = 'your-api-key-here'; // 查询素材 async function queryAssets(taskNo) { @@ -496,8 +573,8 @@ async function main() { ```python import requests -API_BASE = 'https://3d.yydd.ai/api/external' -API_KEY = 'b78af0ec58a078105f63aaca3256918f' +API_BASE = 'https://sc.jiansun.vip/api/external' +API_KEY = 'your-api-key-here' def query_assets(task_no): response = requests.get( @@ -525,7 +602,7 @@ if result['success']: ### 管理页面 -- **访问地址**: `https://3d.yydd.ai/admin/api-keys` +- **访问地址**: `https://sc.jiansun.vip/admin/api-keys` - **权限要求**: 超级管理员或系统管理员 ### 功能说明 @@ -553,4 +630,5 @@ if result['success']: *文档版本: 1.2* *更新日期: 2026-01-07* -*更新内容: 更新实际接口地址和 API Key 示例* \ No newline at end of file +*更新内容: 更新实际接口地址和 API Key 示例* + diff --git a/lib/__tests__/auth-cookie.test.ts b/lib/__tests__/auth-cookie.test.ts new file mode 100644 index 0000000..e792ae2 --- /dev/null +++ b/lib/__tests__/auth-cookie.test.ts @@ -0,0 +1,45 @@ +import { + getAuthSessionCookieCandidates, + getAuthSessionCookieName, + PLAIN_AUTH_SESSION_COOKIE_NAME, + resolveSecureAuthCookieFromRequest, + SECURE_AUTH_SESSION_COOKIE_NAME, +} from '@/lib/auth-cookie' + +describe('auth cookie protocol resolution', () => { + it('HTTPS 请求使用 secure cookie', () => { + expect( + resolveSecureAuthCookieFromRequest({ + headers: { + 'x-forwarded-proto': 'https', + }, + }) + ).toBe(true) + }) + + it('HTTP 请求使用普通 cookie', () => { + expect( + resolveSecureAuthCookieFromRequest({ + headers: { + 'x-forwarded-proto': 'http', + }, + nextUrl: { + protocol: 'http:', + }, + }) + ).toBe(false) + }) + + it('优先和兜底 cookie 名称都可返回', () => { + expect(getAuthSessionCookieName(true)).toBe(SECURE_AUTH_SESSION_COOKIE_NAME) + expect(getAuthSessionCookieName(false)).toBe(PLAIN_AUTH_SESSION_COOKIE_NAME) + expect(getAuthSessionCookieCandidates(true)).toEqual([ + SECURE_AUTH_SESSION_COOKIE_NAME, + PLAIN_AUTH_SESSION_COOKIE_NAME, + ]) + expect(getAuthSessionCookieCandidates(false)).toEqual([ + PLAIN_AUTH_SESSION_COOKIE_NAME, + SECURE_AUTH_SESSION_COOKIE_NAME, + ]) + }) +}) diff --git a/lib/__tests__/material-cleanup-standard-model.test.ts b/lib/__tests__/material-cleanup-standard-model.test.ts index 808613c..ee9611b 100644 --- a/lib/__tests__/material-cleanup-standard-model.test.ts +++ b/lib/__tests__/material-cleanup-standard-model.test.ts @@ -23,14 +23,17 @@ describe('标准模型清理覆盖测试', () => { expect(standardModelBlock).not.toContain('deletedAt: null') }) - test('白底图资产清理应同时覆盖旧键与SKU级新键', async () => { + test('白底图资产清理应同时覆盖旧键与新旧 SKU 级键', async () => { const fs = require('fs') const path = require('path') const filePath = path.join(__dirname, '../material-cleanup.ts') const content = fs.readFileSync(filePath, 'utf-8') - expect(content).toContain("assetType: 'standard-model-white-bg-zip'") - expect(content).toContain("assetType: { startsWith: 'standard-model-white-bg-zip:' }") + expect(content).toContain('LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE') + expect(content).toContain('LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX') + expect(content).toContain('STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX') + expect(content).toContain('STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX') + expect(content).toContain('STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX') }) }) diff --git a/lib/__tests__/standard-model-sku-uniqueness.test.ts b/lib/__tests__/standard-model-sku-uniqueness.test.ts new file mode 100644 index 0000000..5c4d729 --- /dev/null +++ b/lib/__tests__/standard-model-sku-uniqueness.test.ts @@ -0,0 +1,31 @@ +describe('标准模型 SKU 唯一性回归测试', () => { + test('新增 SKU 接口应校验同一素材下 skuName 唯一', async () => { + const fs = require('fs') + const path = require('path') + + const filePath = path.join(process.cwd(), 'app/api/materials/[id]/skus/route.ts') + const content = fs.readFileSync(filePath, 'utf-8') + + expect(content).toContain('所有标准模型下 SKU 名称不能重复') + expect(content).toContain("materialType: '标准模型'") + expect(content).toContain('material: {') + expect(content).toContain('deletedAt: null,') + expect(content).toContain('deletedAt: null') + expect(content).toContain('skuName: normalizedSkuName') + }) + + test('更新 SKU 接口应在改名时校验同一素材下 skuName 唯一', async () => { + const fs = require('fs') + const path = require('path') + + const filePath = path.join(process.cwd(), 'app/api/materials/[id]/skus/[skuId]/route.ts') + const content = fs.readFileSync(filePath, 'utf-8') + + expect(content).toContain('所有标准模型下 SKU 名称不能重复') + expect(content).toContain('excludeSkuId ? { NOT: { id: excludeSkuId } }') + expect(content).toContain("materialType: '标准模型'") + expect(content).toContain('material: {') + expect(content).toContain('deletedAt: null,') + expect(content).toContain('updateData.skuName = normalizedSkuName') + }) +}) diff --git a/lib/__tests__/standard-model-soft-delete-sync.test.ts b/lib/__tests__/standard-model-soft-delete-sync.test.ts new file mode 100644 index 0000000..ae24c24 --- /dev/null +++ b/lib/__tests__/standard-model-soft-delete-sync.test.ts @@ -0,0 +1,40 @@ +describe('标准模型软删除同步回归测试', () => { + test('单条素材软删除时应同步软删除 materialSku', async () => { + const fs = require('fs') + const path = require('path') + + const filePath = path.join(process.cwd(), 'app/api/materials/route.ts') + const content = fs.readFileSync(filePath, 'utf-8') + + expect(content).toContain('prisma.materialSku.updateMany') + expect(content).toContain('materialId: id') + expect(content).toContain('data: { deletedAt }') + }) + + test('批量软删除时应同步软删除 materialSku', async () => { + const fs = require('fs') + const path = require('path') + + const filePath = path.join(process.cwd(), 'app/api/admin/materials/batch-delete/route.ts') + const content = fs.readFileSync(filePath, 'utf-8') + + expect(content).toContain('prisma.materialSku.updateMany') + expect(content).toContain('materialId: { in: materials.map((m) => m.id) }') + expect(content).toContain('data: {') + expect(content).toContain('deletedAt,') + }) + + test('恢复素材时应恢复 materialSku 并校验活动标准模型 SKU 冲突', async () => { + const fs = require('fs') + const path = require('path') + + const filePath = path.join(process.cwd(), 'app/api/materials/restore/route.ts') + const content = fs.readFileSync(filePath, 'utf-8') + + expect(content).toContain("material.materialType === '标准模型'") + expect(content).toContain('恢复失败:以下 SKU 已被其他标准模型占用:') + expect(content).toContain('prisma.materialSku.updateMany') + expect(content).toContain('materialType: \'标准模型\'') + expect(content).toContain('deletedAt: null') + }) +}) diff --git a/lib/__tests__/standard-model-white-background.test.ts b/lib/__tests__/standard-model-white-background.test.ts index 18c5d31..ac1f960 100644 --- a/lib/__tests__/standard-model-white-background.test.ts +++ b/lib/__tests__/standard-model-white-background.test.ts @@ -1,62 +1,219 @@ import { + buildWhiteBackgroundFixedFileName, + countWhiteBackgroundAssets, + countWhiteBackgroundFixedViews, + countWhiteBackgroundOtherImages, + getWhiteBackgroundAssetWhereForSku, + getMissingWhiteBackgroundViewsFromAssetGroup, + getWhiteBackgroundOtherAssetType, + getNextWhiteBackgroundOtherIndex, + getWhiteBackgroundRelativeDirectory, + getWhiteBackgroundRelativeFilePath, + getWhiteBackgroundViewAssetType, + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + hasAnyWhiteBackgroundAssetGroup, + hasCompleteWhiteBackgroundAssetGroup, + parseStandardModelWhiteBgAssetType, + STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, + STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX, STANDARD_MODEL_WHITE_BG_VIEWS, + STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, + STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX, + getAllWhiteBackgroundAssetTypesForSku, + getCurrentWhiteBackgroundAssetType, getMissingWhiteBackgroundViews, hasCompleteWhiteBackgroundSet, getWhiteBackgroundFieldName, - hasReachedStandardModelSkuLimit, + normalizeWhiteBackgroundSkuName, } from '@/lib/standard-model-white-background' describe('standard-model-white-background helpers', () => { const createMockFile = (name: string): File => ({ name } as unknown as File) - test('returns missing views when not all six images are provided', () => { + test('returns missing views when not all eight images are provided', () => { const files = { - leftTop: createMockFile('left-top.jpg'), - rightTop: createMockFile('right-top.jpg'), + leftFront45: createMockFile('left-front-45.jpg'), + rightFront45: createMockFile('right-front-45.jpg'), + leftBack45: createMockFile('left-back-45.jpg'), + rightBack45: null, front: createMockFile('front.jpg'), - left: null, - right: null, back: null, + left: createMockFile('left.jpg'), + right: null, } const missing = getMissingWhiteBackgroundViews(files) - expect(missing).toEqual(['left', 'right', 'back']) + expect(missing).toEqual(['rightBack45', 'back', 'right']) }) test('recognizes complete white background image set', () => { const files = { - leftTop: createMockFile('left-top.jpg'), - rightTop: createMockFile('right-top.jpg'), + leftFront45: createMockFile('left-front-45.jpg'), + rightFront45: createMockFile('right-front-45.jpg'), + leftBack45: createMockFile('left-back-45.jpg'), + rightBack45: createMockFile('right-back-45.jpg'), front: createMockFile('front.jpg'), + back: createMockFile('back.jpg'), left: createMockFile('left.jpg'), right: createMockFile('right.jpg'), - back: createMockFile('back.jpg'), } expect(hasCompleteWhiteBackgroundSet(files)).toBe(true) expect(getMissingWhiteBackgroundViews(files)).toEqual([]) }) - test('exports six canonical white background views', () => { + test('exports eight canonical white background views', () => { expect(STANDARD_MODEL_WHITE_BG_VIEWS.map((item) => item.key)).toEqual([ - 'leftTop', - 'rightTop', + 'leftFront45', + 'rightFront45', + 'leftBack45', + 'rightBack45', 'front', + 'back', 'left', 'right', - 'back', ]) }) test('builds backend field names from view key', () => { - expect(getWhiteBackgroundFieldName('leftTop')).toBe('whiteBgLeftTop') + expect(getWhiteBackgroundFieldName('leftFront45')).toBe('whiteBgLeftFront45') expect(getWhiteBackgroundFieldName('front')).toBe('whiteBgFront') }) - test('enforces single SKU limit for standard model', () => { - expect(hasReachedStandardModelSkuLimit(0)).toBe(false) - expect(hasReachedStandardModelSkuLimit(1)).toBe(true) - expect(hasReachedStandardModelSkuLimit(3)).toBe(true) + test('exports other images field name', () => { + expect(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME).toBe('whiteBgOtherImages') + }) + + test('exports current white background asset type helpers', () => { + expect(STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX).toBe('standard-model-white-bg-zip-v2:') + expect(getCurrentWhiteBackgroundAssetType('sku-1')).toBe('standard-model-white-bg-zip-v2:sku-1') + expect(getAllWhiteBackgroundAssetTypesForSku('sku-1')).toContain('standard-model-white-bg-zip-v2:sku-1') + expect(getAllWhiteBackgroundAssetTypesForSku('sku-1')).toContain('standard-model-white-bg-zip') + expect(getAllWhiteBackgroundAssetTypesForSku('sku-1')).toContain('standard-model-white-bg-zip:sku-1') + }) + + test('exports per-image asset type helpers', () => { + expect(STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX).toBe('standard-model-white-bg-view:') + expect(STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX).toBe('standard-model-white-bg-other:') + expect(getWhiteBackgroundViewAssetType('sku-1', 'front')).toBe('standard-model-white-bg-view:sku-1:front') + expect(getWhiteBackgroundOtherAssetType('sku-1', 2)).toBe('standard-model-white-bg-other:sku-1:2') + }) + + test('parses per-image asset types', () => { + expect(parseStandardModelWhiteBgAssetType('standard-model-white-bg-view:sku-1:front')).toEqual({ + kind: 'view', + skuId: 'sku-1', + viewKey: 'front', + }) + + expect(parseStandardModelWhiteBgAssetType('standard-model-white-bg-other:sku-1:3')).toEqual({ + kind: 'other', + skuId: 'sku-1', + index: 3, + }) + }) + + test('builds white background storage paths', () => { + expect(getWhiteBackgroundRelativeDirectory('material-1', 'sku-1')).toBe( + 'uploads/skus/material-1/white-background/sku-1', + ) + expect(getWhiteBackgroundRelativeFilePath('material-1', 'sku-1', 'front.jpg')).toBe( + 'uploads/skus/material-1/white-background/sku-1/front.jpg', + ) + expect(getWhiteBackgroundZipFileName('SKU001')).toBe('SKU001_白底图.zip') + }) + + test('builds fixed white background file name with sku and category', () => { + expect(normalizeWhiteBackgroundSkuName('sku_01-a')).toBe('SKU_01-A') + expect(buildWhiteBackgroundFixedFileName('sku_01-a', '休闲椅', '左前45°', '.PNG')).toBe( + 'SKU_01-A-休闲椅-左前45°.png', + ) + expect(buildWhiteBackgroundFixedFileName('sku001', '', '正视图', 'jpg')).toBe( + 'SKU001-正视图.jpg', + ) + }) + + test('builds prisma where clause for a sku white background', () => { + expect(getWhiteBackgroundAssetWhereForSku('sku-1')).toEqual({ + OR: [ + { assetType: 'standard-model-white-bg-zip-v2:sku-1' }, + { assetType: 'standard-model-white-bg-zip' }, + { assetType: 'standard-model-white-bg-zip:sku-1' }, + { assetType: { startsWith: 'standard-model-white-bg-view:sku-1:' } }, + { assetType: { startsWith: 'standard-model-white-bg-other:sku-1:' } }, + ], + }) + }) + + test('groups white background assets by sku and recognizes completeness', () => { + const grouped = groupWhiteBackgroundAssetsBySku([ + { + assetType: 'standard-model-white-bg-view:sku-1:leftFront45', + fileUrl: 'uploads/a.jpg', + fileName: '左前45°.jpg', + sortOrder: 0, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:rightFront45', + fileUrl: 'uploads/b.jpg', + fileName: '右前45°.jpg', + sortOrder: 1, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:leftBack45', + fileUrl: 'uploads/c.jpg', + fileName: '左后45°.jpg', + sortOrder: 2, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:rightBack45', + fileUrl: 'uploads/d.jpg', + fileName: '右后45°.jpg', + sortOrder: 3, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:front', + fileUrl: 'uploads/e.jpg', + fileName: '正视图.jpg', + sortOrder: 4, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:back', + fileUrl: 'uploads/f.jpg', + fileName: '后视图.jpg', + sortOrder: 5, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:left', + fileUrl: 'uploads/g.jpg', + fileName: '左视图.jpg', + sortOrder: 6, + }, + { + assetType: 'standard-model-white-bg-view:sku-1:right', + fileUrl: 'uploads/h.jpg', + fileName: '右视图.jpg', + sortOrder: 7, + }, + { + assetType: 'standard-model-white-bg-other:sku-1:1', + fileUrl: 'uploads/other-1.jpg', + fileName: '01-other.jpg', + sortOrder: 1000, + }, + ]) + + const group = grouped.get('sku-1') + expect(group).toBeDefined() + expect(group?.other).toHaveLength(1) + expect(hasAnyWhiteBackgroundAssetGroup(group)).toBe(true) + expect(hasCompleteWhiteBackgroundAssetGroup(group)).toBe(true) + expect(countWhiteBackgroundFixedViews(group)).toBe(8) + expect(countWhiteBackgroundOtherImages(group)).toBe(1) + expect(countWhiteBackgroundAssets(group)).toBe(9) + expect(getNextWhiteBackgroundOtherIndex(group)).toBe(2) + expect(getMissingWhiteBackgroundViewsFromAssetGroup(group)).toEqual([]) }) }) diff --git a/lib/auth-cookie.ts b/lib/auth-cookie.ts new file mode 100644 index 0000000..f3d9f22 --- /dev/null +++ b/lib/auth-cookie.ts @@ -0,0 +1,140 @@ +import { headers } from 'next/headers' + +const AUTH_COOKIE_BASE_URL = + process.env.APP_PUBLIC_URL || + process.env.NEXTAUTH_URL || + '' + +export const SECURE_AUTH_SESSION_COOKIE_NAME = '__Secure-next-auth.session-token' +export const PLAIN_AUTH_SESSION_COOKIE_NAME = 'next-auth.session-token' + +type HeaderAccessor = + | Headers + | { + get?: (name: string) => string | null | undefined + [key: string]: string | string[] | undefined | ((name: string) => string | null | undefined) + } + +type RequestLike = { + headers?: HeaderAccessor + nextUrl?: { + protocol?: string + } + url?: string +} + +function normalizeProtocol(value?: string | null) { + if (!value) { + return null + } + + const normalized = value.split(',')[0].trim().replace(/:$/, '').toLowerCase() + + if (normalized === 'https' || normalized === 'http') { + return normalized + } + + return null +} + +function getHeaderValue(headersLike: HeaderAccessor | undefined, name: string) { + if (!headersLike) { + return null + } + + if (typeof headersLike.get === 'function') { + return headersLike.get(name) ?? null + } + + const recordHeaders = headersLike as Record + const directValue = recordHeaders[name] + if (Array.isArray(directValue)) { + return directValue[0] ?? null + } + + if (typeof directValue === 'string') { + return directValue + } + + const lowerCaseValue = recordHeaders[name.toLowerCase()] + if (Array.isArray(lowerCaseValue)) { + return lowerCaseValue[0] ?? null + } + + return typeof lowerCaseValue === 'string' ? lowerCaseValue : null +} + +function resolveProtocolFromHeaders(headersLike?: HeaderAccessor) { + const forwardedProto = normalizeProtocol(getHeaderValue(headersLike, 'x-forwarded-proto')) + if (forwardedProto) { + return forwardedProto + } + + const forwardedSsl = getHeaderValue(headersLike, 'x-forwarded-ssl') + if (typeof forwardedSsl === 'string' && forwardedSsl.toLowerCase() === 'on') { + return 'https' + } + + const forwardedPort = getHeaderValue(headersLike, 'x-forwarded-port') + if (forwardedPort === '443') { + return 'https' + } + + return null +} + +function resolveProtocolFromUrl(url?: string) { + if (!url) { + return null + } + + try { + return normalizeProtocol(new URL(url).protocol) + } catch { + return null + } +} + +export function resolveSecureAuthCookieFromRequest(request?: RequestLike) { + const protocolFromHeaders = resolveProtocolFromHeaders(request?.headers) + if (protocolFromHeaders) { + return protocolFromHeaders === 'https' + } + + const protocolFromNextUrl = normalizeProtocol(request?.nextUrl?.protocol) + if (protocolFromNextUrl) { + return protocolFromNextUrl === 'https' + } + + const protocolFromUrl = resolveProtocolFromUrl(request?.url) + if (protocolFromUrl) { + return protocolFromUrl === 'https' + } + + return AUTH_COOKIE_BASE_URL.startsWith('https://') +} + +export function resolveSecureAuthCookieForCurrentRequest() { + try { + return resolveSecureAuthCookieFromRequest({ + headers: headers(), + }) + } catch { + return AUTH_COOKIE_BASE_URL.startsWith('https://') + } +} + +export function getAuthSessionCookieName(useSecureCookie: boolean) { + return useSecureCookie + ? SECURE_AUTH_SESSION_COOKIE_NAME + : PLAIN_AUTH_SESSION_COOKIE_NAME +} + +export function getAuthSessionCookieCandidates(preferredSecureCookie: boolean) { + const preferredName = getAuthSessionCookieName(preferredSecureCookie) + const fallbackName = getAuthSessionCookieName(!preferredSecureCookie) + + return preferredName === fallbackName + ? [preferredName] + : [preferredName, fallbackName] +} diff --git a/lib/auth.ts b/lib/auth.ts index f0c8c96..fd8fca7 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,6 +2,7 @@ import { NextAuthOptions } from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import { prisma } from '@/lib/prisma' import bcrypt from 'bcryptjs' +import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie' export const authOptions: NextAuthOptions = { providers: [ @@ -113,19 +114,8 @@ export const authOptions: NextAuthOptions = { // R43修复:缩短会话过期时间从30天到7天,提高安全性 maxAge: 7 * 24 * 60 * 60, // 7天 }, - cookies: { - sessionToken: { - name: `next-auth.session-token`, - options: { - httpOnly: true, - sameSite: 'lax', // 允许跨站请求携带cookie(钉钉登录需要) - path: '/', - // 根据 NEXTAUTH_URL 的协议来判断是否使用 secure - // 如果 NEXTAUTH_URL 是 HTTPS,则使用 secure: true - // 如果是 HTTP(内网环境),则使用 secure: false - secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false, - }, - }, + get useSecureCookies() { + return resolveSecureAuthCookieForCurrentRequest() }, secret: process.env.NEXTAUTH_SECRET, } diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts index bdca355..4ff593c 100644 --- a/lib/chunk-upload.ts +++ b/lib/chunk-upload.ts @@ -149,12 +149,6 @@ async function uploadChunk( let lastProgressTime = 0 let lastProgressLoaded = 0 - const formData = new FormData() - formData.append('uploadId', uploadId) - formData.append('chunkIndex', chunkIndex.toString()) - formData.append('chunk', chunk) - formData.append('fileName', fileName) - return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() @@ -165,6 +159,11 @@ async function uploadChunk( options.onRequest?.(xhr) xhr.open('POST', '/api/materials/upload/chunk') + xhr.setRequestHeader('Content-Type', 'application/octet-stream') + xhr.setRequestHeader('X-Upload-Id', uploadId) + xhr.setRequestHeader('X-Chunk-Index', chunkIndex.toString()) + xhr.setRequestHeader('X-File-Name', encodeURIComponent(fileName)) + xhr.setRequestHeader('X-Chunk-Size', chunk.size.toString()) // 记录开始上传时间 xhr.upload.onloadstart = () => { @@ -249,7 +248,7 @@ async function uploadChunk( reject(new Error(`上传块 ${chunkIndex} 超时`)) } - xhr.send(formData) + xhr.send(chunk) }) } @@ -785,4 +784,3 @@ export async function clearAllUploadStates(): Promise { console.error('[ChunkUpload] Failed to clear states:', error) } } - diff --git a/lib/download-token.ts b/lib/download-token.ts index a3793c5..4c45d8c 100644 --- a/lib/download-token.ts +++ b/lib/download-token.ts @@ -11,12 +11,21 @@ import jwt from 'jsonwebtoken'; const TOKEN_EXPIRY = '100y'; // 100年 ≈ 永不过期 // 下载类型 -export type DownloadType = 'all' | 'main' | 'sku' | 'detail' | 'psd' | 'single'; +export type DownloadType = + | 'all' + | 'main' + | 'sku' + | 'detail' + | 'psd' + | 'single' + | 'white-background-single' + | 'white-background-zip'; export interface DownloadTokenPayload { materialId: string; downloadType: DownloadType; assetId?: string; // 单个资产下载时使用 + skuId?: string; // SKU 级资源下载时使用 } interface DecodedToken extends DownloadTokenPayload { @@ -55,6 +64,7 @@ export function verifyDownloadToken(token: string): DownloadTokenPayload | null materialId: decoded.materialId, downloadType: decoded.downloadType, assetId: decoded.assetId, + skuId: decoded.skuId, }; } catch (error) { return null; diff --git a/lib/file-cleanup.ts b/lib/file-cleanup.ts index 120f305..a309a5d 100644 --- a/lib/file-cleanup.ts +++ b/lib/file-cleanup.ts @@ -6,6 +6,7 @@ import { deleteFromS3, isS3Url } from './s3'; import { promises as fs } from 'fs'; import path from 'path'; +import { prisma } from './prisma'; export interface DeleteFileResult { success: boolean; @@ -13,6 +14,21 @@ export interface DeleteFileResult { error?: string; } +function getFileUrlVariants(fileUrl: string): string[] { + const trimmed = fileUrl.trim(); + if (!trimmed) { + return []; + } + + return Array.from( + new Set([ + trimmed, + trimmed.replace(/\\/g, '/'), + trimmed.replace(/\//g, '\\'), + ].filter(Boolean)), + ); +} + /** * 删除单个文件或目录(统一入口) * @param fileUrl 文件 URL(S3 URL 或本地路径) @@ -133,3 +149,92 @@ export async function deleteFiles( return { deletedCount, failedCount, errors }; } + +export async function hasActiveFileReferences( + fileUrl: string, + options?: { + excludeAssetIds?: string[]; + excludeSkuIds?: string[]; + excludeMaterialIds?: string[]; + } +): Promise { + if (!fileUrl || !fileUrl.trim()) { + return false; + } + + const variants = getFileUrlVariants(fileUrl); + if (variants.length === 0) { + return false; + } + + const assetWhere: any = { + fileUrl: { in: variants }, + }; + if (options?.excludeAssetIds?.length) { + assetWhere.NOT = { id: { in: options.excludeAssetIds } }; + } + const assetRefs = await prisma.materialAsset.count({ where: assetWhere }); + if (assetRefs > 0) { + return true; + } + + const skuWhere: any = { + deletedAt: null, + OR: [ + { filePath: { in: variants } }, + { previewUrl: { in: variants } }, + ], + }; + if (options?.excludeSkuIds?.length) { + skuWhere.NOT = { id: { in: options.excludeSkuIds } }; + } + const skuRefs = await prisma.materialSku.count({ where: skuWhere }); + if (skuRefs > 0) { + return true; + } + + const materialWhere: any = { + deletedAt: null, + OR: [ + { previewUrl: { in: variants } }, + { zipFilePath: { in: variants } }, + { videoSourceUrl: { in: variants } }, + { videoPreview540Url: { in: variants } }, + { videoPreviewClipUrl: { in: variants } }, + { videoCoverUrl: { in: variants } }, + ...variants.map((variant) => ({ + displayImages: { has: variant }, + })), + ], + }; + if (options?.excludeMaterialIds?.length) { + materialWhere.NOT = { id: { in: options.excludeMaterialIds } }; + } + const materialRefs = await prisma.material.count({ where: materialWhere }); + return materialRefs > 0; +} + +export async function deleteFileIfUnreferenced( + fileUrl: string, + options?: { + excludeAssetIds?: string[]; + excludeSkuIds?: string[]; + excludeMaterialIds?: string[]; + basePath?: string; + } +): Promise { + const referenced = await hasActiveFileReferences(fileUrl, options); + if (referenced) { + return { + success: true, + skipped: true, + reason: 'referenced_elsewhere', + }; + } + + const result = await deleteFile(fileUrl, options?.basePath); + return { + ...result, + skipped: false, + }; +} diff --git a/lib/material-cleanup.ts b/lib/material-cleanup.ts index 21356e0..c264a33 100644 --- a/lib/material-cleanup.ts +++ b/lib/material-cleanup.ts @@ -5,6 +5,13 @@ import path from 'path' import { prisma } from './prisma' +import { + LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE, + LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, + STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX, + STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX, + STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX, +} from './standard-model-white-background' export interface MaterialFileInfo { fileUrls: string[] @@ -144,8 +151,11 @@ export async function collectMaterialFiles( where: { materialId, OR: [ - { assetType: 'standard-model-white-bg-zip' }, - { assetType: { startsWith: 'standard-model-white-bg-zip:' } }, + { assetType: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE }, + { assetType: { startsWith: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX } }, ], }, select: { fileUrl: true }, @@ -197,8 +207,11 @@ export async function deleteMaterialRelatedRecords( where: { materialId, OR: [ - { assetType: 'standard-model-white-bg-zip' }, - { assetType: { startsWith: 'standard-model-white-bg-zip:' } }, + { assetType: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE }, + { assetType: { startsWith: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX } }, + { assetType: { startsWith: STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX } }, ], }, }) diff --git a/lib/network-detection.ts b/lib/network-detection.ts index e6df457..3d1f140 100644 --- a/lib/network-detection.ts +++ b/lib/network-detection.ts @@ -13,6 +13,7 @@ import { NextRequest } from 'next/server' // 公网域名白名单(明确标识为公网域名) const PUBLIC_DOMAINS = [ '3d.yydd.ai', + 'sc.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk', ] diff --git a/lib/product-package.ts b/lib/product-package.ts index d55c738..c77f79f 100644 --- a/lib/product-package.ts +++ b/lib/product-package.ts @@ -12,6 +12,7 @@ import path from 'path'; // TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务) // TASK202601141713525624-01-01-01 - 三级子任务(可无限层级) const TASK_NO_REGEX = /^TASK\d{12,18}(-\d{2})*$/; +const DELETED_TASK_NO_SUFFIX_REGEX = /_DELETED_\d+_\d+$/; // 任务状态 export const TASK_STATUS = { @@ -42,6 +43,22 @@ export function validateTaskNo(taskNo: string): boolean { return TASK_NO_REGEX.test(taskNo); } +/** + * 从历史软删除 taskNo 中恢复原始任务号 + */ +export function getOriginalTaskNo(taskNo?: string | null): string | null { + if (!taskNo) { + return null; + } + + const trimmed = taskNo.trim(); + if (!trimmed) { + return null; + } + + return trimmed.replace(DELETED_TASK_NO_SUFFIX_REGEX, ''); +} + /** * 检查任务号是否已存在(排除已删除的素材) */ @@ -214,11 +231,132 @@ export async function getMaterialCoverUrl(materialId: string): Promise { + const deletedMaterials = await prisma.material.findMany({ + where: { + taskNo, + deletedAt: { not: null }, + }, + select: { + id: true, + materialType: true, + deletedAt: true, + }, + }); + + if (deletedMaterials.length === 0) { + return { + deletedMaterials: 0, + deletedFiles: 0, + failedFiles: 0, + }; + } + + const { collectMaterialFiles, deleteMaterialRelatedRecords } = await import('./material-cleanup'); + const { deleteFiles } = await import('./file-cleanup'); + const { ensureMaterialDownloadLogsTable } = await import('./material-download-log'); + + await ensureMaterialDownloadLogsTable(); + + let deletedFiles = 0; + let failedFiles = 0; + + for (const material of deletedMaterials) { + const fileInfo = await collectMaterialFiles(material.id, material); + const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]; + + if (filesToDelete.length > 0) { + const result = await deleteFiles(filesToDelete); + deletedFiles += result.deletedCount; + failedFiles += result.failedCount; + } + + await deleteMaterialRelatedRecords(material.id, material.materialType); + await prisma.material.delete({ + where: { id: material.id }, + }); + } + + return { + deletedMaterials: deletedMaterials.length, + deletedFiles, + failedFiles, + }; +} + +/** + * 在素材成功完成后再写入任务号,避免失败中的半成品占用任务号 + */ +export async function assignTaskNoOnComplete( + materialId: string, + taskNo?: string | null, +): Promise<{ + deletedMaterials: number; + deletedFiles: number; + failedFiles: number; +} | null> { + const normalizedTaskNo = taskNo?.trim(); + if (!normalizedTaskNo) { + await prisma.material.update({ + where: { id: materialId }, + data: { + taskStatus: TASK_STATUS.COMPLETED, + } as any, + }); + return null; + } + + if (!validateTaskNo(normalizedTaskNo)) { + throw new Error('任务号格式错误'); + } + + const existingMaterial = await prisma.material.findFirst({ + where: { + taskNo: normalizedTaskNo, + deletedAt: null, + NOT: { id: materialId }, + } as any, + select: { + id: true, + productModel: true, + materialType: true, + }, + }); + + if (existingMaterial) { + throw new Error( + `任务号 ${normalizedTaskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` + ); + } + + const cleanupResult = await purgeDeletedMaterialsByTaskNo(normalizedTaskNo); + + await prisma.material.update({ + where: { id: materialId }, + data: { + taskNo: normalizedTaskNo, + taskStatus: TASK_STATUS.COMPLETED, + } as any, + }); + + return cleanupResult; +} + /** * 清理任务-详情素材的所有文件(用于上传失败时的回滚) * @param materialId 素材ID diff --git a/lib/public-app-url.ts b/lib/public-app-url.ts new file mode 100644 index 0000000..8d0b8e0 --- /dev/null +++ b/lib/public-app-url.ts @@ -0,0 +1,51 @@ +const DEFAULT_PUBLIC_APP_URL = 'https://sc.jiansun.vip' + +const LEGACY_PUBLIC_APP_ORIGINS = ['https://3d.yydd.ai'] + +function isPrivateOrLocalHost(hostname: string): boolean { + const lower = hostname.toLowerCase() + if (lower === 'localhost' || lower === '127.0.0.1' || lower === '::1') { + return true + } + + return /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(lower) +} + +function normalizeOrigin(value?: string | null): string | null { + if (!value) return null + + const trimmed = value.trim() + if (!trimmed) return null + + try { + const parsed = new URL(trimmed.match(/^https?:\/\//i) ? trimmed : `https://${trimmed}`) + if (isPrivateOrLocalHost(parsed.hostname)) { + return null + } + return parsed.origin + } catch { + return null + } +} + +export function getPublicAppOrigin(): string { + const candidates = [ + process.env.NEXT_PUBLIC_APP_PUBLIC_URL, + process.env.APP_PUBLIC_URL, + process.env.NEXTAUTH_URL, + DEFAULT_PUBLIC_APP_URL, + ] + + for (const candidate of candidates) { + const normalized = normalizeOrigin(candidate) + if (normalized) { + return normalized + } + } + + return DEFAULT_PUBLIC_APP_URL +} + +export function getAllowedPublicAppOrigins(): string[] { + return Array.from(new Set([getPublicAppOrigin(), ...LEGACY_PUBLIC_APP_ORIGINS])) +} diff --git a/lib/standard-model-white-background.ts b/lib/standard-model-white-background.ts index 2cd9499..297a187 100644 --- a/lib/standard-model-white-background.ts +++ b/lib/standard-model-white-background.ts @@ -5,22 +5,53 @@ export interface StandardModelWhiteBgView { } export type StandardModelWhiteBgKey = - | 'leftTop' - | 'rightTop' + | 'leftFront45' + | 'rightFront45' + | 'leftBack45' + | 'rightBack45' | 'front' + | 'back' | 'left' | 'right' - | 'back' export const STANDARD_MODEL_WHITE_BG_VIEWS: StandardModelWhiteBgView[] = [ - { key: 'leftTop', label: '左侧俯视', fieldName: 'whiteBgLeftTop' }, - { key: 'rightTop', label: '右侧俯视', fieldName: 'whiteBgRightTop' }, + { key: 'leftFront45', label: '左前45°', fieldName: 'whiteBgLeftFront45' }, + { key: 'rightFront45', label: '右前45°', fieldName: 'whiteBgRightFront45' }, + { key: 'leftBack45', label: '左后45°', fieldName: 'whiteBgLeftBack45' }, + { key: 'rightBack45', label: '右后45°', fieldName: 'whiteBgRightBack45' }, { key: 'front', label: '正视图', fieldName: 'whiteBgFront' }, + { key: 'back', label: '后视图', fieldName: 'whiteBgBack' }, { key: 'left', label: '左视图', fieldName: 'whiteBgLeft' }, { key: 'right', label: '右视图', fieldName: 'whiteBgRight' }, - { key: 'back', label: '后视图', fieldName: 'whiteBgBack' }, ] +export const STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME = 'whiteBgOtherImages' + +export const LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE = 'standard-model-white-bg-zip' +export const LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX = 'standard-model-white-bg-zip:' +export const STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX = 'standard-model-white-bg-zip-v2:' +export const STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX = 'standard-model-white-bg-view:' +export const STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX = 'standard-model-white-bg-other:' + +export interface ParsedStandardModelWhiteBgAssetType { + kind: 'view' | 'other' | 'zip' + skuId: string + viewKey?: StandardModelWhiteBgKey + index?: number +} + +export interface WhiteBackgroundAssetLike { + assetType: string + sortOrder?: number | string | bigint | null +} + +export interface GroupedWhiteBackgroundAssets { + skuId: string + fixed: Partial> + other: T[] + zipAssets: T[] +} + export function getWhiteBackgroundFieldName(key: StandardModelWhiteBgKey): string { const target = STANDARD_MODEL_WHITE_BG_VIEWS.find((item) => item.key === key) return target ? target.fieldName : `whiteBg${key}` @@ -40,6 +71,302 @@ export function hasCompleteWhiteBackgroundSet( return getMissingWhiteBackgroundViews(files).length === 0 } -export function hasReachedStandardModelSkuLimit(existingSkuCount: number): boolean { - return existingSkuCount >= 1 +export function normalizeWhiteBackgroundSkuName(value: string): string { + return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() +} + +function sanitizeWhiteBackgroundFilePart(value: string): string { + return String(value || '') + .replace(/[\/\\]/g, '-') + .replace(/[<>:"|?*]/g, '') + .replace(/\s+/g, '') + .trim() +} + +export function buildWhiteBackgroundFixedFileName( + skuName: string, + productCategoryName: string, + viewLabel: string, + ext: string, +): string { + const normalizedSkuName = normalizeWhiteBackgroundSkuName(skuName) + const safeCategoryName = sanitizeWhiteBackgroundFilePart(productCategoryName) + const safeViewLabel = sanitizeWhiteBackgroundFilePart(viewLabel) + const normalizedExt = ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}` + const baseName = [normalizedSkuName, safeCategoryName, safeViewLabel].filter(Boolean).join('-') + + return `${baseName || 'white-background'}${normalizedExt}` +} + +export function getCurrentWhiteBackgroundAssetType(skuId: string): string { + return `${STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX}${skuId}` +} + +export function getWhiteBackgroundViewAssetType( + skuId: string, + key: StandardModelWhiteBgKey, +): string { + return `${STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX}${skuId}:${key}` +} + +export function getWhiteBackgroundOtherAssetType(skuId: string, index: number): string { + return `${STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX}${skuId}:${index}` +} + +export function getLegacyWhiteBackgroundAssetTypes(skuId: string): string[] { + return [ + LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE, + `${LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX}${skuId}`, + ] +} + +export function getAllWhiteBackgroundAssetTypesForSku(skuId: string): string[] { + return [ + getCurrentWhiteBackgroundAssetType(skuId), + ...getLegacyWhiteBackgroundAssetTypes(skuId), + ] +} + +export function getWhiteBackgroundViewAssetPrefixForSku(skuId: string): string { + return `${STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX}${skuId}:` +} + +export function getWhiteBackgroundOtherAssetPrefixForSku(skuId: string): string { + return `${STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX}${skuId}:` +} + +export function getWhiteBackgroundAssetWhereForSku(skuId: string) { + return { + OR: [ + { assetType: getCurrentWhiteBackgroundAssetType(skuId) }, + { assetType: LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE }, + { assetType: `${LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX}${skuId}` }, + { assetType: { startsWith: getWhiteBackgroundViewAssetPrefixForSku(skuId) } }, + { assetType: { startsWith: getWhiteBackgroundOtherAssetPrefixForSku(skuId) } }, + ], + } +} + +export function getWhiteBackgroundRelativeDirectory(materialId: string, skuId: string): string { + return ['uploads', 'skus', materialId, 'white-background', skuId].join('/') +} + +export function getWhiteBackgroundRelativeFilePath( + materialId: string, + skuId: string, + fileName: string, +): string { + return `${getWhiteBackgroundRelativeDirectory(materialId, skuId)}/${fileName}`.replace(/\\/g, '/') +} + +export function getWhiteBackgroundZipFileName(skuName: string): string { + return `${skuName}_白底图.zip` +} + +export function countWhiteBackgroundFixedViews( + group?: Pick, 'fixed'> | null, +): number { + if (!group) { + return 0 + } + + return STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => Boolean(group.fixed[view.key])).length +} + +export function countWhiteBackgroundOtherImages( + group?: Pick, 'other'> | null, +): number { + return group?.other?.length || 0 +} + +export function countWhiteBackgroundAssets( + group?: Pick, 'fixed' | 'other' | 'zipAssets'> | null, +): number { + if (!group) { + return 0 + } + + return ( + countWhiteBackgroundFixedViews(group) + + countWhiteBackgroundOtherImages(group) + + (group.zipAssets?.length || 0) + ) +} + +export function hasAnyWhiteBackgroundAssetGroup( + group?: Pick, 'fixed' | 'other' | 'zipAssets'> | null, +): boolean { + return countWhiteBackgroundAssets(group) > 0 +} + +export function getMissingWhiteBackgroundViewsFromAssetGroup( + group?: Pick, 'fixed'> | null, +): StandardModelWhiteBgView[] { + if (!group) { + return [...STANDARD_MODEL_WHITE_BG_VIEWS] + } + + return STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !group.fixed[view.key]) +} + +export function parseStandardModelWhiteBgAssetType( + assetType: string, +): ParsedStandardModelWhiteBgAssetType | null { + const trimmed = String(assetType || '').trim() + if (!trimmed) { + return null + } + + if (trimmed.startsWith(STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX)) { + const remainder = trimmed.slice(STANDARD_MODEL_WHITE_BG_VIEW_ASSET_TYPE_PREFIX.length) + const separatorIndex = remainder.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + const skuId = remainder.slice(0, separatorIndex) + const viewKey = remainder.slice(separatorIndex + 1) as StandardModelWhiteBgKey + if (!STANDARD_MODEL_WHITE_BG_VIEWS.some((view) => view.key === viewKey)) { + return null + } + + return { + kind: 'view', + skuId, + viewKey, + } + } + + if (trimmed.startsWith(STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX)) { + const remainder = trimmed.slice(STANDARD_MODEL_WHITE_BG_OTHER_ASSET_TYPE_PREFIX.length) + const separatorIndex = remainder.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + const skuId = remainder.slice(0, separatorIndex) + const index = Number(remainder.slice(separatorIndex + 1)) + if (!Number.isInteger(index) || index <= 0) { + return null + } + + return { + kind: 'other', + skuId, + index, + } + } + + if (trimmed === LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE) { + return { + kind: 'zip', + skuId: '', + } + } + + if (trimmed.startsWith(LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX)) { + return { + kind: 'zip', + skuId: trimmed.slice(LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX.length), + } + } + + if (trimmed.startsWith(STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX)) { + return { + kind: 'zip', + skuId: trimmed.slice(STANDARD_MODEL_WHITE_BG_ASSET_TYPE_PREFIX.length), + } + } + + return null +} + +export function isWhiteBackgroundAssetType(assetType: string): boolean { + return parseStandardModelWhiteBgAssetType(assetType) !== null +} + +function getAssetSortOrder(asset: WhiteBackgroundAssetLike): number { + const raw = asset.sortOrder + if (typeof raw === 'bigint') { + return Number(raw) + } + if (typeof raw === 'string') { + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : 0 + } + if (typeof raw === 'number') { + return Number.isFinite(raw) ? raw : 0 + } + return 0 +} + +export function groupWhiteBackgroundAssetsBySku( + assets: T[], +): Map> { + const groups = new Map>() + + for (const asset of assets) { + const parsed = parseStandardModelWhiteBgAssetType(asset.assetType) + if (!parsed) { + continue + } + + const groupSkuId = parsed.skuId + if (!groupSkuId) { + continue + } + + const current = groups.get(groupSkuId) || { + skuId: groupSkuId, + fixed: {}, + other: [], + zipAssets: [], + } + + if (parsed.kind === 'view' && parsed.viewKey) { + current.fixed[parsed.viewKey] = asset + } else if (parsed.kind === 'other') { + current.other.push(asset) + } else if (parsed.kind === 'zip') { + current.zipAssets.push(asset) + } + + groups.set(groupSkuId, current) + } + + groups.forEach((group) => { + group.other.sort((left, right) => getAssetSortOrder(left) - getAssetSortOrder(right)) + group.zipAssets.sort((left, right) => getAssetSortOrder(left) - getAssetSortOrder(right)) + }) + + return groups +} + +export function hasCompleteWhiteBackgroundAssetGroup( + group?: Pick, 'fixed'> | null, +): boolean { + if (!group) { + return false + } + + return STANDARD_MODEL_WHITE_BG_VIEWS.every((view) => Boolean(group.fixed[view.key])) +} + +export function getNextWhiteBackgroundOtherIndex( + group?: Pick, 'other'> | null, +): number { + if (!group?.other?.length) { + return 1 + } + + let maxIndex = 0 + + for (const asset of group.other) { + const parsed = parseStandardModelWhiteBgAssetType(asset.assetType) + if (parsed?.kind === 'other' && parsed.index && parsed.index > maxIndex) { + maxIndex = parsed.index + } + } + + return maxIndex + 1 } diff --git a/lib/url-rewrite.ts b/lib/url-rewrite.ts index d396cf2..2aed517 100644 --- a/lib/url-rewrite.ts +++ b/lib/url-rewrite.ts @@ -5,7 +5,8 @@ * 也要在「返回给前端之前」自动替换为实际可访问的 MinIO 地址。 * * 注意:这里处理的是资源文件(图片、视频等)的存储地址,不是应用访问地址。 - * - 应用访问地址:https://3d.yydd.ai/(用户访问的网站) + * - 应用访问地址:https://sc.jiansun.vip/(当前主站) + * - 旧域名:https://3d.yydd.ai/(当前仅保留跳转) * - 资源存储地址:https://s3.yydd.ai 或 https://s3.1080p.hk(图片、视频等文件) * * 智能选择策略(针对资源文件地址): @@ -470,6 +471,7 @@ export function ensureHttpsUrl(url?: string | null): string | null { 's3.1080p.hk', 's3.yydd.ai', '3d.yydd.ai', + 'sc.jiansun.vip', ] // 检查是否为公网域名或非内网地址 diff --git a/middleware.ts b/middleware.ts index 34ddb0d..2701c5b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { getToken } from 'next-auth/jwt' +import { + getAuthSessionCookieCandidates, + resolveSecureAuthCookieFromRequest, +} from '@/lib/auth-cookie' // 注意:不在middleware中启动内存监控 // middleware使用Edge Runtime,不支持Node.js API @@ -46,7 +50,22 @@ export async function middleware(request: NextRequest) { // 处理需要认证的页面 if (pathname === '/' || pathname.startsWith('/admin')) { - const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET }) + const preferredSecureCookie = resolveSecureAuthCookieFromRequest(request) + const cookieCandidates = getAuthSessionCookieCandidates(preferredSecureCookie) + let token = null + + for (const cookieName of cookieCandidates) { + token = await getToken({ + req: request, + secret: process.env.NEXTAUTH_SECRET, + cookieName, + secureCookie: cookieName.startsWith('__Secure-'), + }) + + if (token) { + break + } + } if (!token) { const url = new URL('/login', request.url) @@ -124,4 +143,3 @@ export async function middleware(request: NextRequest) { export const config = { matcher: ['/', '/admin/:path*', '/api/:path*'], } -