fix: 收紧系统权限并完善考核调研链路

This commit is contained in:
chenpei 2026-07-10 10:57:16 +08:00
parent 8e8d87dc18
commit 779cdba8a1
94 changed files with 4382 additions and 1144 deletions

View File

@ -124,8 +124,8 @@ docker restart youyidian-3d-material
### MinIO/S3 存储配置
**MinIO 服务器 (192.168.58.20:9000):**
- 访问密钥:`GXPAccessKey2025`
- 秘密密钥:`GXPSecretKey2025SecureMinIO`
- 访问密钥:由服务器环境变量 `S3_ACCESS_KEY_ID` 提供
- 秘密密钥:由服务器环境变量 `S3_SECRET_ACCESS_KEY` 提供
- 存储桶:`gongxiangpan`
- 文件路径:`由壹点AI/`

View File

@ -163,7 +163,7 @@ export default function ApiKeysPage() {
{apiKeys.map((key) => (
<tr key={key.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">{key.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono">{key.key.substring(0, 16)}...</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono">{key.key}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs rounded ${key.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
{key.isActive ? '启用' : '禁用'}

View File

@ -157,18 +157,15 @@ export default function AdminPage() {
}, [])
const fetchDepartments = useCallback(async () => {
try {
// 阶段三:获取包含层级关系的部门数据
const response = await fetch('/api/departments?includeHierarchy=true')
if (response.ok) {
const data = await response.json()
setDepartments(data)
// 默认所有部门收起
setCollapsedDepartments(new Set(data.map((d: Department) => d.id)))
}
} catch (error) {
console.error('Error fetching departments:', error)
// 阶段三:获取包含层级关系的部门数据
const response = await fetch('/api/departments?includeHierarchy=true')
if (!response.ok) {
throw new Error('部门数据加载失败')
}
const data = await response.json()
setDepartments(data)
// 默认所有部门收起
setCollapsedDepartments(new Set(data.map((d: Department) => d.id)))
}, [])
const fetchData = useCallback(async () => {
@ -185,25 +182,23 @@ export default function AdminPage() {
? `/api/materials?page=1&pageSize=${materialsPageSize}&deleted=true`
: `/api/materials?page=1&pageSize=${materialsPageSize}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
const materialsData = data.materials || data
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
setCurrentMaterialsPage(1)
setHasMoreMaterials(materialsData.length >= materialsPageSize)
// 保存总数(用于显示)
if (data.totalCount !== undefined) {
setTotalMaterialsCount(data.totalCount)
}
if (!response.ok) throw new Error('素材数据加载失败')
const data = await response.json()
const materialsData = data.materials || data
setMaterials(materialsData)
setDisplayedMaterialsCount(materialsData.length)
setCurrentMaterialsPage(1)
setHasMoreMaterials(materialsData.length >= materialsPageSize)
// 保存总数(用于显示)
if (data.totalCount !== undefined) {
setTotalMaterialsCount(data.totalCount)
}
} else if (activeTab === 'users') {
const response = await fetch('/api/admin/users')
if (response.ok) {
const data = await response.json()
setUsers(data)
}
fetchDepartments()
if (!response.ok) throw new Error('用户数据加载失败')
const data = await response.json()
setUsers(data)
await fetchDepartments()
} else if (activeTab === 'categories') {
const [categoriesRes, scenesRes, platformsRes, productCategoriesRes] = await Promise.all([
fetch('/api/categories'),
@ -211,19 +206,23 @@ export default function AdminPage() {
fetch('/api/platforms'),
fetch('/api/product-categories')
])
if (categoriesRes.ok) setCategories(await categoriesRes.json())
if (scenesRes.ok) setScenes(await scenesRes.json())
if (platformsRes.ok) setPlatforms(await platformsRes.json())
if (productCategoriesRes.ok) setProductCategories(await productCategoriesRes.json())
if (!categoriesRes.ok || !scenesRes.ok || !platformsRes.ok || !productCategoriesRes.ok) {
throw new Error('分类数据加载失败')
}
setCategories(await categoriesRes.json())
setScenes(await scenesRes.json())
setPlatforms(await platformsRes.json())
setProductCategories(await productCategoriesRes.json())
} else if (activeTab === 'departments' || activeTab === 'permissions') {
fetchDepartments()
await fetchDepartments()
}
} catch (error) {
console.error('Error fetching data:', error)
showToast(error instanceof Error ? error.message : '数据加载失败', 'error')
} finally {
setLoading(false)
}
}, [activeTab, canManageSystemTabs, fetchDepartments, showRecycleBin])
}, [activeTab, canManageSystemTabs, fetchDepartments, showRecycleBin, showToast])
// 权限检查
useEffect(() => {
@ -370,7 +369,7 @@ export default function AdminPage() {
</div>
{/* Tabs */}
<div className="flex space-x-4 border-b border-gray-200 dark:border-gray-700 mb-8">
<div className="flex gap-4 overflow-x-auto border-b border-gray-200 dark:border-gray-700 mb-8 [&>button]:shrink-0">
<button
type="button"
onClick={() => setActiveTab('dataStats')}

View File

@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission';
import { getApiKeys, createApiKey } from '@/lib/external-api';
import { getApiKeys, createApiKey, getApiKeyPreview } from '@/lib/external-api';
/**
* GET - 获取 API Key 列表
@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
// 隐藏完整 key,只显示前8位
const maskedKeys = apiKeys.map((k: any) => ({
...k,
key: k.key.substring(0, 8) + '...',
key: getApiKeyPreview(k.key),
}));
return NextResponse.json({

View File

@ -2,10 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { readFile, unlink, mkdir, rm, readdir } from 'fs/promises'
import { readFile, mkdir, rm, readdir } from 'fs/promises'
import { join } from 'path'
import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import {
buildTaskDetailPublicDisplayPath,
uploadPublicDisplayToS3,
@ -20,6 +23,13 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
if (!hasSystemAdminPermission(userRole, isSuperAdminUser)) {
return NextResponse.json({ error: '无权限操作' }, { status: 403 })
}
const body = await request.json()
const { materialId } = body
@ -57,10 +67,14 @@ export async function POST(request: NextRequest) {
// 如果是本地文件,解压并处理
if (detailAsset.fileUrl.startsWith('uploads/')) {
const localPath = join(process.cwd(), detailAsset.fileUrl)
const uploadRootDir = getUploadRootDir()
const localPath = resolveStoredUploadPath(detailAsset.fileUrl)
if (!isPathInAllowedDirs(localPath, [uploadRootDir])) {
return NextResponse.json({ error: '详情图文件路径无效' }, { status: 400 })
}
// 创建临时目录
const tempDir = join(process.cwd(), 'uploads', 'temp', `regenerate-${materialId}`)
const tempDir = join(uploadRootDir, 'temp', `regenerate-${materialId}`)
await mkdir(tempDir, { recursive: true })
// ⚠️ 安全修复:使用 AdmZip 替代 unzip 命令,防止命令注入(R40)

View File

@ -1,11 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { exec } from 'child_process'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { execFile } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
const execFileAsync = promisify(execFile)
const LOG_CONTAINER_NAME = 'youyidian-3d-material'
const DEFAULT_LOG_LINES = 100
const MAX_LOG_LINES = 5000
const MAX_LOG_OUTPUT_LENGTH = 50000
function normalizeLogLines(value: string | null): number {
const parsed = Number.parseInt(value || '', 10)
if (!Number.isFinite(parsed)) {
return DEFAULT_LOG_LINES
}
return Math.min(Math.max(parsed, 1), MAX_LOG_LINES)
}
function filterLogsByKeyword(logs: string, keyword: string): string {
const normalizedKeyword = keyword.trim().toLocaleLowerCase()
if (!normalizedKeyword) {
return logs
}
return logs
.split(/\r?\n/)
.filter((line) => line.toLocaleLowerCase().includes(normalizedKeyword))
.join('\n')
}
/**
* 获取系统日志(仅管理员)
@ -19,45 +44,42 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
const user = await prisma?.user.findUnique({
where: { id: (session.user as any).id },
select: { role: true, isSuperAdmin: true }
})
if (!user?.isSuperAdmin && user?.role !== 'ADMIN') {
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
if (!hasSystemAdminPermission(userRole, isSuperAdminUser)) {
return NextResponse.json({ error: '无权限访问' }, { status: 403 })
}
// 获取查询参数
const searchParams = request.nextUrl.searchParams
const lines = parseInt(searchParams.get('lines') || '100')
const lines = normalizeLogLines(searchParams.get('lines'))
const keyword = searchParams.get('keyword') || ''
// 读取最近的日志
let cmd = `docker logs --tail ${lines} youyidian-3d-material 2>&1`
if (keyword) {
cmd += ` | grep -i "${keyword}" || docker logs --tail ${lines} youyidian-3d-material 2>&1`
}
const { stdout, stderr } = await execAsync(cmd, {
const { stdout, stderr } = await execFileAsync(
'docker',
['logs', '--tail', String(lines), LOG_CONTAINER_NAME],
{
maxBuffer: 10 * 1024 * 1024, // 10MB
timeout: 10000,
})
encoding: 'utf8',
},
)
const logs = stdout || stderr
const rawLogs = [stdout, stderr].filter(Boolean).join('\n')
const logs = filterLogsByKeyword(rawLogs, keyword)
return NextResponse.json({
success: true,
lines: logs.split('\n').length,
logs: logs.substring(0, 50000), // 限制返回大小
truncated: logs.length > 50000,
lines: logs ? logs.split(/\r?\n/).length : 0,
logs: logs.substring(0, MAX_LOG_OUTPUT_LENGTH),
truncated: logs.length > MAX_LOG_OUTPUT_LENGTH,
})
} catch (error) {
console.error('[SystemLogs] Error:', error)
return NextResponse.json(
{
error: error instanceof Error ? error.message : '获取日志失败',
error: '获取日志失败',
logs: '',
},
{ status: 500 }

View File

@ -57,7 +57,7 @@ export async function GET() {
}
}
// DELETE /api/admin/users - 删除用户
// DELETE /api/admin/users - 归档用户(保留其素材和历史记录)
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
@ -77,13 +77,29 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: '不能删除自己' }, { status: 400 });
}
await prisma.user.delete({
const existingUser = await prisma.user.findUnique({
where: { id },
select: { id: true, status: true },
});
return NextResponse.json({ message: '删除成功' });
if (!existingUser) {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
}
await prisma.user.update({
where: { id },
data: {
status: 'archived',
isActive: false,
archivedAt: new Date(),
statusChangedAt: new Date(),
statusChangedBy: (session.user as any).id,
},
});
return NextResponse.json({ message: '用户已归档,素材和历史记录已保留' });
} catch (error) {
console.error('Error deleting user:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 });
return NextResponse.json({ error: '归档失败' }, { status: 500 });
}
}

View File

@ -183,7 +183,7 @@ export async function GET(request: NextRequest) {
})
const data = await loginResponse.json().catch(() => null)
if (!loginResponse.ok || !data?.success || data.needCompleteProfile) {
if (!loginResponse.ok || !data?.success) {
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createDingtalkDirectErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
}
@ -196,6 +196,16 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(url)
}
if (data.needCompleteProfile) {
const userInfo = encodeURIComponent(JSON.stringify(data.user || {}))
const profileResponse = NextResponse.redirect(
new URL(`/complete-profile?userInfo=${userInfo}`, publicOrigin),
)
appendLoginSetCookie(profileResponse, loginResponse)
profileResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return profileResponse
}
if (source === DINGTALK_DIRECT_LOGIN_SOURCE) {
return createCookieLandingResponse(redirectPath, loginResponse)
}

View File

@ -1,22 +1,54 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
getDingtalkProfileCookieCandidates,
getDingtalkProfileCookiePath,
verifyDingtalkProfileToken,
} from '@/lib/dingtalk-profile-token'
export async function POST(request: NextRequest) {
try {
const { userId, department } = await request.json()
const session = await getServerSession(authOptions)
const sessionUserId = (session?.user as any)?.id
const profileCookieNames = getDingtalkProfileCookieCandidates(request)
let profileUserId: string | null = null
for (const cookieName of profileCookieNames) {
profileUserId = await verifyDingtalkProfileToken(request.cookies.get(cookieName)?.value)
if (profileUserId) break
}
const authorizedUserId = String(sessionUserId || profileUserId || '')
if (!userId || !department) {
if (!authorizedUserId) {
return NextResponse.json(
{ success: false, message: '未登录' },
{ status: 401 }
)
}
const { userId: requestedUserId, department } = await request.json()
const normalizedDepartment = typeof department === 'string' ? department.trim() : ''
if (!normalizedDepartment) {
return NextResponse.json(
{ success: false, message: '缺少必要参数' },
{ status: 400 }
)
}
console.log('完善用户信息(选择部门):', { userId, department })
if (requestedUserId && requestedUserId !== authorizedUserId) {
return NextResponse.json(
{ success: false, message: '无权限完善其他用户资料' },
{ status: 403 }
)
}
console.log('完善用户信息(选择部门):', { userId: authorizedUserId, department: normalizedDepartment })
// 获取当前钉钉用户信息
const currentUser = await prisma.user.findUnique({
where: { id: userId }
where: { id: authorizedUserId }
})
if (!currentUser) {
@ -26,32 +58,25 @@ export async function POST(request: NextRequest) {
)
}
// 双写:同时更新 department(旧字段)和 departmentId(新字段)
let updateData: any = {
department: department // 旧字段(向后兼容)
};
const departmentRecord = await prisma.department.findUnique({
where: { name: normalizedDepartment },
select: { id: true, name: true },
})
// 查找对应的部门ID
if (department && department.trim() !== '') {
const dept = await prisma.department.findUnique({
where: { name: department.trim() }
});
if (dept) {
updateData.departmentId = dept.id; // 新字段(阶段二新增)
} else {
// 部门不存在,清除 departmentId
updateData.departmentId = null;
}
} else {
// 部门为空,清除 departmentId
updateData.departmentId = null;
if (!departmentRecord) {
return NextResponse.json(
{ success: false, message: '部门不存在' },
{ status: 400 }
)
}
// 更新用户部门信息
const user = await prisma.user.update({
where: { id: userId },
data: updateData,
where: { id: authorizedUserId },
data: {
department: departmentRecord.name,
departmentId: departmentRecord.id,
},
include: {
departmentRef: true // 包含部门关联信息
}
@ -60,7 +85,7 @@ export async function POST(request: NextRequest) {
console.log('用户信息更新成功:', user.username)
// 返回用户信息(不包含敏感信息)
return NextResponse.json({
const response = NextResponse.json({
success: true,
message: '信息完善成功',
user: {
@ -75,13 +100,22 @@ export async function POST(request: NextRequest) {
status: (user as any).status || (user.isActive ? 'active' : 'pending') // 阶段一新增
}
})
for (const cookieName of profileCookieNames) {
response.cookies.set(cookieName, '', {
httpOnly: true,
secure: cookieName.startsWith('__Secure-'),
path: getDingtalkProfileCookiePath(),
maxAge: 0,
})
}
return response
} catch (error: any) {
} catch (error) {
console.error('完善用户信息失败:', error)
return NextResponse.json(
{
success: false,
message: error.message || '信息提交失败,请稍后再试'
message: '信息提交失败,请稍后再试'
},
{ status: 500 }
)

View File

@ -395,10 +395,12 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
if (data.needCompleteProfile) {
const userInfo = encodeURIComponent(JSON.stringify(data.user || {}))
return createPublicRedirectResponse({
const redirectResponse = createPublicRedirectResponse({
request,
redirectPath: `/complete-profile?userInfo=${userInfo}`,
})
appendSetCookies(redirectResponse, getLoginSetCookies(loginResponse))
return redirectResponse
}
return createSessionTicketTransferPage({ request, redirectPath, loginResponse })

View File

@ -13,6 +13,44 @@ 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';
import {
canUserViewMaterial,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission';
type AssetRouteSessionUser = {
id: string;
role?: string | null;
department?: string | null;
managedDepartment?: string | null;
isSuperAdmin?: boolean | null;
};
async function canManageMaterial(user: AssetRouteSessionUser, materialUserId: string) {
if (materialUserId === user.id || isSystemAdmin(user.role) || user.isSuperAdmin === true) {
return true;
}
return isSuperAdmin(user.id);
}
async function canViewMaterial(
user: AssetRouteSessionUser,
material: { userId: string; materialType: string },
) {
if (material.userId === user.id) {
return true;
}
return canUserViewMaterial(
user.department,
user.role || 'USER',
user.managedDepartment,
material.materialType,
user.id,
);
}
/**
* GET - 获取资产列表
@ -38,6 +76,7 @@ export async function GET(
select: {
id: true,
userId: true,
materialType: true,
previewUrl: true,
zipFilePath: true,
zipFileName: true,
@ -53,6 +92,13 @@ export async function GET(
);
}
if (!(await canViewMaterial(session.user as AssetRouteSessionUser, material))) {
return NextResponse.json(
{ success: false, error: '无权限查看此素材' },
{ status: 403 }
);
}
// 获取资产列表
const assets = await getAssetsByMaterialId(materialId);
@ -111,7 +157,7 @@ export async function POST(
);
}
if (material.userId !== userId) {
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
@ -196,7 +242,15 @@ export async function DELETE(
// 验证素材是否存在且属于当前用户
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) {
@ -206,7 +260,7 @@ export async function DELETE(
);
}
if (material.userId !== userId) {
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material.userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }

View File

@ -1,9 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import path from 'path';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import {
canUserDownloadMaterial,
canUserDownloadSource,
canUserViewMaterial,
isSystemAdmin,
isShareLinkAccess,
} from '@/lib/department-permission';
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
@ -12,6 +16,7 @@ import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
@ -64,16 +69,31 @@ function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl?
}
}
function buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
const zipFilePath = String(material?.zipFilePath || '').trim()
const zipFileName = String(material?.zipFileName || '').trim()
const rawSource = zipFilePath || zipFileName
function getSourceDisplayName(value: unknown): string {
const rawValue = String(value || '').trim()
if (!rawValue) {
return ''
}
try {
if (/^https?:\/\//i.test(rawValue)) {
return decodeURIComponent(path.posix.basename(new URL(rawValue).pathname))
}
} catch {
return ''
}
return path.basename(rawValue.replace(/\\/g, '/'))
}
function buildProtectedUploadUrl(rawSourceValue: unknown): string | null {
const rawSource = String(rawSourceValue || '').trim()
if (!rawSource) {
return null
}
if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) {
return rawSource
if (/^https?:\/\//i.test(rawSource)) {
return null
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
@ -96,7 +116,20 @@ function buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
}
}
return isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}`
return sourceRelativePath ? `/api/uploads/${sourceRelativePath}` : null
}
function buildSourceFileUrl(materialId: string): string {
return `/api/materials/download?id=${encodeURIComponent(materialId)}`
}
function isPreviewAssetType(assetType: unknown): boolean {
const normalized = String(assetType || '').trim()
return (
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
normalized.startsWith('standard-model-white-bg-view:') ||
normalized.startsWith('standard-model-white-bg-other:')
)
}
// GET /api/materials/[id] - 获取单个素材详情
@ -253,6 +286,7 @@ export async function GET(
// 权限检查:如果不是分享链接访问,检查查看权限
const isShare = isShareLinkAccess(request);
let canAccessSource = false;
const videoPreviewPolicy = getVideoPreviewPolicyFields(
(material as any).materialType,
(material as any).createdAt,
@ -302,10 +336,41 @@ export async function GET(
}
}
const canBypassVideoPreviewGate = userRole === 'ADMIN' || userIsSuperAdmin;
const canBypassVideoPreviewGate = isSystemAdmin(userRole) || userIsSuperAdmin;
if (videoPreviewPolicy.videoPreviewBlocked && !canBypassVideoPreviewGate) {
return buildVideoPreviewBlockedResponse();
}
if (isSystemAdmin(userRole) || userIsSuperAdmin) {
canAccessSource = true;
} else {
const userDownloadSetting = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
});
if (userDownloadSetting?.canDownload) {
const canDownloadMaterial = await canUserDownloadMaterial(
userDepartment,
userRole,
managedDepartment,
(material as any).materialType,
userId
);
const requiresSourcePermission =
isProductPackageType((material as any).materialType) ||
is3DOriginalType((material as any).materialType);
const hasSourcePermission = requiresSourcePermission
? await canUserDownloadSource(
userDepartment,
userRole,
managedDepartment,
'详情模板',
userId
)
: true;
canAccessSource = canDownloadMaterial && hasSourcePermission;
}
}
} else {
// 未登录用户且不是分享链接,不允许查看
return NextResponse.json(
@ -340,14 +405,14 @@ export async function GET(
renderer: materialWithUser.renderer,
authorName: displayAuthorName, // 使用处理后的作者名
previewUrl: materialWithUser.previewUrl,
zipFileName: materialWithUser.zipFileName,
zipFilePath: materialWithUser.zipFilePath,
zipFileName: getSourceDisplayName(materialWithUser.zipFileName || materialWithUser.zipFilePath),
zipFilePath: '',
zipFileSize: materialWithUser.zipFileSize.toString(),
downloadCount: materialWithUser.downloadCount,
likeCount: materialWithUser.likeCount,
displayImages: parseDisplayImages(materialWithUser.displayImages || null),
createdAt: materialWithUser.createdAt,
videoSourceUrl: materialWithUser.videoSourceUrl || null,
videoSourceUrl: canAccessSource ? materialWithUser.videoSourceUrl || null : null,
videoPreview540Url: materialWithUser.videoPreview540Url || null,
videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null,
videoCoverUrl: materialWithUser.videoCoverUrl || null,
@ -362,6 +427,7 @@ export async function GET(
Boolean(materialWithUser.isFullSet),
),
assets: formattedAssets,
canDownloadSource: canAccessSource,
user: {
username: materialWithUser.user?.username || '',
},
@ -369,32 +435,15 @@ export async function GET(
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
const { rewriteMediaUrlForClient } = await import('@/lib/url-rewrite');
const { detectIsIntranet } = await import('@/lib/network-detection');
const isIntranet = detectIsIntranet(request);
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = response.zipFileName;
if (response.zipFileName && response.materialType === '音乐') {
const zipFileName = response.zipFileName;
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
// 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理)
processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio');
} else {
// 新素材:本地文件
if (isIntranet) {
// 内网用户:直接访问
processedZipFileName = `/uploads/${zipFileName}`;
} else {
// 外网用户:通过API访问
processedZipFileName = `/api/uploads/${zipFileName}`;
}
}
if (response.zipFileName && response.materialType === '音乐' && canAccessSource) {
processedZipFileName = buildSourceFileUrl(response.id);
}
const rewrittenResponse = {
...response,
sourceFileUrl: buildSourceFileUrl(response, isIntranet),
sourceFileUrl: canAccessSource ? buildSourceFileUrl(response.id) : null,
// 图片:使用统一规则(小文件直接访问,大文件走代理)
previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'),
videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'),
@ -404,23 +453,26 @@ export async function GET(
// 视频:使用统一规则(通常>1MB,会走代理)
videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'),
videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'),
videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'),
videoSourceUrl: canAccessSource ? buildProtectedUploadUrl(response.videoSourceUrl) : null,
// 音频文件:特殊处理
zipFileName: processedZipFileName,
assets: formattedAssets.map((asset: any) => ({
...asset,
fileUrl: rewriteMediaUrlForClient(
asset.fileUrl,
request,
asset.fileKind === 'image' ? 'image' : 'source',
),
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()
: asset.fileSize != null
? String(asset.fileSize)
: '0',
})),
assets: formattedAssets.map((asset: any) => {
const isPreview = isPreviewAssetType(asset.assetType)
return {
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
fileSize:
typeof asset.fileSize === 'bigint'
? asset.fileSize.toString()
: asset.fileSize != null
? String(asset.fileSize)
: '0',
}
}),
};
return NextResponse.json(rewrittenResponse);

View File

@ -18,6 +18,9 @@ import {
type GroupedWhiteBackgroundAssets,
STANDARD_MODEL_WHITE_BG_VIEWS,
} from '@/lib/standard-model-white-background'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { canDownloadSkuMaterial } from '../../authorization'
type DownloadType = 'model' | 'white-background'
@ -37,11 +40,9 @@ function resolveModelDownloadFileName(
return `${skuName}${ext}`
}
function resolveLocalFilePath(fileUrl: string): string {
if (path.isAbsolute(fileUrl)) {
return fileUrl
}
return path.join(process.cwd(), fileUrl)
function resolveLocalFilePath(fileUrl: string): string | null {
const resolvedPath = resolveStoredUploadPath(fileUrl)
return isPathInAllowedDirs(resolvedPath, [getUploadRootDir()]) ? resolvedPath : null
}
function getWhiteBackgroundZipEntries(group: GroupedWhiteBackgroundAssets<WhiteBackgroundAssetRecord>) {
@ -98,6 +99,23 @@ export async function GET(
const { skuId } = params
const materialId = params.id
const material = await prisma.material.findFirst({
where: { id: materialId, deletedAt: null },
select: { id: true, userId: true, materialType: true },
})
if (!material) {
return NextResponse.json({ error: '素材不存在或已被删除' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型才支持 SKU 下载' }, { status: 400 })
}
if (!(await canDownloadSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限下载此素材' }, { status: 403 })
}
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
@ -148,7 +166,7 @@ export async function GET(
let totalSize = 0
for (const entry of allEntries) {
const absolutePath = resolveLocalFilePath(entry.fileUrl)
if (!existsSync(absolutePath)) {
if (!absolutePath || !existsSync(absolutePath)) {
console.error(`[SKU Download] 白底图文件不存在: ${absolutePath}`)
return NextResponse.json({ error: '白底图文件不存在' }, { status: 404 })
}
@ -166,11 +184,19 @@ export async function GET(
const zip = new JSZip()
for (const entry of fixedEntries) {
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
}
const fileBuffer = await fs.readFile(filePath)
zip.file(entry.fileName, fileBuffer)
}
for (const entry of otherEntries) {
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
}
const fileBuffer = await fs.readFile(filePath)
zip.file(`other/${entry.fileName}`, fileBuffer)
}
@ -197,14 +223,14 @@ export async function GET(
return NextResponse.json({ error: 'SKU 没有可下载的模型文件' }, { status: 404 })
}
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
const filePath = resolveLocalFilePath(sku.filePath)
const fileName = resolveModelDownloadFileName(
sku.skuName,
sku.fileName || null,
sku.filePath || null,
)
if (!existsSync(filePath)) {
if (!filePath || !existsSync(filePath)) {
console.error(`[SKU Download] 文件不存在: ${filePath}`)
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
}

View File

@ -10,6 +10,7 @@ import {
getWhiteBackgroundAssetWhereForSku,
getWhiteBackgroundRelativeDirectory,
} from '@/lib/standard-model-white-background'
import { canManageSkuMaterial } from '../authorization'
function normalizeSkuName(value: string): string {
return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim()
@ -40,11 +41,12 @@ export async function GET(
{ params }: { params: { id: string; skuId: string } }
) {
try {
const { skuId } = params
const { id: materialId, skuId } = params
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
materialId,
deletedAt: null,
},
})
@ -92,10 +94,10 @@ export async function PUT(
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { skuId } = params
const { id: materialId, skuId } = params
const existingSku = await prisma.materialSku.findUnique({
where: { id: skuId },
const existingSku = await prisma.materialSku.findFirst({
where: { id: skuId, materialId, deletedAt: null },
include: {
material: {
select: {
@ -111,6 +113,10 @@ export async function PUT(
const userId = (session.user as any).id
if (!(await canManageSkuMaterial(session.user as any, existingSku.material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const body = await request.json()
const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize, sortOrder } = body
const normalizedSkuName = skuName !== undefined ? normalizeSkuName(skuName) : undefined
@ -225,10 +231,10 @@ export async function DELETE(
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { skuId } = params
const { id: materialId, skuId } = params
const existingSku = await prisma.materialSku.findUnique({
where: { id: skuId },
const existingSku = await prisma.materialSku.findFirst({
where: { id: skuId, materialId, deletedAt: null },
include: {
material: {
select: {
@ -244,6 +250,10 @@ export async function DELETE(
const userId = (session.user as any).id
if (!(await canManageSkuMaterial(session.user as any, existingSku.material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const whiteBgAssets = await prisma.materialAsset.findMany({
where: {
materialId: existingSku.materialId,

View File

@ -0,0 +1,42 @@
import {
canUserDownloadMaterial,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
export interface SkuRouteSessionUser {
id: string
role?: string | null
department?: string | null
managedDepartment?: string | null
isSuperAdmin?: boolean | null
}
export interface SkuRouteMaterial {
userId: string
materialType: string
}
export async function canManageSkuMaterial(
user: SkuRouteSessionUser,
material: Pick<SkuRouteMaterial, 'userId'>,
): Promise<boolean> {
if (material.userId === user.id || isSystemAdmin(user.role) || user.isSuperAdmin === true) {
return true
}
return isSuperAdmin(user.id)
}
export async function canDownloadSkuMaterial(
user: SkuRouteSessionUser,
material: SkuRouteMaterial,
): Promise<boolean> {
return canUserDownloadMaterial(
user.department,
user.role || 'USER',
user.managedDepartment,
material.materialType,
user.id,
)
}

View File

@ -13,6 +13,9 @@ import {
STANDARD_MODEL_WHITE_BG_VIEWS,
type GroupedWhiteBackgroundAssets,
} from '@/lib/standard-model-white-background'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { canDownloadSkuMaterial } from '../authorization'
type DownloadType = 'model' | 'white-background'
@ -32,11 +35,9 @@ function resolveModelDownloadFileName(
return `${skuName}${ext}`
}
function resolveLocalFilePath(fileUrl: string): string {
if (path.isAbsolute(fileUrl)) {
return fileUrl
}
return path.join(process.cwd(), fileUrl)
function resolveLocalFilePath(fileUrl: string): string | null {
const resolvedPath = resolveStoredUploadPath(fileUrl)
return isPathInAllowedDirs(resolvedPath, [getUploadRootDir()]) ? resolvedPath : null
}
function getWhiteBackgroundZipEntries(group: GroupedWhiteBackgroundAssets<WhiteBackgroundAssetRecord>) {
@ -96,7 +97,7 @@ export async function POST(
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, userId: true },
})
if (!material) {
@ -107,6 +108,10 @@ export async function POST(
return NextResponse.json({ error: '只有标准模型类型才支持 SKU 批量下载' }, { status: 400 })
}
if (!(await canDownloadSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限下载此素材' }, { status: 403 })
}
const skus = await prisma.materialSku.findMany({
where: {
id: { in: skuIds },
@ -160,12 +165,20 @@ export async function POST(
try {
for (const entry of fixedEntries) {
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
throw new Error('白底图文件路径无效')
}
const fileBuffer = await fs.readFile(filePath)
zip.file(`${sku.skuName}/${entry.fileName}`, fileBuffer)
}
for (const entry of otherEntries) {
const fileBuffer = await fs.readFile(resolveLocalFilePath(entry.fileUrl))
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
throw new Error('白底图文件路径无效')
}
const fileBuffer = await fs.readFile(filePath)
zip.file(`${sku.skuName}/other/${entry.fileName}`, fileBuffer)
}
@ -181,7 +194,10 @@ export async function POST(
continue
}
const filePath = path.join(process.cwd(), 'uploads', sku.filePath)
const filePath = resolveLocalFilePath(sku.filePath)
if (!filePath) {
throw new Error('SKU 文件路径无效')
}
const fileName = resolveModelDownloadFileName(
sku.skuName,
sku.fileName || null,

View File

@ -5,6 +5,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { canManageSkuMaterial } from '../authorization'
// 批量更新 SKU 排序
export async function PUT(
@ -21,7 +22,7 @@ export async function PUT(
// 检查素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId },
where: { id: materialId, deletedAt: null },
select: { id: true, userId: true }
})
@ -31,6 +32,10 @@ export async function PUT(
const userId = (session.user as any).id;
if (!(await canManageSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const body = await request.json()
const { skuIds } = body // 按新顺序排列的 SKU ID 数组

View File

@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma'
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log'
import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview'
import { canManageSkuMaterial } from './authorization'
import {
countWhiteBackgroundAssets,
countWhiteBackgroundFixedViews,
@ -55,7 +56,7 @@ export async function GET(
const excludeSkuId = searchParams.get('excludeSkuId') || ''
const material = await prisma.material.findUnique({
where: { id: materialId },
where: { id: materialId, deletedAt: null },
select: { id: true, materialType: true, userId: true },
})
@ -183,8 +184,8 @@ export async function POST(
const materialId = params.id
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, materialType: true },
where: { id: materialId, deletedAt: null },
select: { id: true, materialType: true, userId: true },
})
if (!material) {
@ -195,6 +196,10 @@ export async function POST(
return NextResponse.json({ error: '只有标准模型类型才能添加 SKU' }, { status: 400 })
}
if (!(await canManageSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
body = await request.json()
const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize } = body
normalizedSkuName = normalizeSkuName(skuName)

View File

@ -5,6 +5,8 @@ import { prisma } from '@/lib/prisma'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { existsSync } from 'fs'
import { sanitizeFileName } from '@/lib/path-security'
import { canManageSkuMaterial } from '../authorization'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -25,7 +27,7 @@ export async function POST(
// 检查素材是否存在且是标准模型类型
const material = await prisma.material.findUnique({
where: { id: materialId },
where: { id: materialId, deletedAt: null },
select: { id: true, materialType: true, userId: true }
})
@ -37,6 +39,10 @@ export async function POST(
return NextResponse.json({ error: '只有标准模型类型才能上传 SKU' }, { status: 400 })
}
if (!(await canManageSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const formData = await request.formData()
const skuName = formData.get('skuName') as string
const file = formData.get('file') as File
@ -53,8 +59,8 @@ export async function POST(
// 生成文件名
const timestamp = Date.now()
const ext = path.extname(file.name)
const fileName = `${skuName}_${timestamp}${ext}`
const ext = path.extname(path.basename(file.name))
const fileName = `${sanitizeFileName(skuName)}_${timestamp}${ext}`
const filePath = path.join(uploadDir, fileName)
// 保存文件

View File

@ -2,9 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import { detectIsIntranet } from '@/lib/network-detection';
import { canUserDownloadSource, isSuperAdmin } from '@/lib/department-permission';
import path from 'path';
import { rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import {
canUserDownloadMaterial,
canUserDownloadSource,
hasSystemAdminPermission,
} from '@/lib/department-permission';
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
@ -34,16 +38,31 @@ function parseDisplayImages(displayImages: any): string[] {
return [];
}
function buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
const zipFilePath = String(material?.zipFilePath || '').trim()
const zipFileName = String(material?.zipFileName || '').trim()
const rawSource = zipFilePath || zipFileName
function getSourceDisplayName(value: unknown): string {
const rawValue = String(value || '').trim()
if (!rawValue) {
return ''
}
try {
if (/^https?:\/\//i.test(rawValue)) {
return decodeURIComponent(path.posix.basename(new URL(rawValue).pathname))
}
} catch {
return ''
}
return path.basename(rawValue.replace(/\\/g, '/'))
}
function buildProtectedUploadUrl(rawSourceValue: unknown): string | null {
const rawSource = String(rawSourceValue || '').trim()
if (!rawSource) {
return null
}
if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) {
return rawSource
if (/^https?:\/\//i.test(rawSource)) {
return null
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
@ -66,7 +85,20 @@ function buildSourceFileUrl(material: any, isIntranet: boolean): string | null {
}
}
return isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}`
return sourceRelativePath ? `/api/uploads/${sourceRelativePath}` : null
}
function buildSourceFileUrl(materialId: string): string {
return `/api/materials/download?id=${encodeURIComponent(materialId)}`
}
function isPreviewAssetType(assetType: unknown): boolean {
const normalized = String(assetType || '').trim()
return (
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
normalized.startsWith('standard-model-white-bg-view:') ||
normalized.startsWith('standard-model-white-bg-other:')
)
}
// GET /api/materials/share - 获取分享素材信息(无需登录,但登录用户会返回点赞和收藏状态)
@ -226,31 +258,52 @@ export async function GET(request: NextRequest) {
// 任务-详情:检查 PSD 下载权限(参考"详情模板"类型的权限)
// 任务-3D原图等:检查 ZIP 下载权限(参考"详情模板"类型的权限)
let canDownloadSource = false;
if ((isProductPackageType(material.materialType as unknown as string) || is3DOriginalType(material.materialType as unknown as string)) && userId) {
let canAccessSource = false;
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
department: true,
managedDepartment: true,
canDownload: true,
isSuperAdmin: true,
},
});
if (user) {
// 任务-详情的 PSD 下载权限和任务-3D原图等的 ZIP 下载权限都参考"详情模板"类型的 canDownloadSource 权限
canDownloadSource = await canUserDownloadSource(
user.department,
user.role,
user.managedDepartment,
'详情模板', // 使用"详情模板"类型检查权限
userId
);
if (hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
canAccessSource = true;
} else if (user.canDownload) {
const canDownloadMaterial = await canUserDownloadMaterial(
user.department,
user.role,
user.managedDepartment,
material.materialType as unknown as string,
userId
);
const requiresSourcePermission =
isProductPackageType(material.materialType as unknown as string) ||
is3DOriginalType(material.materialType as unknown as string);
const hasSourcePermission = requiresSourcePermission
? await canUserDownloadSource(
user.department,
user.role,
user.managedDepartment,
'详情模板',
userId
)
: true;
canAccessSource = canDownloadMaterial && hasSourcePermission;
}
}
}
const response: any = {
...material,
zipFileName: getSourceDisplayName(material.zipFileName || material.zipFilePath),
zipFilePath: '',
videoSourceUrl: canAccessSource ? material.videoSourceUrl : null,
reportTitle: (material as any).reportTitle || null,
isAiGenerated: Boolean((material as any).isAiGenerated),
taskDetailSourceType: (material as any).taskDetailSourceType || null,
@ -259,7 +312,7 @@ export async function GET(request: NextRequest) {
isLiked,
isFavorited,
assets: needsAssetsList(material.materialType as unknown as string) ? assets : undefined,
canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canDownloadSource : undefined,
canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canAccessSource : undefined,
isFullSet: deriveTaskDetailIsFullSet(
material.materialType as unknown as string,
rawAssetsForFullSet,
@ -268,30 +321,15 @@ export async function GET(request: NextRequest) {
};
// 使用统一的媒体URL重写规则(方案C:按文件大小阈值)
const isIntranet = detectIsIntranet(request);
// 处理音频文件的zipFileName(特殊处理)
let processedZipFileName = response.zipFileName;
if (response.zipFileName && response.materialType === '音乐') {
const zipFileName = response.zipFileName;
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
// 老素材:S3 URL,使用统一规则(音频通常>1MB,会走代理)
processedZipFileName = rewriteMediaUrlForClient(zipFileName, request, 'audio');
} else {
// 新素材:本地文件
if (isIntranet) {
// 内网用户:直接访问
processedZipFileName = `/uploads/${zipFileName}`;
} else {
// 外网用户:通过API访问
processedZipFileName = `/api/uploads/${zipFileName}`;
}
}
if (response.zipFileName && response.materialType === '音乐' && canAccessSource) {
processedZipFileName = buildSourceFileUrl(response.id);
}
const rewrittenResponse = {
...response,
sourceFileUrl: buildSourceFileUrl(response, isIntranet),
sourceFileUrl: canAccessSource ? buildSourceFileUrl(response.id) : null,
// 图片:使用统一规则(小文件直接访问,大文件走代理)
previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'),
videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'),
@ -301,18 +339,21 @@ export async function GET(request: NextRequest) {
// 视频:使用统一规则(通常>1MB,会走代理)
videoPreview540Url: rewriteMediaUrlForClient(response.videoPreview540Url, request, 'video'),
videoPreviewClipUrl: rewriteMediaUrlForClient(response.videoPreviewClipUrl, request, 'video'),
videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'),
videoSourceUrl: canAccessSource ? buildProtectedUploadUrl(response.videoSourceUrl) : null,
// 音频文件:特殊处理
zipFileName: processedZipFileName,
// 任务-详情和任务-3D原图等:重写 assets 中的 fileUrl
assets: response.assets?.map((asset: any) => ({
...asset,
fileUrl: rewriteMediaUrlForClient(
asset.fileUrl,
request,
asset.fileKind === 'image' ? 'image' : 'source',
),
})),
assets: response.assets?.map((asset: any) => {
const isPreview = isPreviewAssetType(asset.assetType)
return {
...asset,
fileUrl: isPreview
? rewriteMediaUrlForClient(asset.fileUrl, request, 'image')
: canAccessSource && asset.assetType === 'report-pdf'
? buildProtectedUploadUrl(asset.fileUrl) || ''
: '',
}
}),
};
return NextResponse.json(rewrittenResponse);

View File

@ -1,14 +1,43 @@
// 视频转码API - 完整版本(支持自动恢复)
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import { transcodeQueue, cleanupTranscodeResidues, VIDEO_MATERIAL_TYPES } from '@/lib/video-transcode-queue';
import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
import { executeTranscodeTask } from '@/lib/video-transcode-task';
import { authOptions } from '@/lib/auth';
import { canUserViewMaterial, hasSystemAdminPermission } from '@/lib/department-permission';
export const dynamic = 'force-dynamic';
export const maxDuration = 3600; // 1小时
// 恢复标志:确保只执行一次
let recoveryChecked = false;
const MATERIAL_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const STATUS_RATE_LIMIT = 120;
const START_RATE_LIMIT = 5;
const requestCounts = new Map<string, { count: number; resetAt: number }>();
function consumeRateLimit(key: string, maxRequests: number): number | null {
const now = Date.now();
const current = requestCounts.get(key);
if (!current || current.resetAt <= now) {
requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return null;
}
if (current.count >= maxRequests) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000));
}
current.count += 1;
return null;
}
function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: '请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
);
}
async function countInterruptedVideoTasks(): Promise<number> {
try {
@ -76,16 +105,32 @@ async function ensureRecoveryCompleted(): Promise<void> {
// GET - 查询转码状态
export async function GET(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务(包括 GET 请求)
await ensureRecoveryCompleted();
try {
const { searchParams } = new URL(request.url);
const materialId = searchParams.get('materialId');
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
if (!materialId) {
const sessionUser = session.user as any;
const isSystemAdministrator = hasSystemAdminPermission(
sessionUser.role,
Boolean(sessionUser.isSuperAdmin),
);
const retryAfter = consumeRateLimit(`status:${String(sessionUser.id || 'unknown')}`, STATUS_RATE_LIMIT);
if (retryAfter) {
return rateLimitResponse(retryAfter);
}
if (isSystemAdministrator) {
await ensureRecoveryCompleted();
}
const { searchParams } = new URL(request.url);
const materialId = String(searchParams.get('materialId') || '').trim();
if (!MATERIAL_ID_PATTERN.test(materialId)) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ error: '素材ID格式无效' },
{ status: 400 }
);
}
@ -94,6 +139,8 @@ export async function GET(request: NextRequest) {
where: { id: materialId },
select: {
id: true,
userId: true,
materialType: true,
videoProcessingStatus: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
@ -111,6 +158,19 @@ export async function GET(request: NextRequest) {
);
}
if (!isSystemAdministrator && (material as any).userId !== sessionUser.id) {
const canView = await canUserViewMaterial(
sessionUser.department,
sessionUser.role,
sessionUser.managedDepartment,
(material as any).materialType,
sessionUser.id,
);
if (!canView) {
return NextResponse.json({ error: '无权限查看该素材转码状态' }, { status: 403 });
}
}
// 获取转码队列状态
const queueStatus = transcodeQueue.getStatus();
const isProcessing = queueStatus.processingIds.includes(materialId);
@ -125,14 +185,14 @@ export async function GET(request: NextRequest) {
hasCover: !!material.videoCoverUrl,
duration: material.videoDuration,
attempts: material.transcodeAttempts || 0,
lastError: material.transcodeLastError,
lastError: isSystemAdministrator ? material.transcodeLastError : null,
progress: isProcessing ? queueStatus.progress.get(materialId) || null : null,
stage: isProcessing ? queueStatus.stages.get(materialId) || null : null,
queue: {
waitingIndex: waitingIndex >= 0 ? waitingIndex + 1 : null,
waitingTotal: waitingTotal,
processingIds: queueStatus.processingIds,
maxConcurrent: queueStatus.maxConcurrent,
waitingTotal: isSystemAdministrator ? waitingTotal : waitingIndex >= 0 ? 1 : 0,
processingIds: isProcessing ? [materialId] : [],
maxConcurrent: isSystemAdministrator ? queueStatus.maxConcurrent : null,
},
});
} catch (error) {
@ -146,23 +206,50 @@ export async function GET(request: NextRequest) {
// POST - 启动转码任务
export async function POST(request: NextRequest) {
// 懒加载恢复:第一次调用时自动恢复中断的任务
await ensureRecoveryCompleted();
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const sessionUser = session.user as any;
const isSystemAdministrator = hasSystemAdminPermission(
sessionUser.role,
Boolean(sessionUser.isSuperAdmin),
);
const retryAfter = consumeRateLimit(`start:${String(sessionUser.id || 'unknown')}`, START_RATE_LIMIT);
if (retryAfter) {
return rateLimitResponse(retryAfter);
}
const contentLength = Number(request.headers.get('content-length') || '0');
if (Number.isFinite(contentLength) && contentLength > 2048) {
return NextResponse.json({ error: '请求参数过大' }, { status: 413 });
}
if (isSystemAdministrator) {
await ensureRecoveryCompleted();
}
const { materialId, forceRetry } = await request.json();
if (!materialId) {
if (!MATERIAL_ID_PATTERN.test(String(materialId || '').trim())) {
return NextResponse.json(
{ error: '缺少素材ID' },
{ error: '素材ID格式无效' },
{ status: 400 }
);
}
if (forceRetry !== undefined && typeof forceRetry !== 'boolean') {
return NextResponse.json({ error: 'forceRetry 参数必须是布尔值' }, { status: 400 });
}
const normalizedMaterialId = String(materialId).trim();
const material = await prisma.material.findFirst({
where: { id: materialId, deletedAt: null },
where: { id: normalizedMaterialId, deletedAt: null },
select: {
id: true,
userId: true,
videoSourceUrl: true,
videoCoverUrl: true,
videoProcessingStatus: true,
@ -177,6 +264,10 @@ export async function POST(request: NextRequest) {
);
}
if (!isSystemAdministrator && (material as any).userId !== String(sessionUser.id || '')) {
return NextResponse.json({ error: '只能启动自己素材的转码任务' }, { status: 403 });
}
// 检查是否为视频类型
if (!VIDEO_MATERIAL_TYPES.includes((material as any).materialType)) {
return NextResponse.json(
@ -196,14 +287,14 @@ export async function POST(request: NextRequest) {
// 检查转码状态
if ((material as any).videoProcessingStatus === 'processing' && !forceRetry) {
return NextResponse.json(
{ error: '转码任务正在进行中', materialId },
{ error: '转码任务正在进行中', materialId: normalizedMaterialId },
{ status: 409 }
);
}
if ((material as any).videoProcessingStatus === 'done' && !forceRetry) {
return NextResponse.json(
{ message: '转码已完成', materialId },
{ message: '转码已完成', materialId: normalizedMaterialId },
{ status: 200 }
);
}
@ -213,19 +304,25 @@ export async function POST(request: NextRequest) {
// 加入转码队列
await transcodeQueue.add(
materialId,
normalizedMaterialId,
(material as any).videoSourceUrl,
needsCoverImage,
executeTranscodeTask
);
console.log(`[Transcode] Task queued for material ${materialId}`);
console.log(`[Transcode] Task queued for material ${normalizedMaterialId}`);
const queueStatus = transcodeQueue.getStatus();
return NextResponse.json({
message: '转码任务已启动',
materialId,
materialId: normalizedMaterialId,
status: 'processing',
queueStatus: transcodeQueue.getStatus(),
queueStatus: isSystemAdministrator
? queueStatus
: {
waitingIndex: queueStatus.waiting.findIndex((id) => id === normalizedMaterialId) + 1 || null,
processing: queueStatus.processingIds.includes(normalizedMaterialId),
},
});
} catch (error) {
console.error('[Transcode] POST error:', error);

View File

@ -74,12 +74,12 @@ export async function POST(request: NextRequest) {
// 步骤1: 从chunk临时目录读取原图文件
console.log(`[3D Original] Processing ${assets.main.length} original images...`);
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
if (filePath) {
originalFiles.push({
path: filePath,

View File

@ -74,12 +74,12 @@ export async function POST(request: NextRequest) {
// 步骤1: 从chunk临时目录读取原图文件
console.log(`[AI Original] Processing ${assets.main.length} original images...`);
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
if (filePath) {
originalFiles.push({
path: filePath,

View File

@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '素材类型不匹配' }, { status: 400 })
}
const { getUploadedFile } = await import('@/lib/upload-sessions')
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions')
const assetRecords: Array<{
materialId: string
assetType: string
@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
for (let index = 0; index < files.length; index += 1) {
const item = files[index]
const filePath = await getUploadedFile(item.uploadId)
const filePath = await getOwnedUploadedFile(item.uploadId, (session.user as any).id)
if (!filePath) {
throw new Error(`找不到上传文件:${item.fileName}`)

View File

@ -3,7 +3,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getUploadSession } from '@/lib/upload-sessions'
import {
deleteOwnedUploadSession,
getOwnedUploadSession,
getUploadSession,
isSafeUploadId,
resolveOwnedMergedUpload,
} from '@/lib/upload-sessions'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { promises as fs } from 'fs'
import path from 'path'
@ -11,6 +18,30 @@ export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5分钟
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(key: string, maxRequests: number): number | null {
const now = Date.now()
const current = requestCounts.get(key)
if (!current || current.resetAt <= now) {
requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= maxRequests) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
function rateLimitResponse(retryAfter: number) {
return NextResponse.json(
{ error: '清理请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
/**
* 清理单个上传会话的临时文件
*/
@ -65,7 +96,12 @@ async function cleanupUploadSession(uploadId: string, uploadDir: string): Promis
console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, result.error)
}
return { success: true, deletedFiles, freedSpace }
return {
success: result.success,
deletedFiles,
freedSpace,
error: result.success ? undefined : result.error || '删除临时目录失败',
}
} catch (error: any) {
return {
success: false,
@ -90,16 +126,29 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
// 检查是否为管理员(可选,如果需要限制权限)
// const user = await prisma.user.findUnique({ where: { id: session.user.id } })
// if (user?.role !== 'SUPER_ADMIN') {
// return NextResponse.json({ error: '权限不足' }, { status: 403 })
// }
const sessionUser = session.user as any
if (!hasSystemAdminPermission(sessionUser?.role, Boolean(sessionUser?.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行批量清理' }, { status: 403 })
}
const retryAfter = consumeRateLimit(`bulk:${String(sessionUser?.id || 'unknown')}`, 5)
if (retryAfter) {
return rateLimitResponse(retryAfter)
}
const { searchParams } = new URL(req.url)
const uploadId = searchParams.get('uploadId')
const expiredOnly = searchParams.get('expiredOnly') !== 'false'
const maxAgeHours = parseInt(searchParams.get('maxAge') || '8', 10)
const maxAgeHours = Number(searchParams.get('maxAge') || '8')
const scanLimit = Number(searchParams.get('limit') || '100')
if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) {
return NextResponse.json({ error: 'maxAge 必须是 1 到 168 之间的整数' }, { status: 400 })
}
if (!Number.isInteger(scanLimit) || scanLimit < 1 || scanLimit > 500) {
return NextResponse.json({ error: 'limit 必须是 1 到 500 之间的整数' }, { status: 400 })
}
if (uploadId && !isSafeUploadId(uploadId)) {
return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 })
}
const maxAge = maxAgeHours * 60 * 60 * 1000 // 转换为毫秒
const uploadDir = process.env.UPLOAD_DIR || './uploads'
@ -132,10 +181,13 @@ export async function GET(req: NextRequest) {
} else {
// 清理所有过期或无效的临时文件
const now = Date.now()
const tempDirs = await fs.readdir(tempBaseDir)
const tempDirs = (await fs.readdir(tempBaseDir)).slice(0, scanLimit)
for (const dir of tempDirs) {
const uploadIdFromDir = dir
if (!isSafeUploadId(uploadIdFromDir)) {
continue
}
const tempDir = path.join(tempBaseDir, dir)
try {
@ -219,26 +271,54 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const { uploadId, existingFilePath } = await req.json()
const retryAfter = consumeRateLimit(`own:${String((session.user as any)?.id || 'unknown')}`, 30)
if (retryAfter) {
return rateLimitResponse(retryAfter)
}
const body = await req.json()
const uploadId = typeof body?.uploadId === 'string' ? body.uploadId.trim() : ''
const existingFilePath =
typeof body?.existingFilePath === 'string' ? body.existingFilePath.trim() : ''
if (!uploadId && !existingFilePath) {
return NextResponse.json({ error: '缺少 uploadId 或 existingFilePath' }, { status: 400 })
}
if (uploadId) {
// 验证会话归属
const uploadSession = await getUploadSession(uploadId)
if (uploadSession && uploadSession.userId !== (session.user as any).id) {
return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 })
}
if (uploadId && !isSafeUploadId(uploadId)) {
return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 })
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
let deletedFiles = 0
let freedSpace = 0
const currentUserId = String((session.user as any).id || '')
let effectiveUploadId = uploadId
if (uploadId) {
const result = await cleanupUploadSession(uploadId, uploadDir)
if (existingFilePath) {
const ownedMergedUpload = await resolveOwnedMergedUpload({
existingFilePath,
uploadId: uploadId || null,
userId: currentUserId,
})
if (!ownedMergedUpload) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
effectiveUploadId = ownedMergedUpload.uploadId
} else {
const ownedSession = await getOwnedUploadSession(uploadId, currentUserId)
if (!ownedSession) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
}
const deletedSession = await deleteOwnedUploadSession(effectiveUploadId, currentUserId)
if (!deletedSession) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
if (effectiveUploadId) {
const result = await cleanupUploadSession(effectiveUploadId, uploadDir)
if (!result.success) {
return NextResponse.json(
{ error: result.error || '清理失败' },
@ -249,43 +329,6 @@ export async function POST(req: NextRequest) {
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')
await deleteUploadSession(uploadId)
} catch (err) {
console.warn('[Cleanup] 删除上传会话失败:', err)
}
}
return NextResponse.json({
message: '清理成功',
deletedFiles,

View File

@ -7,39 +7,20 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { completeProductPackageUpload } from '@/lib/product-package-completion'
import {
completeProductPackageUpload,
parsePublicProductPackageAssets,
} from '@/lib/product-package-completion'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
interface AssetUpload {
uploadId?: string
fileUrl?: string
fileName: string
fileSize: number
sortOrder?: number
skuCode?: string
barcodes?: string[]
}
interface PsdAsset {
uploadId?: string
fileUrl?: string
fileName: string
fileSize: number
}
interface CompleteRequestBody {
materialId: string
taskNo?: string
noBarcodes?: boolean
assets: {
main?: AssetUpload[]
sku?: AssetUpload[]
detail?: AssetUpload[]
psd?: PsdAsset[]
}
assets: unknown
}
export async function POST(request: NextRequest) {
@ -53,6 +34,19 @@ export async function POST(request: NextRequest) {
}
const body: CompleteRequestBody = await request.json()
let assets
try {
assets = parsePublicProductPackageAssets(body.assets)
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : '资产参数无效',
},
{ status: 400 },
)
}
const material = await prisma.material.findUnique({
where: { id: body.materialId },
select: {
@ -78,7 +72,7 @@ export async function POST(request: NextRequest) {
const result = await completeProductPackageUpload({
materialId: body.materialId,
taskNo: body.taskNo,
assets: body.assets,
assets,
operatorUserId: (session.user as any).id,
options: {
allowSkuWithoutBarcodes: body.noBarcodes === true,

View File

@ -74,12 +74,12 @@ export async function POST(request: NextRequest) {
// 步骤1: 从chunk临时目录读取原图文件
console.log(`[Real Photo] Processing ${assets.main.length} original images...`);
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const originalFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
if (filePath) {
originalFiles.push({
path: filePath,

View File

@ -23,6 +23,11 @@ import {
LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES,
normalizeVideoMaterialType,
} from '@/lib/video-material-types';
import {
deleteOwnedUploadSession,
resolveOwnedMergedUpload,
type OwnedMergedUpload,
} from '@/lib/upload-sessions';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -189,6 +194,7 @@ export async function POST(request: NextRequest) {
const uploadedFileUrls: string[] = [];
let zipFilePath = '';
let materialId: string | null = null;
let ownedMergedUpload: OwnedMergedUpload | null = null;
try {
console.log('[Upload] Starting upload process...');
@ -210,6 +216,7 @@ export async function POST(request: NextRequest) {
const zipFile = formData.get('zipFile') as File | null;
// 支持分片上传后复用服务器已合并的文件
const existingFilePath = (formData.get('existingFilePath') as string) || '';
const suppliedUploadId = (formData.get('uploadId') as string | null) || null;
const originalFileName = (formData.get('originalFileName') as string) || '';
const productModelInput = (formData.get('productModel') as string) || '';
const categoryName = formData.get('categoryName') as string;
@ -232,6 +239,28 @@ export async function POST(request: NextRequest) {
const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName;
const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName;
if (zipFile && existingFilePath) {
return NextResponse.json(
{ error: '不能同时提交上传文件和服务器合并文件' },
{ status: 400 }
);
}
if (existingFilePath) {
ownedMergedUpload = await resolveOwnedMergedUpload({
existingFilePath,
uploadId: suppliedUploadId,
userId: (session.user as any).id,
});
if (!ownedMergedUpload) {
return NextResponse.json(
{ error: '上传会话不存在、已失效或不属于当前用户' },
{ status: 403 }
);
}
}
console.log('[Upload] Form data received:', {
hasPreviewImage: !!previewImage,
previewImagesCount: previewImages.length,
@ -503,7 +532,7 @@ export async function POST(request: NextRequest) {
// 处理素材文件(主文件:3D场景/PSD/视频源等)
let zipFileName = '';
let zipFilePath = '';
zipFilePath = '';
let zipFileSize = BigInt(0);
if (zipFile || existingFilePath) {
@ -518,7 +547,7 @@ export async function POST(request: NextRequest) {
console.log('[Upload] Using uploaded file buffer');
} else {
// 使用分片合并后的服务器侧文件
sourcePath = existingFilePath;
sourcePath = ownedMergedUpload!.filePath;
const stat = await fs.stat(sourcePath);
zipFileSize = BigInt(stat.size);
ext = path.extname(originalFileName || sourcePath);
@ -867,12 +896,18 @@ export async function POST(request: NextRequest) {
}
// 清理上传会话(如果使用了分片上传)
const uploadId = formData.get('uploadId') as string | null;
if (uploadId) {
if (ownedMergedUpload) {
try {
const { deleteUploadSession } = await import('@/lib/upload-sessions');
await deleteUploadSession(uploadId);
console.log('[Upload] 已清理上传会话:', uploadId);
const deleted = await deleteOwnedUploadSession(
ownedMergedUpload.uploadId,
(session.user as any).id
);
if (!deleted) {
throw new Error('上传会话所有权校验失败');
}
const { cleanupUploadSession } = await import('@/lib/temp-cleanup');
await cleanupUploadSession(ownedMergedUpload.uploadId);
console.log('[Upload] 已清理上传会话:', ownedMergedUpload.uploadId);
} catch (cleanupError) {
console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError);
}

View File

@ -74,12 +74,12 @@ export async function POST(request: NextRequest) {
// 步骤1: 从chunk临时目录读取预览图文件
console.log(`[Task Page] Processing ${assets.main.length} preview images...`);
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const previewFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
if (filePath) {
previewFiles.push({
path: filePath,

View File

@ -74,12 +74,12 @@ export async function POST(request: NextRequest) {
// 步骤1: 从chunk临时目录读取截图文件
console.log(`[Video Screenshot] Processing ${assets.main.length} screenshots...`);
const { getUploadedFile } = await import('@/lib/upload-sessions');
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const screenshotFiles: Array<{ path: string; name: string; size: number }> = [];
for (const asset of assets.main) {
try {
const filePath = await getUploadedFile(asset.uploadId);
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
if (filePath) {
screenshotFiles.push({
path: filePath,

View File

@ -1,10 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
const MAX_TEST_SIZE_BYTES = 5 * 1024 * 1024
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 10
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 下载测速API
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行下载测速' }, { status: 403 })
}
const retryAfter = consumeRateLimit(String(user.id || 'unknown'))
if (retryAfter) {
return NextResponse.json(
{ error: '请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
const body = await request.json()
const size = body.size || 1024 * 1024 // 默认1MB
const size = body?.size === undefined ? 1024 * 1024 : Number(body.size)
if (!Number.isInteger(size) || size < 1 || size > MAX_TEST_SIZE_BYTES) {
return NextResponse.json(
{ error: `size 必须是 1 到 ${MAX_TEST_SIZE_BYTES} 之间的整数` },
{ status: 400 },
)
}
// 生成测试数据
const buffer = Buffer.alloc(size, 0)
@ -12,7 +58,8 @@ export async function POST(request: NextRequest) {
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': size.toString()
'Content-Length': size.toString(),
'Cache-Control': 'no-store',
}
})
} catch (error) {

View File

@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import os from 'os'
import { exec } from 'child_process'
import { promisify } from 'util'
@ -6,8 +7,28 @@ import { getAllSessions } from '@/lib/upload-sessions-redis'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { prisma } from '@/lib/prisma'
import { getNetworkSpeed } from '@/lib/network-bandwidth-monitor'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
const execAsync = promisify(exec)
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 40
const MAX_VISIBLE_UPLOAD_SESSIONS = 100
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 性能监控 - 快速统计接口(用于悬停卡片)
// GET /api/performance/quick
@ -83,8 +104,27 @@ async function getCpuUsage(): Promise<number> {
return estimatedUsage
}
export async function GET() {
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可查看性能数据' }, { status: 403 })
}
const rateLimitKey = `${String(user.id || 'unknown')}:${request.headers.get('x-forwarded-for') || ''}`
const retryAfter = consumeRateLimit(rateLimitKey)
if (retryAfter) {
return NextResponse.json(
{ error: '请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
// 1. 系统性能(先用 Node.js os 模块,后续可接 TrueNAS)
const loadAvg = os.loadavg() // [1min, 5min, 15min]
const totalMem = os.totalmem()
@ -132,7 +172,14 @@ export async function GET() {
const ACTIVE_THRESHOLD = 5 * 60 * 1000 // 5分钟内有活动 → 认为“活跃”
const MAX_SHOW_THRESHOLD = 60 * 60 * 1000 // 超过1小时无活动 → 不再展示(避免长期误导)
uploadSessions.forEach((session, uploadId) => {
Array.from(uploadSessions.entries())
.sort(([, left], [, right]) => {
const leftActivity = left.lastActivityAt || left.createdAt || 0
const rightActivity = right.lastActivityAt || right.createdAt || 0
return rightActivity - leftActivity
})
.slice(0, MAX_VISIBLE_UPLOAD_SESSIONS)
.forEach(([uploadId, session]) => {
const completedChunks = session.chunks.size
const totalChunks = session.totalChunks || 1
const progress = Math.min(
@ -165,7 +212,7 @@ export async function GET() {
})
}
}
})
})
// 批量查询用户信息
const userInfoMap = new Map<string, { username: string; realName?: string }>()

View File

@ -1,22 +1,39 @@
// 数据恢复API - 从S3和本地文件恢复素材数据
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import https from 'https';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { authOptions } from '@/lib/auth';
import { hasSystemAdminPermission } from '@/lib/department-permission';
const UPLOADS_DIR = '/app/uploads';
const ADMIN_EMAIL = 'admin';
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'admin123456';
const DEFAULT_CATEGORY = '默认店铺';
const DEFAULT_SCENE = '默认场景';
const MAX_RESTORE_ITEMS = 500;
const RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000;
const RATE_LIMIT_MAX_REQUESTS = 2;
const requestCounts = new Map<string, { count: number; resetAt: number }>();
function consumeRateLimit(userId: string): number | null {
const now = Date.now();
const current = requestCounts.get(userId);
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return null;
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000));
}
current.count += 1;
return null;
}
// 从S3获取预览图列表
async function fetchS3PreviewList(): Promise<string[]> {
async function fetchS3PreviewList(maxKeys: number): Promise<string[]> {
return new Promise((resolve, reject) => {
https.get('https://s3.1080p.hk/jsxiangche/?max-keys=2000', (res) => {
https.get(`https://s3.1080p.hk/jsxiangche/?max-keys=${maxKeys}`, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
@ -45,12 +62,12 @@ function extractTimestamp(filename: string): number | null {
}
// 扫描本地素材文件
function scanLocalMaterials(dir: string): string[] {
function scanLocalMaterials(dir: string, limit: number): string[] {
try {
return fs.readdirSync(dir).filter(file =>
file.startsWith('zip-') &&
(file.endsWith('.zip') || file.endsWith('.rar') || file.endsWith('.7z'))
);
).slice(0, limit);
} catch (error: any) {
console.error('读取本地文件失败:', error.message);
return [];
@ -97,38 +114,45 @@ function extractProductName(filename: string): string {
// POST - 执行数据恢复
export async function POST(request: NextRequest) {
try {
let adminUser;
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const sessionUser = session.user as any;
if (!hasSystemAdminPermission(sessionUser.role, Boolean(sessionUser.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行数据恢复' }, { status: 403 });
}
const retryAfter = consumeRateLimit(String(sessionUser.id || 'unknown'));
if (retryAfter) {
return NextResponse.json(
{ error: '数据恢复请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
);
}
const limit = Number(request.nextUrl.searchParams.get('limit') || '100');
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RESTORE_ITEMS) {
return NextResponse.json(
{ error: `limit 必须是 1 到 ${MAX_RESTORE_ITEMS} 之间的整数` },
{ status: 400 },
);
}
const adminUser = await prisma.user.findUnique({
where: { id: String(sessionUser.id) },
select: { id: true, username: true },
});
if (!adminUser) {
return NextResponse.json({ error: '当前管理员不存在' }, { status: 403 });
}
const logs: string[] = [];
logs.push('🚀 开始数据恢复...\n');
// 步骤1: 创建管理员用户
logs.push('📝 步骤1: 创建管理员用户');
try {
const hashedPassword = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest('hex');
adminUser = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
username: ADMIN_USERNAME,
password: hashedPassword,
role: 'ADMIN',
isActive: true,
canDownload: true,
},
});
logs.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`);
} catch (error: any) {
if (error.code === 'P2002') {
adminUser = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
logs.push(`ℹ️ 管理员用户已存在`);
} else {
throw error;
}
}
if (!adminUser) {
throw new Error('无法创建或找到管理员用户');
}
logs.push(`📝 步骤1: 使用当前管理员 ${adminUser.username} 作为恢复数据归属人`);
// 步骤2: 创建默认分类和场景
logs.push('\n📝 步骤2: 创建默认分类和场景');
@ -152,17 +176,18 @@ export async function POST(request: NextRequest) {
// 步骤3: 获取S3预览图列表
logs.push('\n📝 步骤3: 获取S3预览图列表');
const s3Previews = await fetchS3PreviewList();
const candidateLimit = Math.min(limit * 4, 2000);
const s3Previews = await fetchS3PreviewList(candidateLimit);
logs.push(`✅ 从S3找到 ${s3Previews.length} 个预览图`);
// 步骤4: 扫描本地素材文件
logs.push('\n📝 步骤4: 扫描本地素材文件');
const localMaterials = scanLocalMaterials(UPLOADS_DIR);
const localMaterials = scanLocalMaterials(UPLOADS_DIR, candidateLimit);
logs.push(`✅ 从本地找到 ${localMaterials.length} 个素材文件`);
// 步骤5: 匹配预览图和素材文件
logs.push('\n📝 步骤5: 匹配预览图和素材文件');
const matches = matchPreviewsWithMaterials(s3Previews, localMaterials);
const matches = matchPreviewsWithMaterials(s3Previews, localMaterials).slice(0, limit);
const withPreview = matches.filter(m => m.previewFile).length;
const withoutPreview = matches.length - withPreview;
logs.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`);
@ -187,7 +212,7 @@ export async function POST(request: NextRequest) {
sceneName: DEFAULT_SCENE,
materialType: '3D场景',
renderer: 'CR',
authorName: ADMIN_USERNAME,
authorName: adminUser.username,
zipFileName: match.materialFile,
zipFilePath: dockerPath,
zipFileSize: BigInt(stats.size),
@ -213,8 +238,7 @@ export async function POST(request: NextRequest) {
logs.push(` 📁 总计: ${matches.length} 个文件`);
logs.push(`\n🎉 数据恢复完成!`);
logs.push(`\n📌 默认信息:`);
logs.push(` 管理员账号: ${ADMIN_USERNAME}`);
logs.push(` 管理员密码: ${ADMIN_PASSWORD}`);
logs.push(` 数据归属管理员: ${adminUser.username}`);
logs.push(` 默认分类: ${DEFAULT_CATEGORY}`);
logs.push(` 默认场景: ${DEFAULT_SCENE}`);
logs.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`);

View File

@ -1,10 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
import { randomBytes } from 'crypto'
import { detectIsIntranet } from '@/lib/network-detection'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
export const dynamic = 'force-dynamic'
const ALLOWED_TEST_TYPES = new Set(['full', 'concurrent', 'mixed', 'quick'])
const MAX_CONCURRENCY = 10
const MAX_REQUEST_COUNT = 50
const MAX_MIXED_DURATION_MS = 10_000
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 2
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 创建S3客户端
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT || '',
@ -465,10 +490,51 @@ async function cleanupAllTestFiles(): Promise<{ deleted: number; failed: number
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行 S3 测速' }, { status: 403 })
}
const retryAfter = consumeRateLimit(String(user.id || 'unknown'))
if (retryAfter) {
return NextResponse.json(
{ error: 'S3 测速请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
const searchParams = request.nextUrl.searchParams
const testType = (searchParams.get('type') || 'full') as 'full' | 'concurrent' | 'mixed' | 'quick'
const concurrency = parseInt(searchParams.get('concurrency') || '10')
const count = parseInt(searchParams.get('count') || '50')
const testTypeRaw = searchParams.get('type') || 'quick'
const concurrency = Number(searchParams.get('concurrency') || '5')
const count = Number(searchParams.get('count') || '20')
const mixedDurationMs = Number(searchParams.get('durationMs') || '5000')
if (!ALLOWED_TEST_TYPES.has(testTypeRaw)) {
return NextResponse.json({ error: 'type 参数无效' }, { status: 400 })
}
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
return NextResponse.json(
{ error: `concurrency 必须是 1 到 ${MAX_CONCURRENCY} 之间的整数` },
{ status: 400 },
)
}
if (!Number.isInteger(count) || count < 1 || count > MAX_REQUEST_COUNT) {
return NextResponse.json(
{ error: `count 必须是 1 到 ${MAX_REQUEST_COUNT} 之间的整数` },
{ status: 400 },
)
}
if (!Number.isInteger(mixedDurationMs) || mixedDurationMs < 1000 || mixedDurationMs > MAX_MIXED_DURATION_MS) {
return NextResponse.json(
{ error: `durationMs 必须是 1000 到 ${MAX_MIXED_DURATION_MS} 之间的整数` },
{ status: 400 },
)
}
const testType = testTypeRaw as 'full' | 'concurrent' | 'mixed' | 'quick'
const isIntranet = detectIsIntranet(request)
@ -496,9 +562,7 @@ export async function GET(request: NextRequest) {
// 2. 单文件上传测试(不同大小)
if (testType === 'full' || testType === 'quick') {
console.log('[S3 Speed Test] 2. Testing single file uploads (various sizes)...')
const uploadSizes: number[] = testType === 'quick'
? [10, 100, 1024] // 快速测试:10KB, 100KB, 1MB
: [10, 50, 100, 500, 1024, 5120, 10240] // 完整测试:10KB到10MB
const uploadSizes: number[] = [10, 100, 1024]
result.upload = {}
for (const sizeKB of uploadSizes) {
@ -527,13 +591,11 @@ export async function GET(request: NextRequest) {
if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') {
console.log('[S3 Speed Test] 3. Testing concurrent uploads...')
const concurrentSizes = testType === 'quick'
? [{ size: 100, concurrency: 5, count: 20 }]
? [{ size: 100, concurrency: Math.min(5, concurrency), count: Math.min(20, count) }]
: [
{ size: 10, concurrency: 10, count: 50 },
{ size: 100, concurrency: 10, count: 50 },
{ size: 1024, concurrency: 5, count: 20 },
{ size: 100, concurrency: 20, count: 100 },
{ size: 100, concurrency: 50, count: 200 },
{ size: 10, concurrency, count },
{ size: 100, concurrency, count },
{ size: 1024, concurrency: Math.min(5, concurrency), count: Math.min(20, count) },
]
result.concurrentUpload = {}
@ -583,11 +645,9 @@ export async function GET(request: NextRequest) {
if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') {
console.log('[S3 Speed Test] 5. Testing concurrent downloads...')
const concurrentConfigs: Array<{ concurrency: number; count: number }> = testType === 'quick'
? [{ concurrency: 5, count: 20 }]
? [{ concurrency: Math.min(5, concurrency), count: Math.min(20, count) }]
: [
{ concurrency: 10, count: 50 },
{ concurrency: 20, count: 100 },
{ concurrency: 50, count: 200 },
{ concurrency, count },
]
result.concurrentDownload = {}
@ -612,7 +672,7 @@ export async function GET(request: NextRequest) {
concurrency,
100, // 100KB上传
testFileUrls,
30000 // 30秒
mixedDurationMs
)
result.mixedLoad = mixedTest
}
@ -621,7 +681,7 @@ export async function GET(request: NextRequest) {
// 7. 压力测试(高并发)
if (testType === 'full') {
console.log('[S3 Speed Test] 7. Testing stress (high concurrency)...')
const stressTest = await testConcurrentUploads(100, 50, 500) // 100并发,50KB,500次
const stressTest = await testConcurrentUploads(concurrency, 50, count)
result.stressTest = stressTest
}

View File

@ -1,35 +1,267 @@
// 文件访问API - 提供本地上传文件的访问
import { NextRequest } from 'next/server';
import fs from 'fs';
import path from 'path';
import { lookup as mimeTypeLookup } from 'mime-types';
import { DownloadErrorCode } from '@/lib/download-errors';
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access';
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import fs from 'fs'
import path from 'path'
import { lookup as mimeTypeLookup } from 'mime-types'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
canUserDownloadMaterial,
canUserDownloadSource,
hasSystemAdminPermission,
} from '@/lib/department-permission'
import { DownloadErrorCode } from '@/lib/download-errors'
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// GET - 获取文件
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const pathSegments = params.path || [];
type FileAccessKind = 'preview' | 'source'
// 健康检查
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response('OK', { status: 200 });
interface FileMaterial {
id: string
materialType: string
}
interface ResolvedFileAccess {
kind: FileAccessKind
material: FileMaterial
}
function isPathInside(basePath: string, targetPath: string): boolean {
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
}
function normalizeStoredPath(value: unknown): string {
return String(value || '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
}
function buildStoredPathCandidates(relativePath: string, filePath: string): string[] {
const normalizedRelativePath = normalizeStoredPath(relativePath).replace(/^\/+/, '')
const candidates = new Set<string>([
normalizedRelativePath,
`uploads/${normalizedRelativePath}`,
`/uploads/${normalizedRelativePath}`,
`/api/uploads/${normalizedRelativePath}`,
normalizeStoredPath(filePath),
])
for (const candidate of Array.from(candidates)) {
candidates.add(candidate.replace(/\//g, '\\'))
}
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads';
const relativePath = path.join(...pathSegments);
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
const filePath = path.resolve(uploadDir, relativePath);
const baseDir = path.resolve(uploadDir);
return Array.from(candidates).filter(Boolean)
}
// 安全检查:防止路径遍历攻击
if (!filePath.startsWith(baseDir)) {
return new Response('Forbidden', { status: 403 });
function isPreviewAssetType(assetType: unknown): boolean {
const normalized = String(assetType || '').trim()
return (
['preview', 'main-preview', 'sku-preview', 'detail-preview', 'report-image'].includes(normalized) ||
normalized.startsWith('standard-model-white-bg-view:') ||
normalized.startsWith('standard-model-white-bg-other:')
)
}
async function resolveFileAccess(
candidates: string[],
): Promise<ResolvedFileAccess | null> {
const normalizedCandidates = new Set(candidates.map(normalizeStoredPath))
const matchesCandidate = (value: unknown) => normalizedCandidates.has(normalizeStoredPath(value))
const materials = await prisma.material.findMany({
where: {
deletedAt: null,
OR: [
{ previewUrl: { in: candidates } },
{ zipFileName: { in: candidates } },
{ zipFilePath: { in: candidates } },
{ videoSourceUrl: { in: candidates } },
{ videoPreview540Url: { in: candidates } },
{ videoPreviewClipUrl: { in: candidates } },
{ videoCoverUrl: { in: candidates } },
{ displayImages: { hasSome: candidates } },
],
} as any,
select: {
id: true,
materialType: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
videoSourceUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
displayImages: true,
},
take: 20,
})
const assetMatches = await prisma.materialAsset.findMany({
where: {
fileUrl: { in: candidates },
material: { deletedAt: null },
} as any,
select: {
assetType: true,
material: {
select: {
id: true,
materialType: true,
},
},
},
take: 20,
})
for (const material of materials as any[]) {
if (
matchesCandidate(material.zipFileName) ||
matchesCandidate(material.zipFilePath) ||
matchesCandidate(material.videoSourceUrl)
) {
return { kind: 'source', material }
}
}
for (const asset of assetMatches as any[]) {
if (!isPreviewAssetType(asset.assetType)) {
return { kind: 'source', material: asset.material }
}
}
for (const material of materials as any[]) {
if (
matchesCandidate(material.previewUrl) ||
matchesCandidate(material.videoPreview540Url) ||
matchesCandidate(material.videoPreviewClipUrl) ||
matchesCandidate(material.videoCoverUrl) ||
(Array.isArray(material.displayImages) && material.displayImages.some(matchesCandidate))
) {
return { kind: 'preview', material }
}
}
const previewAsset = (assetMatches as any[]).find((asset) => isPreviewAssetType(asset.assetType))
return previewAsset ? { kind: 'preview', material: previewAsset.material } : null
}
async function authorizeSourceAccess(
request: NextRequest,
material: FileMaterial,
): Promise<Response | null> {
const session = await getServerSession(authOptions)
if (!session?.user) {
return Response.json(
{ error: '请先登录' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
)
}
const sessionUser = session.user as any
const user = await prisma.user.findUnique({
where: { id: String(sessionUser.id || '') },
select: {
role: true,
department: true,
managedDepartment: true,
canDownload: true,
isSuperAdmin: true,
},
})
if (!user) {
return Response.json(
{ error: '用户不存在' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
)
}
if (hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return null
}
if (!user.canDownload) {
return Response.json(
{ error: '无权限下载该文件' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
)
}
const canDownload = await canUserDownloadMaterial(
user.department,
user.role,
user.managedDepartment,
material.materialType,
String(sessionUser.id),
)
if (!canDownload) {
return Response.json(
{ error: '无权限下载该文件' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
)
}
if (isProductPackageType(material.materialType) || is3DOriginalType(material.materialType)) {
const canDownloadSource = await canUserDownloadSource(
user.department,
user.role,
user.managedDepartment,
'详情模板',
String(sessionUser.id),
)
if (!canDownloadSource) {
return Response.json(
{ error: '无权限下载源文件' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
)
}
}
return null
}
async function handleFileRequest(
request: NextRequest,
pathSegments: string[],
headOnly: boolean,
): Promise<Response> {
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response(headOnly ? null : 'OK', { status: 200 })
}
if (
pathSegments.length === 0 ||
pathSegments.some((segment) => !segment || segment === '.' || segment === '..' || /[\\/]/.test(segment))
) {
return new Response(headOnly ? null : 'Forbidden', { status: 403 })
}
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '/app/uploads')
const relativePath = path.join(...pathSegments)
const normalizedRelativePath = relativePath.replace(/\\/g, '/')
const filePath = path.resolve(uploadDir, relativePath)
if (!isPathInside(uploadDir, filePath)) {
return new Response(headOnly ? null : 'Forbidden', { status: 403 })
}
const access = await resolveFileAccess(buildStoredPathCandidates(relativePath, filePath))
if (!access) {
return new Response(headOnly ? null : 'Not Found', { status: 404 })
}
if (access.kind === 'source') {
const deniedResponse = await authorizeSourceAccess(request, access.material)
if (deniedResponse) {
return headOnly
? new Response(null, {
status: deniedResponse.status,
headers: { 'Cache-Control': 'no-store' },
})
: deniedResponse
}
}
if (isVideoMediaPath(normalizedRelativePath)) {
@ -38,138 +270,92 @@ export async function GET(
`/api/uploads/${normalizedRelativePath}`,
`/uploads/${normalizedRelativePath}`,
normalizedRelativePath,
]);
])
if (blockedPreview) {
return Response.json(
{
error: '视频类素材上传后满 15 天才能预览和下载',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: blockedPreview.message,
},
{
status: 403,
headers: { 'Cache-Control': 'no-store' },
},
);
return headOnly
? new Response(null, { status: 403, headers: { 'Cache-Control': 'no-store' } })
: Response.json(
{
error: '视频类素材上传后满 15 天才能预览和下载',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: blockedPreview.message,
},
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
)
}
}
try {
const stats = await fs.promises.stat(filePath);
const stats = await fs.promises.stat(filePath)
if (!stats.isFile()) {
return new Response('Not Found', { status: 404 });
return new Response(headOnly ? null : 'Not Found', { status: 404 })
}
const range = request.headers.get('range');
const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream';
const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'
const cacheControl =
access.kind === 'source' ? 'private, no-store' : 'public, max-age=31536000, immutable'
// 支持Range请求(用于视频流)
if (headOnly) {
return new Response(null, {
status: 200,
headers: {
'Content-Length': String(stats.size),
'Content-Type': contentType,
'Cache-Control': cacheControl,
},
})
}
const range = request.headers.get('range')
if (range) {
const fileSize = stats.size;
const parts = range.match(/bytes=(\d*)-(\d*)/);
const start = parts && parts[1] ? parseInt(parts[1], 10) : 0;
const end = parts && parts[2] ? parseInt(parts[2], 10) : fileSize - 1;
// 验证Range
if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) {
return new Response('Invalid Range', { status: 416 });
const parts = range.match(/bytes=(\d*)-(\d*)/)
const start = parts && parts[1] ? Number.parseInt(parts[1], 10) : 0
const end = parts && parts[2] ? Number.parseInt(parts[2], 10) : stats.size - 1
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stats.size) {
return new Response('Invalid Range', { status: 416 })
}
const stream = fs.createReadStream(filePath, { start, end });
const stream = fs.createReadStream(filePath, { start, end })
return new Response(stream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Range': `bytes ${start}-${end}/${stats.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(end - start + 1),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Cache-Control': cacheControl,
},
});
})
}
// 普通请求
const stream = fs.createReadStream(filePath);
const stream = fs.createReadStream(filePath)
return new Response(stream as any, {
status: 200,
headers: {
'Content-Length': String(stats.size),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
'Cache-Control': cacheControl,
},
});
})
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response('Not Found', { status: 404 });
if (error?.code === 'ENOENT') {
return new Response(headOnly ? null : 'Not Found', { status: 404 })
}
return new Response('Server Error', { status: 500 });
return new Response(headOnly ? null : 'Server Error', { status: 500 })
}
}
// HEAD - 检查文件是否存在
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } },
) {
return handleFileRequest(request, params.path || [], false)
}
export async function HEAD(
request: NextRequest,
{ params }: { params: { path: string[] } }
{ params }: { params: { path: string[] } },
) {
const pathSegments = params.path || [];
// 健康检查
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response(null, { status: 200 });
}
const uploadDir = process.env.UPLOAD_DIR || '/app/uploads';
const relativePath = path.join(...pathSegments);
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
const filePath = path.resolve(uploadDir, relativePath);
const baseDir = path.resolve(uploadDir);
// 安全检查:防止路径遍历攻击
if (!filePath.startsWith(baseDir)) {
return new Response(null, { status: 403 });
}
if (isVideoMediaPath(normalizedRelativePath)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([
request.url,
`/api/uploads/${normalizedRelativePath}`,
`/uploads/${normalizedRelativePath}`,
normalizedRelativePath,
]);
if (blockedPreview) {
return new Response(null, {
status: 403,
headers: { 'Cache-Control': 'no-store' },
});
}
}
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile()) {
return new Response(null, { status: 404 });
}
const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream';
return new Response(null, {
status: 200,
headers: {
'Content-Length': String(stats.size),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch (error: any) {
if (error.code === 'ENOENT') {
return new Response(null, { status: 404 });
}
return new Response(null, { status: 500 });
}
return handleFileRequest(request, params.path || [], true)
}

View File

@ -2,7 +2,6 @@
import { useEffect, useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import Image from 'next/image'
import { parseApiError, parseFetchError } from '@/lib/error-handler'
@ -133,24 +132,9 @@ function CompleteProfileContent() {
return
}
// 已激活用户:使用 NextAuth 自动登录
const result = await signIn('credentials', {
email: data.user.email,
password: 'dingtalk_auto_login',
redirect: false,
})
if (result?.error) {
throw new Error('登录失败')
}
// 智能跳转:保持在内网或外网环境
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
window.location.href = window.location.origin
} else {
router.push('/')
router.refresh()
}
setSuccess('信息提交成功')
router.push('/')
router.refresh()
} catch (error: any) {
console.error('提交失败:', error)
const { message } = parseApiError(error)

View File

@ -2,7 +2,6 @@
import { useEffect, useRef, useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
const DINGTALK_DIRECT_LOGIN_SOURCE = 'work_notice_direct'
@ -110,9 +109,6 @@ function DingtalkCallbackContent() {
setMessage('登录成功,正在跳转...')
setStatus('success')
// 检查来源:优先使用state中的origin,其次使用referrer
const isFromIntranet = originUrl && originUrl.includes('192.168.')
// 通知父窗口登录成功(如果在 iframe 或新窗口中)
if (window.opener) {
// 新窗口模式:通知打开窗口的父页面
@ -137,39 +133,19 @@ function DingtalkCallbackContent() {
'*'
)
} else {
// 不在 iframe 中(直接访问回调页面)
if (isFromIntranet) {
// 从内网来的:不在这里创建session,跳转回内网后由内网页面创建
setMessage('登录成功,正在返回内网...')
// 将用户信息作为参数传递,让内网页面创建session
window.location.href = `${originUrl}/login?dingtalk_success=true&email=${encodeURIComponent(data.user.email)}&redirect=${encodeURIComponent(redirectPath)}`
} else {
// 外网直接访问时,后端通常已经随 /api/dingtalk/login 响应写入素材库会话。
// 保留前端 signIn 兜底,兼容旧响应或特殊浏览器没有接收 cookie 的情况。
let hasServerSession = false
if (data.sessionCreated) {
await new Promise((resolve) => setTimeout(resolve, 200))
const sessionResponse = await fetch('/api/auth/session', { cache: 'no-store' })
const sessionData = await sessionResponse.json().catch(() => null)
hasServerSession = Boolean(sessionData?.user)
}
if (!hasServerSession) {
const signInResult = await signIn('credentials', {
email: data.user.email,
password: 'dingtalk_auto_login',
redirect: false,
})
if (signInResult?.error) {
throw new Error(signInResult.error)
}
}
setTimeout(() => {
window.location.href = redirectPath
}, 1000)
await new Promise((resolve) => setTimeout(resolve, 200))
const sessionResponse = await fetch('/api/auth/session', {
cache: 'no-store',
credentials: 'include',
})
const sessionData = await sessionResponse.json().catch(() => null)
if (!sessionData?.user) {
throw new Error('登录会话写入失败,请重新使用钉钉登录')
}
setTimeout(() => {
window.location.href = redirectPath
}, 300)
}
} catch (error: any) {
console.error('钉钉登录错误:', error)

View File

@ -2,7 +2,6 @@
import { useState, useEffect } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { ensureHttpsUrl } from '@/lib/url-rewrite'
@ -13,7 +12,6 @@ import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
const DEFAULT_LOGIN_LOGO_URL = '/api/favicon'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
@ -103,43 +101,6 @@ export default function LoginPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 处理钉钉登录成功后的自动登录(内网场景)
useEffect(() => {
const handleDingtalkAutoLogin = async () => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkSuccess = urlParams.get('dingtalk_success')
const userEmail = urlParams.get('email')
const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect'))
if (dingtalkSuccess === 'true' && userEmail) {
setLoading(true)
try {
// 在内网域名下创建 session
const result = await signIn('credentials', {
email: decodeURIComponent(userEmail),
password: 'dingtalk_auto_login',
redirect: false,
})
if (result?.error) {
setError('登录失败,请重试')
} else {
// 清除 URL 参数并跳转到首页
window.history.replaceState({}, '', '/login')
window.location.href = `${window.location.origin}${redirectPath}`
}
} catch (error) {
console.error('自动登录失败:', error)
const { message } = parseApiError(error)
setError(message)
} finally {
setLoading(false)
}
}
}
handleDingtalkAutoLogin()
}, [router])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
console.log('🔐 开始登录流程...', { email, hasPassword: !!password })
@ -247,45 +208,16 @@ export default function LoginPage() {
if (event.data && event.data.type === 'dingtalk_login_success') {
popup?.close()
window.removeEventListener('message', handleMessage)
// 创建 NextAuth session
try {
const result = await signIn('credentials', {
email: event.data.userInfo.email,
password: 'dingtalk_auto_login',
redirect: false,
})
if (result?.error) {
setError('登录失败,请重试')
} else {
// 短暂延迟,避免浏览器扩展脚本在处理事件时出错
await new Promise(resolve => setTimeout(resolve, 100))
// 登录成功,智能跳转
const originUrl = event.data.originUrl
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
if (originUrl && originUrl.includes('192.168.')) {
// 从内网发起的登录,跳回内网
window.location.href = `${originUrl}${targetPath}`
} else if (window.location.hostname.includes('192.168.')) {
// 当前在内网,刷新页面
window.location.href = `${window.location.origin}${targetPath}`
} else {
// 外网访问,跳转到原始业务页面
window.location.href = `${window.location.origin}${targetPath}`
}
}
} catch (error) {
console.error('创建session失败:', error)
const { message } = parseApiError(error)
setError(message)
}
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
} else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {
popup?.close()
window.removeEventListener('message', handleMessage)
// 跳转到信息完善页面
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
router.push(`/complete-profile?userInfo=${userInfoParam}`)
window.location.href = new URL(
`/complete-profile?userInfo=${userInfoParam}`,
dingtalkCallbackOrigin,
).toString()
} else if (event.data && event.data.type === 'dingtalk_login_error') {
popup?.close()
window.removeEventListener('message', handleMessage)

View File

@ -128,6 +128,10 @@ export default function Home() {
const transcodeStartTimes = useRef<Map<string, number>>(new Map())
const transcodePollingFlags = useRef<Map<string, boolean>>(new Map()) // 标记是否正在轮询
const fetchMaterialsRef = useRef<typeof fetchMaterials | null>(null) // 存储fetchMaterials函数引用
const materialsRequestIdRef = useRef(0)
const materialsAbortRef = useRef<AbortController | null>(null)
const facetsRequestIdRef = useRef(0)
const facetsAbortRef = useRef<AbortController | null>(null)
// ✅ 清理转码Map的函数(直接操作Map,避免循环依赖)
const cleanupTranscodeMaps = useCallback(() => {
@ -558,6 +562,11 @@ export default function Home() {
// 获取素材列表(支持分页和追加模式)
const fetchMaterials = useCallback(
async (page = 1, append = false) => {
const requestId = ++materialsRequestIdRef.current
materialsAbortRef.current?.abort()
const controller = new AbortController()
materialsAbortRef.current = controller
try {
// 修复:只在首次加载(无搜索词且无结果)时显示全屏加载
// 搜索时只使用 isTransitionLoading,避免整个页面被替换导致 SearchBar 丢失焦点
@ -572,8 +581,11 @@ export default function Home() {
const params = buildFilterParams({ includePaging: true, page })
const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}`
const response = await fetch(url)
if (response.ok) {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`素材加载失败 (${response.status})`)
}
if (requestId === materialsRequestIdRef.current) {
const data = await response.json()
// 素材ID验证和previewUrl fallback
const materialsList = ((data.materials || data) as Material[])
@ -623,21 +635,21 @@ export default function Home() {
return appendedCount
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return 0
console.error('Error fetching materials:', error)
if (!append) {
setMaterials([])
setDisplayedCount(0)
setCurrentMaterialsTotalCount(0)
if (requestId === materialsRequestIdRef.current) {
showToast(error instanceof Error ? error.message : '素材加载失败', { variant: 'error' })
}
} finally {
if (!append) {
if (!append && requestId === materialsRequestIdRef.current) {
setLoading(false)
setIsTransitionLoading(false)
}
if (materialsAbortRef.current === controller) materialsAbortRef.current = null
}
return 0
},
[materials.length, buildFilterParams]
[materials.length, buildFilterParams, showToast]
)
// 更新fetchMaterialsRef
@ -986,11 +998,17 @@ export default function Home() {
// IntersectionObserver 逻辑已移至 LoadMoreTrigger 组件
const fetchFacets = useCallback(async () => {
const requestId = ++facetsRequestIdRef.current
facetsAbortRef.current?.abort()
const controller = new AbortController()
facetsAbortRef.current = controller
try {
const params = buildFilterParams({ includeSort: false, includePaging: false })
const url = `/api/materials/facets${params.toString() ? '?' + params.toString() : ''}`
const response = await fetch(url)
if (response.ok) {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) throw new Error(`筛选数据加载失败 (${response.status})`)
if (requestId === facetsRequestIdRef.current) {
const data = await response.json()
setCategories(data.categories || [])
setScenes(data.scenes || [])
@ -1005,9 +1023,20 @@ export default function Home() {
)
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
console.error('Error fetching material facets:', error)
if (requestId === facetsRequestIdRef.current) {
showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' })
}
} finally {
if (facetsAbortRef.current === controller) facetsAbortRef.current = null
}
}, [buildFilterParams])
}, [buildFilterParams, showToast])
useEffect(() => () => {
materialsAbortRef.current?.abort()
facetsAbortRef.current?.abort()
}, [])
useEffect(() => {
if (status !== 'authenticated') return

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +1,40 @@
import { NextRequest } from 'next/server'
import { createReadStream, statSync } from 'fs'
import { promises as fsp } from 'fs'
import path from 'path'
import mime from 'mime-types'
import { DownloadErrorCode } from '@/lib/download-errors'
import { findBlockedVideoPreviewByUrl, isVideoMediaPath } from '@/lib/video-preview-access'
// Fallback streaming for /uploads/* when NAS/static mapping is not configured.
// Supports Range requests for video playback.
export async function GET(req: NextRequest, context: { params: { path?: string[] } }) {
const segments = context.params.path || []
export const dynamic = 'force-dynamic'
// Health probe: /uploads/health
if (segments.length === 1 && segments[0] === 'health') {
return new Response('OK', { status: 200 })
function redirectToProtectedUploadRoute(
request: NextRequest,
pathSegments: string[],
): Response {
if (pathSegments.length === 1 && pathSegments[0] === 'health') {
return new Response(request.method === 'HEAD' ? null : 'OK', { status: 200 })
}
const uploadsRoot = process.env.UPLOAD_DIR || './uploads'
// Prevent path traversal
const safeRel = path.join(...segments)
const normalizedSafeRel = safeRel.replace(/\\/g, '/')
const absPath = path.resolve(uploadsRoot, safeRel)
const rootResolved = path.resolve(uploadsRoot)
if (!absPath.startsWith(rootResolved)) {
return new Response('Forbidden', { status: 403 })
if (
pathSegments.length === 0 ||
pathSegments.some((segment) => !segment || segment === '.' || segment === '..' || /[\\/]/.test(segment))
) {
return new Response(request.method === 'HEAD' ? null : 'Forbidden', { status: 403 })
}
if (isVideoMediaPath(normalizedSafeRel)) {
const blockedPreview = await findBlockedVideoPreviewByUrl([
req.url,
`/uploads/${normalizedSafeRel}`,
`/api/uploads/${normalizedSafeRel}`,
normalizedSafeRel,
])
if (blockedPreview) {
return Response.json(
{
error: '视频类素材上传后满 15 天才能预览和下载',
code: DownloadErrorCode.VIDEO_WAIT_PERIOD,
details: blockedPreview.message,
},
{
status: 403,
headers: { 'Cache-Control': 'no-store' },
},
)
}
}
try {
const stat = await fsp.stat(absPath)
if (!stat.isFile()) {
return new Response('Not Found', { status: 404 })
}
const range = req.headers.get('range')
const contentType = (mime.contentType(path.extname(absPath)) as string) || 'application/octet-stream'
if (range) {
const size = stat.size
const match = range.match(/bytes=(\d*)-(\d*)/)
const start = match && match[1] ? parseInt(match[1], 10) : 0
const end = match && match[2] ? parseInt(match[2], 10) : size - 1
if (isNaN(start) || isNaN(end) || start > end || end >= size) {
return new Response('Invalid Range', { status: 416 })
}
const chunkSize = end - start + 1
const stream = createReadStream(absPath, { start, end })
return new Response(stream as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
}
const stream = createReadStream(absPath)
return new Response(stream as any, {
status: 200,
headers: {
'Content-Length': stat.size.toString(),
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
} catch (err: any) {
if (err.code === 'ENOENT') {
return new Response('Not Found', { status: 404 })
}
return new Response('Server Error', { status: 500 })
}
const target = new URL(
`/api/uploads/${pathSegments.map((segment) => encodeURIComponent(segment)).join('/')}`,
request.url,
)
target.search = request.nextUrl.search
return Response.redirect(target, 307)
}
export async function GET(
request: NextRequest,
{ params }: { params: { path?: string[] } },
) {
return redirectToProtectedUploadRoute(request, params.path || [])
}
export async function HEAD(
request: NextRequest,
{ params }: { params: { path?: string[] } },
) {
return redirectToProtectedUploadRoute(request, params.path || [])
}

View File

@ -4,9 +4,9 @@
# 状态:旧构建缓存方案,不是当前 Git 增量部署的默认前置步骤
param(
[string]$BuildServerIP = '192.168.42.134',
[string]$BuildServerIP = '192.168.40.206',
[string]$BuildServerUser = 'root',
[string]$BuildServerPassword = 'Jsjj@8888',
[string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD,
[string]$ImageName = 'youyidian-3d-base',
[string]$ImageTag = 'latest'
)
@ -25,6 +25,10 @@ Write-Host "║ ║"
Write-Host "╚════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
Write-Host ""
if ([string]::IsNullOrWhiteSpace($BuildServerPassword)) {
throw '缺少构建机密码,请先设置 BUILD_SERVER_PASSWORD 环境变量'
}
function Invoke-BuildServer {
param([string]$Command, [switch]$Silent)
$result = & "C:\Program Files\PuTTY\plink.exe" -batch -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $Command 2>&1

View File

@ -66,7 +66,9 @@ function clearMaterialVersionCookie() {
export default function Header({ session }: HeaderProps) {
const router = useRouter()
const isAdmin = session ? (session.user as any)?.role === 'ADMIN' : false
const role = session ? String((session.user as any)?.role || '') : ''
const isAdmin = ['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)
const canViewPerformance = ['ADMIN', 'SYSTEM_ADMIN'].includes(role) || Boolean((session?.user as any)?.isSuperAdmin)
const [showDropdown, setShowDropdown] = useState(false)
const [isInternalNetwork, setIsInternalNetwork] = useState(false)
const [networkChecked, setNetworkChecked] = useState(false)
@ -182,7 +184,7 @@ export default function Header({ session }: HeaderProps) {
}
}
if (!showPerfCard) {
if (!canViewPerformance || !showPerfCard) {
clearIntervalSafe()
return
}
@ -213,10 +215,11 @@ export default function Header({ session }: HeaderProps) {
return () => {
clearIntervalSafe()
}
}, [showPerfCard])
}, [canViewPerformance, showPerfCard])
// 悬停事件处理:延迟显示 / 隐藏性能卡片
const handlePerfMouseEnter = () => {
if (!canViewPerformance) return
// 取消隐藏定时器
if (perfHideTimerRef.current !== null) {
window.clearTimeout(perfHideTimerRef.current)
@ -232,6 +235,7 @@ export default function Header({ session }: HeaderProps) {
}
const handlePerfMouseLeave = () => {
if (!canViewPerformance) return
// 取消显示定时器
if (perfHoverTimerRef.current !== null) {
window.clearTimeout(perfHoverTimerRef.current)
@ -306,7 +310,11 @@ export default function Header({ session }: HeaderProps) {
{/* Network Status Indicator + 性能监控悬停卡片 */}
{networkChecked && (
<div className="relative hidden sm:block" onMouseEnter={handlePerfMouseEnter} onMouseLeave={handlePerfMouseLeave}>
<div
className="relative hidden sm:block"
onMouseEnter={canViewPerformance ? handlePerfMouseEnter : undefined}
onMouseLeave={canViewPerformance ? handlePerfMouseLeave : undefined}
>
<div
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
isInternalNetwork
@ -321,7 +329,7 @@ export default function Header({ session }: HeaderProps) {
</div>
{/* 性能监控悬停卡片(优化版) */}
{showPerfCard && (
{canViewPerformance && showPerfCard && (
<div className="absolute right-0 mt-2 w-96 bg-gray-900 text-gray-100 rounded-lg shadow-xl border border-gray-700 p-4 z-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-gray-300">系统性能</span>

View File

@ -263,9 +263,9 @@ export default function UserManagement({
}
}
// 删除用户
// 归档用户,素材和历史记录保留
const handleDeleteUser = async (id: string) => {
if (!confirm('确定要删除这个用户吗?该用户的所有素材也会被删除。')) return
if (!confirm('确定要归档这个用户吗?归档后账号无法登录,素材和历史记录会保留。')) return
try {
const response = await fetch(`/api/admin/users?id=${id}`, {
@ -273,8 +273,12 @@ export default function UserManagement({
})
if (response.ok) {
setUsers(prevUsers => prevUsers.filter(u => u.id !== id))
showToast('删除成功')
setUsers(prevUsers => prevUsers.map(user => (
user.id === id
? { ...user, status: 'archived', isActive: false, archivedAt: new Date().toISOString() }
: user
)))
showToast('用户已归档')
} else {
const parsedError = await parseFetchError(response)
showToast(parsedError.message, 'error')

View File

@ -1,8 +1,6 @@
# 当前影子环境发布不使用本文件作为入口。
# 影子发布以 scripts/git-incremental-deploy.ps1 -Target shadow 为准;本文件仅作本地/单机/基础环境参考。
# 不要按此文件推断当前线上影子容器启动参数、uploads 挂载或健康检查。
version: '3.8'
services:
redis-shadow:
image: redis:7-alpine
@ -53,16 +51,16 @@ services:
- NODE_ENV=production
- UPLOAD_DIR=/app/uploads
# 影子系统连接到 stage 数据库(需要先创建 lobechat_stage 数据库)
- DATABASE_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat_stage
- DATABASE_URL=${SHADOW_DATABASE_URL:?SHADOW_DATABASE_URL is required}
- DATABASE_DRIVER=node
- KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=
- KEY_VAULTS_SECRET=${SHADOW_KEY_VAULTS_SECRET:?SHADOW_KEY_VAULTS_SECRET is required}
- VECTOR_DB=pgvector
- PGVECTOR_DB_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat_stage
- PGVECTOR_DB_URL=${SHADOW_PGVECTOR_DB_URL:?SHADOW_PGVECTOR_DB_URL is required}
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://192.168.58.20:9000 # 内网地址,服务器直接访问不走FRP
- S3_BUCKET=gongxiangpan
- S3_ACCESS_KEY_ID=GXPAccessKey2025
- S3_SECRET_ACCESS_KEY=GXPSecretKey2025SecureMinIO
- S3_ACCESS_KEY_ID=${SHADOW_S3_ACCESS_KEY_ID:?SHADOW_S3_ACCESS_KEY_ID is required}
- S3_SECRET_ACCESS_KEY=${SHADOW_S3_SECRET_ACCESS_KEY:?SHADOW_S3_SECRET_ACCESS_KEY is required}
- S3_REGION=us-east-1
- S3_PUBLIC_DOMAIN=https://s3.jiansun.vip/gongxiangpan
- S3_ENABLE_PATH_STYLE=1
@ -75,18 +73,18 @@ services:
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
- SERVER_IP=192.168.58.20
- MINIO_API_PORT=9000
- NEXTAUTH_SECRET=your-secret-key-change-this-in-production-shadow
- GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E
- NEXTAUTH_SECRET=${SHADOW_NEXTAUTH_SECRET:?SHADOW_NEXTAUTH_SECRET is required}
- GEMINI_API_KEY=${GEMINI_API_KEY:?GEMINI_API_KEY is required}
- GEMINI_API_BASE_URL=https://api.huandutech.com/v1
- GEMINI_MODEL=gemini-2.0-flash-exp
- DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f
- DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY
- DINGTALK_CLIENT_SECRET=${DINGTALK_CLIENT_SECRET:?DINGTALK_CLIENT_SECRET is required}
- VIDEO_TRANSCODE_CODEC=libx265
- VIDEO_TRANSCODE_PRESET=medium
- VIDEO_TRANSCODE_CRF=22
- VIDEO_AUDIO_BITRATE=160k
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=yydd-product-package-download-token-secret-2026
- DOWNLOAD_TOKEN_SECRET=${SHADOW_DOWNLOAD_TOKEN_SECRET:?SHADOW_DOWNLOAD_TOKEN_SECRET is required}
# Redis配置(影子系统使用独立的 Redis 实例)
- USE_REDIS=true
- REDIS_URL=redis://redis-shadow:6379

View File

@ -1,8 +1,6 @@
# 当前生产发布不使用本文件作为入口。
# 生产发布以 scripts/git-incremental-deploy.ps1 为准;本文件仅作本地/单机/基础环境参考。
# 不要按此文件推断当前线上容器启动参数、uploads 挂载或健康检查。
version: '3.8'
services:
app:
build:
@ -32,16 +30,16 @@ services:
start_period: 40s
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
- DATABASE_URL=${DATABASE_URL:?DATABASE_URL is required}
- DATABASE_DRIVER=node
- KEY_VAULTS_SECRET=/7DcksaICAECGjB38O3s8AE0wj2NoGzwIog0iUUMugY=
- KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET:?KEY_VAULTS_SECRET is required}
- VECTOR_DB=pgvector
- PGVECTOR_DB_URL=postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat
- PGVECTOR_DB_URL=${PGVECTOR_DB_URL:?PGVECTOR_DB_URL is required}
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://192.168.58.20:9000
- S3_BUCKET=gongxiangpan
- S3_ACCESS_KEY_ID=GXPAccessKey2025
- S3_SECRET_ACCESS_KEY=GXPSecretKey2025SecureMinIO
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID is required}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY is required}
- S3_REGION=us-east-1
- S3_PUBLIC_DOMAIN=https://s3.jiansun.vip/gongxiangpan
- S3_ENABLE_PATH_STYLE=1
@ -54,19 +52,19 @@ services:
# 内网地址配置(用于流量分离:内网用户走内网网络,不占用外网带宽)
- SERVER_IP=192.168.58.20
- MINIO_API_PORT=9000
- NEXTAUTH_SECRET=your-secret-key-change-this-in-production
- GEMINI_API_KEY=sk-NqhrkeBudn6pUtStnOuOnGyDfasogvFSsex4YgjDXJU3SK3E
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required}
- GEMINI_API_KEY=${GEMINI_API_KEY:?GEMINI_API_KEY is required}
- GEMINI_API_BASE_URL=https://api.huandutech.com/v1
- GEMINI_MODEL=gemini-2.0-flash-exp
- DINGTALK_CLIENT_ID=ding1bs1gxwrcfu16x0f
- DINGTALK_CLIENT_SECRET=qUmj_gBDzpPEYR3RA3hRqKzwu11a986O-wKfP8V5lDHXn7XJrXxW79KIeP5G-mcY
- DINGTALK_CLIENT_SECRET=${DINGTALK_CLIENT_SECRET:?DINGTALK_CLIENT_SECRET is required}
# 视频转码配置
- VIDEO_TRANSCODE_CODEC=libx265
- VIDEO_TRANSCODE_PRESET=medium
- VIDEO_TRANSCODE_CRF=22
- VIDEO_AUDIO_BITRATE=160k
# 下载 Token 密钥(用于外部接口文件下载鉴权)
- DOWNLOAD_TOKEN_SECRET=yydd-product-package-download-token-secret-2026
- DOWNLOAD_TOKEN_SECRET=${DOWNLOAD_TOKEN_SECRET:?DOWNLOAD_TOKEN_SECRET is required}
# Redis配置(用于上传会话存储)
- USE_REDIS=true
- REDIS_URL=redis://redis:6379

View File

@ -1,8 +1,9 @@
# AI 生图平台第一层桶缓存落地方案
> 更新时间:2026-05-07
> 更新时间:2026-07-09
> 当前正式资源域名:`https://s3.jiansun.vip`
> 当前正式入口机:`103.118.245.6`
> 当前正式入口机:`218.93.9.38`
> 历史入口机:`103.118.245.6`(不再作为当前默认排障入口)
> 已停用缓存入口机:`180.97.215.57`
> 当前桶本体:`192.168.58.20:9000`
@ -21,7 +22,7 @@ AI 生图平台不再沿用“整个 `/uploads/` 都进长缓存”的口径,
```text
AI 生图平台
-> https://s3.jiansun.vip/gongxiangpan/public-display/...
-> 103.118.245.6
-> 218.93.9.38
-> nginx
-> 127.0.0.1:2323
-> FRP
@ -72,7 +73,7 @@ AI 生图平台
- 源文件 / ZIP / PSD / 视频源等下载型对象继续落在生产机 uploads:`/mnt/PoolOne/apps/youyidian-3d/uploads`
- `s3.1080p.hk/jsxiangche` 只保留极少数老素材兼容,不作为新素材或新预览写入目标
- 当前不再以 `X-Cache-Status` 作为正式验证标准
- 当前优先验证 `s3.jiansun.vip -> 103.118.245.6 -> 192.168.58.20:9000` 是否可达
- 当前优先验证 `s3.jiansun.vip -> 218.93.9.38 -> 192.168.58.20:9000` 是否可达
## 6. 历史验证结果
@ -123,4 +124,4 @@ AI 生图平台后续如果要对接正式展示图,统一按下面这个口
AI 生图平台现在的第一层桶缓存口径是:
**新生成的展示型图片统一写到 `s3.jiansun.vip` 的 `/gongxiangpan/public-display/`,当前正式入口收敛到 `103.118.245.6`;`180.97.215.57` 缓存入口已停用,历史 `/uploads/` 继续可访问,但不再作为默认展示路径。**
**新生成的展示型图片统一写到 `s3.jiansun.vip` 的 `/gongxiangpan/public-display/`,当前正式入口收敛到 `218.93.9.38`;`180.97.215.57` 缓存入口已停用,`103.118.245.6` 不再作为当前默认排障入口,历史 `/uploads/` 继续可访问,但不再作为默认展示路径。**

View File

@ -2,7 +2,8 @@
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览直链`
> 当前主域名:`https://sc.jiansun.vip`
> 当前资源主域名:`https://s3.jiansun.vip`
> 当前资源基地址:`https://s3.jiansun.vip/gongxiangpan`
> 当前资源对象前缀:`由壹点AI/public-display/...`
> 历史旧资源域名:`https://s3.yydd.ai`(仅用于识别历史链接,不再作为对接口径)
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
@ -18,10 +19,12 @@
### 1.1 当前网络口径
- 接口查询、鉴权和下载 token 入口统一使用 `https://sc.jiansun.vip`
- `white-background` 分支里返回的 `previewUrl`,新预览默认走 `https://s3.jiansun.vip/gongxiangpan/public-display/white-background/...`
- `task-detail` 分支返回的预览图直链,当前默认资源域名使用 `https://s3.jiansun.vip`
- `white-background` 分支里返回的 `previewUrl`,新预览默认走 `https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/white-background/...`
- `task-detail` 分支返回的预览图直链,当前默认走 `https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/...`
- `/uploads/...` 只作为源文件下载、内网访问或少量历史预览兼容路径,不作为新预览默认路径
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为默认对接口径
- 当前对外读取仍使用素材平台现有 S3/MinIO 资源链路,不使用七牛云存储桶作为对接口径
- 七牛云 bucket / CDN 只是备用迁移方案,当前未投入使用;接口返回、图片排查和对外联调都不要把 `scqny.jiansun.vip` 或 `scqnycdn.jiansun.vip` 当作当前资源入口
返回统一格式:
@ -138,7 +141,7 @@ X-API-Key: your-api-key
"label": "左前45°",
"fileName": "TEXT001452-旗舰店-左前45°.webp",
"fileSize": 123456,
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-leftFront45.webp",
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-leftFront45.webp",
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx"
},
{
@ -146,7 +149,7 @@ X-API-Key: your-api-key
"label": "右前45°",
"fileName": "TEXT001452-旗舰店-右前45°.webp",
"fileSize": 123456,
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-rightFront45.webp",
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-rightFront45.webp",
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=yyy"
}
],
@ -156,7 +159,7 @@ X-API-Key: your-api-key
"label": "其他图片1",
"fileName": "01-detail-1.webp",
"fileSize": 123456,
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/other/01-detail-1.webp",
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/other/01-detail-1.webp",
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=zzz"
}
]
@ -247,7 +250,7 @@ X-API-Key: your-api-key
"hasWhiteBackground": true,
"whiteBackgroundCount": 2,
"previewImage": {
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-rightFront45.webp",
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/white-background/484bd047-c956-48cf-a0f4-401f696aef8d/sku-id-1/TEXT001452-rightFront45.webp",
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
"fileName": "TEXT001452-旗舰店-右前45°.webp",
"fileSize": 123456
@ -297,7 +300,8 @@ X-API-Key: your-api-key
### 4.1 适用场景
- `resourceType=task-detail`
- 按 `任务-详情` 素材类型直接查询,不再要求先存在 `标准模型`
- 按 SPU 查询时,直接按 `任务-详情` 素材类型查询,不要求先存在 `标准模型`
- 按 SKU 查询时,需要先通过 `标准模型` SKU 解析出所属 SPU,再按该 SPU 查询 `任务-详情` 候选
- 只返回:
- `materialType='任务-详情'`
- 同时拥有主图、SKU 图、详情图
@ -305,7 +309,7 @@ X-API-Key: your-api-key
的候选素材
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
- 当前默认返回的资源直链域名是 `https://s3.jiansun.vip`
- 当前默认返回的资源直链基地址是 `https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/...`
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为默认示例地址
---
@ -349,18 +353,18 @@ X-API-Key: your-api-key
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/main/main-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/main/main-1.webp"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/sku/sku-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/sku/sku-1.webp"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/detail/detail-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/detail/detail-1.webp"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/main/main-1.webp",
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/sku/sku-1.webp",
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/detail/detail-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/main/main-1.webp",
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/sku/sku-1.webp",
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/detail/detail-1.webp"
]
}
}
@ -411,18 +415,18 @@ X-API-Key: your-api-key
"isFullSet": true,
"downloadUrls": {
"main": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/main/main-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/main/main-1.webp"
],
"sku": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/sku/sku-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/sku/sku-1.webp"
],
"detail": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/detail/detail-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/detail/detail-1.webp"
],
"all": [
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/main/main-1.webp",
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/sku/sku-1.webp",
"https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-2/detail/detail-1.webp"
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/main/main-1.webp",
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/sku/sku-1.webp",
"https://s3.jiansun.vip/gongxiangpan/由壹点AI/public-display/task-detail/material-2/detail/detail-1.webp"
]
}
}
@ -452,7 +456,7 @@ X-API-Key: your-api-key
- `task-detail` 分支返回的是**全部候选**,不是只返回一条。
- 预览链接可以直接打开或下载。
- 历史 `任务-详情` 会按同一套资产规则判断,不再依赖人工勾选。
- 历史 `任务-详情` 会按同一套资产规则判断,不再依赖人工勾选;正常新链路会返回主图 / SKU 图 / 详情图预览直链,如果极少数历史素材缺少预览资产,返回数组可能为空,需要素材平台补预览后再对接。
- 顶层 `categoryName` / `shopName` 是便捷字段;如果要给用户展示候选素材的真实店铺,请优先使用 `candidates[].shopName`。
## 5. 下载说明
@ -500,8 +504,8 @@ GET https://sc.jiansun.vip/api/external/download?token=xxx
补充说明:
- 当前默认回包中的预览图资源域名为 `https://s3.jiansun.vip`
- 新任务详情预览路径默认位于 `/gongxiangpan/public-display/task-detail/...`
- 当前默认回包中的预览图资源基地址为 `https://s3.jiansun.vip/gongxiangpan`
- 新任务详情预览路径默认位于 `/gongxiangpan/由壹点AI/public-display/task-detail/...`
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为当前默认对接地址
## 6. 错误码
@ -581,7 +585,7 @@ curl -X GET \
## 9. 当前联调状态
截至 `2026-03-24`,正式环境对应代码口径已更新为:
截至 `2026-07-09`,当前项目对应代码和配置口径为:
- `/api/external/standard-model-white-background` 已支持 `white-background` 和 `task-detail`
- 不传 `resourceType` 时,白底图老调用方式保持兼容
@ -590,5 +594,6 @@ curl -X GET \
- 白底图已采用 `previewUrl` / `downloadUrl` 双轨语义
- 后续新上传白底图将统一转为 WebP 存储,并额外生成预览级 WebP
- `任务-详情` 整套字段 `isFullSet` 已改为按主图 / SKU 图 / 详情图自动判定
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`
- 相关测试已通过:`standard-model-white-background`、`white-background-preview`、`standard-model-white-background-route`、`white-background-upload-route`
- 对外文档默认资源基地址已统一到 `https://s3.jiansun.vip/gongxiangpan`
- 当前 public-display 资源路径包含对象前缀 `由壹点AI/public-display/...`
- 当前对外读取不使用七牛云存储桶

View File

@ -27,7 +27,7 @@
- Git 仓库:`http://git.1080p.hk/lunanting/gongxiangp2.git`
- 分支:`main`
- 构建机:`192.168.42.134`
- 构建机:`192.168.40.206`
- 生产机:`192.168.58.20`
- 正式容器:`youyidian-3d-material`
- 正式端口:`4320`
@ -38,7 +38,7 @@
- 本机可用 PowerShell 5.1+。
- 本机已安装 PuTTY 工具,`plink.exe` 和 `pscp.exe` 可在 PATH 中找到,或位于 `C:\Program Files\PuTTY\`。
- 本机能访问构建机 `192.168.42.134`。
- 本机能访问构建机 `192.168.40.206`。
- 构建机能访问 Git 仓库和生产机 `192.168.58.20`。
- 构建机本地 Registry 端口 `5000` 可用。
- 已通过环境变量或参数提供构建机密码:`BUILD_SERVER_PASSWORD` 或 `-BuildServerPassword`。

View File

@ -1,14 +1,15 @@
# 网络拓扑文档
> 更新时间:2026-05-07
> 更新时间:2026-07-09
> 本文只记录当前正式口径,不再把历史旁路和备用域名混进默认方案。
## 1. 正式入口
- 主站:`sc.jiansun.vip -> 103.118.245.6`
- 资源 / S3:`s3.jiansun.vip -> 103.118.245.6`
- 主站:`sc.jiansun.vip -> 218.93.9.38`
- 资源 / S3:`s3.jiansun.vip -> 218.93.9.38`
- 内网生产机:`192.168.58.20`
- 当前公网入口机:香港服务器 `103.118.245.6`
- 当前公网入口机:新穿透服务器 `218.93.9.38`
- 历史入口:`103.118.245.6` 不再作为当前默认排障入口。
- 已停用入口:`180.97.215.57` 不再作为正式资源入口或缓存入口使用。
- 历史入口:`180.97.221.140`、`156.239.227.105` 不再作为当前默认排障入口。
@ -19,7 +20,7 @@
```text
公网用户
-> https://sc.jiansun.vip
-> 103.118.245.6
-> 218.93.9.38
-> nginx
-> 127.0.0.1:2321
-> FRP 专用入口
@ -39,7 +40,7 @@
```text
公网用户
-> https://s3.jiansun.vip
-> 103.118.245.6
-> 218.93.9.38
-> nginx
-> 127.0.0.1:2323
-> FRP
@ -49,7 +50,7 @@
说明:
- `180.97.215.57` 已停用,不再作为 `s3.jiansun.vip` 的正式链路。
- 当前资源域名和主站域名都收敛到 `103.118.245.6` 入口机。
- 当前资源域名和主站域名都收敛到 `218.93.9.38` 入口机。
- `s3.jiansun.vip` 是否命中缓存不再作为当前正式验证标准,先以域名可达、FRP 可达、MinIO 回源可达为准。
- `s3.yydd.ai` 不再作为正式资源入口。
@ -85,7 +86,7 @@
## 4. 已完成的入口收口
### 4.1 `103.118.245.6`
### 4.1 `218.93.9.38`
- 主站 `nginx upstream` 使用 `127.0.0.1:2321`
- 资源 / S3 `nginx upstream` 使用 `127.0.0.1:2323`
@ -108,9 +109,9 @@
- `frpc-sc-entry` 仍保留正式主站 `2321 -> 4320`
- `frpc-s3-jiansun-entry` 保留正式资源入口 `2323 -> 9000`
- `frpc-s3-entry` 面向 `180.97.215.57` 的旧资源入口不再作为正式链路使用
- `frpc-sc-health-check` 必须检查 `103.118.245.6:7002`
- `frpc-sc-health-check` 必须检查 `218.93.9.38:7002`
- 2026-05-07 曾因 `/root/frp/frpc-sc-health-check.sh` 仍检查旧入口 `156.239.227.105:7002`,误判正式 `frpc-sc-entry` 异常并约每 70 秒重启一次,导致公网上传从 `413` 修复后继续出现 `502`、`499`、`408` 和卡住。
- 已在线修正为 `FRP_SERVER_HOST="103.118.245.6"`,备份:`/root/frp/frpc-sc-health-check.sh.bak-20260507133353`。
- 2026-07-09 当前正式入口已切到 `218.93.9.38`,不要再沿用旧入口判断健康状态。
### 4.4 `180.97.215.57`
@ -121,14 +122,14 @@
### 5.1 主站
- `https://sc.jiansun.vip` 应能通过 `103.118.245.6 -> nginx -> 127.0.0.1:2321 -> FRP -> 192.168.58.20:4320` 返回页面。
- 如果公网直接超时,优先检查 `103.118.245.6` 的公网可达性、`nginx`、`frps-jiansun-entry` 和生产机 `frpc-sc-entry`。
- 上传排障要同时看 `103.118.245.6:/var/log/nginx/error.log`,如果出现 `client intended to send too large body`,说明入口机 `sc.jiansun.vip` 的上传 body 限制配置丢失或未生效。
- `https://sc.jiansun.vip` 应能通过 `218.93.9.38 -> nginx -> 127.0.0.1:2321 -> FRP -> 192.168.58.20:4320` 返回页面。
- 如果公网直接超时,优先检查 `218.93.9.38` 的公网可达性、`nginx`、`frps-jiansun-entry` 和生产机 `frpc-sc-entry`。
- 上传排障要同时看 `218.93.9.38:/var/log/nginx/error.log`,如果出现 `client intended to send too large body`,说明入口机 `sc.jiansun.vip` 的上传 body 限制配置丢失或未生效。
- 如果不再出现 `413`,但 `/api/materials/upload/chunk` 仍出现 `502`、`499`、`408`,继续检查生产机 `frpc-sc-health-check.service` 是否又用旧入口误判并重启 `frpc-sc-entry.service`。
### 5.2 资源 / S3
- `https://s3.jiansun.vip` 应能通过 `103.118.245.6 -> nginx -> 127.0.0.1:2323 -> FRP -> 192.168.58.20:9000` 回源。
- `https://s3.jiansun.vip` 应能通过 `218.93.9.38 -> nginx -> 127.0.0.1:2323 -> FRP -> 192.168.58.20:9000` 回源。
- 不再使用 `180.97.215.57` 或 `127.0.0.1:29000` 作为正式资源验证链路。
### 5.3 历史缓存验证

View File

@ -1,6 +1,6 @@
# 生产存储桶接入信息
> 更新时间:2026-05-08
> 更新时间:2026-07-09
> 本文记录当前素材平台的 MinIO / S3 / uploads 存储事实。
## 1. 当前结论
@ -161,4 +161,4 @@ ssh root@192.168.58.20 "ls -la /mnt/PoolOne/apps/youyidian-3d/uploads | head -20
Resolve-DnsName s3.jiansun.vip
```
当前资源域名应解析到正式入口 `103.118.245.6`,再经入口机 nginx / FRP 回源到 `192.168.58.20:9000`。
当前资源域名应解析到正式入口 `218.93.9.38`,再经入口机 nginx / FRP 回源到 `192.168.58.20:9000`。`103.118.245.6` 是历史入口,不再作为当前默认排障入口。

View File

@ -1,14 +1,14 @@
# 素材库平台预览缓存方案
> 更新时间:2026-05-07
> 更新时间:2026-07-09
> 状态:`180.97.215.57` 缓存入口已停用,本文保留历史方案和当前展示资源口径。
## 1. 当前口径
- 资源入口:`s3.jiansun.vip -> 103.118.245.6`
- 资源入口:`s3.jiansun.vip -> 218.93.9.38`
- 桶本体:`192.168.58.20:9000`
- 已停用缓存入口:`180.97.215.57`
- 历史入口:`180.97.221.140` 不再作为当前默认排障入口
- 历史入口:`103.118.245.6`、`180.97.221.140` 不再作为当前默认排障入口
- 旧桶兼容:`s3.1080p.hk/jsxiangche` 只保留给极少数老素材,不作为新素材写入目标
- 旧缓存目录:`/data/nginx-cache/s3`,不再作为当前正式链路门禁
- 旧缓存上限:`180g`,只作为历史记录保留
@ -55,7 +55,7 @@
- 下面规则属于 `180.97.215.57` 缓存方案的历史记录。
- 当前正式链路不再用 `X-Cache-Status` 作为排障门禁。
- 当前优先验证 `s3.jiansun.vip -> 103.118.245.6 -> 192.168.58.20:9000` 是否可达。
- 当前优先验证 `s3.jiansun.vip -> 218.93.9.38 -> 192.168.58.20:9000` 是否可达。
## 5. 历史验证结果
@ -85,4 +85,4 @@
- 以后只要是“页面上要展示给人看”的成品图,就优先写 `public-display`
- 只要是“下载型、源文件型、大对象”,就不要硬塞进展示资源口径
- 对外说明时,统一写 `s3.jiansun.vip`,不要再把 `s3.yydd.ai` 当正式入口
- 排障时不要再默认检查 `180.97.215.57` 或 `180.97.221.140`,先检查 `103.118.245.6` 入口和 FRP 回源链路
- 排障时不要再默认检查 `103.118.245.6`、`180.97.215.57` 或 `180.97.221.140`,先检查 `218.93.9.38` 入口和 FRP 回源链路

View File

@ -1,6 +1,6 @@
# 统一流量访问规则
> 更新时间:2026-05-07
> 更新时间:2026-07-09
> 目标:统一正式入口、展示资源口径和内外网分工,减少历史残留带来的分流与误判。
## 1. 统一原则
@ -19,7 +19,7 @@
| --- | --- | --- |
| 主站 | `https://sc.jiansun.vip` | 唯一正式主站入口 |
| 资源 / S3 | `https://s3.jiansun.vip` | 唯一正式资源入口 |
| 当前公网入口机 | `103.118.245.6` | 主站和资源域名当前都解析到这台入口机 |
| 当前公网入口机 | `218.93.9.38` | 主站和资源域名当前都解析到这台入口机 |
| 内网生产机 | `192.168.58.20` | 只给服务端和内网运维链路使用 |
## 3. 访问规则
@ -103,5 +103,5 @@
1. 是主站慢,还是资源图慢。
2. 如果是资源图慢,先看链接是不是 `s3.jiansun.vip`。
3. 再看路径是不是 `/gongxiangpan/public-display/`。
4. 再看 `s3.jiansun.vip` 是否解析到当前正式入口 `103.118.245.6`。
5. 最后再看 `103.118.245.6 -> 127.0.0.1:2323 -> 192.168.58.20:9000` 回源链路。
4. 再看 `s3.jiansun.vip` 是否解析到当前正式入口 `218.93.9.38`。
5. 最后再看 `218.93.9.38 -> 127.0.0.1:2323 -> 192.168.58.20:9000` 回源链路。

View File

@ -62,7 +62,7 @@
## 5. 构建机操作范围
构建机:`192.168.42.134`
构建机:`192.168.40.206`
Git 增量部署脚本会在构建机上使用下面目录作为 Git 工作区:

View File

@ -27,7 +27,7 @@
- Git 仓库:`http://git.1080p.hk/lunanting/gongxiangp2.git`
- 分支:`main`
- 构建机:`192.168.42.134`
- 构建机:`192.168.40.206`
- 生产机:`192.168.58.20`
- 正式容器:`youyidian-3d-material`
- 正式端口:`4320`
@ -119,7 +119,7 @@ shadow-latest # 影子环境最新版本
Registry UI:
```text
http://192.168.42.134:5001
http://192.168.40.206:5001
```
## 6. 清理策略

View File

@ -15,6 +15,8 @@ export interface User {
department: string
role: string
isActive: boolean
status?: string
archivedAt?: string | null
canDownload: boolean
managedDepartment?: string
createdAt: string

View File

@ -0,0 +1,34 @@
/** @jest-environment node */
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
},
}))
jest.mock('@/lib/dingtalk-login', () => ({
authorizeDingtalkH5UserForNextAuth: jest.fn(),
}))
jest.mock('@/lib/auth-cookie', () => ({
resolveSecureAuthCookieForCurrentRequest: jest.fn().mockResolvedValue(false),
}))
describe('credentials provider legacy DingTalk password', () => {
test('固定钉钉自动登录口令不能再用于邮箱密码登录', async () => {
const provider = authOptions.providers[0] as any
await expect(provider.options.authorize({
email: 'user@example.com',
password: 'dingtalk_auto_login',
})).rejects.toThrow('邮箱或密码错误')
expect(prisma.user.findUnique).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,123 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/dingtalk/complete-profile/route'
import { prisma } from '@/lib/prisma'
import { verifyDingtalkProfileToken } from '@/lib/dingtalk-profile-token'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/dingtalk-profile-token', () => ({
getDingtalkProfileCookieCandidates: jest.fn(() => ['dingtalk-profile-token']),
getDingtalkProfileCookiePath: jest.fn(() => '/api/dingtalk/complete-profile'),
verifyDingtalkProfileToken: jest.fn().mockResolvedValue(null),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
department: {
findUnique: jest.fn(),
},
},
}))
describe('dingtalk complete-profile route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(verifyDingtalkProfileToken as jest.Mock).mockResolvedValue(null)
})
function createRequest(body: Record<string, unknown>, profileToken?: string) {
return {
json: jest.fn().mockResolvedValue(body),
cookies: {
get: jest.fn((name: string) =>
name === 'dingtalk-profile-token' && profileToken
? { value: profileToken }
: undefined,
),
},
} as any
}
test('匿名请求不得完善资料', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await POST(createRequest({ userId: 'user-1', department: '设计部' }))
expect(response.status).toBe(401)
expect(prisma.user.update).not.toHaveBeenCalled()
})
test('登录用户不得修改其他用户资料', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
const response = await POST(createRequest({ userId: 'user-2', department: '设计部' }))
expect(response.status).toBe(403)
expect(prisma.user.findUnique).not.toHaveBeenCalled()
expect(prisma.user.update).not.toHaveBeenCalled()
})
test('仅更新会话本人并绑定真实部门', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 'user-1' })
;(prisma.department.findUnique as jest.Mock).mockResolvedValue({ id: 'dept-1', name: '设计部' })
;(prisma.user.update as jest.Mock).mockResolvedValue({
id: 'user-1',
username: 'tester',
email: 'tester@example.com',
realName: '测试用户',
avatar: null,
role: 'USER',
department: '设计部',
isActive: false,
status: 'pending',
})
const response = await POST(createRequest({ userId: 'user-1', department: ' 设计部 ' }))
expect(response.status).toBe(200)
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'user-1' },
data: { department: '设计部', departmentId: 'dept-1' },
}))
})
test('首次登录用户可用短时资料凭证完善自己的部门', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
;(verifyDingtalkProfileToken as jest.Mock).mockResolvedValue('user-1')
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 'user-1' })
;(prisma.department.findUnique as jest.Mock).mockResolvedValue({ id: 'dept-1', name: '设计部' })
;(prisma.user.update as jest.Mock).mockResolvedValue({
id: 'user-1',
username: 'tester',
email: 'tester@example.com',
realName: '测试用户',
avatar: null,
role: 'USER',
department: '设计部',
isActive: false,
status: 'pending',
})
const response = await POST(
createRequest({ userId: 'user-1', department: '设计部' }, 'signed-profile-token'),
)
expect(response.status).toBe(200)
expect(verifyDingtalkProfileToken).toHaveBeenCalledWith('signed-profile-token')
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'user-1' },
}))
expect(response.headers.get('set-cookie')).toContain('Max-Age=0')
})
})

View File

@ -0,0 +1,39 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import {
createDingtalkProfileToken,
getDingtalkProfileCookieName,
verifyDingtalkProfileToken,
} from '@/lib/dingtalk-profile-token'
describe('DingTalk profile completion token', () => {
const originalSecret = process.env.NEXTAUTH_SECRET
beforeAll(() => {
process.env.NEXTAUTH_SECRET = 'test-nextauth-secret-with-enough-entropy'
})
afterAll(() => {
if (originalSecret === undefined) {
delete process.env.NEXTAUTH_SECRET
} else {
process.env.NEXTAUTH_SECRET = originalSecret
}
})
test('短时凭证只还原签发时的用户 ID', async () => {
const token = await createDingtalkProfileToken('user-1')
await expect(verifyDingtalkProfileToken(token)).resolves.toBe('user-1')
await expect(verifyDingtalkProfileToken(`${token}tampered`)).resolves.toBeNull()
})
test('HTTPS 请求使用 Secure Cookie 名称', () => {
const request = new NextRequest('https://sc.jiansun.vip/complete-profile', {
headers: { 'x-forwarded-proto': 'https' },
})
expect(getDingtalkProfileCookieName(request)).toBe('__Secure-dingtalk-profile-token')
})
})

View File

@ -1,6 +1,28 @@
/** @jest-environment node */
import { getApiKeyFromRequest } from '@/lib/external-api'
jest.mock('@/lib/prisma', () => ({
prisma: {
apiKey: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
import {
createApiKey,
getApiKeyFromRequest,
getApiKeyPreview,
validateApiKey,
} from '@/lib/external-api'
const apiKeyDb = prisma.apiKey as unknown as {
findUnique: jest.Mock
update: jest.Mock
create: jest.Mock
}
function createRequest(url: string, headers?: Record<string, string>) {
return {
@ -38,3 +60,39 @@ describe('getApiKeyFromRequest', () => {
expect(getApiKeyFromRequest(request, { allowQuery: true })).toBe('header-key')
})
})
describe('API Key 哈希存储', () => {
beforeEach(() => jest.clearAllMocks())
test('新建 Key 只向数据库写入哈希,原始值仅返回一次', async () => {
apiKeyDb.create.mockImplementation(async ({ data }: any) => ({
id: 'key-1',
name: data.name,
key: data.key,
createdAt: new Date('2026-07-10T00:00:00Z'),
}))
const created = await createApiKey('产品平台', 'admin-1')
const storedKey = apiKeyDb.create.mock.calls[0][0].data.key
expect(created.key).toMatch(/^[a-f0-9]{32}$/)
expect(storedKey).not.toBe(created.key)
expect(storedKey).toMatch(/^v1:[a-f0-9]{8}:[a-f0-9]{64}$/)
})
test('已有明文 Key 首次验证成功后迁移成哈希', async () => {
apiKeyDb.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'legacy-1', name: '旧 Key', isActive: true, createdBy: 'admin-1' })
apiKeyDb.update.mockResolvedValue({})
const result = await validateApiKey('legacy-api-key')
expect(result?.id).toBe('legacy-1')
expect(apiKeyDb.update.mock.calls[0][0].data.key).toMatch(/^v1:legacy-a:[a-f0-9]{64}$/)
})
test('后台只展示原始 Key 前缀', () => {
expect(getApiKeyPreview(`v1:12345678:${'a'.repeat(64)}`)).toBe('12345678...')
})
})

View File

@ -321,4 +321,192 @@ describe('external maixiang-spu-assets route', () => {
items: [],
})
})
test('安装视频素材不受 15 天保护期限制,始终返回完整下载链接', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'install-video',
productModel: 'SPU003',
materialType: '安装视频',
categoryName: '顾家',
sceneName: '安装指导',
zipFileName: 'install-guide.mp4',
zipFilePath: 'uploads/install-guide.mp4',
zipFileSize: BigInt(8192),
previewUrl: 'https://s3.example/install-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/install-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/install-clip.mp4',
videoCoverUrl: 'https://s3.example/install-video-cover.jpg',
videoDuration: 120,
createdAt: new Date(), // 刚刚上传
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU003'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(1)
const videoItem = body.data.videos.items[0]
expect(videoItem.materialId).toBe('install-video')
// 安装视频不受保护期限制,previewUrl 和 downloadUrl 不应为空
expect(videoItem.previewUrl).not.toBeNull()
expect(videoItem.previewClipUrl).not.toBeNull()
expect(videoItem.downloadUrl).not.toBeNull()
// 不应包含 blocked 字段
expect(videoItem.videoBlocked).toBeUndefined()
})
test('非安装视频上传未满 15 天时应返回 blocked 字段并隐藏下载链接', async () => {
const recentDate = new Date()
recentDate.setDate(recentDate.getDate() - 3) // 3 天前,未满 15 天
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'recent-video',
productModel: 'SPU004',
materialType: '淘系-主图视频',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'recent-video.mp4',
zipFilePath: 'uploads/recent-video.mp4',
zipFileSize: BigInt(1024),
previewUrl: 'https://s3.example/recent-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/recent-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/recent-clip.mp4',
videoCoverUrl: 'https://s3.example/recent-video-cover.jpg',
videoDuration: 45,
createdAt: recentDate,
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU004'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(1)
const videoItem = body.data.videos.items[0]
expect(videoItem.materialId).toBe('recent-video')
// blocked 字段应完整返回
expect(videoItem.videoBlocked).toBe(true)
expect(videoItem.videoBlockReason).toBe('VIDEO_WAIT_PERIOD')
expect(videoItem.videoRemainingDays).toBeGreaterThan(0)
expect(videoItem.videoRemainingDays).toBeLessThanOrEqual(15)
expect(videoItem.videoBlockMessage).toContain('满 15 天才能下载')
expect(videoItem.videoAvailableAt).not.toBeNull()
// 预览和下载链接应置空
expect(videoItem.previewUrl).toBeNull()
expect(videoItem.previewClipUrl).toBeNull()
expect(videoItem.downloadUrl).toBeNull()
// coverUrl 仍然保留(封面无需保护)
expect(videoItem.coverUrl).not.toBeNull()
})
test('混合场景:部分视频允许、部分视频禁用时各自返回对应字段', async () => {
const recentDate = new Date()
recentDate.setDate(recentDate.getDate() - 5) // 5 天前
const oldDate = new Date()
oldDate.setDate(oldDate.getDate() - 30) // 30 天前
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
// 受保护的视频(未满 15 天)
{
id: 'blocked-video',
productModel: 'SPU005',
materialType: '抖音-千川素材',
categoryName: '朴美',
sceneName: '卧室',
zipFileName: 'blocked.mp4',
zipFilePath: 'uploads/blocked.mp4',
zipFileSize: BigInt(2048),
previewUrl: 'https://s3.example/blocked-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/blocked-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/blocked-clip.mp4',
videoCoverUrl: 'https://s3.example/blocked-video-cover.jpg',
videoDuration: 30,
createdAt: recentDate,
},
// 允许的视频(已满 15 天)
{
id: 'allowed-video',
productModel: 'SPU005',
materialType: '抖音-千川素材',
categoryName: '朴美',
sceneName: '卧室',
zipFileName: 'allowed.mp4',
zipFilePath: 'uploads/allowed.mp4',
zipFileSize: BigInt(4096),
previewUrl: 'https://s3.example/allowed-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/allowed-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/allowed-clip.mp4',
videoCoverUrl: 'https://s3.example/allowed-video-cover.jpg',
videoDuration: 60,
createdAt: oldDate,
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU005'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(2)
const blockedItem = body.data.videos.items.find((v: any) => v.materialId === 'blocked-video')
const allowedItem = body.data.videos.items.find((v: any) => v.materialId === 'allowed-video')
// blocked 视频
expect(blockedItem.videoBlocked).toBe(true)
expect(blockedItem.previewUrl).toBeNull()
expect(blockedItem.previewClipUrl).toBeNull()
expect(blockedItem.downloadUrl).toBeNull()
// allowed 视频
expect(allowedItem.videoBlocked).toBeUndefined()
expect(allowedItem.previewUrl).not.toBeNull()
expect(allowedItem.previewClipUrl).not.toBeNull()
expect(allowedItem.downloadUrl).not.toBeNull()
})
test('买家秀素材始终不受视频保护策略影响', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'buyer-show-recent',
productModel: 'SPU006',
materialType: '买家秀图片',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'buyer-recent.zip',
zipFilePath: 'uploads/buyer-recent.zip',
zipFileSize: BigInt(512),
previewUrl: 'https://s3.example/buyer-cover.jpg',
displayImages: ['https://s3.example/buyer-img-1.jpg'],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
createdAt: new Date(), // 刚刚上传
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU006'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.buyerShow.total).toBe(1)
const buyerItem = body.data.buyerShow.items[0]
// 买家秀素材不包含 videoBlocked 字段
expect(buyerItem.videoBlocked).toBeUndefined()
// previewUrls 和 downloadUrl 始终存在
expect(Array.isArray(buyerItem.previewUrls)).toBe(true)
expect(buyerItem.previewUrls.length).toBeGreaterThan(0)
expect(buyerItem.downloadUrl).toMatch(/^https:\/\/download\.example\//)
})
})

View File

@ -0,0 +1,82 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { addAsset, getAssetsByMaterialId } from '@/lib/product-package'
import {
DELETE,
GET,
POST,
} from '@/app/api/materials/[id]/assets/route'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
canUserViewMaterial: jest.fn().mockResolvedValue(false),
isSuperAdmin: jest.fn().mockResolvedValue(false),
isSystemAdmin: jest.fn(() => false),
}))
jest.mock('@/lib/product-package', () => ({
getAssetsByMaterialId: jest.fn(),
addAsset: jest.fn(),
}))
jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn() }))
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
jest.mock('@/lib/material-cleanup', () => ({ normalizeDisplayImages: jest.fn(() => []) }))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
update: jest.fn(),
},
materialAsset: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
$transaction: jest.fn(),
},
}))
describe('material assets route authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-2', role: 'USER', department: '设计部' },
})
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
displayImages: [],
})
})
test('无查看权限的用户不得读取资产列表', async () => {
const response = await GET({} as any, { params: { id: 'material-1' } })
expect(response.status).toBe(403)
expect(getAssetsByMaterialId).not.toHaveBeenCalled()
})
test('非所有者不得添加资产', async () => {
const response = await POST({
json: jest.fn().mockResolvedValue({ assetType: 'preview', fileUrl: 'uploads/a.webp' }),
} as any, { params: { id: 'material-1' } })
expect(response.status).toBe(403)
expect(addAsset).not.toHaveBeenCalled()
})
test('非所有者不得删除资产', async () => {
const response = await DELETE({
url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1',
} as any, { params: { id: 'material-1' } })
expect(response.status).toBe(403)
expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled()
})
})

View File

@ -6,7 +6,14 @@ import { PUT as putMaterialUpdate } from '@/app/api/materials/update/route'
import { getServerSession } from 'next-auth'
import { updateMaterialWithSharedRules } from '@/lib/material-update'
import { prisma } from '@/lib/prisma'
import { canUserViewMaterial, isShareLinkAccess, isSuperAdmin } from '@/lib/department-permission'
import {
canUserDownloadMaterial,
canUserDownloadSource,
canUserViewMaterial,
isShareLinkAccess,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
jest.mock('next-auth', () => ({
@ -22,13 +29,19 @@ jest.mock('@/lib/prisma', () => ({
material: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/department-permission', () => ({
canUserDownloadMaterial: jest.fn(),
canUserDownloadSource: jest.fn(),
canUserViewMaterial: jest.fn(),
isShareLinkAccess: jest.fn(),
isSuperAdmin: jest.fn(),
isSystemAdmin: jest.fn(),
}))
jest.mock('@/lib/task-detail-full-set', () => ({
@ -54,7 +67,11 @@ describe('material edit routes', () => {
})
;(isShareLinkAccess as jest.Mock).mockReturnValue(false)
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true)
;(canUserDownloadSource as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
;(deriveTaskDetailIsFullSet as jest.Mock).mockReturnValue(false)
;(updateMaterialWithSharedRules as jest.Mock).mockResolvedValue({
material: {
@ -211,7 +228,7 @@ describe('material edit routes', () => {
expect(body.zipFileSize).toBe('123')
expect(body.assets[0].fileSize).toBe('456')
expect(body.taskDetailSourceType).toBe('渲染')
expect(body.sourceFileUrl).toBe('/api/uploads/brand-vi/original.pdf')
expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1')
})
test('PUT 路由应透传任务详情出图方式字段', async () => {

View File

@ -0,0 +1,50 @@
import sharp from 'sharp'
import {
PERFORMANCE_SURVEY_IMAGE_LIMITS,
assertPerformanceSurveyImageDimensions,
processPerformanceSurveyImages,
} from '@/lib/performance-survey-image-processing'
describe('performance survey image processing', () => {
test('serially converts a long source image into ordered 1500px WebP slices', async () => {
const source = await sharp({
create: {
width: 24,
height: 3100,
channels: 4,
background: { r: 32, g: 96, b: 180, alpha: 1 },
},
}).png().toBuffer()
const processed = await processPerformanceSurveyImages([{
buffer: source,
mimeType: 'image/png',
sourceName: 'long-image.png',
}])
expect(processed).toHaveLength(3)
expect(processed.map((item) => item.sliceIndex)).toEqual([0, 1, 2])
const metadata = await Promise.all(processed.map((item) => sharp(item.buffer).metadata()))
expect(metadata.map((item) => item.format)).toEqual(['webp', 'webp', 'webp'])
expect(metadata.map((item) => item.height)).toEqual([1500, 1500, 100])
})
test('rejects excessive pixel and per-image slice budgets before processing', () => {
expect(() => assertPerformanceSurveyImageDimensions(10_000, 10_000, 'huge.png')).toThrow('像素总量过大')
expect(() => assertPerformanceSurveyImageDimensions(
1,
PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight * (PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage + 1),
'too-long.png',
)).toThrow('最多允许 24 个切片')
})
test('rejects more than 12 source images before decoding', async () => {
const inputs = Array.from({ length: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount + 1 }, (_, index) => ({
buffer: Buffer.from('not-decoded'),
mimeType: 'image/png',
sourceName: `${index}.png`,
}))
await expect(processPerformanceSurveyImages(inputs)).rejects.toThrow('最多上传 12 张图片')
})
})

View File

@ -0,0 +1,38 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { POST } from '@/app/api/materials/upload/product-package/complete/route'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
},
},
}))
describe('product-package complete public route security', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
})
test.each([
['fileUrl', { fileUrl: 'http://127.0.0.1:3000/internal' }],
['localFilePath', { localFilePath: 'C:\\Windows\\win.ini' }],
])('公开 complete 路由拒绝 %s 来源', async (_source, sourceInput) => {
const response = await POST({
json: jest.fn().mockResolvedValue({
materialId: 'material-1',
assets: {
main: [{ ...sourceInput, fileName: 'main.png', fileSize: 10 }],
},
}),
} as any)
expect(response.status).toBe(400)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
})
})

View File

@ -1,6 +1,9 @@
/** @jest-environment node */
import { completeProductPackageUpload } from '@/lib/product-package-completion'
import {
completeProductPackageUpload,
parsePublicProductPackageAssets,
} from '@/lib/product-package-completion'
import { prisma } from '@/lib/prisma'
import {
queueTaskDetailBarcodeImagesUpdatedNotification,
@ -8,6 +11,7 @@ import {
} from '@/lib/external-auto-listing'
import { createMaterialLog } from '@/lib/material-log'
import { createZipFromFiles } from '@/lib/task-material-processor'
import { getOwnedUploadSession } from '@/lib/upload-sessions'
jest.mock('@/lib/prisma', () => ({
prisma: {
@ -49,6 +53,7 @@ jest.mock('@/lib/task-material-processor', () => ({
jest.mock('@/lib/upload-sessions', () => ({
getUploadedFile: jest.fn(),
getOwnedUploadSession: jest.fn().mockResolvedValue({ userId: 'user-1' }),
}))
jest.mock('@/lib/material-log', () => ({
@ -259,4 +264,78 @@ describe('product-package-completion', () => {
}),
).rejects.toThrow('不能同时绑定')
})
test('公开上传资产不得携带 localFilePath', () => {
expect(() =>
parsePublicProductPackageAssets({
main: [
{
uploadId: 'upload-1',
localFilePath: 'C:\\Windows\\win.ini',
fileName: 'main.png',
fileSize: 10,
},
],
}),
).toThrow('只允许使用 uploadId')
})
test('公开上传资产不得使用 fileUrl 触发服务端请求', () => {
expect(() =>
parsePublicProductPackageAssets({
main: [
{
fileUrl: 'http://127.0.0.1:3000/api/admin/system-logs',
fileName: 'main.png',
fileSize: 10,
},
],
}),
).toThrow('只允许使用 uploadId')
})
test('公开上传资产只保留完成流程需要的字段', () => {
expect(
parsePublicProductPackageAssets({
sku: [
{
uploadId: ' upload-1 ',
fileName: ' sku.png ',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001', 123],
unexpected: 'ignored',
},
],
}),
).toEqual({
main: undefined,
sku: [
{
uploadId: 'upload-1',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001'],
},
],
detail: undefined,
psd: undefined,
})
})
test('不能消费其他用户的上传会话', async () => {
;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce(null)
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ uploadId: 'upload-other', fileName: 'main.png', fileSize: 10 }],
},
})).rejects.toThrow('不属于当前用户')
})
})

View File

@ -13,6 +13,12 @@ jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn().mockResolvedValue(false),
hasSystemAdminPermission: jest.fn((role: string, isSuperAdmin: boolean) =>
isSuperAdmin || role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
}))
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
unlink: jest.fn(),
@ -71,7 +77,7 @@ describe('admin regenerate-preview route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1' },
user: { id: 'admin-1', role: 'ADMIN' },
})
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
@ -131,4 +137,18 @@ describe('admin regenerate-preview route', () => {
},
})
})
test('普通登录用户不得重建其他素材的预览', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
const response = await POST({
json: jest.fn().mockResolvedValue({ materialId: 'material-1' }),
} as any)
expect(response.status).toBe(403)
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,150 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { canDownloadSkuMaterial, canManageSkuMaterial } from '@/app/api/materials/[id]/skus/authorization'
import { POST as createSku } from '@/app/api/materials/[id]/skus/route'
import {
DELETE as deleteSku,
GET as getSku,
PUT as updateSku,
} from '@/app/api/materials/[id]/skus/[skuId]/route'
import { POST as uploadSku } from '@/app/api/materials/[id]/skus/upload/route'
import { PUT as reorderSkus } from '@/app/api/materials/[id]/skus/reorder/route'
import { GET as downloadSku } from '@/app/api/materials/[id]/skus/[skuId]/download/route'
import { POST as batchDownloadSkus } from '@/app/api/materials/[id]/skus/batch-download/route'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
jest.mock('@/lib/material-download-log', () => ({ ensureMaterialDownloadLogsTable: jest.fn() }))
jest.mock('@/lib/file-cleanup', () => ({
deleteFileIfUnreferenced: jest.fn(),
deleteLocalFile: jest.fn(),
}))
jest.mock('@/app/api/materials/[id]/skus/authorization', () => ({
canManageSkuMaterial: jest.fn(),
canDownloadSkuMaterial: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
materialSku: {
findFirst: jest.fn(),
findMany: jest.fn(),
aggregate: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
materialAsset: {
findMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
materialDownloadLog: {
create: jest.fn(),
},
$transaction: jest.fn(),
},
}))
describe('SKU routes object authorization', () => {
const session = { user: { id: 'user-2', role: 'USER', department: '设计部' } }
const material = { id: 'material-1', userId: 'user-1', materialType: '标准模型', productModel: 'SPU-1' }
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue(session)
;(canManageSkuMaterial as jest.Mock).mockResolvedValue(false)
;(canDownloadSkuMaterial as jest.Mock).mockResolvedValue(false)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
})
test.each([
['创建', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material)
return createSku({ json: jest.fn() } as any, { params: { id: 'material-1' } })
}],
['上传', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material)
return uploadSku({ formData: jest.fn() } as any, { params: { id: 'material-1' } })
}],
['排序', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material)
return reorderSkus({ json: jest.fn() } as any, { params: { id: 'material-1' } })
}],
])('非所有者不得%s SKU', async (_label, invoke) => {
const response = await invoke()
expect(response.status).toBe(403)
expect(prisma.materialSku.create).not.toHaveBeenCalled()
expect(prisma.materialSku.update).not.toHaveBeenCalled()
})
test.each([
['更新', updateSku],
['删除', deleteSku],
])('非所有者不得%s目标 SKU', async (_label, handler) => {
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
id: 'sku-1',
materialId: 'material-1',
material: { userId: 'user-1' },
})
const response = await handler({ json: jest.fn() } as any, {
params: { id: 'material-1', skuId: 'sku-1' },
})
expect(response.status).toBe(403)
expect(prisma.materialSku.update).not.toHaveBeenCalled()
expect(prisma.materialSku.delete).not.toHaveBeenCalled()
})
test('SKU 详情查询必须同时绑定 materialId 与 skuId', async () => {
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
id: 'sku-1',
materialId: 'material-1',
skuName: 'SKU-1',
previewUrl: '',
fileName: null,
filePath: '',
fileSize: BigInt(0),
sortOrder: 0,
whiteBackgroundUploadedBy: null,
})
const response = await getSku({} as any, {
params: { id: 'material-1', skuId: 'sku-1' },
})
expect(response.status).toBe(200)
expect(prisma.materialSku.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'sku-1', materialId: 'material-1', deletedAt: null },
}))
})
test('无素材下载权限时单个和批量下载均在读文件前拒绝', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material)
const singleResponse = await downloadSku(
{ url: 'http://localhost/api/materials/material-1/skus/sku-1/download' } as any,
{ params: { id: 'material-1', skuId: 'sku-1' } },
)
const batchResponse = await batchDownloadSkus(
{ json: jest.fn().mockResolvedValue({ skuIds: ['sku-1'] }) } as any,
{ params: { id: 'material-1' } },
)
expect(singleResponse.status).toBe(403)
expect(batchResponse.status).toBe(403)
expect(prisma.materialSku.findMany).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,67 @@
/** @jest-environment node */
import { execFile } from 'child_process'
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/admin/system-logs/route'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn().mockResolvedValue(false),
hasSystemAdminPermission: jest.fn((role: string, isSuperAdmin: boolean) =>
isSuperAdmin || role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
}))
jest.mock('child_process', () => ({
execFile: jest.fn(),
}))
describe('admin system-logs route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
;(execFile as unknown as jest.Mock).mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: Function) =>
callback(null, 'INFO ready\nERROR failed\n', ''),
)
})
test('关键词和行数不得进入 shell,并在进程输出后过滤', async () => {
const keyword = '"; touch /tmp/pwn; #'
const request = new NextRequest(
`http://localhost/api/admin/system-logs?lines=999999&keyword=${encodeURIComponent(keyword)}`,
)
const response = await GET(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(execFile).toHaveBeenCalledWith(
'docker',
['logs', '--tail', '5000', 'youyidian-3d-material'],
expect.objectContaining({ encoding: 'utf8' }),
expect.any(Function),
)
expect((execFile as unknown as jest.Mock).mock.calls[0][2]).not.toHaveProperty('shell')
expect(JSON.stringify((execFile as unknown as jest.Mock).mock.calls[0].slice(0, 3))).not.toContain(keyword)
expect(body.logs).toBe('')
})
test('普通用户不得调用 docker', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
const response = await GET(new NextRequest('http://localhost/api/admin/system-logs'))
expect(response.status).toBe(403)
expect(execFile).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,129 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/materials/transcode/route'
import { prisma } from '@/lib/prisma'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { transcodeQueue } from '@/lib/video-transcode-queue'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
count: jest.fn(),
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/department-permission', () => ({
canUserViewMaterial: jest.fn(),
hasSystemAdminPermission: jest.fn(),
}))
jest.mock('@/lib/video-transcode-task', () => ({
executeTranscodeTask: jest.fn(),
}))
jest.mock('@/lib/video-transcode-queue', () => ({
VIDEO_MATERIAL_TYPES: ['淘系-主图视频'],
cleanupTranscodeResidues: jest.fn(),
transcodeQueue: {
add: jest.fn(),
getStatus: jest.fn(() => ({
waiting: [],
processingIds: ['material-1'],
progress: new Map(),
stages: new Map(),
maxConcurrent: 1,
})),
recoverOnStartup: jest.fn().mockResolvedValue(1),
recoverInterruptedTasks: jest.fn(),
},
}))
function createRequest() {
return new NextRequest('http://localhost/api/materials/transcode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ materialId: 'material-1', forceRetry: true }),
})
}
describe('transcode route authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'owner-1', role: 'USER', isSuperAdmin: false },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'owner-1',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
})
test('素材所有者可以重试自己的转码任务', async () => {
const response = await POST(createRequest())
expect(response.status).toBe(200)
expect(transcodeQueue.add).toHaveBeenCalledWith(
'material-1',
'uploads/materials/video.mp4',
true,
expect.any(Function),
)
expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled()
})
test('普通用户不能启动其他用户素材的转码任务', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-owner',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
const response = await POST(createRequest())
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toContain('只能启动自己素材')
expect(transcodeQueue.add).not.toHaveBeenCalled()
})
test('系统管理员可以恢复队列并启动任意素材的转码任务', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'SYSTEM_ADMIN', isSuperAdmin: false },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-owner',
videoSourceUrl: 'uploads/materials/video.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'failed',
materialType: '淘系-主图视频',
})
const response = await POST(createRequest())
expect(response.status).toBe(200)
expect(transcodeQueue.recoverOnStartup).toHaveBeenCalledTimes(1)
expect(transcodeQueue.add).toHaveBeenCalledTimes(1)
})
})

View File

@ -44,7 +44,7 @@ describe('video download policy integration', () => {
expect(content).toContain('getVideoPreviewPolicyFields(')
expect(content).toContain('canBypassVideoPreviewGate')
expect(content).toContain("userRole === 'ADMIN'")
expect(content).toContain('isSystemAdmin(userRole)')
expect(content).toContain('return buildVideoPreviewBlockedResponse()')
})
@ -66,7 +66,8 @@ describe('video download policy integration', () => {
)
expect(apiUploadsRoute).toContain('findBlockedVideoPreviewByUrl(')
expect(uploadsRoute).toContain('findBlockedVideoPreviewByUrl(')
expect(uploadsRoute).toContain('/api/uploads/')
expect(uploadsRoute).toContain('Response.redirect(target, 307)')
})
test('媒体代理应拦截等待期内的视频预览文件旧链接', () => {

View File

@ -15,6 +15,7 @@ describe('white-background-preview helpers', () => {
beforeEach(() => {
process.env.S3_PUBLIC_DOMAIN = 'https://s3.jiansun.vip/gongxiangpan'
delete process.env.S3_FILE_PATH
})
afterEach(async () => {

View File

@ -5,6 +5,8 @@ import bcrypt from 'bcryptjs'
import { resolveSecureAuthCookieForCurrentRequest } from '@/lib/auth-cookie'
import { authorizeDingtalkH5UserForNextAuth } from '@/lib/dingtalk-login'
const LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD = 'dingtalk_auto_login'
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
@ -44,6 +46,10 @@ export const authOptions: NextAuthOptions = {
throw new Error('请输入邮箱和密码')
}
if (credentials.password === LEGACY_DINGTALK_AUTO_LOGIN_PASSWORD) {
throw new Error('邮箱或密码错误')
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
})
@ -118,10 +124,45 @@ export const authOptions: NextAuthOptions = {
token.isSuperAdmin = (user as any).isSuperAdmin; // 添加超级管理员标识到token
token.department = (user as any).department || '';
token.managedDepartment = (user as any).managedDepartment || null;
token.accountStatus = 'active';
token.authCheckedAt = Date.now();
} else if (token.id) {
const lastCheckedAt = Number((token as any).authCheckedAt || 0)
if (Date.now() - lastCheckedAt >= 30_000) {
const currentUser = await prisma.user.findUnique({
where: { id: String(token.id) },
select: {
role: true,
status: true,
isActive: true,
avatar: true,
department: true,
managedDepartment: true,
isSuperAdmin: true,
},
})
const currentStatus = currentUser?.status || (currentUser?.isActive ? 'active' : 'pending')
const accountDisabled = !currentUser || currentStatus === 'suspended' || currentStatus === 'archived'
;(token as any).accountStatus = accountDisabled ? 'disabled' : currentStatus
;(token as any).authCheckedAt = Date.now()
if (currentUser && !accountDisabled) {
token.role = currentUser.role
token.avatar = currentUser.avatar
token.isSuperAdmin = currentUser.isSuperAdmin
token.department = currentUser.department || ''
token.managedDepartment = currentUser.managedDepartment || null
}
}
}
return token;
},
async session({ session, token }) {
if ((token as any).accountStatus === 'disabled') {
;(session as any).user = null
return session
}
if (session?.user && token) {
(session.user as any).role = token.role || 'USER';
(session.user as any).id = token.id;

View File

@ -13,6 +13,13 @@ import {
import bcrypt from 'bcryptjs'
import { encode } from 'next-auth/jwt'
import type { Role } from '@prisma/client'
import { randomBytes } from 'crypto'
import {
createDingtalkProfileToken,
DINGTALK_PROFILE_TOKEN_MAX_AGE_SECONDS,
getDingtalkProfileCookieName,
getDingtalkProfileCookiePath,
} from '@/lib/dingtalk-profile-token'
type DingtalkUserInfo = {
name?: string | null
@ -37,6 +44,10 @@ type DingtalkNextAuthUser = {
let configValidated = false
async function createDingtalkOnlyPasswordHash() {
return bcrypt.hash(randomBytes(32).toString('hex'), 10)
}
function validateDingtalkConfig(
appSource: DingtalkLoginAppSource = 'login',
): { clientId: string; clientSecret: string } | null {
@ -345,8 +356,6 @@ async function createOrUpdateUserSession(
})
let needCompleteProfile = false
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
if (!user) {
const existingUserByName = await prisma.user.findUnique({
where: { username: displayName },
@ -362,7 +371,6 @@ async function createOrUpdateUserSession(
realName: displayName,
avatar: userInfo.avatar || existingUserByName.avatar,
phone: userInfo.mobile || existingUserByName.phone,
password: hashedPassword,
},
})
console.log('账号关联成功:', user.username)
@ -379,7 +387,7 @@ async function createOrUpdateUserSession(
data: {
username: displayName,
email: emailToUse,
password: hashedPassword,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
@ -403,14 +411,13 @@ async function createOrUpdateUserSession(
realName: displayName,
avatar: userInfo.avatar || user.avatar,
phone: userInfo.mobile || user.phone,
password: hashedPassword,
},
})
}
if (needCompleteProfile) {
warmPerformanceSurveyProfile(user)
return NextResponse.json({
const response = NextResponse.json({
success: true,
needCompleteProfile: true,
message: '请选择您的部门',
@ -425,6 +432,19 @@ async function createOrUpdateUserSession(
isActive: user.isActive,
},
})
const secureCookie = resolveSecureAuthCookieFromRequest(request)
response.cookies.set(
getDingtalkProfileCookieName(request),
await createDingtalkProfileToken(user.id),
{
httpOnly: true,
sameSite: options?.cookieSameSite === 'none' && secureCookie ? 'none' : 'lax',
secure: secureCookie,
path: getDingtalkProfileCookiePath(),
maxAge: DINGTALK_PROFILE_TOKEN_MAX_AGE_SECONDS,
},
)
return response
}
const userStatus = (user as any).status || (user.isActive ? 'active' : 'pending')
@ -504,8 +524,6 @@ async function resolveDingtalkUserForSession(
where: { dingtalkUserId: userId },
})
const hashedPassword = await bcrypt.hash('dingtalk_auto_login', 10)
if (!user) {
const existingUserByName = await prisma.user.findUnique({
where: { username: displayName },
@ -521,7 +539,6 @@ async function resolveDingtalkUserForSession(
realName: displayName,
avatar: userInfo.avatar || existingUserByName.avatar,
phone: userInfo.mobile || existingUserByName.phone,
password: hashedPassword,
},
})
console.log('账号关联成功:', user.username)
@ -538,7 +555,7 @@ async function resolveDingtalkUserForSession(
data: {
username: displayName,
email: emailToUse,
password: hashedPassword,
password: await createDingtalkOnlyPasswordHash(),
realName: displayName,
avatar: userInfo.avatar || null,
phone: userInfo.mobile || null,
@ -562,7 +579,6 @@ async function resolveDingtalkUserForSession(
realName: displayName,
avatar: userInfo.avatar || user.avatar,
phone: userInfo.mobile || user.phone,
password: hashedPassword,
},
})
}

View File

@ -0,0 +1,64 @@
import { decode, encode } from 'next-auth/jwt'
import type { NextRequest } from 'next/server'
import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie'
const PROFILE_TOKEN_PURPOSE = 'dingtalk-profile-completion'
const PROFILE_TOKEN_MAX_AGE_SECONDS = 10 * 60
const PROFILE_COOKIE_PATH = '/api/dingtalk/complete-profile'
const PROFILE_COOKIE_NAME = 'dingtalk-profile-token'
const SECURE_PROFILE_COOKIE_NAME = '__Secure-dingtalk-profile-token'
function getSecret() {
const secret = process.env.NEXTAUTH_SECRET
if (!secret) {
throw new Error('登录会话未配置 NEXTAUTH_SECRET')
}
return secret
}
export function getDingtalkProfileCookiePath() {
return PROFILE_COOKIE_PATH
}
export function getDingtalkProfileCookieName(request: NextRequest) {
return resolveSecureAuthCookieFromRequest(request)
? SECURE_PROFILE_COOKIE_NAME
: PROFILE_COOKIE_NAME
}
export function getDingtalkProfileCookieCandidates(request: NextRequest) {
const preferred = getDingtalkProfileCookieName(request)
return preferred === SECURE_PROFILE_COOKIE_NAME
? [SECURE_PROFILE_COOKIE_NAME, PROFILE_COOKIE_NAME]
: [PROFILE_COOKIE_NAME, SECURE_PROFILE_COOKIE_NAME]
}
export async function createDingtalkProfileToken(userId: string) {
return encode({
secret: getSecret(),
maxAge: PROFILE_TOKEN_MAX_AGE_SECONDS,
token: {
sub: userId,
purpose: PROFILE_TOKEN_PURPOSE,
} as any,
})
}
export async function verifyDingtalkProfileToken(token: string | null | undefined) {
const normalizedToken = String(token || '').trim()
if (!normalizedToken) {
return null
}
try {
const payload = await decode({ token: normalizedToken, secret: getSecret() })
if (payload?.purpose !== PROFILE_TOKEN_PURPOSE || !payload.sub) {
return null
}
return String(payload.sub)
} catch {
return null
}
}
export const DINGTALK_PROFILE_TOKEN_MAX_AGE_SECONDS = PROFILE_TOKEN_MAX_AGE_SECONDS

View File

@ -6,6 +6,19 @@ import { prisma } from './prisma';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const API_KEY_HASH_PREFIX = 'v1';
function hashApiKey(apiKey: string): string {
const preview = apiKey.slice(0, 8);
const digest = crypto.createHash('sha256').update(apiKey).digest('hex');
return `${API_KEY_HASH_PREFIX}:${preview}:${digest}`;
}
export function getApiKeyPreview(storedKey: string): string {
const match = storedKey.match(/^v1:([^:]{1,16}):[a-f0-9]{64}$/i);
return `${match?.[1] || storedKey.slice(0, 8)}...`;
}
// 错误码
export const API_ERROR = {
INVALID_API_KEY: {
@ -81,8 +94,9 @@ export async function validateApiKey(apiKey: string | null) {
return null;
}
const keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
const hashedKey = hashApiKey(apiKey);
let keyRecord = await prisma.apiKey.findUnique({
where: { key: hashedKey },
select: {
id: true,
name: true,
@ -91,6 +105,19 @@ export async function validateApiKey(apiKey: string | null) {
},
});
// 已有明文 Key 在首次成功调用时原地迁移为哈希,不改变调用方使用方式。
if (!keyRecord) {
keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
select: {
id: true,
name: true,
isActive: true,
createdBy: true,
},
});
}
if (!keyRecord || !keyRecord.isActive) {
return null;
}
@ -98,7 +125,10 @@ export async function validateApiKey(apiKey: string | null) {
// 更新最后使用时间
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
data: {
lastUsedAt: new Date(),
key: hashedKey,
},
});
return keyRecord;
@ -209,7 +239,7 @@ export async function createApiKey(name: string, createdBy: string) {
const apiKey = await prisma.apiKey.create({
data: {
name,
key,
key: hashApiKey(key),
createdBy,
},
});

View File

@ -0,0 +1,152 @@
import sharp from 'sharp'
import { sliceImage } from '@/lib/image-processor'
export const PERFORMANCE_SURVEY_IMAGE_LIMITS = {
maxSourceCount: 12,
maxSourceBytes: 20 * 1024 * 1024,
maxPixelsPerImage: 60_000_000,
maxWidth: 16_000,
sliceHeight: 1500,
maxSlicesPerImage: 24,
maxSlicesPerBatch: 48,
targetSliceSizeKB: 500,
} as const
export type PerformanceSurveyImageInput = {
buffer: Buffer
mimeType: string
sourceName: string
}
export type ProcessedPerformanceSurveyImage = {
buffer: Buffer
sourceIndex: number
sliceIndex: number
sliceCount: number
sourceName: string
}
type PerformanceSurveyImagePlan = {
input: PerformanceSurveyImageInput
sourceIndex: number
width: number
height: number
sliceCount: number
}
export class PerformanceSurveyImageProcessingError extends Error {}
export function getPerformanceSurveyImageSliceCount(height: number) {
return Math.ceil(height / PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight)
}
export function assertPerformanceSurveyImageDimensions(width: number, height: number, sourceName = '图片') {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」尺寸无效`)
}
if (width > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth) {
throw new PerformanceSurveyImageProcessingError(
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth}px`,
)
}
if (width * height > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage) {
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」像素总量过大`)
}
const sliceCount = getPerformanceSurveyImageSliceCount(height)
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage) {
throw new PerformanceSurveyImageProcessingError(
`图片「${sourceName}」过长,最多允许 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage} 个切片`,
)
}
return sliceCount
}
async function buildPerformanceSurveyImagePlan(
input: PerformanceSurveyImageInput,
sourceIndex: number,
): Promise<PerformanceSurveyImagePlan> {
if (!input.mimeType.startsWith('image/')) {
throw new PerformanceSurveyImageProcessingError(`文件「${input.sourceName}」不是图片`)
}
if (input.buffer.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」超过 20MB`)
}
try {
const metadata = await sharp(input.buffer, {
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
sequentialRead: true,
}).metadata()
if (!metadata.width || !metadata.height) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」尺寸无效`)
}
return {
input,
sourceIndex,
width: metadata.width,
height: metadata.height,
sliceCount: assertPerformanceSurveyImageDimensions(metadata.width, metadata.height, input.sourceName),
}
} catch (error) {
if (error instanceof PerformanceSurveyImageProcessingError) throw error
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」无法读取或像素总量过大`)
}
}
export async function processPerformanceSurveyImages(
inputs: PerformanceSurveyImageInput[],
): Promise<ProcessedPerformanceSurveyImage[]> {
if (inputs.length === 0) {
throw new PerformanceSurveyImageProcessingError('每个案例至少上传 1 张图片')
}
if (inputs.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyImageProcessingError(
`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`,
)
}
const plans: PerformanceSurveyImagePlan[] = []
let plannedSliceCount = 0
for (let sourceIndex = 0; sourceIndex < inputs.length; sourceIndex += 1) {
const plan = await buildPerformanceSurveyImagePlan(inputs[sourceIndex], sourceIndex)
plannedSliceCount += plan.sliceCount
if (plannedSliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch) {
throw new PerformanceSurveyImageProcessingError(
`单个案例处理后最多生成 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch} 个切片`,
)
}
plans.push(plan)
}
const output: ProcessedPerformanceSurveyImage[] = []
for (const plan of plans) {
let slices: Buffer[]
try {
slices = await sliceImage(
plan.input.buffer,
PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
PERFORMANCE_SURVEY_IMAGE_LIMITS.targetSliceSizeKB,
)
} catch {
throw new PerformanceSurveyImageProcessingError(`图片「${plan.input.sourceName}」处理失败`)
}
if (slices.length === 0 || slices.length > plan.sliceCount) {
throw new PerformanceSurveyImageProcessingError(`图片「${plan.input.sourceName}」切片数量异常`)
}
slices.forEach((buffer, sliceIndex) => {
output.push({
buffer,
sourceIndex: plan.sourceIndex,
sliceIndex,
sliceCount: slices.length,
sourceName: plan.input.sourceName,
})
})
}
return output
}

View File

@ -176,6 +176,42 @@ function clean(value: unknown) {
return normalized.length > 0 ? normalized : null
}
const ACCOUNT_PROFILE_WHERE_CACHE_MS = Math.max(
5,
Number(process.env.PERFORMANCE_SURVEY_ACCOUNT_PROFILE_WHERE_CACHE_SECONDS || 60),
) * 1000
const PROFILE_BACKGROUND_REFRESH_MIN_MS = Math.max(
10,
Number(process.env.PERFORMANCE_SURVEY_PROFILE_BACKGROUND_REFRESH_MIN_SECONDS || 60),
) * 1000
const PROFILE_BACKGROUND_REFRESH_RETENTION_MS = Math.max(PROFILE_BACKGROUND_REFRESH_MIN_MS * 10, 10 * 60 * 1000)
const PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT = 32
const PROFILE_BACKGROUND_REFRESH_MAX_HISTORY = 1024
let accountProfileWhereCache: { expiresAt: number; where: any } | null = null
let accountProfileWhereBuildPromise: Promise<any> | null = null
const profileRefreshInFlight = new Map<string, Promise<unknown>>()
const profileRefreshStartedAt = new Map<string, number>()
function pruneProfileRefreshTracking(now = Date.now()) {
for (const [key, startedAt] of Array.from(profileRefreshStartedAt.entries())) {
if (!profileRefreshInFlight.has(key) && now - startedAt >= PROFILE_BACKGROUND_REFRESH_RETENTION_MS) {
profileRefreshStartedAt.delete(key)
}
}
while (profileRefreshStartedAt.size > PROFILE_BACKGROUND_REFRESH_MAX_HISTORY) {
const oldestKey = Array.from(profileRefreshStartedAt.keys()).find((key) => !profileRefreshInFlight.has(key))
if (!oldestKey) break
profileRefreshStartedAt.delete(oldestKey)
}
}
export function invalidatePerformanceSurveyAccountProfileWhereCache() {
accountProfileWhereCache = null
accountProfileWhereBuildPromise = null
}
function parseExtension(value?: string | null) {
if (!value) return {}
try {
@ -755,6 +791,29 @@ async function listMaterialAccountSyncIdentities(limit?: number) {
}
export async function getPerformanceSurveyAccountProfileWhere() {
if (accountProfileWhereCache && accountProfileWhereCache.expiresAt > Date.now()) {
return accountProfileWhereCache.where
}
if (accountProfileWhereBuildPromise) {
return accountProfileWhereBuildPromise
}
accountProfileWhereBuildPromise = buildPerformanceSurveyAccountProfileWhere()
.then((where) => {
accountProfileWhereCache = {
where,
expiresAt: Date.now() + ACCOUNT_PROFILE_WHERE_CACHE_MS,
}
return where
})
.finally(() => {
accountProfileWhereBuildPromise = null
})
return accountProfileWhereBuildPromise
}
async function buildPerformanceSurveyAccountProfileWhere() {
const identities = await listMaterialAccountSyncIdentities()
const userIds = Array.from(new Set(identities.map((item) => clean(item.userId)).filter(Boolean))) as string[]
const dingtalkUserIds = Array.from(new Set(identities.map((item) => clean(item.dingtalkUserId)).filter(Boolean))) as string[]
@ -1427,12 +1486,39 @@ export async function previewPerformanceSurveyEmployeeByIdentity(identity: Repor
}
}
function getProfileRefreshKey(identity: ReportIdentity & { userId?: string }) {
return clean(identity.dingtalkUserId)
|| clean(identity.email)?.toLowerCase()
|| clean(identity.userId)
|| clean(identity.username)?.toLowerCase()
|| clean(identity.realName)
|| clean(identity.displayName)
|| 'anonymous'
}
function refreshPerformanceSurveyEmployeeProfileInBackground(identity: ReportIdentity & { userId?: string }) {
if (!isPerformanceSurveyReportDbConfigured()) return
void syncPerformanceSurveyEmployeeByIdentity(identity).catch((error) => {
console.warn('[performance-survey] 后台人员档案同步失败:', error)
})
const key = getProfileRefreshKey(identity)
pruneProfileRefreshTracking()
if (profileRefreshInFlight.has(key)) return
if (profileRefreshInFlight.size >= PROFILE_BACKGROUND_REFRESH_MAX_IN_FLIGHT) return
const lastStartedAt = profileRefreshStartedAt.get(key) || 0
if (Date.now() - lastStartedAt < PROFILE_BACKGROUND_REFRESH_MIN_MS) return
profileRefreshStartedAt.set(key, Date.now())
const refreshPromise = syncPerformanceSurveyEmployeeByIdentity(identity)
.catch((error) => {
console.warn('[performance-survey] 后台人员档案同步失败:', error)
})
.finally(() => {
profileRefreshInFlight.delete(key)
pruneProfileRefreshTracking()
})
profileRefreshInFlight.set(key, refreshPromise)
pruneProfileRefreshTracking()
}
export async function ensurePerformanceSurveyEmployeeProfileForUser(identity: ReportIdentity & { userId?: string }) {
@ -1487,6 +1573,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
await upsertFallbackEmployeeProfileForUser(identity)
}
const departmentCount = await upsertDepartments()
invalidatePerformanceSurveyAccountProfileWhereCache()
return {
scannedAccounts: identities.length,
upserted: 0,
@ -1588,6 +1675,7 @@ export async function syncPerformanceSurveyEmployees(options?: { limit?: number
}
const departmentCount = await upsertDepartments()
invalidatePerformanceSurveyAccountProfileWhereCache()
return {
scannedAccounts: identities.length,

View File

@ -3,7 +3,6 @@ import type { Session } from 'next-auth'
import { pinyin } from 'pinyin-pro'
import { prisma } from '@/lib/prisma'
import { getDingtalkWorkNoticeConfigStatus } from '@/lib/dingtalk-client'
import { sanitizeFileName } from '@/lib/image-processor'
import {
calculateAggregateScore,
calculateResponseScores,
@ -26,6 +25,11 @@ import {
getPerformanceSurveyAccountProfileWhere,
syncPerformanceSurveyEmployees,
} from '@/lib/performance-survey-report-sync'
import {
PERFORMANCE_SURVEY_IMAGE_LIMITS,
PerformanceSurveyImageProcessingError,
processPerformanceSurveyImages,
} from '@/lib/performance-survey-image-processing'
import { uploadToS3 } from '@/lib/s3'
export class PerformanceSurveyApiError extends Error {
@ -63,6 +67,34 @@ function clean(value: unknown) {
return normalized.length > 0 ? normalized : null
}
const PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MS = Math.max(
5,
Number(process.env.PERFORMANCE_SURVEY_DEPARTMENT_CACHE_SECONDS || 60),
) * 1000
const PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES = Math.max(
8,
Math.min(Number(process.env.PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES || 64), 512),
)
const performanceSurveyDepartmentCache = new Map<string, { expiresAt: number; value: any[] }>()
const performanceSurveyDepartmentCachePromises = new Map<string, Promise<any[]>>()
function clearPerformanceSurveyDepartmentCache() {
performanceSurveyDepartmentCache.clear()
performanceSurveyDepartmentCachePromises.clear()
}
function prunePerformanceSurveyDepartmentCache(now = Date.now()) {
for (const [key, cached] of Array.from(performanceSurveyDepartmentCache.entries())) {
if (cached.expiresAt <= now) performanceSurveyDepartmentCache.delete(key)
}
while (performanceSurveyDepartmentCache.size > PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MAX_ENTRIES) {
const oldestKey = performanceSurveyDepartmentCache.keys().next().value
if (!oldestKey) break
performanceSurveyDepartmentCache.delete(oldestKey)
}
}
function normalizeSearchText(value: unknown) {
return String(value ?? '')
.trim()
@ -306,6 +338,27 @@ function getQuestionMaxScore(template: PerformanceSurveyTemplateShape, question:
return Number.isFinite(maxScore) && maxScore > 0 ? maxScore : 20
}
function calculateSubmittedPerformanceSurveyScores(template: PerformanceSurveyTemplateShape, value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new PerformanceSurveyApiError('请逐项完成评分后再提交')
}
const scores = value as Record<string, unknown>
const missingQuestion = template.questions.find((question) => !Object.prototype.hasOwnProperty.call(scores, question.id))
if (missingQuestion) {
throw new PerformanceSurveyApiError(`请完成评分项「${missingQuestion.title}」`)
}
try {
return {
scores,
result: calculateResponseScores(template, scores),
}
} catch (error) {
throw new PerformanceSurveyApiError(error instanceof Error ? error.message : '评分数据无效')
}
}
function isVisualCaseTemplate(templateOrCode?: Pick<PerformanceSurveyTemplateShape, 'surveyType' | 'code'> | string | null) {
if (!templateOrCode) return false
if (typeof templateOrCode === 'string') return templateOrCode === 'visual_brand_tone'
@ -386,6 +439,7 @@ function isSurveyInManagerScope(ctx: UserContext, survey: { creatorUserId: strin
}
async function canManageSurvey(ctx: UserContext, survey: { id: string; creatorUserId: string; departmentName?: string | null }) {
if (survey.creatorUserId === ctx.user.id) return true
if (!ctx.isDepartmentAdmin) return false
if (isSurveyInManagerScope(ctx, survey)) return true
if (!ctx.scopeDepartment) return false
@ -640,74 +694,103 @@ export async function searchPerformanceSurveyEmployees(session: Session | null,
export async function listPerformanceSurveyDepartments(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const isEvaluatorPicker = clean(params?.get('purpose')) === 'evaluator'
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
const cacheKey = [
isEvaluatorPicker ? 'evaluator' : 'scoped',
ctx.isSystemAdmin ? 'system' : ctx.scopeDepartment || ctx.user.id,
].join(':')
prunePerformanceSurveyDepartmentCache()
const cached = performanceSurveyDepartmentCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return cached.value
}
const pending = performanceSurveyDepartmentCachePromises.get(cacheKey)
if (pending) return pending
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: { AND: and },
select: {
departmentName: true,
departmentPath: true,
},
})
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
for (let index = 0; index < parts.length; index += 1) {
const fullPath = parts.slice(0, index + 1).join('/')
const parentPath = index > 0 ? parts.slice(0, index).join('/') : null
const existing = nodeByPath.get(fullPath)
if (existing) {
existing.employeeCount += 1
continue
}
nodeByPath.set(fullPath, {
id: getDepartmentNodeId(fullPath),
name: parts[index],
parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID,
parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME,
employeeCount: 1,
selectable: true,
value: getDepartmentPathValue(fullPath),
departmentName: parts[index],
departmentPath: fullPath,
fullPath,
})
const loadDepartments = async () => {
const accountWhere = await getPerformanceSurveyAccountProfileWhere()
const and: any[] = [
accountWhere,
{ departmentName: { not: null } },
]
if (!ctx.isSystemAdmin && ctx.scopeDepartment && !isEvaluatorPicker) {
and.push(getScopeDepartmentWhere(ctx.scopeDepartment))
}
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: { AND: and },
select: {
departmentName: true,
departmentPath: true,
},
})
const nodeByPath = new Map<string, any>()
for (const employee of employees) {
const departmentName = clean(employee.departmentName)
const pathParts = parseDepartmentPathParts(employee.departmentPath)
const parts = pathParts.length > 0 ? pathParts : departmentName ? [departmentName] : []
if (parts.length === 0) continue
for (let index = 0; index < parts.length; index += 1) {
const fullPath = parts.slice(0, index + 1).join('/')
const parentPath = index > 0 ? parts.slice(0, index).join('/') : null
const existing = nodeByPath.get(fullPath)
if (existing) {
existing.employeeCount += 1
continue
}
nodeByPath.set(fullPath, {
id: getDepartmentNodeId(fullPath),
name: parts[index],
parentId: parentPath ? getDepartmentNodeId(parentPath) : ALL_DEPARTMENTS_NODE_ID,
parentName: parentPath ? parts[index - 1] : ALL_DEPARTMENTS_NODE_NAME,
employeeCount: 1,
selectable: true,
value: getDepartmentPathValue(fullPath),
departmentName: parts[index],
departmentPath: fullPath,
fullPath,
})
}
}
return [
{
id: ALL_DEPARTMENTS_NODE_ID,
name: ALL_DEPARTMENTS_NODE_NAME,
parentId: null,
parentName: null,
employeeCount: employees.length,
selectable: false,
value: null,
departmentPath: null,
fullPath: null,
},
...Array.from(nodeByPath.values()).sort((a, b) => {
const depthDiff = parseDepartmentPathParts(a.departmentPath).length - parseDepartmentPathParts(b.departmentPath).length
if (depthDiff !== 0) return depthDiff
if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount
return a.fullPath.localeCompare(b.fullPath, 'zh-CN')
}),
]
}
return [
{
id: ALL_DEPARTMENTS_NODE_ID,
name: ALL_DEPARTMENTS_NODE_NAME,
parentId: null,
parentName: null,
employeeCount: employees.length,
selectable: false,
value: null,
departmentPath: null,
fullPath: null,
},
...Array.from(nodeByPath.values()).sort((a, b) => {
const depthDiff = parseDepartmentPathParts(a.departmentPath).length - parseDepartmentPathParts(b.departmentPath).length
if (depthDiff !== 0) return depthDiff
if (b.employeeCount !== a.employeeCount) return b.employeeCount - a.employeeCount
return a.fullPath.localeCompare(b.fullPath, 'zh-CN')
}),
]
const promise = loadDepartments()
.then((value) => {
performanceSurveyDepartmentCache.delete(cacheKey)
performanceSurveyDepartmentCache.set(cacheKey, {
value,
expiresAt: Date.now() + PERFORMANCE_SURVEY_DEPARTMENT_CACHE_MS,
})
prunePerformanceSurveyDepartmentCache()
return value
})
.finally(() => {
performanceSurveyDepartmentCachePromises.delete(cacheKey)
})
performanceSurveyDepartmentCachePromises.set(cacheKey, promise)
return promise
}
export async function listPerformanceSurveys(session: Session | null, params?: URLSearchParams) {
@ -1170,20 +1253,25 @@ async function assertNoMonthlyAssessmentDuplicate(
export async function getPerformanceSurveyDashboard(session: Session | null, params?: URLSearchParams) {
const ctx = await getPerformanceSurveyContext(session)
const requestedView = clean(params?.get('view'))
const dashboardView = requestedView === 'personal' || requestedView === 'public' ? requestedView : 'all'
const includePersonal = dashboardView !== 'public'
const includePublic = dashboardView !== 'personal'
const currentMonth = getCurrentMonthRange()
const monthRange = getMonthRange(params?.get('month')) || currentMonth
const monthValue = clean(params?.get('month')) || currentMonth.value
const allTemplates = await listPerformanceSurveyTemplates()
const templates = getMonthlyTemplates(allTemplates)
const personalMonths = getRecentMonthValues(12)
const personalMonthRange = getMonthSpanRange(personalMonths)
const personalMonths = includePersonal ? getRecentMonthValues(12) : []
const personalEmployee = ctx.employee
const personalTargets = personalEmployee
? await getMonthlyTargets([personalEmployee.id], personalMonthRange)
const personalTargets = includePersonal && personalEmployee
? await getMonthlyTargets([personalEmployee.id], getMonthSpanRange(personalMonths))
: []
const publicEmployees = includePublic ? await getPublicDashboardEmployees(ctx, params) : []
const publicTargets = includePublic
? await getMonthlyTargets(publicEmployees.map((employee) => employee.id), monthRange)
: []
const publicEmployees = await getPublicDashboardEmployees(ctx, params)
const publicTargets = await getMonthlyTargets(publicEmployees.map((employee) => employee.id), monthRange)
const publicDetailAllowed = Boolean(ctx.isSystemAdmin || ctx.isLeadership)
const publicRows = publicEmployees
.map((employee) => buildMonthlyRow(
@ -1198,6 +1286,7 @@ export async function getPerformanceSurveyDashboard(session: Session | null, par
return {
month: monthValue,
filters: {
view: dashboardView,
month: monthValue,
department: clean(params?.get('department')),
employeeId: clean(params?.get('employeeId')),
@ -1219,7 +1308,7 @@ export async function getPerformanceSurveyDashboard(session: Session | null, par
})),
personal: {
employee: personalEmployee ? serializeEmployee(personalEmployee) : null,
rows: personalEmployee
rows: includePersonal && personalEmployee
? personalMonths.map((month) => buildMonthlyRow(personalEmployee, month, templates, personalTargets, true))
: [],
},
@ -1673,7 +1762,8 @@ export async function submitPerformanceSurveyResponse(session: Session | null, s
}
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
const scoreResult = calculateResponseScores(template, body?.scores || {})
const submittedScores = calculateSubmittedPerformanceSurveyScores(template, body?.scores)
const scoreResult = submittedScores.result
const response = await prisma.$transaction(async (tx) => {
const created = await tx.performanceSurveyResponse.create({
@ -1693,7 +1783,7 @@ export async function submitPerformanceSurveyResponse(session: Session | null, s
departmentName: ctx.user.department,
snapshotAt: new Date().toISOString(),
},
scores: body?.scores || {},
scores: submittedScores.scores as any,
comment: clean(body?.comment),
totalScore: scoreResult.totalScore,
achievementScore: scoreResult.achievementScore,
@ -1865,19 +1955,45 @@ export async function createPerformanceSurveyWorkItem(session: Session | null, s
const files = readImageFiles(formData)
if (files.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片')
if (files.length > 12) throw new PerformanceSurveyApiError('单个案例最多上传 12 张图片')
if (files.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`)
}
const title = readFormText(formData, 'title') || `案例 ${survey.workItems.length + 1}`
const description = readFormText(formData, 'description')
const imageUrls: string[] = []
const imageInputs = []
for (const file of files) {
if (!file.type.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件')
if (file.size > 20 * 1024 * 1024) throw new PerformanceSurveyApiError('单张图片不能超过 20MB')
const buffer = Buffer.from(await file.arrayBuffer())
const safeName = sanitizeFileName(file.name || 'case-image')
const ext = safeName.includes('.') ? safeName.slice(safeName.lastIndexOf('.')) : '.jpg'
const fileName = `performance-survey/work-items/${surveyId}/${targetId}/${Date.now()}-${randomUUID()}${ext}`
imageUrls.push(await uploadToS3(buffer, fileName, file.type || 'image/jpeg'))
if (file.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
throw new PerformanceSurveyApiError('单张图片不能超过 20MB')
}
imageInputs.push({
buffer: Buffer.from(await file.arrayBuffer()),
mimeType: file.type,
sourceName: file.name || 'case-image',
})
}
let processedImages
try {
processedImages = await processPerformanceSurveyImages(imageInputs)
} catch (error) {
if (error instanceof PerformanceSurveyImageProcessingError) {
throw new PerformanceSurveyApiError(error.message)
}
throw error
}
const imageUrls: string[] = []
const uploadBatchId = `${Date.now()}-${randomUUID()}`
for (const image of processedImages) {
const fileName = [
'performance-survey/work-items',
surveyId,
targetId,
`${uploadBatchId}-${image.sourceIndex + 1}-${image.sliceIndex + 1}.webp`,
].join('/')
imageUrls.push(await uploadToS3(image.buffer, fileName, 'image/webp'))
}
const created = await prisma.performanceSurveyWorkItem.create({
@ -1941,7 +2057,8 @@ export async function submitPerformanceSurveyWorkItemScore(
throw new PerformanceSurveyApiError('你已经完成该评价,不能重复提交')
}
const scoreResult = calculateResponseScores(template, body?.scores || {})
const submittedScores = calculateSubmittedPerformanceSurveyScores(template, body?.scores)
const scoreResult = submittedScores.result
const existing = await prisma.performanceSurveyWorkItemScore.findFirst({
where: {
workItemId,
@ -1961,7 +2078,7 @@ export async function submitPerformanceSurveyWorkItemScore(
evaluatorUserId: ctx.user.id,
evaluatorEmployeeId: ctx.employee?.id || null,
evaluatorName: getDisplayName(ctx.user),
scores: body?.scores || {},
scores: submittedScores.scores as any,
comment: clean(body?.comment),
totalScore: scoreResult.totalScore,
},
@ -2057,6 +2174,7 @@ export async function syncPerformanceSurveyEmployeeProfiles(session: Session | n
const ctx = await getPerformanceSurveyContext(session)
if (!ctx.isSystemAdmin) throw new PerformanceSurveyApiError('需要系统管理员权限', 403)
const result = await syncPerformanceSurveyEmployees({ limit: Number(body?.limit || 3000) })
clearPerformanceSurveyDepartmentCache()
return result
}

View File

@ -9,7 +9,7 @@ import {
} from '@/lib/task-material-processor'
import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths'
import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
import { getUploadedFile } from '@/lib/upload-sessions'
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
import { createMaterialLog } from '@/lib/material-log'
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
import {
@ -42,16 +42,18 @@ export interface ProductPackageSourceInput {
fileSize: number
}
export interface ProductPackageAssetsInput {
main?: ProductPackageImageInput[]
sku?: ProductPackageImageInput[]
detail?: ProductPackageImageInput[]
psd?: ProductPackageSourceInput[]
}
export interface CompleteProductPackageUploadInput {
materialId: string
taskNo?: string | null
operatorUserId: string
assets: {
main?: ProductPackageImageInput[]
sku?: ProductPackageImageInput[]
detail?: ProductPackageImageInput[]
psd?: ProductPackageSourceInput[]
}
assets: ProductPackageAssetsInput
options?: {
allowSkuWithoutBarcodes?: boolean
zipTimeoutMs?: number
@ -59,6 +61,98 @@ export interface CompleteProductPackageUploadInput {
}
}
type PublicProductPackageAsset = Record<string, unknown>
function parsePublicAssetGroup(
value: unknown,
group: 'main' | 'sku' | 'detail' | 'psd',
): ProductPackageImageInput[] | ProductPackageSourceInput[] | undefined {
if (value == null) {
return undefined
}
if (!Array.isArray(value)) {
throw new Error(`${group} 资产格式错误`)
}
return value.map((rawAsset, index) => {
if (!rawAsset || typeof rawAsset !== 'object' || Array.isArray(rawAsset)) {
throw new Error(`${group} 第 ${index + 1} 个资产格式错误`)
}
const asset = rawAsset as PublicProductPackageAsset
if (
Object.prototype.hasOwnProperty.call(asset, 'localFilePath') ||
Object.prototype.hasOwnProperty.call(asset, 'fileUrl')
) {
throw new Error('公开上传接口只允许使用 uploadId')
}
const uploadId = typeof asset.uploadId === 'string' ? asset.uploadId.trim() : ''
const fileName = typeof asset.fileName === 'string' ? asset.fileName.trim() : ''
const fileSize = Number(asset.fileSize)
if (!uploadId || !fileName || !Number.isFinite(fileSize) || fileSize < 0) {
throw new Error(`${group} 第 ${index + 1} 个资产参数无效`)
}
const parsedAsset: ProductPackageImageInput | ProductPackageSourceInput = {
uploadId,
fileName,
fileSize,
}
if (group !== 'psd') {
const imageAsset = parsedAsset as ProductPackageImageInput
if (Number.isInteger(asset.sortOrder) && Number(asset.sortOrder) >= 0) {
imageAsset.sortOrder = Number(asset.sortOrder)
}
if (typeof asset.skuCode === 'string') {
imageAsset.skuCode = asset.skuCode
}
if (Array.isArray(asset.barcodes)) {
imageAsset.barcodes = asset.barcodes.filter(
(barcode): barcode is string => typeof barcode === 'string',
)
}
}
return parsedAsset
})
}
export function parsePublicProductPackageAssets(value: unknown): ProductPackageAssetsInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('assets 参数格式错误')
}
const assets = value as Record<string, unknown>
return {
main: parsePublicAssetGroup(assets.main, 'main') as ProductPackageImageInput[] | undefined,
sku: parsePublicAssetGroup(assets.sku, 'sku') as ProductPackageImageInput[] | undefined,
detail: parsePublicAssetGroup(assets.detail, 'detail') as ProductPackageImageInput[] | undefined,
psd: parsePublicAssetGroup(assets.psd, 'psd') as ProductPackageSourceInput[] | undefined,
}
}
async function assertOwnedProductPackageUploadSessions(
assets: ProductPackageAssetsInput,
operatorUserId: string,
) {
const uploadIds = Array.from(new Set(
[assets.main, assets.sku, assets.detail, assets.psd]
.flatMap((group) => group || [])
.map((asset) => String(asset.uploadId || '').trim())
.filter(Boolean),
))
for (const uploadId of uploadIds) {
if (!(await getOwnedUploadSession(uploadId, operatorUserId))) {
throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`)
}
}
}
interface GeneratedProductPackagePreviewAsset {
fileUrl: string
fileName: string
@ -388,6 +482,8 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
throw new Error('缺少必填参数')
}
await assertOwnedProductPackageUploadSessions(assets, operatorUserId)
const totalImages =
normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length
if (totalImages === 0) {

View File

@ -20,6 +20,9 @@
* 📚 详细说明请查看:docs/upload-sessions-redis-explanation.md
*/
import path from 'path'
import { lstat, readFile } from 'fs/promises'
export interface UploadSession {
fileName: string
fileSize: number
@ -71,6 +74,8 @@ if (USE_REDIS) {
// 内存存储(单实例部署可以使用)
const memorySessions = new Map<string, UploadSession>()
const MAX_UPLOAD_SESSION_AGE_MS = 8 * 60 * 60 * 1000
const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
function setMemorySession(uploadId: string, session: UploadSession) {
memorySessions.set(uploadId, session)
@ -81,7 +86,79 @@ function deleteMemorySession(uploadId: string) {
}
function getMemorySession(uploadId: string): UploadSession | null {
return memorySessions.get(uploadId) || null
const session = memorySessions.get(uploadId) || null
if (!session) {
return null
}
if (Date.now() - session.createdAt > MAX_UPLOAD_SESSION_AGE_MS) {
memorySessions.delete(uploadId)
return null
}
return session
}
export function isSafeUploadId(uploadId: string): boolean {
return SAFE_UPLOAD_ID_PATTERN.test(String(uploadId || '').trim())
}
function getUploadRoot(): string {
return path.resolve(process.env.UPLOAD_DIR || './uploads')
}
function isPathInside(basePath: string, targetPath: string): boolean {
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
}
async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSession | null> {
if (!isSafeUploadId(uploadId)) {
return null
}
const tempRoot = path.join(getUploadRoot(), 'temp')
const sessionDir = path.resolve(tempRoot, uploadId)
if (!isPathInside(tempRoot, sessionDir)) {
return null
}
try {
const rawMetadata = await readFile(path.join(sessionDir, 'meta.json'), 'utf8')
const metadata = JSON.parse(rawMetadata)
const createdAt = Number(metadata?.createdAt)
const fileSize = Number(metadata?.fileSize)
const totalChunks = Number(metadata?.totalChunks)
const fileName = typeof metadata?.fileName === 'string' ? metadata.fileName : ''
const userId = typeof metadata?.userId === 'string' ? metadata.userId : ''
const ageMs = Date.now() - createdAt
if (
!fileName ||
!userId ||
!Number.isFinite(createdAt) ||
ageMs < -5 * 60 * 1000 ||
ageMs > MAX_UPLOAD_SESSION_AGE_MS ||
!Number.isFinite(fileSize) ||
fileSize <= 0 ||
!Number.isInteger(totalChunks) ||
totalChunks <= 0
) {
return null
}
const recoveredSession: UploadSession = {
fileName,
fileSize,
totalChunks,
chunks: new Map(),
createdAt,
userId,
}
setMemorySession(uploadId, recoveredSession)
return recoveredSession
} catch {
return null
}
}
async function withRedisFallback<T>(
@ -102,7 +179,7 @@ async function withRedisFallback<T>(
// 清理过期会话(8小时)- 仅内存Map需要
if (!USE_REDIS || !redisModule) {
setInterval(() => {
const memoryCleanupTimer = setInterval(() => {
const now = Date.now()
const expireTime = 8 * 60 * 60 * 1000 // 8小时
const keysToDelete: string[] = []
@ -125,6 +202,7 @@ if (!USE_REDIS || !redisModule) {
console.log(`[UploadSessions] 当前会话数: ${memorySessions.size}`)
}
}, 60 * 60 * 1000) // 每小时清理一次
memoryCleanupTimer.unref()
}
/**
@ -154,13 +232,109 @@ export async function getUploadSession(
const session = await redisModule!.getUploadSession(uploadId)
if (session) {
setMemorySession(uploadId, session)
return session
}
return session
return getMemorySession(uploadId)
},
async () => getMemorySession(uploadId)
)
}
/**
* 只返回属于指定用户的上传会话。
* complete 端点会提前删除 Redis 会话,因此允许从同目录 meta.json 恢复所有权信息。
*/
export async function getOwnedUploadSession(
uploadId: string,
userId: string
): Promise<UploadSession | null> {
const normalizedUploadId = String(uploadId || '').trim()
const normalizedUserId = String(userId || '').trim()
if (!isSafeUploadId(normalizedUploadId) || !normalizedUserId) {
return null
}
const session =
(await getUploadSession(normalizedUploadId)) ||
(await getUploadSessionFromMetadata(normalizedUploadId))
return session?.userId === normalizedUserId ? session : null
}
export async function getOwnedUploadedFile(
uploadId: string,
userId: string
): Promise<string | null> {
const session = await getOwnedUploadSession(uploadId, userId)
if (!session) {
return null
}
return getUploadedFile(uploadId)
}
export interface OwnedMergedUpload {
uploadId: string
filePath: string
session: UploadSession
}
/**
* 校验分片合并文件确实位于当前用户的上传会话目录,且大小与 init 元数据一致。
*/
export async function resolveOwnedMergedUpload(input: {
existingFilePath: string
userId: string
uploadId?: string | null
}): Promise<OwnedMergedUpload | null> {
const suppliedPath = String(input.existingFilePath || '').trim()
const userId = String(input.userId || '').trim()
if (!suppliedPath || !userId) {
return null
}
const uploadRoot = getUploadRoot()
const tempRoot = path.join(uploadRoot, 'temp')
const targetPath = path.resolve(suppliedPath)
if (!isPathInside(tempRoot, targetPath)) {
return null
}
const derivedUploadId = path.basename(path.dirname(targetPath))
const suppliedUploadId = String(input.uploadId || '').trim()
if (
!isSafeUploadId(derivedUploadId) ||
(suppliedUploadId && suppliedUploadId !== derivedUploadId)
) {
return null
}
const sessionDir = path.resolve(tempRoot, derivedUploadId)
if (path.dirname(targetPath) !== sessionDir || !path.basename(targetPath).startsWith('merged-')) {
return null
}
const session = await getOwnedUploadSession(derivedUploadId, userId)
if (!session) {
return null
}
try {
const stats = await lstat(targetPath)
if (!stats.isFile() || stats.isSymbolicLink() || stats.size !== session.fileSize) {
return null
}
} catch {
return null
}
return {
uploadId: derivedUploadId,
filePath: targetPath,
session,
}
}
/**
* 获取已上传的完整文件路径(从chunk临时目录)
* 返回组装后的完整文件路径
@ -240,6 +414,16 @@ export async function deleteUploadSession(uploadId: string): Promise<void> {
)
}
export async function deleteOwnedUploadSession(uploadId: string, userId: string): Promise<boolean> {
const session = await getOwnedUploadSession(uploadId, userId)
if (!session) {
return false
}
await deleteUploadSession(uploadId)
return true
}
/**
* 检查会话是否存在
*/

View File

@ -9,12 +9,28 @@ import {
const API_CORS_ALLOWED_HEADERS =
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie'
function getAllowedCorsOrigins(request: NextRequest): Set<string> {
const values = [
request.nextUrl.origin,
process.env.APP_PUBLIC_URL,
process.env.NEXTAUTH_URL,
...(process.env.CORS_ALLOWED_ORIGINS || '').split(','),
]
return new Set(values.map((value) => String(value || '').trim().replace(/\/+$/, '')).filter(Boolean))
}
function isAllowedCorsOrigin(request: NextRequest, requestOrigin: string | null): boolean {
if (!requestOrigin) return true
return getAllowedCorsOrigins(request).has(requestOrigin.replace(/\/+$/, ''))
}
// 注意:不在middleware中启动内存监控
// middleware使用Edge Runtime,不支持Node.js API
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
export async function middleware(request: NextRequest) {
const { pathname, origin } = request.nextUrl
const { pathname } = request.nextUrl
const requestOrigin = request.headers.get('origin')
const isAuthEntryPage =
pathname === '/login' ||
@ -24,24 +40,36 @@ export async function middleware(request: NextRequest) {
// 处理API路由的CORS
if (pathname.startsWith('/api/')) {
const corsOriginAllowed = isAllowedCorsOrigin(request, requestOrigin)
if (!corsOriginAllowed) {
return NextResponse.json({ error: '不允许的跨域来源' }, { status: 403 })
}
// 处理OPTIONS预检请求
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
const preflightResponse = new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Origin': requestOrigin || origin,
'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS',
'Access-Control-Allow-Headers': API_CORS_ALLOWED_HEADERS,
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
},
})
if (requestOrigin) {
preflightResponse.headers.set('Access-Control-Allow-Credentials', 'true')
preflightResponse.headers.set('Access-Control-Allow-Origin', requestOrigin)
}
return preflightResponse
}
// 为所有API响应添加CORS头
const response = NextResponse.next()
response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin)
response.headers.set('Vary', 'Origin')
if (requestOrigin) {
response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Origin', requestOrigin)
}
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
response.headers.set('Access-Control-Allow-Headers', API_CORS_ALLOWED_HEADERS)
@ -79,7 +107,8 @@ export async function middleware(request: NextRequest) {
// 管理员页面需要管理员权限
if (pathname.startsWith('/admin')) {
if ((token as any)?.role !== 'ADMIN') {
const role = String((token as any)?.role || '')
if (!['ADMIN', 'SYSTEM_ADMIN', 'DEPT_ADMIN'].includes(role)) {
const url = new URL('/', request.url)
const redirectResponse = NextResponse.redirect(url)
// 为重定向响应也添加缓存控制头

View File

@ -71,8 +71,6 @@ const nextConfig = {
bodySizeLimit: '10gb', // Server Actions 的限制 (10GB)
},
},
// 增加 API Routes 的 body size 限制(默认 10MB,改为 100MB)
proxyClientMaxBodySize: 100 * 1024 * 1024, // 100MB,足够容纳 50MB 分片
// 注意:CORS配置移到middleware.ts中处理,因为需要动态设置origin
// 当使用credentials时,不能使用通配符*

View File

@ -8,6 +8,7 @@
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",

View File

@ -6,10 +6,10 @@
import { PrismaClient } from '@prisma/client';
import { access } from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const prisma = new PrismaClient();
interface MaterialStatus {
@ -54,11 +54,15 @@ async function checkProductionFile(filePath: string): Promise<{ exists: boolean;
// 转义路径中的特殊字符
serverPath = serverPath.replace(/'/g, "'\\''");
// 使用 plink 检查生产服务器上的文件
// 添加 -no-antispoof 参数避免键盘交互式认证问题
const command = `& "C:\\Program Files\\PuTTY\\plink.exe" -batch -no-antispoof -pw "jsjj8888" root@192.168.58.20 "if [ -f '${serverPath}' ]; then stat -c '%s' '${serverPath}'; else echo 'NOT_FOUND'; fi"`;
const { stdout } = await execAsync(command, { shell: 'powershell.exe' });
const password = process.env.PRODUCTION_SERVER_PASSWORD;
if (!password) {
throw new Error('必须设置 PRODUCTION_SERVER_PASSWORD');
}
const remoteCommand = `if [ -f '${serverPath}' ]; then stat -c '%s' '${serverPath}'; else echo 'NOT_FOUND'; fi`;
const { stdout } = await execFileAsync(
'C:\\Program Files\\PuTTY\\plink.exe',
['-batch', '-no-antispoof', '-pw', password, 'root@192.168.58.20', remoteCommand],
);
const output = stdout.trim();
if (output === 'NOT_FOUND' || output === '' || output.includes('not found') || output.includes('No such file')) {

View File

@ -8,8 +8,12 @@ import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3'
import { existsSync, unlinkSync } from 'fs'
import path from 'path'
const PROD_DB_URL = process.env.PROD_DATABASE_URL || 'postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat'
const SHADOW_DB_URL = process.env.SHADOW_DATABASE_URL || 'postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat_stage'
const PROD_DB_URL = process.env.PROD_DATABASE_URL
const SHADOW_DB_URL = process.env.SHADOW_DATABASE_URL
if (!PROD_DB_URL || !SHADOW_DB_URL) {
throw new Error('必须设置 PROD_DATABASE_URL 和 SHADOW_DATABASE_URL')
}
const prodPrisma = new PrismaClient({
datasources: {

View File

@ -4,11 +4,15 @@
# 注意:执行前请确保已备份生产库!
param(
[string]$DatabaseUrl = "postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat",
[string]$DatabaseUrl = $env:DATABASE_URL,
[string]$MigrationFile = "d:\gongxiangp2\prisma\migrations\20251206000000_add_soft_delete_and_favorites\migration.sql",
[string]$ContainerName = "gongxiangpan-postgres-1"
)
if ([string]::IsNullOrWhiteSpace($DatabaseUrl)) {
throw '缺少数据库连接,请先设置 DATABASE_URL 环境变量'
}
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "生产库迁移执行脚本" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan

View File

@ -6,7 +6,7 @@
set -e
DATABASE_URL="postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat"
DATABASE_URL="${DATABASE_URL:?DATABASE_URL is required}"
MIGRATION_FILE="$(dirname "$0")/../prisma/migrations/20251206000000_add_soft_delete_and_favorites/migration.sql"
CONTAINER_NAME="gongxiangpan-postgres-1"

View File

@ -66,9 +66,6 @@ try {
$RemoteCommand = "echo '$QueryBase64' | base64 -d | docker exec -i gongxiangpan-postgres-1 psql -U gongxiangpan -d $DatabaseName"
Write-Host "执行查询(可能需要输入密码)..." -ForegroundColor Green
Write-Host "提示:密码是 Jsjj@8888" -ForegroundColor Gray
Write-Host ""
# 使用 ssh(推荐配置密钥以避免每次输入密码)
$Result = ssh -o StrictHostKeyChecking=no root@$ServerIP $RemoteCommand

View File

@ -32,9 +32,13 @@ $ErrorActionPreference = 'Stop'
# 配置
$ServerIP = "192.168.58.20"
$ServerPassword = "Jsjj@8888"
$ServerPassword = $env:PRODUCTION_SERVER_PASSWORD
$PlinkPath = "C:\Program Files\PuTTY\plink.exe"
if ([string]::IsNullOrWhiteSpace($ServerPassword)) {
throw '缺少生产服务器密码,请先设置 PRODUCTION_SERVER_PASSWORD 环境变量'
}
# 根据目标选择容器和数据库
if ($Target -eq 'production') {
$ContainerName = "youyidian-3d-material"

View File

@ -1,9 +1,14 @@
import { PrismaClient } from '@prisma/client';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('必须设置 DATABASE_URL');
}
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL || 'postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat',
url: databaseUrl,
},
},
});

View File

@ -3,11 +3,15 @@
# 状态:旧构建缓存方案,不是当前 Git 增量部署的默认前置步骤
param(
[string]$BuildServerIP = '192.168.42.134',
[string]$BuildServerIP = '192.168.40.206',
[string]$BuildServerUser = 'root',
[string]$BuildServerPassword = 'Jsjj@8888'
[string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD
)
if ([string]::IsNullOrWhiteSpace($BuildServerPassword)) {
throw '缺少构建机密码,请先设置 BUILD_SERVER_PASSWORD 环境变量'
}
# 设置编码
try {
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

View File

@ -1,11 +1,11 @@
# 由壹点3D素材 - 构建机一键配置脚本
# 功能:安装 Docker Compose + Registry + UI
# 目标:192.168.42.134(构建机)
# 目标:192.168.40.206(构建机)
param(
[string]$BuildServerIP = '192.168.42.134',
[string]$BuildServerIP = '192.168.40.206',
[string]$BuildServerUser = 'root',
[string]$BuildServerPassword = 'Jsjj@8888',
[string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD,
[switch]$SkipDockerCompose, # 跳过 Docker Compose 安装
[switch]$SkipRegistry, # 跳过 Registry 安装
[switch]$Help
@ -112,6 +112,9 @@ function Main {
Write-Host ""
if ($Help) { Show-Help; return }
if ([string]::IsNullOrWhiteSpace($BuildServerPassword)) {
throw '缺少构建机密码,请先设置 BUILD_SERVER_PASSWORD 环境变量'
}
Write-Host "📋 配置信息" -ForegroundColor Magenta
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan

View File

@ -6,8 +6,8 @@
param(
[string]$ProdServerIP = '192.168.58.20',
[string]$ProdServerUser = 'root',
[string]$ProdServerPassword = 'jsjj8888',
[string]$BuildServerIP = '192.168.42.134',
[string]$ProdServerPassword = $env:PRODUCTION_SERVER_PASSWORD,
[string]$BuildServerIP = '192.168.40.206',
[switch]$DryRun, # 只检查,不修改
[switch]$Help
)
@ -117,6 +117,9 @@ function Main {
Write-Host ""
if ($Help) { Show-Help; return }
if ([string]::IsNullOrWhiteSpace($ProdServerPassword)) {
throw '缺少生产服务器密码,请先设置 PRODUCTION_SERVER_PASSWORD 环境变量'
}
Write-Host "📋 配置信息" -ForegroundColor Magenta
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan