fix: 收紧系统权限并完善考核调研链路
This commit is contained in:
parent
8e8d87dc18
commit
779cdba8a1
@ -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/`
|
||||
|
||||
|
||||
@ -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 ? '启用' : '禁用'}
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
42
app/api/materials/[id]/skus/authorization.ts
Normal file
42
app/api/materials/[id]/skus/authorization.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 数组
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
// 保存文件
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }>()
|
||||
|
||||
@ -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、分类、场景等信息`);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
51
app/page.tsx
51
app/page.tsx
@ -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
@ -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 || [])
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/` 继续可访问,但不再作为默认展示路径。**
|
||||
|
||||
@ -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/...`
|
||||
- 当前对外读取不使用七牛云存储桶
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 历史缓存验证
|
||||
|
||||
@ -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` 是历史入口,不再作为当前默认排障入口。
|
||||
|
||||
@ -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 回源链路
|
||||
|
||||
@ -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` 回源链路。
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
## 5. 构建机操作范围
|
||||
|
||||
构建机:`192.168.42.134`
|
||||
构建机:`192.168.40.206`
|
||||
|
||||
Git 增量部署脚本会在构建机上使用下面目录作为 Git 工作区:
|
||||
|
||||
|
||||
@ -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. 清理策略
|
||||
|
||||
@ -15,6 +15,8 @@ export interface User {
|
||||
department: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
status?: string
|
||||
archivedAt?: string | null
|
||||
canDownload: boolean
|
||||
managedDepartment?: string
|
||||
createdAt: string
|
||||
|
||||
34
lib/__tests__/auth-legacy-dingtalk-password.test.ts
Normal file
34
lib/__tests__/auth-legacy-dingtalk-password.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
123
lib/__tests__/dingtalk-complete-profile-route.test.ts
Normal file
123
lib/__tests__/dingtalk-complete-profile-route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
39
lib/__tests__/dingtalk-profile-token.test.ts
Normal file
39
lib/__tests__/dingtalk-profile-token.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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...')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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\//)
|
||||
})
|
||||
})
|
||||
|
||||
82
lib/__tests__/material-assets-authorization.test.ts
Normal file
82
lib/__tests__/material-assets-authorization.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 () => {
|
||||
|
||||
50
lib/__tests__/performance-survey-image-processing.test.ts
Normal file
50
lib/__tests__/performance-survey-image-processing.test.ts
Normal 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 张图片')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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('不属于当前用户')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
150
lib/__tests__/sku-route-authorization.test.ts
Normal file
150
lib/__tests__/sku-route-authorization.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
67
lib/__tests__/system-logs-route.test.ts
Normal file
67
lib/__tests__/system-logs-route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
129
lib/__tests__/transcode-route-authorization.test.ts
Normal file
129
lib/__tests__/transcode-route-authorization.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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('媒体代理应拦截等待期内的视频预览文件旧链接', () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
41
lib/auth.ts
41
lib/auth.ts
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
64
lib/dingtalk-profile-token.ts
Normal file
64
lib/dingtalk-profile-token.ts
Normal 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
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
152
lib/performance-survey-image-processing.ts
Normal file
152
lib/performance-survey-image-processing.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会话是否存在
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
// 为重定向响应也添加缓存控制头
|
||||
|
||||
@ -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时,不能使用通配符*
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user