同步当前工作区源码并修复白底图上传问题
This commit is contained in:
parent
7fdc6bb91d
commit
482447fe40
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)..." && \
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
163
app/api/external/download/route.ts
vendored
163
app/api/external/download/route.ts
vendored
@ -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
|
||||
*/
|
||||
|
||||
9
app/api/external/product-assets/route.ts
vendored
9
app/api/external/product-assets/route.ts
vendored
@ -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);
|
||||
|
||||
|
||||
232
app/api/external/standard-model-white-background/route.ts
vendored
Normal file
232
app/api/external/standard-model-white-background/route.ts
vendored
Normal 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', '服务器内部错误')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,37 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { deleteFileIfUnreferenced, deleteLocalFile } from '@/lib/file-cleanup'
|
||||
import {
|
||||
getWhiteBackgroundAssetWhereForSku,
|
||||
getWhiteBackgroundRelativeDirectory,
|
||||
} from '@/lib/standard-model-white-background'
|
||||
|
||||
function normalizeSkuName(value: string): string {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim()
|
||||
}
|
||||
|
||||
async function findDuplicateStandardModelSkuName(skuName: string, excludeSkuId?: string) {
|
||||
return prisma.materialSku.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
skuName: {
|
||||
equals: skuName,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
...(excludeSkuId ? { NOT: { id: excludeSkuId } } : {}),
|
||||
material: {
|
||||
is: {
|
||||
materialType: '标准模型',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
}
|
||||
|
||||
// 获取单个 SKU 详情
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; skuId: string } }
|
||||
@ -17,17 +43,16 @@ export async function GET(
|
||||
const { skuId } = params
|
||||
|
||||
const sku = await prisma.materialSku.findFirst({
|
||||
where: {
|
||||
where: {
|
||||
id: skuId,
|
||||
deletedAt: null // 只获取未删除的SKU
|
||||
}
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!sku) {
|
||||
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 返回时统一字段命名(前端使用zipFilePath等)
|
||||
return NextResponse.json({
|
||||
id: sku.id,
|
||||
materialId: sku.materialId,
|
||||
@ -38,7 +63,7 @@ export async function GET(
|
||||
zipFileSize: sku.fileSize.toString(),
|
||||
sortOrder: sku.sortOrder,
|
||||
createdAt: sku.createdAt,
|
||||
updatedAt: sku.updatedAt
|
||||
updatedAt: sku.updatedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取 SKU 详情失败:', error)
|
||||
@ -46,7 +71,6 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 SKU
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; skuId: string } }
|
||||
@ -59,30 +83,25 @@ export async function PUT(
|
||||
|
||||
const { skuId } = params
|
||||
|
||||
// 检查 SKU 是否存在,并获取关联的素材信息用于权限检查
|
||||
const existingSku = await prisma.materialSku.findFirst({
|
||||
where: {
|
||||
id: skuId,
|
||||
deletedAt: null // 只获取未删除的SKU
|
||||
},
|
||||
const existingSku = await prisma.materialSku.findUnique({
|
||||
where: { id: skuId },
|
||||
include: {
|
||||
material: {
|
||||
select: {
|
||||
userId: true // 获取素材的上传者ID
|
||||
}
|
||||
}
|
||||
}
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingSku) {
|
||||
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 检查权限:只有素材上传者或管理员可以编辑 SKU
|
||||
const userId = (session.user as any).id;
|
||||
const userId = (session.user as any).id
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true }
|
||||
select: { role: true },
|
||||
})
|
||||
|
||||
if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') {
|
||||
@ -91,10 +110,21 @@ export async function PUT(
|
||||
|
||||
const body = await request.json()
|
||||
const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize, sortOrder } = body
|
||||
const normalizedSkuName = skuName !== undefined ? normalizeSkuName(skuName) : undefined
|
||||
|
||||
if (normalizedSkuName !== undefined && !normalizedSkuName) {
|
||||
return NextResponse.json({ error: 'SKU 名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (normalizedSkuName !== undefined && normalizedSkuName !== existingSku.skuName) {
|
||||
const duplicateSku = await findDuplicateStandardModelSkuName(normalizedSkuName, skuId)
|
||||
if (duplicateSku) {
|
||||
return NextResponse.json({ error: '所有标准模型下 SKU 名称不能重复' }, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
// 构建更新数据(前端传的是zipFilePath等,需要映射到数据库字段)
|
||||
const updateData: any = {}
|
||||
if (skuName !== undefined) updateData.skuName = skuName
|
||||
if (normalizedSkuName !== undefined) updateData.skuName = normalizedSkuName
|
||||
if (previewUrl !== undefined) updateData.previewUrl = previewUrl || null
|
||||
if (zipFileName !== undefined) updateData.fileName = zipFileName || null
|
||||
if (zipFilePath !== undefined) updateData.filePath = zipFilePath
|
||||
@ -103,7 +133,7 @@ export async function PUT(
|
||||
|
||||
const updatedSku = await prisma.materialSku.update({
|
||||
where: { id: skuId },
|
||||
data: updateData
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
const changes: Record<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 })
|
||||
|
||||
@ -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')
|
||||
|
||||
for (const sku of skus) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
|
||||
|
||||
// 检查文件是否存在
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
console.warn(`SKU文件不存在: ${filePath}`)
|
||||
if (downloadType === 'white-background') {
|
||||
const whiteBackgroundAssets = await prisma.materialAsset.findMany({
|
||||
where: {
|
||||
materialId,
|
||||
OR: skuIds.flatMap((skuId: string) => getWhiteBackgroundAssetWhereForSku(skuId).OR),
|
||||
},
|
||||
select: {
|
||||
assetType: true,
|
||||
fileUrl: true,
|
||||
fileName: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
})
|
||||
|
||||
const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku<WhiteBackgroundAssetRecord>(
|
||||
whiteBackgroundAssets,
|
||||
)
|
||||
|
||||
for (const sku of skus) {
|
||||
const whiteBackgroundGroup = whiteBackgroundMap.get(sku.id)
|
||||
if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 读取文件并添加到ZIP
|
||||
const fileBuffer = await fs.readFile(filePath)
|
||||
const fileName = sku.fileName || `${sku.skuName}.zip`
|
||||
zip.file(fileName, fileBuffer)
|
||||
addedCount++
|
||||
} catch (error) {
|
||||
console.error(`添加SKU文件失败: ${sku.id}`, error)
|
||||
// 继续处理其他文件
|
||||
const { fixedEntries, otherEntries } = getWhiteBackgroundZipEntries(whiteBackgroundGroup)
|
||||
if (fixedEntries.length === 0 && otherEntries.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of fixedEntries) {
|
||||
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
|
||||
zip.file(`${sku.skuName}/${entry.fileName}`, fileBuffer)
|
||||
}
|
||||
|
||||
for (const entry of otherEntries) {
|
||||
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
|
||||
zip.file(`${sku.skuName}/other/${entry.fileName}`, fileBuffer)
|
||||
}
|
||||
|
||||
addedCount++
|
||||
} catch (error) {
|
||||
console.error(`[SKU Batch Download] 添加白底图失败: ${sku.id}`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const sku of skus) {
|
||||
try {
|
||||
if (!sku.filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
|
||||
const fileName = resolveModelDownloadFileName(
|
||||
sku.skuName,
|
||||
sku.fileName || null,
|
||||
sku.filePath || null,
|
||||
)
|
||||
|
||||
const fileBuffer = await fs.readFile(filePath)
|
||||
zip.file(fileName, fileBuffer)
|
||||
addedCount++
|
||||
} catch (error) {
|
||||
console.error(`[SKU Batch Download] 添加模型失败: ${sku.id}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addedCount === 0) {
|
||||
return NextResponse.json({ error: '没有可下载的文件' }, { status: 404 })
|
||||
return NextResponse.json(
|
||||
{ error: downloadType === 'white-background' ? '没有可下载的白底图文件' : '没有可下载的模型文件' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// 生成ZIP文件
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: 'nodebuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
compressionOptions: { level: 6 },
|
||||
})
|
||||
|
||||
const filename = `${material.productModel}_SKU批量下载_${Date.now()}.zip`
|
||||
const filename = `${material.productModel}_${downloadType === 'white-background' ? 'SKU白底图' : 'SKU模型'}_${Date.now()}.zip`
|
||||
|
||||
recordBatchDownloadLog(materialId, userId).catch((error) => {
|
||||
console.error('[SKU Batch Download] 记录下载日志失败:', error)
|
||||
@ -123,7 +224,7 @@ export async function POST(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SKU批量下载失败:', error)
|
||||
console.error('[SKU Batch Download] 下载失败:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') },
|
||||
{ status: 500 }
|
||||
@ -151,4 +252,3 @@ async function recordBatchDownloadLog(materialId: string, userId: string) {
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -6,52 +6,140 @@ import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import {
|
||||
countWhiteBackgroundAssets,
|
||||
countWhiteBackgroundFixedViews,
|
||||
countWhiteBackgroundOtherImages,
|
||||
getWhiteBackgroundAssetWhereForSku,
|
||||
groupWhiteBackgroundAssetsBySku,
|
||||
hasAnyWhiteBackgroundAssetGroup,
|
||||
hasCompleteWhiteBackgroundAssetGroup,
|
||||
} from '@/lib/standard-model-white-background'
|
||||
|
||||
function normalizeSkuName(value: string): string {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim()
|
||||
}
|
||||
|
||||
async function findDuplicateStandardModelSkuName(skuName: string, excludeSkuId?: string) {
|
||||
return prisma.materialSku.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
skuName: {
|
||||
equals: skuName,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
...(excludeSkuId ? { NOT: { id: excludeSkuId } } : {}),
|
||||
material: {
|
||||
is: {
|
||||
materialType: '标准模型',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
materialId: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 获取素材的所有 SKU 列表
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const materialId = params.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const validateSkuName = searchParams.get('validateSkuName')
|
||||
const excludeSkuId = searchParams.get('excludeSkuId') || ''
|
||||
|
||||
// 检查素材是否存在
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId },
|
||||
select: { id: true, materialType: true }
|
||||
select: { id: true, materialType: true },
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 获取 SKU 列表,按排序顺序(排除已删除的)
|
||||
if (validateSkuName !== null) {
|
||||
const normalizedSkuName = normalizeSkuName(validateSkuName)
|
||||
if (!normalizedSkuName) {
|
||||
return NextResponse.json({ valid: false, error: 'SKU 名称不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const duplicateSku = await findDuplicateStandardModelSkuName(
|
||||
normalizedSkuName,
|
||||
excludeSkuId || undefined,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
valid: !duplicateSku,
|
||||
normalizedSkuName,
|
||||
error: duplicateSku ? '所有标准模型下 SKU 名称不能重复' : null,
|
||||
})
|
||||
}
|
||||
|
||||
const skus = await prisma.materialSku.findMany({
|
||||
where: {
|
||||
where: {
|
||||
materialId,
|
||||
deletedAt: null // 只获取未删除的SKU
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
// 转换 BigInt 为字符串,并统一字段命名(前端使用zipFilePath等)
|
||||
// 使用智能 URL 重写(根据用户网络位置选择内网或外网地址)
|
||||
const skusWithStringSize = skus.map(sku => ({
|
||||
id: sku.id,
|
||||
materialId: sku.materialId,
|
||||
skuName: sku.skuName,
|
||||
previewUrl: rewriteStorageUrlForClient((sku as any).previewUrl || '', request),
|
||||
zipFileName: (sku as any).fileName || '',
|
||||
zipFilePath: sku.filePath, // 统一使用zipFilePath(本地文件路径,不需要重写)
|
||||
zipFileSize: sku.fileSize.toString(),
|
||||
sortOrder: sku.sortOrder,
|
||||
createdAt: sku.createdAt,
|
||||
updatedAt: sku.updatedAt
|
||||
}))
|
||||
const whiteBackgroundAssets = skus.length > 0
|
||||
? await prisma.materialAsset.findMany({
|
||||
where: {
|
||||
materialId,
|
||||
OR: skus.flatMap((sku) => getWhiteBackgroundAssetWhereForSku(sku.id).OR),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
assetType: true,
|
||||
fileName: true,
|
||||
fileUrl: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
})
|
||||
: []
|
||||
|
||||
const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets)
|
||||
|
||||
return NextResponse.json({
|
||||
skus: skusWithStringSize,
|
||||
total: skus.length
|
||||
skus: skus.map((sku) => {
|
||||
const whiteBackgroundGroup = whiteBackgroundMap.get(sku.id)
|
||||
const whiteBackgroundUploaded = hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)
|
||||
const whiteBackgroundComplete = hasCompleteWhiteBackgroundAssetGroup(whiteBackgroundGroup)
|
||||
const whiteBackgroundFixedCount = countWhiteBackgroundFixedViews(whiteBackgroundGroup)
|
||||
const whiteBackgroundOtherCount = countWhiteBackgroundOtherImages(whiteBackgroundGroup)
|
||||
const whiteBackgroundCount = countWhiteBackgroundAssets(whiteBackgroundGroup)
|
||||
const previewAsset =
|
||||
Object.values(whiteBackgroundGroup?.fixed || {}).find(Boolean) ||
|
||||
whiteBackgroundGroup?.other?.[0] ||
|
||||
whiteBackgroundGroup?.zipAssets?.[0]
|
||||
|
||||
return {
|
||||
id: sku.id,
|
||||
materialId: sku.materialId,
|
||||
skuName: sku.skuName,
|
||||
previewUrl: rewriteStorageUrlForClient((sku as any).previewUrl || '', request),
|
||||
zipFileName: (sku as any).fileName || '',
|
||||
zipFilePath: sku.filePath,
|
||||
zipFileSize: sku.fileSize.toString(),
|
||||
sortOrder: sku.sortOrder,
|
||||
createdAt: sku.createdAt,
|
||||
updatedAt: sku.updatedAt,
|
||||
whiteBackgroundUploaded,
|
||||
whiteBackgroundComplete,
|
||||
whiteBackgroundFixedCount,
|
||||
whiteBackgroundOtherCount,
|
||||
whiteBackgroundCount,
|
||||
whiteBackgroundFileName: whiteBackgroundUploaded ? `${sku.skuName}_白底图.zip` : '',
|
||||
whiteBackgroundFileUrl: previewAsset?.fileUrl || '',
|
||||
}
|
||||
}),
|
||||
total: skus.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取 SKU 列表失败:', error)
|
||||
@ -59,7 +147,6 @@ export async function GET(
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的 SKU
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
@ -71,13 +158,11 @@ export async function POST(
|
||||
}
|
||||
|
||||
const userId = (session.user as any).id
|
||||
|
||||
const materialId = params.id
|
||||
|
||||
// 检查素材是否存在且是标准模型类型
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: materialId },
|
||||
select: { id: true, materialType: true }
|
||||
select: { id: true, materialType: true },
|
||||
})
|
||||
|
||||
if (!material) {
|
||||
@ -90,31 +175,34 @@ export async function POST(
|
||||
|
||||
const body = await request.json()
|
||||
const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize } = body
|
||||
const normalizedSkuName = normalizeSkuName(skuName)
|
||||
|
||||
// 验证必填字段
|
||||
if (!skuName || !zipFilePath) {
|
||||
return NextResponse.json({ error: '缺少必填字段:skuName 和 zipFilePath' }, { status: 400 })
|
||||
if (!normalizedSkuName || !previewUrl) {
|
||||
return NextResponse.json({ error: '缺少必填字段:skuName 和 previewUrl' }, { status: 400 })
|
||||
}
|
||||
|
||||
const duplicateSku = await findDuplicateStandardModelSkuName(normalizedSkuName)
|
||||
if (duplicateSku) {
|
||||
return NextResponse.json({ error: '所有标准模型下 SKU 名称不能重复' }, { status: 409 })
|
||||
}
|
||||
|
||||
// 获取当前最大排序值
|
||||
const maxSortOrder = await prisma.materialSku.aggregate({
|
||||
where: { materialId },
|
||||
_max: { sortOrder: true }
|
||||
_max: { sortOrder: true },
|
||||
})
|
||||
|
||||
const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1
|
||||
|
||||
// 创建 SKU
|
||||
const sku = await prisma.materialSku.create({
|
||||
data: {
|
||||
materialId,
|
||||
skuName,
|
||||
skuName: normalizedSkuName,
|
||||
previewUrl: previewUrl || null,
|
||||
filePath: zipFilePath, // 前端传的是zipFilePath,存储到filePath
|
||||
filePath: zipFilePath || '',
|
||||
fileName: zipFileName || null,
|
||||
fileSize: BigInt(zipFileSize || 0),
|
||||
sortOrder: newSortOrder
|
||||
} as any // 临时类型断言,因为 Prisma Client 可能还未完全更新
|
||||
sortOrder: newSortOrder,
|
||||
} as any,
|
||||
})
|
||||
|
||||
await createMaterialLog({
|
||||
@ -145,27 +233,24 @@ export async function POST(
|
||||
},
|
||||
})
|
||||
|
||||
// 返回时转换为前端期望的字段名
|
||||
return NextResponse.json({
|
||||
id: sku.id,
|
||||
materialId: sku.materialId,
|
||||
skuName: sku.skuName,
|
||||
previewUrl: (sku as any).previewUrl || '',
|
||||
zipFileName: (sku as any).fileName || '',
|
||||
zipFilePath: sku.filePath, // 返回时使用zipFilePath
|
||||
zipFilePath: sku.filePath,
|
||||
zipFileSize: sku.fileSize.toString(),
|
||||
sortOrder: sku.sortOrder,
|
||||
createdAt: sku.createdAt,
|
||||
updatedAt: sku.updatedAt
|
||||
updatedAt: sku.updatedAt,
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('添加 SKU 失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : undefined
|
||||
console.error('错误详情:', { errorMessage, errorStack, error })
|
||||
return NextResponse.json({
|
||||
return NextResponse.json({
|
||||
error: '添加 SKU 失败',
|
||||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
|
||||
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined,
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { createMaterialLog } from '@/lib/material-log';
|
||||
import { getOriginalTaskNo } from '@/lib/product-package';
|
||||
|
||||
// POST /api/materials/restore - 恢复已删除的素材
|
||||
export async function POST(request: NextRequest) {
|
||||
@ -30,14 +31,108 @@ export async function POST(request: NextRequest) {
|
||||
if (!material) {
|
||||
return NextResponse.json({ error: '素材不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const restoredTaskNo = getOriginalTaskNo((material as any).taskNo);
|
||||
if (restoredTaskNo && restoredTaskNo !== (material as any).taskNo) {
|
||||
const existingMaterial = await prisma.material.findFirst({
|
||||
where: {
|
||||
taskNo: restoredTaskNo,
|
||||
deletedAt: null,
|
||||
NOT: { id },
|
||||
} as any,
|
||||
select: {
|
||||
id: true,
|
||||
productModel: true,
|
||||
materialType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMaterial) {
|
||||
return NextResponse.json(
|
||||
{ error: `恢复失败:原任务号 ${restoredTaskNo} 已被素材 ${existingMaterial.productModel}(${existingMaterial.materialType})占用` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (material.materialType === '标准模型') {
|
||||
const materialSkus = await prisma.materialSku.findMany({
|
||||
where: { materialId: id },
|
||||
select: {
|
||||
skuName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const skuNames = Array.from(
|
||||
new Set(
|
||||
materialSkus
|
||||
.map((sku) => String(sku.skuName || '').trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
|
||||
if (skuNames.length > 0) {
|
||||
const conflictingSkus = await prisma.materialSku.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
skuName: {
|
||||
in: skuNames,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
materialId: {
|
||||
not: id,
|
||||
},
|
||||
material: {
|
||||
is: {
|
||||
materialType: '标准模型',
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
skuName: true,
|
||||
material: {
|
||||
select: {
|
||||
productModel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
if (conflictingSkus.length > 0) {
|
||||
const conflictSummary = conflictingSkus
|
||||
.map((item) => `${item.skuName}(${item.material.productModel})`)
|
||||
.join('、');
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `恢复失败:以下 SKU 已被其他标准模型占用:${conflictSummary}` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复素材(将 deletedAt 设为 null)
|
||||
await prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction([
|
||||
prisma.material.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
...(restoredTaskNo && restoredTaskNo !== (material as any).taskNo
|
||||
? { taskNo: restoredTaskNo }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
prisma.materialSku.updateMany({
|
||||
where: {
|
||||
materialId: id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// 记录操作日志
|
||||
const userId = (session.user as any).id;
|
||||
@ -50,6 +145,14 @@ export async function POST(request: NextRequest) {
|
||||
oldValue: material.deletedAt?.toISOString() || null,
|
||||
newValue: null,
|
||||
},
|
||||
...(restoredTaskNo && restoredTaskNo !== (material as any).taskNo
|
||||
? {
|
||||
taskNo: {
|
||||
oldValue: (material as any).taskNo || null,
|
||||
newValue: restoredTaskNo,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, // 任务号(可选)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, // 任务号(可选)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,127 +1,224 @@
|
||||
import { once } from 'events'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// Configure route to handle large files
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 10800 // 3 hours timeout
|
||||
export const maxDuration = 10800
|
||||
|
||||
interface ChunkRequestPayload {
|
||||
uploadId: string
|
||||
chunkIndex: number
|
||||
fileName: string
|
||||
chunkSizeHint: number
|
||||
mode: 'raw' | 'multipart'
|
||||
chunkFile?: File | null
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestStartTime = Date.now()
|
||||
console.log(`[UploadChunk] 📥 收到请求 at ${new Date().toISOString()}`)
|
||||
|
||||
|
||||
try {
|
||||
// 性能监控:认证
|
||||
const t1 = Date.now()
|
||||
console.log(`[UploadChunk] 🔐 开始认证...`)
|
||||
const authStartedAt = Date.now()
|
||||
const session = await getServerSession(authOptions)
|
||||
const authTime = Date.now() - t1
|
||||
const authTime = Date.now() - authStartedAt
|
||||
console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`)
|
||||
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
// 性能监控:解析FormData
|
||||
const t2 = Date.now()
|
||||
console.log(`[UploadChunk] 📦 开始解析FormData...`)
|
||||
const formData = await req.formData()
|
||||
const formDataTime = Date.now() - t2
|
||||
console.log(`[UploadChunk] 📦 FormData解析完成: ${formDataTime}ms`)
|
||||
|
||||
const uploadId = formData.get('uploadId') as string
|
||||
const chunkIndex = parseInt(formData.get('chunkIndex') as string)
|
||||
const chunk = formData.get('chunk') as File
|
||||
const fileName = formData.get('fileName') as string
|
||||
|
||||
console.log(`[UploadChunk] 📊 Chunk ${chunkIndex}, 大小: ${(chunk.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
const payloadStartedAt = Date.now()
|
||||
const payload = await resolveChunkPayload(req)
|
||||
const payloadResolveTime = Date.now() - payloadStartedAt
|
||||
console.log(
|
||||
`[UploadChunk] 📦 请求参数解析完成: ${payloadResolveTime}ms, mode=${payload.mode}, chunkIndex=${payload.chunkIndex}`,
|
||||
)
|
||||
|
||||
if (!uploadId || isNaN(chunkIndex) || !chunk) {
|
||||
console.error(`[UploadChunk] ❌ 参数错误: uploadId="${uploadId}", chunkIndex=${chunkIndex}, hasChunk=${!!chunk}`)
|
||||
if (!payload.uploadId || Number.isNaN(payload.chunkIndex)) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 性能监控:获取会话
|
||||
const t3 = Date.now()
|
||||
console.log(`[UploadChunk] 🔍 开始获取会话: ${uploadId}`)
|
||||
const sessionData = await getUploadSession(uploadId)
|
||||
const getSessionTime = Date.now() - t3
|
||||
const sessionStartedAt = Date.now()
|
||||
const sessionData = await getUploadSession(payload.uploadId)
|
||||
const getSessionTime = Date.now() - sessionStartedAt
|
||||
console.log(`[UploadChunk] 🔍 获取会话完成: ${getSessionTime}ms, 会话存在: ${!!sessionData}`)
|
||||
|
||||
|
||||
if (!sessionData) {
|
||||
console.log(`[UploadChunk] ❌ 会话不存在: ${uploadId}`)
|
||||
console.log(`[UploadChunk] ❌ 会话不存在: ${payload.uploadId}`)
|
||||
return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 })
|
||||
}
|
||||
|
||||
// 验证用户
|
||||
if (sessionData.userId !== (session.user as any).id) {
|
||||
return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 验证chunkIndex范围
|
||||
if (chunkIndex < 0 || chunkIndex >= sessionData.totalChunks) {
|
||||
console.error(`[UploadChunk] ❌ chunkIndex超出范围: ${chunkIndex}, 总数: ${sessionData.totalChunks}`)
|
||||
return NextResponse.json({ error: `chunkIndex超出范围: ${chunkIndex}/${sessionData.totalChunks}` }, { status: 400 })
|
||||
if (payload.chunkIndex < 0 || payload.chunkIndex >= sessionData.totalChunks) {
|
||||
console.error(
|
||||
`[UploadChunk] ❌ chunkIndex超出范围: ${payload.chunkIndex}, 总数: ${sessionData.totalChunks}`,
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: `chunkIndex超出范围: ${payload.chunkIndex}/${sessionData.totalChunks}` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// 性能监控:准备目录
|
||||
const t4 = Date.now()
|
||||
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
|
||||
const mkdirStartedAt = Date.now()
|
||||
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', payload.uploadId)
|
||||
await mkdir(tempDir, { recursive: true })
|
||||
const mkdirTime = Date.now() - t4
|
||||
|
||||
// 性能监控:转换Buffer
|
||||
const t5 = Date.now()
|
||||
const chunkFilePath = path.join(tempDir, `chunk-${chunkIndex}`)
|
||||
const chunkBuffer = Buffer.from(await chunk.arrayBuffer())
|
||||
const bufferTime = Date.now() - t5
|
||||
|
||||
// 性能监控:写入文件
|
||||
const t6 = Date.now()
|
||||
await writeFile(chunkFilePath, chunkBuffer)
|
||||
const writeFileTime = Date.now() - t6
|
||||
const mkdirTime = Date.now() - mkdirStartedAt
|
||||
|
||||
// 性能监控:更新会话
|
||||
const t7 = Date.now()
|
||||
await updateChunkWithStats(uploadId, chunkIndex, chunkFilePath, chunk.size)
|
||||
const updateChunkTime = Date.now() - t7
|
||||
const chunkFilePath = path.join(tempDir, `chunk-${payload.chunkIndex}`)
|
||||
const writeStartedAt = Date.now()
|
||||
const writtenBytes =
|
||||
payload.mode === 'raw'
|
||||
? await writeRawRequestBodyToFile(req, chunkFilePath)
|
||||
: await writeMultipartChunkToFile(payload.chunkFile, chunkFilePath)
|
||||
const writeTime = Date.now() - writeStartedAt
|
||||
|
||||
const statsStartedAt = Date.now()
|
||||
await updateChunkWithStats(payload.uploadId, payload.chunkIndex, chunkFilePath, writtenBytes)
|
||||
const updateChunkTime = Date.now() - statsStartedAt
|
||||
|
||||
// 计算总耗时
|
||||
const totalTime = Date.now() - requestStartTime
|
||||
|
||||
// 输出详细的性能分析
|
||||
console.log(`[UploadChunk] ⏱️ Chunk ${chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, {
|
||||
认证: `${authTime}ms (${(authTime/totalTime*100).toFixed(1)}%)`,
|
||||
FormData解析: `${formDataTime}ms (${(formDataTime/totalTime*100).toFixed(1)}%)`,
|
||||
获取会话: `${getSessionTime}ms (${(getSessionTime/totalTime*100).toFixed(1)}%)`,
|
||||
创建目录: `${mkdirTime}ms (${(mkdirTime/totalTime*100).toFixed(1)}%)`,
|
||||
转换Buffer: `${bufferTime}ms (${(bufferTime/totalTime*100).toFixed(1)}%)`,
|
||||
写入文件: `${writeFileTime}ms (${(writeFileTime/totalTime*100).toFixed(1)}%)`,
|
||||
更新会话: `${updateChunkTime}ms (${(updateChunkTime/totalTime*100).toFixed(1)}%)`,
|
||||
大小: `${(chunk.size / 1024 / 1024).toFixed(2)}MB`
|
||||
console.log(`[UploadChunk] ⏱️ Chunk ${payload.chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, {
|
||||
认证: `${authTime}ms (${percent(authTime, totalTime)}%)`,
|
||||
请求参数解析: `${payloadResolveTime}ms (${percent(payloadResolveTime, totalTime)}%)`,
|
||||
获取会话: `${getSessionTime}ms (${percent(getSessionTime, totalTime)}%)`,
|
||||
创建目录: `${mkdirTime}ms (${percent(mkdirTime, totalTime)}%)`,
|
||||
流式写盘: `${writeTime}ms (${percent(writeTime, totalTime)}%)`,
|
||||
更新会话: `${updateChunkTime}ms (${percent(updateChunkTime, totalTime)}%)`,
|
||||
大小: `${(writtenBytes / 1024 / 1024).toFixed(2)}MB`,
|
||||
模式: payload.mode,
|
||||
})
|
||||
|
||||
// 如果FormData解析时间占比超过80%,说明是网络接收慢
|
||||
if (formDataTime / totalTime > 0.8) {
|
||||
console.log(`[UploadChunk] ⚠️ 警告: FormData解析占比${(formDataTime/totalTime*100).toFixed(1)}%,可能是网络接收慢!`)
|
||||
|
||||
if (writeTime / totalTime > 0.8) {
|
||||
console.log(
|
||||
`[UploadChunk] ⚠️ 流式写盘阶段占比 ${percent(writeTime, totalTime)}%,当前主要瓶颈仍在网络接收`,
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
uploadedChunks: sessionData.chunks.size + 1,
|
||||
totalChunks: sessionData.totalChunks,
|
||||
serverProcessTime: totalTime, // 返回给前端
|
||||
serverProcessTime: totalTime,
|
||||
receivedBytes: writtenBytes,
|
||||
mode: payload.mode,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[UploadChunk] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '上传块失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ error: '上传块失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChunkPayload(req: NextRequest): Promise<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)
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
// 保持旧行为:清理失败时仅记录,不阻断新建流程
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, // 任务号(可选)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, // 任务号(可选)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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, // 任务号(可选)
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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素材',
|
||||
},
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
9
app/favicon.ico/route.ts
Normal 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)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
81
app/page.tsx
81
app/page.tsx
@ -13,9 +13,26 @@ import { useToast } from '@/hooks/useToast'
|
||||
import { parseDownloadError } from '@/lib/download-errors'
|
||||
import { validateAndTriggerDownload } from '@/lib/client-download'
|
||||
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
|
||||
import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
// 首页拆分组件
|
||||
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
|
||||
|
||||
const PUBLIC_APP_ORIGIN = getPublicAppOrigin()
|
||||
const PUBLIC_DOMAIN = (() => {
|
||||
try {
|
||||
return new URL(PUBLIC_APP_ORIGIN).hostname
|
||||
} catch {
|
||||
return 'sc.jiansun.vip'
|
||||
}
|
||||
})()
|
||||
const KNOWN_PUBLIC_HOSTS = getAllowedPublicAppOrigins().map((origin) => {
|
||||
try {
|
||||
return new URL(origin).hostname
|
||||
} catch {
|
||||
return origin
|
||||
}
|
||||
})
|
||||
|
||||
interface Material {
|
||||
id: string
|
||||
userId: string
|
||||
@ -232,9 +249,8 @@ export default function Home() {
|
||||
console.warn('[网络检测] 当前在内网地址但无法访问,可能用户已切换到公网环境,自动回退到公网域名')
|
||||
|
||||
// 重定向到公网域名
|
||||
const publicDomain = '3d.yydd.ai' // 公网域名
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
window.location.replace(`https://${publicDomain}${currentPath}`)
|
||||
window.location.replace(`https://${PUBLIC_DOMAIN}${currentPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
@ -254,7 +270,7 @@ export default function Home() {
|
||||
// 注意:每次访问公网域名时都重新检测,不依赖 sessionStorage
|
||||
// 这样可以确保内网用户在清除缓存后仍能正常跳转
|
||||
const hasRedirected = sessionStorage.getItem('intranet-auto-redirect')
|
||||
if (hasRedirected && window.location.hostname === '3d.yydd.ai') {
|
||||
if (hasRedirected && KNOWN_PUBLIC_HOSTS.includes(window.location.hostname)) {
|
||||
// 如果已经跳过,且当前访问的是公网域名,清除标记并重新检测
|
||||
console.debug('[内网切换] 检测到已跳过标记,但访问的是公网域名,重新检测...')
|
||||
sessionStorage.removeItem('intranet-auto-redirect')
|
||||
@ -267,36 +283,35 @@ export default function Home() {
|
||||
// 检查当前协议
|
||||
const isHttps = window.location.protocol === 'https:'
|
||||
|
||||
// 统一检测逻辑:无论HTTPS还是HTTP,都先尝试直接访问内网地址
|
||||
// 如果能访问内网地址,说明用户在内网,直接跳转
|
||||
// 公网 HTTPS 页面不能主动探测 http:// 内网地址,否则会触发 mixed content
|
||||
// 因此仅在 HTTP 场景下尝试直连内网地址;HTTPS 场景统一走后端 /api/network-status
|
||||
let canAccessIntranet = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败
|
||||
|
||||
// 尝试直接访问内网地址(通过fetch)
|
||||
const intranetUrl = `http://192.168.58.20:4320/api/network-status`
|
||||
const intranetResponse = await fetch(intranetUrl, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
mode: 'cors', // 使用CORS模式
|
||||
credentials: 'omit', // 不发送cookies,避免CORS问题
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (intranetResponse.ok) {
|
||||
const intranetData = await intranetResponse.json()
|
||||
const hostname = intranetData.hostname || ''
|
||||
if (!isHttps) {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败
|
||||
|
||||
// 关键判断:如果返回的hostname是192.168.58.20,说明客户端能直接访问内网地址
|
||||
const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true
|
||||
const intranetUrl = `http://192.168.58.20:4320/api/network-status`
|
||||
const intranetResponse = await fetch(intranetUrl, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (isTargetServer) {
|
||||
canAccessIntranet = true
|
||||
console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户')
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (intranetResponse.ok) {
|
||||
const intranetData = await intranetResponse.json()
|
||||
const hostname = intranetData.hostname || ''
|
||||
const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true
|
||||
|
||||
if (isTargetServer) {
|
||||
canAccessIntranet = true
|
||||
console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -335,14 +350,10 @@ export default function Home() {
|
||||
if (isIntranet) {
|
||||
canAccessIntranet = true
|
||||
console.debug('[内网切换] 后端API检测:判定为内网用户(isIntranet:', isIntranet, ')')
|
||||
} else {
|
||||
// 即使clientIp是公网IP,也可能是通过代理访问的内网用户
|
||||
// 在这种情况下,如果直接访问内网地址失败(可能是混合内容阻止),
|
||||
// 我们可以尝试通过创建一个隐藏的iframe来检测是否能够访问内网地址
|
||||
} else if (!isHttps) {
|
||||
// 仅 HTTP 场景下继续尝试 iframe 探测,避免 HTTPS 公网页面触发 mixed content
|
||||
console.debug('[内网切换] 后端API检测:clientIp为公网IP(', clientIp, '),可能是代理IP,尝试其他检测方式')
|
||||
|
||||
// 尝试通过创建隐藏的iframe来检测是否能够访问内网地址
|
||||
// 这可以绕过混合内容的限制
|
||||
const testIframe = document.createElement('iframe')
|
||||
testIframe.style.display = 'none'
|
||||
testIframe.src = `http://192.168.58.20:4320/api/network-status`
|
||||
@ -1579,7 +1590,7 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 内网切换提示已移除:现在统一使用 https://3d.yydd.ai,通过DNS解析自动区分内网/外网 */}
|
||||
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
|
||||
|
||||
<RankingPanel />
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
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">点击上方“编辑”按钮添加</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
170
components/upload/StandardModelSkuZipUpload.tsx
Normal file
170
components/upload/StandardModelSkuZipUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
|
||||
|
||||
@ -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
|
||||
|
||||
249
docs/AI生图白底图接口文档.md
Normal file
249
docs/AI生图白底图接口文档.md
Normal 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 下载链接
|
||||
|
||||
@ -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`
|
||||
- 网络诊断与对外接口文档
|
||||
|
||||
138
docs/外部接口文档.md
138
docs/外部接口文档.md
@ -1,6 +1,9 @@
|
||||
# 素材平台 - 外部接口文档
|
||||
|
||||
> 本文档供外部任务系统对接使用
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 旧域名:`https://3d.yydd.ai`,保留兼容并跳转到新域名
|
||||
> 资源域名:`https://s3.yydd.ai`
|
||||
|
||||
## 概述
|
||||
|
||||
@ -8,7 +11,7 @@
|
||||
|
||||
### 基础信息
|
||||
|
||||
- **接口地址**: `https://3d.yydd.ai/api/external/`
|
||||
- **接口地址**: `https://sc.jiansun.vip/api/external/`
|
||||
- **鉴权方式**: API Key(请求头 `X-API-Key`)
|
||||
- **数据格式**: JSON
|
||||
|
||||
@ -17,7 +20,7 @@
|
||||
#### 获取 API Key
|
||||
|
||||
1. **登录素材平台管理后台**
|
||||
- 访问:`https://3d.yydd.ai/admin/api-keys`
|
||||
- 访问:`https://sc.jiansun.vip/admin/api-keys`
|
||||
- 需要**超级管理员**或**系统管理员**权限
|
||||
|
||||
2. **创建 API Key**
|
||||
@ -109,14 +112,14 @@ GET /api/external/product-assets?taskNo={任务号}
|
||||
```bash
|
||||
# cURL 示例
|
||||
curl -X GET \
|
||||
'https://3d.yydd.ai/api/external/product-assets?taskNo=TASK202601030184' \
|
||||
-H 'X-API-Key: b78af0ec58a078105f63aaca3256918f'
|
||||
'https://sc.jiansun.vip/api/external/product-assets?taskNo=TASK202601030184' \
|
||||
-H 'X-API-Key: your-api-key-here'
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/external/product-assets?taskNo=TASK202601030184 HTTP/1.1
|
||||
Host: 3d.yydd.ai
|
||||
X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
Host: sc.jiansun.vip
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
**响应示例(成功 - 产品全案)**
|
||||
@ -137,7 +140,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
{
|
||||
"id": "asset-uuid-1",
|
||||
"url": "https://s3.yydd.ai/...",
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "主图1.jpg",
|
||||
"fileSize": 1024000
|
||||
}
|
||||
@ -146,7 +149,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
{
|
||||
"id": "asset-uuid-2",
|
||||
"url": "https://s3.yydd.ai/...",
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "SKU白色.jpg",
|
||||
"fileSize": 512000
|
||||
}
|
||||
@ -155,7 +158,7 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
{
|
||||
"id": "asset-uuid-3",
|
||||
"url": "https://s3.yydd.ai/...",
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "详情1.jpg",
|
||||
"fileSize": 2048000,
|
||||
"sortOrder": 0
|
||||
@ -165,18 +168,18 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
{
|
||||
"id": "asset-uuid-5",
|
||||
"url": "uploads/product-package/xxx/psd/psd-xxx.psd",
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "源文件1.psd",
|
||||
"fileSize": 52428800
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloadUrls": {
|
||||
"all": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"main": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"sku": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"detail": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"psd": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
"all": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"main": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"sku": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"detail": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"psd": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -199,13 +202,13 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
{
|
||||
"id": "material-uuid-1",
|
||||
"url": "https://s3.yydd.ai/...",
|
||||
"downloadUrl": "https://3d.yydd.ai/api/external/download?token=xxx",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "material.zip",
|
||||
"fileSize": 52428800
|
||||
}
|
||||
],
|
||||
"downloadUrls": {
|
||||
"all": "https://3d.yydd.ai/api/external/download?token=xxx"
|
||||
"all": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -296,18 +299,18 @@ GET /api/external/download?token={下载令牌}
|
||||
```bash
|
||||
# cURL 示例 - 打包下载(全部)
|
||||
curl -X GET \
|
||||
'https://3d.yydd.ai/api/external/download?token=xxx' \
|
||||
'https://sc.jiansun.vip/api/external/download?token=xxx' \
|
||||
-o 'assets.zip'
|
||||
|
||||
# cURL 示例 - 下载单个PSD文件(使用PSD资源的downloadUrl)
|
||||
curl -X GET \
|
||||
'https://3d.yydd.ai/api/external/download?token=xxx' \
|
||||
'https://sc.jiansun.vip/api/external/download?token=xxx' \
|
||||
-o 'source.psd'
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/external/download?token=xxx HTTP/1.1
|
||||
Host: 3d.yydd.ai
|
||||
Host: sc.jiansun.vip
|
||||
```
|
||||
|
||||
**响应**
|
||||
@ -364,14 +367,14 @@ DELETE /api/external/product-assets?taskNo={任务号}
|
||||
```bash
|
||||
# cURL 示例
|
||||
curl -X DELETE \
|
||||
'https://3d.yydd.ai/api/external/product-assets?taskNo=TASK202601030184' \
|
||||
-H 'X-API-Key: b78af0ec58a078105f63aaca3256918f'
|
||||
'https://sc.jiansun.vip/api/external/product-assets?taskNo=TASK202601030184' \
|
||||
-H 'X-API-Key: your-api-key-here'
|
||||
```
|
||||
|
||||
```http
|
||||
DELETE /api/external/product-assets?taskNo=TASK202601030184 HTTP/1.1
|
||||
Host: 3d.yydd.ai
|
||||
X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
Host: sc.jiansun.vip
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
**响应示例(成功)**
|
||||
@ -389,6 +392,76 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
|
||||
---
|
||||
|
||||
### 4. 查询标准模型白底图
|
||||
|
||||
供 AI 生图平台通过 `SKU` 直接获取白底图。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /api/external/standard-model-white-background?sku={SKU}
|
||||
```
|
||||
|
||||
**请求头**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| X-API-Key | string | 是 | API 访问密钥 |
|
||||
|
||||
**请求参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| sku | string | 是 | SKU 号,按标准模型全局唯一规则查询 |
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"sku": "SKU001",
|
||||
"materialId": "material-uuid",
|
||||
"productModel": "SPU001",
|
||||
"whiteBackground": {
|
||||
"fixed": [
|
||||
{
|
||||
"key": "leftFront45",
|
||||
"label": "左前45°",
|
||||
"fileName": "左前45°.jpg",
|
||||
"fileSize": 123456,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
}
|
||||
],
|
||||
"other": [
|
||||
{
|
||||
"key": "other-1",
|
||||
"label": "其他图片1",
|
||||
"fileName": "01-detail.jpg",
|
||||
"fileSize": 654321,
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
}
|
||||
]
|
||||
},
|
||||
"downloadUrls": {
|
||||
"zip": "https://sc.jiansun.vip/api/external/download?token=xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误说明**
|
||||
|
||||
| 状态码 | code | 说明 |
|
||||
|------|------|------|
|
||||
| 400 | INVALID_SKU | SKU 参数为空或非法 |
|
||||
| 401 | INVALID_API_KEY | API Key 无效 |
|
||||
| 404 | SKU_NOT_FOUND | 未找到对应标准模型 SKU |
|
||||
| 409 | SKU_NOT_UNIQUE | SKU 重复,暂不能对外提供 |
|
||||
| 404 | WHITE_BACKGROUND_NOT_FOUND | 该 SKU 暂无白底图 |
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | HTTP状态码 | 说明 |
|
||||
@ -399,6 +472,10 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
| TOKEN_EXPIRED | 401 | 下载令牌已过期(有效期24小时) |
|
||||
| INVALID_TOKEN | 401 | 下载令牌无效 |
|
||||
| INVALID_TASK_NO | 400 | 任务号格式错误 |
|
||||
| INVALID_SKU | 400 | SKU 参数为空或非法 |
|
||||
| SKU_NOT_FOUND | 404 | 未找到对应标准模型 SKU |
|
||||
| SKU_NOT_UNIQUE | 409 | SKU 存在重复数据 |
|
||||
| WHITE_BACKGROUND_NOT_FOUND | 404 | 该 SKU 暂无白底图 |
|
||||
| SERVER_ERROR | 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
@ -452,8 +529,8 @@ X-API-Key: b78af0ec58a078105f63aaca3256918f
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE = 'https://3d.yydd.ai/api/external';
|
||||
const API_KEY = 'b78af0ec58a078105f63aaca3256918f';
|
||||
const API_BASE = 'https://sc.jiansun.vip/api/external';
|
||||
const API_KEY = 'your-api-key-here';
|
||||
|
||||
// 查询素材
|
||||
async function queryAssets(taskNo) {
|
||||
@ -496,8 +573,8 @@ async function main() {
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_BASE = 'https://3d.yydd.ai/api/external'
|
||||
API_KEY = 'b78af0ec58a078105f63aaca3256918f'
|
||||
API_BASE = 'https://sc.jiansun.vip/api/external'
|
||||
API_KEY = 'your-api-key-here'
|
||||
|
||||
def query_assets(task_no):
|
||||
response = requests.get(
|
||||
@ -525,7 +602,7 @@ if result['success']:
|
||||
|
||||
### 管理页面
|
||||
|
||||
- **访问地址**: `https://3d.yydd.ai/admin/api-keys`
|
||||
- **访问地址**: `https://sc.jiansun.vip/admin/api-keys`
|
||||
- **权限要求**: 超级管理员或系统管理员
|
||||
|
||||
### 功能说明
|
||||
@ -553,4 +630,5 @@ if result['success']:
|
||||
|
||||
*文档版本: 1.2*
|
||||
*更新日期: 2026-01-07*
|
||||
*更新内容: 更新实际接口地址和 API Key 示例*
|
||||
*更新内容: 更新实际接口地址和 API Key 示例*
|
||||
|
||||
|
||||
45
lib/__tests__/auth-cookie.test.ts
Normal file
45
lib/__tests__/auth-cookie.test.ts
Normal 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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
31
lib/__tests__/standard-model-sku-uniqueness.test.ts
Normal file
31
lib/__tests__/standard-model-sku-uniqueness.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
40
lib/__tests__/standard-model-soft-delete-sync.test.ts
Normal file
40
lib/__tests__/standard-model-soft-delete-sync.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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
140
lib/auth-cookie.ts
Normal 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]
|
||||
}
|
||||
16
lib/auth.ts
16
lib/auth.ts
@ -2,6 +2,7 @@ import { NextAuthOptions } from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie'
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
@ -113,19 +114,8 @@ export const authOptions: NextAuthOptions = {
|
||||
// R43修复:缩短会话过期时间从30天到7天,提高安全性
|
||||
maxAge: 7 * 24 * 60 * 60, // 7天
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // 允许跨站请求携带cookie(钉钉登录需要)
|
||||
path: '/',
|
||||
// 根据 NEXTAUTH_URL 的协议来判断是否使用 secure
|
||||
// 如果 NEXTAUTH_URL 是 HTTPS,则使用 secure: true
|
||||
// 如果是 HTTP(内网环境),则使用 secure: false
|
||||
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||
},
|
||||
},
|
||||
get useSecureCookies() {
|
||||
return resolveSecureAuthCookieForCurrentRequest()
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ import { NextRequest } from 'next/server'
|
||||
// 公网域名白名单(明确标识为公网域名)
|
||||
const PUBLIC_DOMAINS = [
|
||||
'3d.yydd.ai',
|
||||
'sc.jiansun.vip',
|
||||
's3.yydd.ai',
|
||||
's3.1080p.hk',
|
||||
]
|
||||
|
||||
@ -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
51
lib/public-app-url.ts
Normal 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]))
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
// 检查是否为公网域名或非内网地址
|
||||
|
||||
@ -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*'],
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user