同步当前工作区源码并修复白底图上传问题

This commit is contained in:
chenpei 2026-03-14 10:36:21 +08:00
parent 7fdc6bb91d
commit 482447fe40
75 changed files with 4912 additions and 1040 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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)..." && \

View File

@ -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) {

View File

@ -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) =>

View File

@ -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<string, any>;
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<Buffer> {
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
*/

View File

@ -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);

View File

@ -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', '服务器内部错误')
}
}

View File

@ -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

View File

@ -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<string, unknown> = {};
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);

View File

@ -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<WhiteBackgroundAssetRecord>) {
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<WhiteBackgroundAssetRecord>(
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 })
}
}

View File

@ -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 } }
@ -19,15 +45,14 @@ export async function GET(
const sku = await prisma.materialSku.findFirst({
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<string, { oldValue: unknown; newValue: unknown }> = {
@ -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 })

View File

@ -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<WhiteBackgroundAssetRecord>) {
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')
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,
},
})
for (const sku of skus) {
try {
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku<WhiteBackgroundAssetRecord>(
whiteBackgroundAssets,
)
// 检查文件是否存在
try {
await fs.access(filePath)
} catch {
console.warn(`SKU文件不存在: ${filePath}`)
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) {
}),
])
}

View File

@ -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: {
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({
error: '添加 SKU 失败',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
}, { status: 500 })
}
}

View File

@ -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,

View File

@ -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<Record<StandardModelWhiteBgKey, File | null>>)
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<string>(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<Record<StandardModelWhiteBgKey, { assetType: string }>> = {
...(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 })
}

View File

@ -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) {
@ -31,13 +32,107 @@ export async function POST(request: NextRequest) {
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,
},
}
: {}),
},
});

View File

@ -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({

View File

@ -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);

View File

@ -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, // 任务号(可选)
},
});

View File

@ -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);

View File

@ -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, // 任务号(可选)
},
});

View File

@ -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 payloadStartedAt = Date.now()
const payload = await resolveChunkPayload(req)
const payloadResolveTime = Date.now() - payloadStartedAt
console.log(
`[UploadChunk] 📦 请求参数解析完成: ${payloadResolveTime}ms, mode=${payload.mode}, chunkIndex=${payload.chunkIndex}`,
)
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`)
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
const mkdirTime = Date.now() - mkdirStartedAt
// 性能监控:转换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 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 t6 = Date.now()
await writeFile(chunkFilePath, chunkBuffer)
const writeFileTime = Date.now() - t6
const statsStartedAt = Date.now()
await updateChunkWithStats(payload.uploadId, payload.chunkIndex, chunkFilePath, writtenBytes)
const updateChunkTime = Date.now() - statsStartedAt
// 性能监控:更新会话
const t7 = Date.now()
await updateChunkWithStats(uploadId, chunkIndex, chunkFilePath, chunk.size)
const updateChunkTime = Date.now() - t7
// 计算总耗时
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({
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<ChunkRequestPayload> {
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<number> {
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<number> {
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)
}

View File

@ -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]
}

View File

@ -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

View File

@ -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);
// 保持旧行为:清理失败时仅记录,不阻断新建流程
}
}

View File

@ -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);

View File

@ -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, // 任务号(可选)
},
});

View File

@ -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)) {

View File

@ -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);

View File

@ -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, // 任务号(可选)
},
});

View File

@ -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);

View File

@ -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, // 任务号(可选)
},
});

View File

@ -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素材',
},
});

View File

@ -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();

View File

@ -23,7 +23,7 @@ function CompleteProfileContent() {
const [userInfo, setUserInfo] = useState<any>(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(() => {

9
app/favicon.ico/route.ts Normal file
View File

@ -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)
}

View File

@ -25,9 +25,9 @@ export async function generateMetadata(): Promise<Metadata> {
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({
</html>
)
}

View File

@ -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() {
</div>
)
}

View File

@ -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秒超时,快速失败
if (!isHttps) {
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
})
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
})
clearTimeout(timeoutId)
clearTimeout(timeoutId)
if (intranetResponse.ok) {
const intranetData = await intranetResponse.json()
const hostname = intranetData.hostname || ''
if (intranetResponse.ok) {
const intranetData = await intranetResponse.json()
const hostname = intranetData.hostname || ''
const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true
// 关键判断:如果返回的hostname是192.168.58.20,说明客户端能直接访问内网地址
const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true
if (isTargetServer) {
canAccessIntranet = true
console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户')
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() {
</div>
)}
{/* 内网切换提示已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网 */}
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<RankingPanel />

View File

@ -82,7 +82,7 @@ export default function RegisterPage() {
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Image
src="http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png"
src="https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png"
alt="由壹点3D素材"
width={80}
height={80}

View File

@ -13,6 +13,7 @@ import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import AudioVisualizer from '@/components/AudioVisualizer'
import ProductPackageDetail from '@/components/detail/ProductPackageDetail'
import TaskPageDetail from '@/components/detail/TaskPageDetail'
import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail'
import { parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
@ -61,6 +62,11 @@ interface Material {
filePath: string
fileSize: string
sortOrder: number
whiteBackgroundUploaded?: boolean
whiteBackgroundComplete?: boolean
whiteBackgroundFixedCount?: number
whiteBackgroundOtherCount?: number
whiteBackgroundCount?: number
}>
}
@ -85,6 +91,8 @@ export default function SharePage() {
const [audioProgress, setAudioProgress] = useState(0) // 0-100
// SKU选中状态(用于标准模型类型的批量下载)
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(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()}
/>
</div>
) : isTaskPageType ? (
<div className="max-w-7xl mx-auto">
<TaskPageDetail
material={{
id: material.id,
taskNo: material.taskNo || '',
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
taskStatus: material.taskStatus || 'pending',
taskFetchedAt: material.taskFetchedAt,
assets: material.assets || [],
}}
canDownloadSource={material.canDownloadSource ?? false}
showToast={showToast}
onDownload={async (type, assetId) => {
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()}
/>
</div>
) : is3DOriginalType ? (
/* 3D原图/AI原图/实拍原图/视频截图类型:使用专用详情组件 */
<div className="max-w-7xl mx-auto">
@ -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() {
{/* 操作按钮 - 统一样式 */}
<div className="space-y-3 pt-2">
<button
onClick={handleDownloadByDirectLink}
disabled={downloading}
className="w-full btn-primary py-3 text-base font-medium relative overflow-hidden disabled:opacity-75"
>
{downloading ? (
<>
<div
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
<span className="relative z-10 flex items-center justify-center gap-2">
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
{material.materialType === '标准模型' ? (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => {
if (selectedSkuIds.size > 0) {
void handleBatchDownloadSkus('model')
} else {
showToast('请先选择要下载模型的 SKU', { variant: 'error' })
}
}}
disabled={downloading}
className="w-full py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-xl text-base font-medium transition-colors disabled:opacity-75"
>
{downloading && downloadingBatchType === 'model' ? (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
打包模型中...
</span>
) : (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{selectedSkuIds.size > 0 ? `下载模型 (${selectedSkuIds.size}个)` : '下载模型'}
</span>
)}
</button>
<button
onClick={() => {
if (selectedSkuIds.size > 0) {
void handleBatchDownloadSkus('white-background')
} else {
showToast('请先选择要下载白底图的 SKU', { variant: 'error' })
}
}}
disabled={downloading}
className="w-full py-3 bg-gray-600 hover:bg-gray-700 text-white rounded-xl text-base font-medium transition-colors disabled:opacity-75"
>
{downloading && downloadingBatchType === 'white-background' ? (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
正在打包白底图...
</span>
) : (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{selectedSkuIds.size > 0 ? `下载白底图 (${selectedSkuIds.size}个)` : '下载白底图'}
</span>
)}
</button>
</div>
) : (
<button
onClick={handleDownloadByDirectLink}
disabled={downloading}
className="w-full btn-primary py-3 text-base font-medium relative overflow-hidden disabled:opacity-75"
>
{downloading ? (
<>
<div
className="absolute left-0 top-0 bottom-0 bg-blue-600 transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
<span className="relative z-10 flex items-center justify-center gap-2">
<svg className="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
下载中 {downloadProgress}%
</span>
</>
) : (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
下载中 {downloadProgress}%
立即下载
</span>
</>
) : (
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{material.materialType === '标准模型' && selectedSkuIds.size > 0
? `立即下载 (${selectedSkuIds.size}个)`
: '立即下载'}
</span>
)}
</button>
)}
</button>
)}
<button
onClick={() => router.back()}
@ -1017,6 +1147,7 @@ export default function SharePage() {
}}
onSaved={() => {
setShowSkuEditModal(false)
setSkuListRefreshKey((prev) => prev + 1)
// 重新获取SKU列表
if (material.materialType === '标准模型') {
fetch(`/api/materials/${materialId}/skus`)

View File

@ -21,7 +21,7 @@ export default function Header({ session }: HeaderProps) {
const [showDropdown, setShowDropdown] = useState(false)
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
const [networkChecked, setNetworkChecked] = useState(false)
// 网络切换横幅已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网
// 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const [showNetworkDiagnose, setShowNetworkDiagnose] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
@ -46,7 +46,7 @@ export default function Header({ session }: HeaderProps) {
// 系统设置
const [headerBgColor, setHeaderBgColor] = useState('#1f2937')
const [headerLogoUrl, setHeaderLogoUrl] = useState('http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
const [headerLogoUrl, setHeaderLogoUrl] = useState('https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
const [siteName, setSiteName] = useState('由壹点3D素材')
// 加载系统设置
@ -57,7 +57,7 @@ export default function Header({ session }: HeaderProps) {
if (response.ok) {
const settings = await response.json()
setHeaderBgColor(settings.headerBgColor || '#1f2937')
setHeaderLogoUrl(settings.headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setHeaderLogoUrl(settings.headerLogoUrl || 'https://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png')
setSiteName(settings.siteName || '由壹点3D素材')
}
} catch (error) {
@ -87,7 +87,7 @@ export default function Header({ session }: HeaderProps) {
const isExternal = currentHost !== '192.168.58.20' && currentHost !== 'localhost'
setIsAccessingFromExternal(isExternal)
// 网络切换横幅已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网
// 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
} catch (error) {
console.error('网络检测失败:', error)
setIsInternalNetwork(false)
@ -109,7 +109,7 @@ export default function Header({ session }: HeaderProps) {
}
}, [])
// 内网切换功能已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网
// 内网切换功能已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
// 点击外部关闭下拉菜单
useEffect(() => {
@ -236,7 +236,7 @@ export default function Header({ session }: HeaderProps) {
{/* 系统维护通知横幅 */}
<MaintenanceBanner />
{/* 网络切换横幅已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网 */}
{/* 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
<header
className="sticky top-0 z-50 shadow-sm"
@ -714,6 +714,7 @@ export default function Header({ session }: HeaderProps) {
'next-',
'workbox-',
currentOrigin,
'sc.jiansun.vip',
'3d.yydd.ai',
'192.168.58.20',
'gongxiang',

View File

@ -4,6 +4,8 @@ import { useState, useEffect, useRef } from 'react'
import Image from 'next/image'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
import StandardModelWhiteBackgroundUpload from './upload/StandardModelWhiteBackgroundUpload'
import StandardModelSkuZipUpload from './upload/StandardModelSkuZipUpload'
import {
DndContext,
closestCenter,
@ -23,7 +25,13 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { parseFetchError, handleError, parseApiError } from '@/lib/error-handler'
import { parseApiError } from '@/lib/error-handler'
import { formatSpeed } from '@/components/upload/upload-utils'
import {
STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME,
STANDARD_MODEL_WHITE_BG_VIEWS,
type StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
// SKU 数据类型
interface Sku {
@ -36,6 +44,18 @@ interface Sku {
zipFileSize: string
sortOrder: number
uploaderId: string
whiteBackgroundUploaded?: boolean
whiteBackgroundComplete?: boolean
whiteBackgroundFixedCount?: number
whiteBackgroundOtherCount?: number
whiteBackgroundCount?: number
}
interface WhiteBgOtherImage {
id: string
file: File
previewUrl: string
name: string
}
interface SkuEditModalProps {
@ -45,11 +65,14 @@ interface SkuEditModalProps {
onSaved: () => void // 保存成功后的回调
}
function normalizeSkuNameInput(value: string): string {
return value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
}
export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: SkuEditModalProps) {
const [skus, setSkus] = useState<Sku[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingSkuId, setUploadingSkuId] = useState<string | null>(null)
const { toast, showToast } = useToast()
// 新增/编辑 SKU 表单
@ -61,9 +84,24 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
const [newSkuZipFile, setNewSkuZipFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ zip: 0, preview: 0 })
const [activeWhiteBgSku, setActiveWhiteBgSku] = useState<Sku | null>(null)
const [activeModelSku, setActiveModelSku] = useState<Sku | null>(null)
const [modelZipFile, setModelZipFile] = useState<File | null>(null)
const [modelUploading, setModelUploading] = useState(false)
const [modelUploadProgress, setModelUploadProgress] = useState(0)
const [modelUploadError, setModelUploadError] = useState('')
const [whiteBgFiles, setWhiteBgFiles] = useState<Partial<Record<StandardModelWhiteBgKey, File | null>>>({})
const [whiteBgPreviewUrls, setWhiteBgPreviewUrls] = useState<Partial<Record<StandardModelWhiteBgKey, string>>>({})
const [whiteBgOtherImages, setWhiteBgOtherImages] = useState<WhiteBgOtherImage[]>([])
const [whiteBgUploading, setWhiteBgUploading] = useState(false)
const [whiteBgUploadProgress, setWhiteBgUploadProgress] = useState(0)
const [whiteBgUploadSpeed, setWhiteBgUploadSpeed] = useState(0)
const [whiteBgError, setWhiteBgError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
const whiteBgPreviewUrlsRef = useRef<Partial<Record<StandardModelWhiteBgKey, string>>>({})
const whiteBgOtherImagesRef = useRef<WhiteBgOtherImage[]>([])
// 加载 SKU 列表
useEffect(() => {
@ -87,6 +125,87 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
}
}
const resetWhiteBackgroundForm = () => {
Object.values(whiteBgPreviewUrlsRef.current).forEach((url) => {
if (url && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
}
})
whiteBgOtherImagesRef.current.forEach((image) => {
if (image.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(image.previewUrl)
}
})
setWhiteBgFiles({})
setWhiteBgPreviewUrls({})
setWhiteBgOtherImages([])
setWhiteBgError('')
setWhiteBgUploading(false)
setWhiteBgUploadProgress(0)
setWhiteBgUploadSpeed(0)
}
const handleOpenWhiteBackground = (sku: Sku) => {
if (showAddForm) {
handleCancelEdit()
}
handleCloseModelUpload()
resetWhiteBackgroundForm()
setActiveWhiteBgSku(sku)
}
const handleCloseWhiteBackground = () => {
resetWhiteBackgroundForm()
setActiveWhiteBgSku(null)
}
const resetModelUploadForm = () => {
setModelZipFile(null)
setModelUploadProgress(0)
setModelUploadError('')
setModelUploading(false)
}
const handleOpenModelUpload = (sku: Sku) => {
if (showAddForm) {
handleCancelEdit()
}
handleCloseWhiteBackground()
resetModelUploadForm()
setActiveModelSku(sku)
}
const handleCloseModelUpload = () => {
resetModelUploadForm()
setActiveModelSku(null)
}
useEffect(() => {
whiteBgPreviewUrlsRef.current = whiteBgPreviewUrls
}, [whiteBgPreviewUrls])
useEffect(() => {
whiteBgOtherImagesRef.current = whiteBgOtherImages
}, [whiteBgOtherImages])
useEffect(() => {
return () => {
Object.values(whiteBgPreviewUrlsRef.current).forEach((url) => {
if (url && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
}
})
whiteBgOtherImagesRef.current.forEach((image) => {
if (image.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(image.previewUrl)
}
})
}
}, [])
// 格式化文件大小
const formatFileSize = (bytes: string | number): string => {
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
@ -127,6 +246,189 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
setNewSkuPreviewUrl(previewUrl)
}
const handleSelectWhiteBackgroundImage = (key: StandardModelWhiteBgKey, file: File) => {
setWhiteBgError('')
setWhiteBgFiles((prev) => ({
...prev,
[key]: file,
}))
setWhiteBgPreviewUrls((prev) => {
const previousUrl = prev[key]
if (previousUrl && previousUrl.startsWith('blob:')) {
URL.revokeObjectURL(previousUrl)
}
return {
...prev,
[key]: URL.createObjectURL(file),
}
})
}
const handleRemoveWhiteBackgroundImage = (key: StandardModelWhiteBgKey) => {
setWhiteBgFiles((prev) => {
const next = { ...prev }
delete next[key]
return next
})
setWhiteBgPreviewUrls((prev) => {
const previousUrl = prev[key]
if (previousUrl && previousUrl.startsWith('blob:')) {
URL.revokeObjectURL(previousUrl)
}
const next = { ...prev }
delete next[key]
return next
})
}
const handleSelectWhiteBackgroundOthers = (files: FileList | File[]) => {
const fileList = Array.from(files || []).filter((file) => file.type.startsWith('image/'))
if (fileList.length === 0) {
return
}
setWhiteBgError('')
setWhiteBgOtherImages((prev) => [
...prev,
...fileList.map((file, index) => ({
id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`,
file,
previewUrl: URL.createObjectURL(file),
name: file.name,
})),
])
}
const handleRemoveWhiteBackgroundOther = (id: string) => {
setWhiteBgOtherImages((prev) => {
const target = prev.find((item) => item.id === id)
if (target?.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(target.previewUrl)
}
return prev.filter((item) => item.id !== id)
})
}
const handleUploadWhiteBackground = async () => {
if (!activeWhiteBgSku) {
showToast('请先选择要管理白底图的 SKU', { variant: 'error' })
return
}
const selectedFixedCount = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBgFiles[view.key]).length
if (selectedFixedCount === 0 && whiteBgOtherImages.length === 0) {
const errorMessage = '请至少选择 1 张白底图'
setWhiteBgError(errorMessage)
showToast(errorMessage, { variant: 'error' })
return
}
const formData = new FormData()
formData.append('skuId', activeWhiteBgSku.id)
formData.append('skuName', activeWhiteBgSku.skuName)
STANDARD_MODEL_WHITE_BG_VIEWS.forEach((view) => {
const file = whiteBgFiles[view.key]
if (file) {
formData.append(view.fieldName, file)
}
})
whiteBgOtherImages.forEach((image) => {
formData.append(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, image.file)
})
try {
setWhiteBgUploading(true)
setWhiteBgUploadProgress(0)
setWhiteBgUploadSpeed(0)
setWhiteBgError('')
const response = await new Promise<XMLHttpRequest>((resolve, reject) => {
const xhr = new XMLHttpRequest()
let speedStartTime = Date.now()
let speedStartLoaded = 0
xhr.upload.addEventListener('progress', (event) => {
if (!event.lengthComputable) {
return
}
const progress = Math.round((event.loaded / event.total) * 100)
setWhiteBgUploadProgress(progress)
const currentTime = Date.now()
const elapsed = (currentTime - speedStartTime) / 1000
if (elapsed > 0) {
const speed = (event.loaded - speedStartLoaded) / elapsed
setWhiteBgUploadSpeed(speed)
speedStartTime = currentTime
speedStartLoaded = event.loaded
}
})
xhr.onload = () => resolve(xhr)
xhr.onerror = () => reject(new Error('上传白底图失败'))
xhr.onabort = () => reject(new Error('上传已取消'))
xhr.open('POST', `/api/materials/${materialId}/white-background`)
xhr.send(formData)
})
if (response.status < 200 || response.status >= 300) {
let message = '上传白底图失败'
try {
const error = JSON.parse(response.responseText || '{}')
message = error.error || message
} catch {}
setWhiteBgError(message)
showToast(message, { variant: 'error' })
return
}
const data = JSON.parse(response.responseText || '{}')
const fixedCount =
typeof data.fixedCount === 'number' ? data.fixedCount : selectedFixedCount
const otherCount =
typeof data.otherCount === 'number' ? data.otherCount : whiteBgOtherImages.length
const whiteBackgroundCount =
typeof data.whiteBackgroundCount === 'number' ? data.whiteBackgroundCount : fixedCount + otherCount
const isUploaded = Boolean(data.whiteBackgroundUploaded ?? whiteBackgroundCount > 0)
const isComplete = Boolean(data.isComplete)
setSkus((prev) =>
prev.map((sku) =>
sku.id === activeWhiteBgSku.id
? {
...sku,
whiteBackgroundUploaded: isUploaded,
whiteBackgroundComplete: isComplete,
whiteBackgroundFixedCount: fixedCount,
whiteBackgroundOtherCount: otherCount,
whiteBackgroundCount,
}
: sku,
),
)
showToast(
isComplete
? '白底图上传成功'
: `白底图已保存,共 ${whiteBackgroundCount} 张,固定角度 ${fixedCount}/${STANDARD_MODEL_WHITE_BG_VIEWS.length}`,
{ variant: 'success' },
)
handleCloseWhiteBackground()
} catch (error) {
console.error('上传白底图失败:', error)
const message = error instanceof Error ? error.message : '上传白底图失败'
setWhiteBgError(message)
showToast(message, { variant: 'error' })
} finally {
setWhiteBgUploading(false)
}
}
// 上传压缩包到本地存储(带进度)
const handleUploadZip = async (file: File, skuName: string, onProgress?: (progress: number) => void): Promise<{ fileName: string; filePath: string; fileSize: number } | null> => {
const formData = new FormData()
@ -228,6 +530,8 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
// 开始编辑SKU
const handleEditSku = (sku: Sku) => {
handleCloseWhiteBackground()
handleCloseModelUpload()
setEditingSkuId(sku.id)
setNewSkuName(sku.skuName)
setNewSkuPreviewUrl(sku.previewUrl || '')
@ -237,6 +541,79 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
setUploadProgress({ zip: 0, preview: 0 })
}
const handleSaveModelZip = async () => {
if (!activeModelSku) {
showToast('请先选择要管理模型的 SKU', { variant: 'error' })
return
}
if (!modelZipFile) {
const errorMessage = '请先选择模型压缩包'
setModelUploadError(errorMessage)
showToast(errorMessage, { variant: 'error' })
return
}
try {
setModelUploading(true)
setModelUploadProgress(0)
setModelUploadError('')
const uploadedZipResult = await handleUploadZip(modelZipFile, activeModelSku.skuName, (progress) => {
setModelUploadProgress(progress)
})
if (!uploadedZipResult) {
setModelUploadError('上传模型压缩包失败')
return
}
const response = await fetch(`/api/materials/${materialId}/skus/${activeModelSku.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuName: activeModelSku.skuName,
previewUrl: activeModelSku.previewUrl,
zipFileName: uploadedZipResult.fileName,
zipFilePath: uploadedZipResult.filePath,
zipFileSize: uploadedZipResult.fileSize,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
const message = error.error || '保存模型压缩包失败'
setModelUploadError(message)
showToast(message, { variant: 'error' })
return
}
const updatedSku = await response.json()
setSkus((prev) =>
prev.map((sku) =>
sku.id === activeModelSku.id
? {
...sku,
zipFileName: updatedSku.zipFileName || '',
zipFilePath: updatedSku.zipFilePath || '',
zipFileSize: updatedSku.zipFileSize || '0',
}
: sku,
),
)
showToast(activeModelSku.zipFilePath ? '模型压缩包已更新' : '模型压缩包上传成功', { variant: 'success' })
handleCloseModelUpload()
} catch (error) {
console.error('保存模型压缩包失败:', error)
const message = error instanceof Error ? error.message : '保存模型压缩包失败'
setModelUploadError(message)
showToast(message, { variant: 'error' })
} finally {
setModelUploading(false)
}
}
// 取消编辑/添加
const handleCancelEdit = () => {
// 清理本地预览URL
@ -252,22 +629,49 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
setUploadProgress({ zip: 0, preview: 0 })
}
const validateSkuNameBeforeUpload = async (rawSkuName: string): Promise<string | null> => {
const normalizedSkuName = normalizeSkuNameInput(rawSkuName).trim()
if (!normalizedSkuName) {
showToast('请输入 SKU 名称', { variant: 'error' })
return null
}
try {
const params = new URLSearchParams({
validateSkuName: normalizedSkuName,
})
if (editingSkuId) {
params.set('excludeSkuId', editingSkuId)
}
const response = await fetch(`/api/materials/${materialId}/skus?${params.toString()}`)
const result = await response.json().catch(() => ({}))
if (!response.ok || result.valid === false) {
showToast(result.error || '所有标准模型下 SKU 名称不能重复', { variant: 'error' })
return null
}
return result.normalizedSkuName || normalizedSkuName
} catch (error) {
console.error('校验 SKU 唯一性失败:', error)
showToast('校验 SKU 唯一性失败,请稍后重试', { variant: 'error' })
return null
}
}
// 添加或更新 SKU(此时才开始上传文件)
const handleAddSku = async () => {
if (!newSkuName.trim()) {
showToast('请输入 SKU 名称', { variant: 'error' })
const normalizedSkuName = await validateSkuNameBeforeUpload(newSkuName)
if (!normalizedSkuName) {
return
}
// 编辑模式:预览图或压缩包可以只更新其中一个,也可以都不更新(只改名称)
// 新增模式:必须都有
// 新增模式:只要求 SKU 名称 + 预览图,压缩包可后传
if (!editingSkuId) {
if (!newSkuPreviewFile) {
showToast('请选择预览图', { variant: 'error' })
return
}
if (!newSkuZipFile) {
showToast('请选择压缩包', { variant: 'error' })
return
}
}
// 编辑模式:允许只修改名称,不更新文件
@ -294,7 +698,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
// 如果有新压缩包,上传它
if (newSkuZipFile) {
const uploadedZipResult = await handleUploadZip(newSkuZipFile, newSkuName.trim(), (progress) => {
const uploadedZipResult = await handleUploadZip(newSkuZipFile, normalizedSkuName, (progress) => {
setUploadProgress(prev => ({ ...prev, zip: progress }))
})
if (!uploadedZipResult) {
@ -317,7 +721,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuName: newSkuName.trim(),
skuName: normalizedSkuName,
previewUrl: previewUrl || currentSku.previewUrl,
zipFileName: zipResult?.fileName || currentSku.zipFileName,
zipFilePath: zipResult?.filePath || currentSku.zipFilePath,
@ -344,8 +748,8 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
return
}
// 新增模式:创建SKU
if (!previewUrl || !zipResult) {
// 新增模式:创建SKU(模型压缩包可后传)
if (!previewUrl) {
showToast('上传失败', { variant: 'error' })
return
}
@ -354,11 +758,11 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuName: newSkuName.trim(),
skuName: normalizedSkuName,
previewUrl: previewUrl,
zipFileName: zipResult.fileName,
zipFilePath: zipResult.filePath,
zipFileSize: zipResult.fileSize
zipFileName: zipResult?.fileName || '',
zipFilePath: zipResult?.filePath || '',
zipFileSize: zipResult?.fileSize || 0
})
})
@ -401,6 +805,12 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
})
if (response.ok) {
if (activeWhiteBgSku?.id === skuId) {
handleCloseWhiteBackground()
}
if (activeModelSku?.id === skuId) {
handleCloseModelUpload()
}
setSkus(skus.filter(sku => sku.id !== skuId))
} else {
const error = await response.json()
@ -470,6 +880,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
// 可拖拽的SKU项组件
function SortableSkuItem({ sku, index }: { sku: Sku; index: number }) {
const hasModelZip = !!sku.zipFilePath?.trim()
const {
attributes,
listeners,
@ -489,7 +900,11 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-xl"
className={`flex items-center gap-4 rounded-xl border p-4 ${
sku.whiteBackgroundUploaded
? 'border-emerald-200 bg-emerald-50/60 dark:border-emerald-900/40 dark:bg-emerald-900/10'
: 'border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-900/10'
}`}
>
{/* 拖拽手柄 */}
<div
@ -517,16 +932,63 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
{/* SKU 信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{sku.skuName}
</p>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{sku.skuName}
</p>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${
hasModelZip
? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'
: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'
}`}
>
{hasModelZip ? '模型已上传' : '模型未上传'}
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${
sku.whiteBackgroundUploaded
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}`}
>
{sku.whiteBackgroundUploaded
? `白底图已上传 ${sku.whiteBackgroundCount || 0} 张${sku.whiteBackgroundComplete ? '(已补齐)' : ''}`
: '白底图未上传'}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sku.zipFileName} · {formatFileSize(sku.zipFileSize)}
{hasModelZip ? `${sku.zipFileName} · ${formatFileSize(sku.zipFileSize)}` : '当前还没有模型压缩包'}
</p>
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{hasModelZip ? '当前可直接重传模型压缩包' : '当前可补传模型压缩包'}
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
<button
onClick={() => handleOpenWhiteBackground(sku)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
title={sku.whiteBackgroundUploaded ? '继续补传白底图' : '上传白底图'}
type="button"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
白底图
</button>
<button
onClick={() => handleOpenModelUpload(sku)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
title={hasModelZip ? '重传模型压缩包' : '上传模型压缩包'}
type="button"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
模型
</button>
{/* 编辑 */}
<button
onClick={() => handleEditSku(sku)}
@ -554,6 +1016,15 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
// 关闭弹窗
const handleClose = () => {
if ((showAddForm || activeWhiteBgSku || activeModelSku) && !uploading && !whiteBgUploading && !modelUploading) {
const confirmed = window.confirm('当前还有未保存的编辑内容,确定直接关闭吗?')
if (!confirmed) {
return
}
}
handleCloseWhiteBackground()
handleCloseModelUpload()
// 清理本地预览URL
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(newSkuPreviewUrl)
@ -569,6 +1040,10 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
onClose()
}
const canSubmitSkuForm = editingSkuId
? !!newSkuName.trim() && !uploading && !saving
: !!newSkuName.trim() && !!newSkuPreviewFile && !uploading && !saving
if (!isOpen) return null
return (
@ -594,6 +1069,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
{!showAddForm ? (
<button
onClick={() => {
handleCloseWhiteBackground()
setEditingSkuId(null)
setNewSkuName('')
setNewSkuPreviewFile(null)
@ -601,7 +1077,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
setNewSkuZipFile(null)
setShowAddForm(true)
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors flex items-center gap-2"
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -630,7 +1106,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
<input
type="text"
value={newSkuName}
onChange={(e) => setNewSkuName(e.target.value)}
onChange={(e) => setNewSkuName(normalizeSkuNameInput(e.target.value))}
placeholder="SKU 名称(如:白色款)"
className="w-72 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 flex-shrink-0"
/>
@ -743,7 +1219,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="text-[10px] text-gray-500 mt-0.5">压缩包</span>
<span className="text-[10px] text-gray-500 mt-0.5">压缩包(可后传)</span>
</>
)}
</button>
@ -756,41 +1232,106 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
/>
</div>
{/* 操作按钮 */}
<button
onClick={handleAddSku}
disabled={saving || uploading || !newSkuName || !newSkuPreviewFile || !newSkuZipFile}
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors relative"
>
{uploading ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</span>
) : saving ? (
'保存中...'
) : editingSkuId ? (
'更新'
) : (
'添加'
)}
</button>
<button
onClick={handleCancelEdit}
disabled={uploading}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
)}
{activeWhiteBgSku && (
<div className="mb-6">
<div className="mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
白底图管理
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
当前 SKU:{activeWhiteBgSku.skuName}
</p>
</div>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-300">
固定角度支持分次补传,文件会按 SKU + 品类 + 视角 自动命名。
</p>
{whiteBgUploading && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>上传进度 {whiteBgUploadProgress}%</span>
<span>{whiteBgUploadSpeed > 0 ? formatSpeed(whiteBgUploadSpeed) : '计算中...'}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${whiteBgUploadProgress}%` }}
/>
</div>
</div>
)}
</div>
<StandardModelWhiteBackgroundUpload
imageUrls={whiteBgPreviewUrls}
otherImages={whiteBgOtherImages.map((image) => ({
id: image.id,
url: image.previewUrl,
name: image.name,
}))}
uploading={whiteBgUploading}
error={whiteBgError}
onSelectImage={handleSelectWhiteBackgroundImage}
onRemoveImage={handleRemoveWhiteBackgroundImage}
onSelectOtherImages={handleSelectWhiteBackgroundOthers}
onRemoveOtherImage={handleRemoveWhiteBackgroundOther}
/>
</div>
)}
{activeModelSku && (
<div className="mb-6">
<div className="mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
模型文件管理
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
当前 SKU:{activeModelSku.skuName}
</p>
</div>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-300">
底部按钮区会显示“保存模型”,保存成功后会自动收起。
</p>
{modelUploading && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>上传进度 {modelUploadProgress}%</span>
<span>{modelUploadProgress < 100 ? '上传中...' : '即将保存...'}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${modelUploadProgress}%` }}
/>
</div>
</div>
)}
</div>
<StandardModelSkuZipUpload
selectedFile={modelZipFile}
existingFileName={activeModelSku.zipFileName}
existingFileSize={activeModelSku.zipFileSize}
uploading={modelUploading}
error={modelUploadError}
onSelectFile={(file) => {
setModelUploadError('')
setModelZipFile(file)
}}
onRemoveFile={() => {
setModelZipFile(null)
setModelUploadError('')
}}
/>
</div>
)}
{/* SKU 列表(支持拖拽排序) */}
{skus.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
@ -837,13 +1378,101 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
</div>
{/* 底部按钮 */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={handleClose}
className="px-6 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
完成
</button>
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-wrap justify-end gap-3">
{showAddForm ? (
<>
<button
onClick={handleCancelEdit}
disabled={uploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
取消
</button>
<button
onClick={handleAddSku}
disabled={!canSubmitSkuForm}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{uploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : saving ? (
'保存中...'
) : editingSkuId ? (
'保存 SKU'
) : (
'添加 SKU'
)}
</button>
</>
) : activeWhiteBgSku ? (
<>
<button
type="button"
onClick={handleCloseWhiteBackground}
disabled={whiteBgUploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
收起
</button>
<button
type="button"
onClick={handleUploadWhiteBackground}
disabled={whiteBgUploading}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{whiteBgUploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : (
'保存白底图'
)}
</button>
</>
) : activeModelSku ? (
<>
<button
type="button"
onClick={handleCloseModelUpload}
disabled={modelUploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
收起
</button>
<button
type="button"
onClick={handleSaveModelZip}
disabled={modelUploading}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{modelUploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : (
'保存模型'
)}
</button>
</>
) : (
<button
onClick={handleClose}
className="px-6 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
完成
</button>
)}
</div>
</div>

View File

@ -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<string>) => void // 选中状态变化回调
canEdit: boolean
onEditClick: () => void
onSelectionChange?: (selectedIds: Set<string>) => 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<Sku[]>([])
const [loading, setLoading] = useState(true)
const { toast, showToast } = useToast()
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(new Set())
const [downloadingSkuId, setDownloadingSkuId] = useState<string | null>(null)
// 移除批量下载状态,由父组件处理
const [previewImage, setPreviewImage] = useState<string | null>(null) // 大图预览
const [downloadingWhiteBgSkuId, setDownloadingWhiteBgSkuId] = useState<string | null>(null)
const [previewImage, setPreviewImage] = useState<string | null>(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
? new Set<string>()
: 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 (
<div className="py-4 text-center text-gray-500 dark:text-gray-400 text-sm">
加载中...
</div>
)
return <div className="py-4 text-center text-gray-500 dark:text-gray-400 text-sm">加载中...</div>
}
if (skus.length === 0) {
@ -125,7 +168,7 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
{canEdit && (
<button
onClick={onEditClick}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -139,15 +182,12 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
return (
<div className="flex flex-col">
{/* 标题栏 - 编辑按钮与标题同一行 */}
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-900 dark:text-white">
SKU 列表 ({skus.length}个)
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">SKU 列表 ({skus.length}个)</span>
{canEdit && (
<button
onClick={onEditClick}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded-lg transition-colors"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded-lg transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -157,7 +197,6 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
)}
</div>
{/* 全选栏 */}
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
@ -169,34 +208,16 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
<span className="text-xs text-gray-600 dark:text-gray-300">全选</span>
</label>
{selectedSkuIds.size > 0 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
已选 {selectedSkuIds.size} 个
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">已选 {selectedSkuIds.size} 个</span>
)}
</div>
{/* SKU 列表(可滚动区域) */}
<div
className="overflow-y-auto space-y-2 pr-1"
style={{ maxHeight: 'calc(100vh - 500px)' }}
>
{skus.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p>暂无 SKU</p>
{canEdit && (
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">点击上方&ldquo;编辑&rdquo;按钮添加</p>
)}
</div>
) : (
skus.map(sku => (
<div className="overflow-y-auto space-y-2 pr-1" style={{ maxHeight: 'calc(100vh - 500px)' }}>
{skus.map((sku) => (
<div
key={sku.id}
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
{/* 复选框 */}
<input
type="checkbox"
checked={selectedSkuIds.has(sku.id)}
@ -204,7 +225,6 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
/>
{/* 预览图(点击放大) */}
<div
className="w-10 h-10 flex-shrink-0 rounded overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setPreviewImage(sku.previewUrl)}
@ -219,32 +239,36 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
/>
</div>
{/* SKU 信息 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{sku.skuName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(sku.zipFileSize)}
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{sku.skuName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
模型:{sku.zipFilePath && sku.zipFilePath.trim() ? '已上传' : '未上传'}
<span className="mx-1.5 text-gray-300 dark:text-gray-600">·</span>
白底图:{sku.whiteBackgroundUploaded ? `已上传 ${sku.whiteBackgroundCount || 0} 张` : '未上传'}
{sku.whiteBackgroundUploaded && sku.whiteBackgroundComplete === false ? '(未补齐)' : ''}
</p>
</div>
{/* 下载按钮 */}
<button
onClick={() => handleDownloadSku(sku)}
disabled={downloadingSkuId === sku.id}
className="flex-shrink-0 px-2.5 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{downloadingSkuId === sku.id ? '下载中' : '下载'}
</button>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
onClick={() => handleDownloadWhiteBackground(sku)}
disabled={!sku.whiteBackgroundUploaded || downloadingWhiteBgSkuId === sku.id}
className="px-2.5 py-1.5 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{downloadingWhiteBgSkuId === sku.id ? '打包白底图...' : '白底图'}
</button>
<button
onClick={() => handleDownloadSku(sku)}
disabled={downloadingSkuId === sku.id || !sku.zipFilePath || !sku.zipFilePath.trim()}
className="px-2.5 py-1.5 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded-lg disabled:opacity-50 transition-colors"
>
{downloadingSkuId === sku.id ? '下载中' : '模型'}
</button>
</div>
</div>
))
)}
))}
</div>
{/* 批量下载按钮已移至父组件统一处理 */}
{/* 大图预览弹窗 */}
{previewImage && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
@ -261,15 +285,17 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
/>
<button
onClick={() => setPreviewImage(null)}
className="absolute top-2 right-2 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center"
className="absolute top-4 right-4 w-10 h-10 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
{toast && toast.show && <ToastBubble message={toast.message} variant={toast.variant} />}
</div>
)
}

View File

@ -148,6 +148,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const xhrRef = useRef<XMLHttpRequest | null>(null)
const singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志
const singleUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set()) // 单文件上传的所有活跃XHR
const singleUploadServerStateRef = useRef<{ uploadId?: string; existingFilePath?: string } | null>(null)
const batchFileInputRef = useRef<HTMLInputElement>(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 {

View File

@ -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) => ({

View File

@ -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) => ({

View File

@ -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<HTMLLabelElement>) => {
event.preventDefault()
event.stopPropagation()
setIsDragging(false)
const file = event.dataTransfer.files?.[0]
if (!file || !isSupportedZipFile(file)) {
return
}
onSelectFile(file)
}
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
模型压缩包 {selectedFile ? '已选择' : hasExistingFile ? '可重传' : '待上传'}
</span>
{uploading && (
<span className="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
上传处理中
</span>
)}
</div>
<div className="mt-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/30 dark:text-gray-300">
{selectedFile
? `待上传:${selectedFile.name}(${formatFileSize(selectedFile.size)})`
: hasExistingFile
? `当前模型:${existingFileName}(${formatFileSize(existingFileSize)})`
: '当前还没有模型压缩包,请先选择要上传的文件。'}
</div>
{error && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
)}
<div className="mt-5">
<input
id="standard-model-sku-zip-upload"
type="file"
accept=".zip,.rar,.7z"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file && isSupportedZipFile(file)) {
onSelectFile(file)
}
event.currentTarget.value = ''
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */}
<label
htmlFor="standard-model-sku-zip-upload"
className={`group block min-h-40 cursor-pointer rounded-2xl border-2 border-dashed p-4 transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => {
event.preventDefault()
event.stopPropagation()
setIsDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const { clientX, clientY } = event
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
setIsDragging(false)
}
}}
onDragOver={(event) => {
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="flex min-h-32 flex-col items-center justify-center text-center">
<div className="relative flex w-full max-w-sm flex-col items-center rounded-2xl border border-gray-200 bg-white/90 px-4 py-6 dark:border-gray-700 dark:bg-gray-900/40">
<button
type="button"
aria-label="移除已选择的模型压缩包"
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onRemoveFile()
}}
>
×
</button>
<svg className="h-8 w-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="mt-3 max-w-full truncate text-sm font-medium text-gray-900 dark:text-white">
{selectedFile.name}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatFileSize(selectedFile.size)}
</p>
<p className="mt-3 text-[11px] text-gray-500 dark:text-gray-400">
点击或拖拽可重新选择模型压缩包
</p>
</div>
</div>
) : (
<div className="flex min-h-32 flex-col items-center justify-center text-center">
<svg className="h-8 w-8 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">拖拽压缩包到这里,或点击上传</span>
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">支持 ZIP / RAR / 7Z</span>
</div>
)}
</label>
</div>
</div>
)
}

View File

@ -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<Record<StandardModelWhiteBgKey, string>>
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<StandardModelWhiteBgKey | null>(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 (
<div>
<p className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
产品白底图(6张) *
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{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 (
<div key={view.key} className="space-y-2">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">{view.label}</p>
<input
id={inputId}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
onSelectImage(view.key, file)
}
event.currentTarget.value = ''
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要处理拖拽上传事件 */}
<div
className={`border-2 border-dashed rounded-apple p-3 transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => handleDragEnter(event, view.key)}
onDragLeave={(event) => handleDragLeave(event, view.key)}
onDragOver={handleDragOver}
onDrop={(event) => handleDrop(event, view.key)}
>
{imageUrl ? (
<div className="space-y-2">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
const files = event.dataTransfer.files
if (!files || files.length === 0) {
return
}
onSelectOtherImages(files)
}
return (
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
固定角度 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length}
</span>
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
可分次补传
</span>
{otherImages.length > 0 && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
其他图片 {otherImages.length} 张
</span>
)}
{uploading && (
<span className="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
上传处理中
</span>
)}
</div>
<div className="mt-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/30 dark:text-gray-300">
{missingLabels.length > 0
? `未上传:${missingLabels.join('、')}(可先保存,后续再补)`
: '8 张固定角度已齐,可直接保存。'}
</div>
{error && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
)}
<div className="mt-5">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">固定角度</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">可选,建议补齐 8 张</span>
</div>
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => {
const inputId = `white-bg-${view.key}`
const imageUrl = imageUrls[view.key]
const isDragging = draggingKey === view.key
return (
<div key={view.key} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-gray-700 dark:text-gray-200">{view.label}</p>
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
可选
</span>
</div>
<input
id={inputId}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
onSelectImage(view.key, file)
}
event.currentTarget.value = ''
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */}
<label
htmlFor={inputId}
className={`group block cursor-pointer rounded-2xl border-2 border-dashed p-3 transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: imageUrl
? 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/30'
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/50 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => handleDragEnter(event, view.key)}
onDragLeave={(event) => handleDragLeave(event, view.key)}
onDragOver={handleDragOver}
onDrop={(event) => handleDrop(event, view.key)}
>
{imageUrl ? (
<div className="space-y-2">
<div className="relative h-36 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<Image
src={imageUrl}
alt={view.label}
fill
unoptimized
className="object-cover"
/>
<button
type="button"
aria-label={`删除${view.label}`}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onRemoveImage(view.key)
}}
>
×
</button>
</div>
<p className="text-[11px] text-gray-500 dark:text-gray-400">点击或拖拽可重新上传</p>
</div>
) : (
<div className="flex h-36 flex-col items-center justify-center rounded-xl bg-white text-center dark:bg-gray-800">
<svg className="h-6 w-6 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="mt-2 text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</span>
</div>
)}
</label>
</div>
)
})}
</div>
</div>
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-gray-700">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">其他图片</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">可选,多张</span>
</div>
<input
id="white-bg-other-images"
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
if (event.target.files && event.target.files.length > 0) {
onSelectOtherImages(event.target.files)
}
event.currentTarget.value = ''
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 其他图片区需要拖拽上传 */}
<label
htmlFor="white-bg-other-images"
className={`block min-h-28 cursor-pointer rounded-2xl border-2 border-dashed px-4 py-5 transition-colors ${
isOtherDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => {
event.preventDefault()
event.stopPropagation()
setIsOtherDragging(true)
}}
onDragLeave={(event) => {
event.preventDefault()
event.stopPropagation()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const { clientX, clientY } = event
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
setIsOtherDragging(false)
}
}}
onDragOver={handleDragOver}
onDrop={handleOtherDrop}
>
{otherImages.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{otherImages.map((image) => (
<div key={image.id} className="rounded-2xl border border-gray-200 bg-white/80 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="relative h-32 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<Image
src={imageUrl}
alt={view.label}
src={image.url}
alt={image.name}
fill
unoptimized
className="object-cover"
/>
</div>
<div className="flex gap-2">
<label
htmlFor={inputId}
className="flex-1 text-center text-xs px-2 py-1 rounded-apple bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-pointer transition-colors"
>
更换
</label>
<button
type="button"
className="text-xs px-2 py-1 rounded-apple bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onClick={() => onRemoveImage(view.key)}
aria-label={`删除${image.name}`}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onRemoveOtherImage(image.id)
}}
>
删除
×
</button>
</div>
<p className="text-[11px] text-blue-600 dark:text-blue-400">支持拖拽图片替换</p>
<p className="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">{image.name}</p>
</div>
) : (
<label
htmlFor={inputId}
className="group w-full h-24 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex flex-col items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer"
>
<svg className="w-5 h-5 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>上传{view.label}</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</span>
</label>
)}
))}
<div className="flex h-full min-h-[10rem] flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-white/70 px-4 text-center dark:border-gray-600 dark:bg-gray-800/50">
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">继续添加</span>
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">拖拽或点击追加</span>
</div>
</div>
</div>
)
})}
) : (
<div className="flex min-h-28 flex-col items-center justify-center text-center">
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">拖拽图片到这里,或点击追加</span>
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">支持一次添加多张其他图片</span>
</div>
)}
</label>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
白底图需一次上传完整6张,系统会自动打包为白底图压缩包用于下载。
</p>
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
💡 每个角度支持拖拽上传,拖入后会自动替换对应位置图片。
</p>
</div>
)
}

View File

@ -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,

View File

@ -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) => ({

View File

@ -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) => ({

View File

@ -101,7 +101,7 @@ export async function uploadSingleFile(params: {
onStageChange?: (stage: string) => void
isCancelled?: () => boolean // 取消检查回调
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => 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,
}
}

View File

@ -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
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)

View File

@ -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

View File

@ -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 下载链接

View File

@ -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 = "<REDACTED>"
[[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 = "<REDACTED>"
### 3. 资源访问
[webServer]
addr = "0.0.0.0"
port = 7500
user = "admin"
password = "<REDACTED>"
```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`
- 网络诊断与对外接口文档

View File

@ -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`
- **权限要求**: 超级管理员或系统管理员
### 功能说明
@ -554,3 +631,4 @@ if result['success']:
*文档版本: 1.2*
*更新日期: 2026-01-07*
*更新内容: 更新实际接口地址和 API Key 示例*

View File

@ -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,
])
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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([])
})
})

140
lib/auth-cookie.ts Normal file
View File

@ -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<string, string | string[] | undefined>
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]
}

View File

@ -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,
}

View File

@ -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<void> {
console.error('[ChunkUpload] Failed to clear states:', error)
}
}

View File

@ -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;

View File

@ -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<boolean> {
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<DeleteFileResult & { skipped?: boolean; reason?: string }> {
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,
};
}

View File

@ -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 } },
],
},
})

View File

@ -13,6 +13,7 @@ import { NextRequest } from 'next/server'
// 公网域名白名单(明确标识为公网域名)
const PUBLIC_DOMAINS = [
'3d.yydd.ai',
'sc.jiansun.vip',
's3.yydd.ai',
's3.1080p.hk',
]

View File

@ -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<string |
*/
export async function softDeleteByTaskNo(taskNo: string) {
return prisma.material.updateMany({
where: { taskNo },
where: {
taskNo,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
}
/**
* 当任务号被复用时,清理此前已软删除的旧素材,避免残留孤儿文件
*/
export async function purgeDeletedMaterialsByTaskNo(taskNo: string): Promise<{
deletedMaterials: number;
deletedFiles: number;
failedFiles: number;
}> {
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

51
lib/public-app-url.ts Normal file
View File

@ -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]))
}

View File

@ -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<T extends WhiteBackgroundAssetLike> {
skuId: string
fixed: Partial<Record<StandardModelWhiteBgKey, T>>
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<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, '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<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, 'other'> | null,
): number {
return group?.other?.length || 0
}
export function countWhiteBackgroundAssets(
group?: Pick<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, 'fixed' | 'other' | 'zipAssets'> | null,
): number {
if (!group) {
return 0
}
return (
countWhiteBackgroundFixedViews(group) +
countWhiteBackgroundOtherImages(group) +
(group.zipAssets?.length || 0)
)
}
export function hasAnyWhiteBackgroundAssetGroup(
group?: Pick<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, 'fixed' | 'other' | 'zipAssets'> | null,
): boolean {
return countWhiteBackgroundAssets(group) > 0
}
export function getMissingWhiteBackgroundViewsFromAssetGroup(
group?: Pick<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, '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<T extends WhiteBackgroundAssetLike>(
assets: T[],
): Map<string, GroupedWhiteBackgroundAssets<T>> {
const groups = new Map<string, GroupedWhiteBackgroundAssets<T>>()
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<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, '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<GroupedWhiteBackgroundAssets<WhiteBackgroundAssetLike>, '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
}

View File

@ -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',
]
// 检查是否为公网域名或非内网地址

View File

@ -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*'],
}