diff --git a/CLAUDE.md b/CLAUDE.md index ed8f95f..e78617c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` diff --git a/app/admin/api-keys/page.tsx b/app/admin/api-keys/page.tsx index 8aeeb01..9c9d24c 100644 --- a/app/admin/api-keys/page.tsx +++ b/app/admin/api-keys/page.tsx @@ -163,7 +163,7 @@ export default function ApiKeysPage() { {apiKeys.map((key) => ( {key.name} - {key.key.substring(0, 16)}... + {key.key} {key.isActive ? '启用' : '禁用'} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 236d1be..2799be1 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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() { {/* Tabs */} -
+
- {detail.loading &&
加载中...
} - {detail.error && } - {detail.data && ( -
-
-
最终分
-
- {formatNullableScore(detail.data.score)} /120 +
+ {detail.loading &&
加载中...
} + {detail.error && } + {detail.data && ( +
+
+ + + +
-
- {detail.data.workItems?.length > 0 && ( -
- {detail.data.workItems.map((item: any, index: number) => ( -
-
-
-
作品 {index + 1}
-

{item.title}

- {item.description &&

{item.description}

} -
-
{formatNullableScore(item.averageScore)}
+
+ 核心评分 +
+ + + +
+
+ + {isVisualDetail && ( +
+ 视觉评分作品 + {detail.data.workItems?.length > 0 ? ( +
+ {detail.data.workItems.map((item: any, index: number) => ( +
+
+
+
作品 {index + 1}
+

{item.title}

+ {item.description &&

{item.description}

} +
+
{formatNullableScore(item.averageScore)}
+
+ + +
+ ))}
- - -
- ))} -
- )} + ) : ( +
+ 暂无作品图片 +
+ )} +
+ )} - -
- )} +
+ 详细评价内容 + +
+
+ )} +
+
+ +
), document.body) } +function DetailMetaItem({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function DetailSectionTitle({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function DetailScoreCard({ + label, + value, + suffix, + strong = false, +}: { + label: string + value: ReactNode + suffix?: string + strong?: boolean +}) { + return ( +
+
{label}
+
+ {value} + {suffix && {suffix}} +
+
+ ) +} + function ScoreParticipantList({ items }: { items: any[] }) { if (!items.length) { - return
暂无评分记录
+ return
暂无评分记录
} return ( -
+
{items.map((item) => ( -
+
{item.name}
{formatNullableScore(item.score)}
@@ -1282,7 +1496,7 @@ function CreatedList({ items, onRefresh, emptyTitle = '还没有发起过考核调研', - resultBackHref = '/performance-survey?tab=created', + resultBackHref = '/performance-survey?tab=manage', }: { items: any[] onRefresh: () => Promise @@ -1725,7 +1939,7 @@ function MyResults({ items }: { items: any[] }) {
看结果 -> @@ -2525,16 +2739,18 @@ function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) }, [targets, targetId]) useEffect(() => { - if (questions.length > 0) { - setScores( - Object.fromEntries( - questions.map((question) => [question.id, 16]), - ), - ) - } - }, [questions]) + setScores({}) + setComment('') + setMessage(null) + setAccountMenuOpen(false) + }, [activeTargetId, questions]) const submit = async () => { + const hasUnratedQuestion = questions.some((question) => !Number.isFinite(scores[question.id])) + if (questions.length === 0 || hasUnratedQuestion) { + setMessage('请逐项完成评分后再提交') + return + } setSubmitting(true) setMessage(null) try { @@ -2557,9 +2773,9 @@ function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) const activeTarget = detail.data.targets.find((target: any) => target.id === activeTargetId) const submitted = activeTarget?.currentAssignment?.status === 'submitted' - const scoreValues = questions.map((question) => Number(scores[question.id] ?? 16)) + const scoreValues = questions.map((question) => Number(scores[question.id] ?? 0)) const scoreTotal = scoreValues.reduce((sum, value) => sum + value, 0) - const ratedCount = questions.filter((question) => typeof scores[question.id] === 'number').length + const ratedCount = questions.filter((question) => Number.isFinite(scores[question.id])).length const surveyStatusLabel = getSurveyStatusLabel(detail.data.status) const dueLabel = detail.data.dueAt ? `截止 ${formatDate(detail.data.dueAt)}` : '无截止时间' const viewer = session?.user || {} @@ -2569,10 +2785,17 @@ function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) : typeof viewer.image === 'string' ? viewer.image : '' - const goToSurveyTab = (nextTab: string) => { + const goToSurveyTab = (nextTab: PerformanceSurveyDashboardTab) => { setAccountMenuOpen(false) router.push(`/performance-survey?tab=${encodeURIComponent(nextTab)}`) } + const changeTarget = (nextTargetId: string) => { + setScores({}) + setComment('') + setMessage(null) + setAccountMenuOpen(false) + setTargetId(nextTargetId) + } if (isVisualTemplate(template)) { return ( @@ -2583,7 +2806,7 @@ function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) targets={targets} activeTarget={activeTarget} activeTargetId={activeTargetId} - setTargetId={setTargetId} + setTargetId={changeTarget} viewerName={viewerName} viewerAvatar={viewerAvatar} dueLabel={dueLabel} @@ -2629,7 +2852,7 @@ function SurveySubmit({ surveyId, session }: { surveyId: string; session: any }) 切换评价对象 setFiles(Array.from(event.target.files || []))} + onChange={(event) => { + const selectedFiles = Array.from(event.target.files || []) + const validationError = validatePerformanceSurveyImageFiles(selectedFiles) + if (validationError) { + setFiles([]) + setMessage(validationError) + event.currentTarget.value = '' + return + } + setFiles(selectedFiles) + setMessage(null) + }} className="text-sm text-[#667085] file:mr-3 file:rounded-full file:border-0 file:bg-[#eef6ff] file:px-3 file:py-2 file:text-sm file:font-semibold file:text-[#1677ff]" /> + {selectedPreviews.length > 0 && ( +
+
已选择 {selectedPreviews.length} 张图片
+ item.url)} variant="gallery" /> +
+ )} {message &&
{message}
}
- {existingScore ? `${formatNullableScore(existingScore.totalScore)} 分` : `${total} 分`} + {existingScore ? `${formatNullableScore(existingScore.totalScore)} 分` : allRated ? `${total} 分` : '待评分'}
- + {existingScore ? (
@@ -3171,9 +3444,10 @@ function VisualWorkItemCard({
) : canScore ? (
- {(template?.questions || []).map((question, questionIndex) => { + {questions.map((question, questionIndex) => { const maxScore = getQuestionMaxScore(template, question) - const value = scores[question.id] ?? Math.min(6, maxScore) + const isRated = Number.isFinite(scores[question.id]) + const value = isRated ? scores[question.id] : 0 const percent = Math.max(0, Math.min(100, (value / maxScore) * 100)) const accentColor = getQuestionAccentColor(questionIndex) return ( @@ -3183,13 +3457,23 @@ function VisualWorkItemCard({
{question.title}
{question.description &&
{question.description}
}
-
{value}/{maxScore}
+
{isRated ? `${value}/${maxScore}` : '未评分'}
{ + if (!isRated) setScores((current) => ({ ...current, [question.id]: 0 })) + }} + onKeyDown={(event) => { + if (!isRated && (event.key === 'ArrowLeft' || event.key === 'Home')) { + setScores((current) => ({ ...current, [question.id]: 0 })) + } + }} onChange={(event) => setScores((current) => ({ ...current, [question.id]: Number(event.target.value) }))} className="survey-score-range mt-2 w-full" style={{ '--score-percent': `${percent}%`, '--score-color': accentColor } as CSSProperties} @@ -3207,7 +3491,7 @@ function VisualWorkItemCard({ {message &&
{message}
} + {restImages.length > 0 && ( +
+ {restImages.map((url, index) => ( + + ))} +
+ )} +
+ setActiveIndex(null)} + /> + + ) + } + + const gridClass = variant === 'gallery' + ? 'mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4' + : 'mt-4 grid grid-cols-2 gap-2' + const imageClass = variant === 'gallery' + ? 'aspect-square h-auto w-full object-cover transition-transform duration-300 group-hover:scale-105' + : 'aspect-[4/3] h-auto w-full object-cover' + return ( -
- {images.map((url, index) => ( - +
+ {displayImages.map((url, index) => ( + + ))} +
+ setActiveIndex(null)} + /> + + ) +} + +function ImageFullscreenViewer({ + images, + activeIndex, + onChange, + onClose, +}: { + images: string[] + activeIndex: number | null + onChange: (index: number) => void + onClose: () => void +}) { + const [portalReady, setPortalReady] = useState(false) + const dialogRef = useRef(null) + const closeButtonRef = useRef(null) + + useEffect(() => { + setPortalReady(true) + }, []) + + const currentIndex = activeIndex == null ? null : Math.max(0, Math.min(images.length - 1, activeIndex)) + const isOpen = currentIndex != null + const canNavigate = images.length > 1 + + const goPrevious = useCallback(() => { + if (currentIndex == null || !canNavigate) return + onChange((currentIndex - 1 + images.length) % images.length) + }, [canNavigate, currentIndex, images.length, onChange]) + + const goNext = useCallback(() => { + if (currentIndex == null || !canNavigate) return + onChange((currentIndex + 1) % images.length) + }, [canNavigate, currentIndex, images.length, onChange]) + + useEffect(() => { + if (!isOpen) return + + const previousOverflow = document.body.style.overflow + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null + document.body.style.overflow = 'hidden' + const focusFrame = window.requestAnimationFrame(() => closeButtonRef.current?.focus()) + + return () => { + window.cancelAnimationFrame(focusFrame) + document.body.style.overflow = previousOverflow + previousFocus?.focus() + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose() + return + } + if (event.key === 'ArrowLeft') { + event.preventDefault() + goPrevious() + return + } + if (event.key === 'ArrowRight') { + event.preventDefault() + goNext() + return + } + keepFocusInsideModal(event, dialogRef.current) + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [goNext, goPrevious, isOpen, onClose]) + + if (currentIndex == null || !portalReady) return null + + const currentImage = images[currentIndex] + + return createPortal(( +
+
event.stopPropagation()}> +
+ {currentIndex + 1} / {images.length} +
+ +
+ +
+ {canNavigate && ( + + )} + + {canNavigate && ( + + )} +
- ) + ), document.body) } function getSurveyInitials(name: string) { @@ -3275,10 +3790,28 @@ function getSurveyStatusLabel(status: string) { } function getPerformanceSurveyBackHref(value: string | null) { - if (!value) return '/performance-survey?tab=results' - if (!value.startsWith('/performance-survey')) return '/performance-survey?tab=results' - if (value.startsWith('//')) return '/performance-survey?tab=results' - return value + const fallback = '/performance-survey?tab=personal' + if (!value || value.startsWith('//')) return fallback + + try { + const parsed = new URL(value, 'https://performance-survey.local') + if (parsed.origin !== 'https://performance-survey.local') return fallback + if (parsed.pathname !== '/performance-survey' && !parsed.pathname.startsWith('/performance-survey/')) { + return fallback + } + + const legacySegment = parsed.pathname.match(/^\/performance-survey\/(personal|public|manage|pending|submitted|results|created|teacher|admin)\/?$/)?.[1] + if (legacySegment) { + parsed.pathname = '/performance-survey' + parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(legacySegment)) + } else if (parsed.pathname === '/performance-survey' && parsed.searchParams.has('tab')) { + parsed.searchParams.set('tab', normalizePerformanceSurveyDashboardTab(parsed.searchParams.get('tab'))) + } + + return `${parsed.pathname}${parsed.search}${parsed.hash}` + } catch { + return fallback + } } function formatResultScore(value: unknown) { @@ -3572,6 +4105,12 @@ function MobileSurveyRangeStyles() { box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22); } + .survey-score-range[data-rated="false"]::-webkit-slider-thumb { + border-color: #b8c2cf; + background: #f6f8fb; + box-shadow: none; + } + .survey-score-range::-moz-range-track { height: 4px; border-radius: 999px; @@ -3592,6 +4131,13 @@ function MobileSurveyRangeStyles() { background: #ffffff; box-shadow: 0 2px 6px rgba(22, 119, 255, 0.22); } + + + .survey-score-range[data-rated="false"]::-moz-range-thumb { + border-color: #b8c2cf; + background: #f6f8fb; + box-shadow: none; + } `} ) } diff --git a/app/uploads/[...path]/route.ts b/app/uploads/[...path]/route.ts index 9fea0e1..b156c5c 100644 --- a/app/uploads/[...path]/route.ts +++ b/app/uploads/[...path]/route.ts @@ -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 || []) } diff --git a/build-base-image.ps1 b/build-base-image.ps1 index 910599f..004bd2e 100644 --- a/build-base-image.ps1 +++ b/build-base-image.ps1 @@ -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 diff --git a/components/Header.tsx b/components/Header.tsx index 47af10c..0580e8b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -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 && ( -
+
{/* 性能监控悬停卡片(优化版) */} - {showPerfCard && ( + {canViewPerformance && showPerfCard && (
系统性能 diff --git a/components/admin/UserManagement.tsx b/components/admin/UserManagement.tsx index cb84227..f06b790 100644 --- a/components/admin/UserManagement.tsx +++ b/components/admin/UserManagement.tsx @@ -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') diff --git a/docker-compose.shadow.yml b/docker-compose.shadow.yml index e90c4a0..c2bad87 100644 --- a/docker-compose.shadow.yml +++ b/docker-compose.shadow.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index ada4c50..72b68fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/AI生图平台第一层桶缓存落地方案.md b/docs/AI生图平台第一层桶缓存落地方案.md index ccd59ee..19fbd0f 100644 --- a/docs/AI生图平台第一层桶缓存落地方案.md +++ b/docs/AI生图平台第一层桶缓存落地方案.md @@ -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/` 继续可访问,但不再作为默认展示路径。** diff --git a/docs/AI生图白底图以及详情页面接口文档.md b/docs/AI生图白底图以及详情页面接口文档.md index 1205eae..5cc1252 100644 --- a/docs/AI生图白底图以及详情页面接口文档.md +++ b/docs/AI生图白底图以及详情页面接口文档.md @@ -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/...` +- 当前对外读取不使用七牛云存储桶 diff --git a/docs/Git增量部署方案.md b/docs/Git增量部署方案.md index ebd51f9..b9b2f81 100644 --- a/docs/Git增量部署方案.md +++ b/docs/Git增量部署方案.md @@ -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`。 diff --git a/docs/NETWORK_TOPOLOGY.md b/docs/NETWORK_TOPOLOGY.md index ac0c0ba..22612f7 100644 --- a/docs/NETWORK_TOPOLOGY.md +++ b/docs/NETWORK_TOPOLOGY.md @@ -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 历史缓存验证 diff --git a/docs/生产存储桶接入信息.md b/docs/生产存储桶接入信息.md index 9457eaa..d93dd0a 100644 --- a/docs/生产存储桶接入信息.md +++ b/docs/生产存储桶接入信息.md @@ -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` 是历史入口,不再作为当前默认排障入口。 diff --git a/docs/素材库平台预览缓存方案.md b/docs/素材库平台预览缓存方案.md index b49e0ba..60af04a 100644 --- a/docs/素材库平台预览缓存方案.md +++ b/docs/素材库平台预览缓存方案.md @@ -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 回源链路 diff --git a/docs/统一流量访问规则.md b/docs/统一流量访问规则.md index b07ca79..04cb992 100644 --- a/docs/统一流量访问规则.md +++ b/docs/统一流量访问规则.md @@ -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` 回源链路。 diff --git a/docs/部署脚本安全说明.md b/docs/部署脚本安全说明.md index 3dab806..281b238 100644 --- a/docs/部署脚本安全说明.md +++ b/docs/部署脚本安全说明.md @@ -62,7 +62,7 @@ ## 5. 构建机操作范围 -构建机:`192.168.42.134` +构建机:`192.168.40.206` Git 增量部署脚本会在构建机上使用下面目录作为 Git 工作区: diff --git a/docs/部署规范.md b/docs/部署规范.md index d528f99..faccfb0 100644 --- a/docs/部署规范.md +++ b/docs/部署规范.md @@ -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. 清理策略 diff --git a/hooks/useAdminState.ts b/hooks/useAdminState.ts index 4249b85..878337b 100644 --- a/hooks/useAdminState.ts +++ b/hooks/useAdminState.ts @@ -15,6 +15,8 @@ export interface User { department: string role: string isActive: boolean + status?: string + archivedAt?: string | null canDownload: boolean managedDepartment?: string createdAt: string diff --git a/lib/__tests__/auth-legacy-dingtalk-password.test.ts b/lib/__tests__/auth-legacy-dingtalk-password.test.ts new file mode 100644 index 0000000..c6bd12d --- /dev/null +++ b/lib/__tests__/auth-legacy-dingtalk-password.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/dingtalk-complete-profile-route.test.ts b/lib/__tests__/dingtalk-complete-profile-route.test.ts new file mode 100644 index 0000000..b0a1f90 --- /dev/null +++ b/lib/__tests__/dingtalk-complete-profile-route.test.ts @@ -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, 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') + }) +}) diff --git a/lib/__tests__/dingtalk-profile-token.test.ts b/lib/__tests__/dingtalk-profile-token.test.ts new file mode 100644 index 0000000..7c48708 --- /dev/null +++ b/lib/__tests__/dingtalk-profile-token.test.ts @@ -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') + }) +}) diff --git a/lib/__tests__/external-api-key.test.ts b/lib/__tests__/external-api-key.test.ts index a2e91f2..9b64a7f 100644 --- a/lib/__tests__/external-api-key.test.ts +++ b/lib/__tests__/external-api-key.test.ts @@ -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) { 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...') + }) +}) diff --git a/lib/__tests__/maixiang-spu-assets-route.test.ts b/lib/__tests__/maixiang-spu-assets-route.test.ts index f110661..e3b8180 100644 --- a/lib/__tests__/maixiang-spu-assets-route.test.ts +++ b/lib/__tests__/maixiang-spu-assets-route.test.ts @@ -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\//) + }) }) diff --git a/lib/__tests__/material-assets-authorization.test.ts b/lib/__tests__/material-assets-authorization.test.ts new file mode 100644 index 0000000..88195dc --- /dev/null +++ b/lib/__tests__/material-assets-authorization.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts index bcea0b4..487a568 100644 --- a/lib/__tests__/material-edit-routes.test.ts +++ b/lib/__tests__/material-edit-routes.test.ts @@ -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 () => { diff --git a/lib/__tests__/performance-survey-image-processing.test.ts b/lib/__tests__/performance-survey-image-processing.test.ts new file mode 100644 index 0000000..cce6e7b --- /dev/null +++ b/lib/__tests__/performance-survey-image-processing.test.ts @@ -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 张图片') + }) +}) diff --git a/lib/__tests__/product-package-complete-route-security.test.ts b/lib/__tests__/product-package-complete-route-security.test.ts new file mode 100644 index 0000000..867ca72 --- /dev/null +++ b/lib/__tests__/product-package-complete-route-security.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts index 3e6c379..809f89e 100644 --- a/lib/__tests__/product-package-completion.test.ts +++ b/lib/__tests__/product-package-completion.test.ts @@ -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('不属于当前用户') + }) }) diff --git a/lib/__tests__/regenerate-preview-route.test.ts b/lib/__tests__/regenerate-preview-route.test.ts index 222e14e..bc4438f 100644 --- a/lib/__tests__/regenerate-preview-route.test.ts +++ b/lib/__tests__/regenerate-preview-route.test.ts @@ -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() + }) }) diff --git a/lib/__tests__/sku-route-authorization.test.ts b/lib/__tests__/sku-route-authorization.test.ts new file mode 100644 index 0000000..9149953 --- /dev/null +++ b/lib/__tests__/sku-route-authorization.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/system-logs-route.test.ts b/lib/__tests__/system-logs-route.test.ts new file mode 100644 index 0000000..617c93b --- /dev/null +++ b/lib/__tests__/system-logs-route.test.ts @@ -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() + }) +}) diff --git a/lib/__tests__/transcode-route-authorization.test.ts b/lib/__tests__/transcode-route-authorization.test.ts new file mode 100644 index 0000000..6738e11 --- /dev/null +++ b/lib/__tests__/transcode-route-authorization.test.ts @@ -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) + }) +}) diff --git a/lib/__tests__/video-download-policy-integration.test.ts b/lib/__tests__/video-download-policy-integration.test.ts index f9eaba5..ea06c26 100644 --- a/lib/__tests__/video-download-policy-integration.test.ts +++ b/lib/__tests__/video-download-policy-integration.test.ts @@ -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('媒体代理应拦截等待期内的视频预览文件旧链接', () => { diff --git a/lib/__tests__/white-background-preview.test.ts b/lib/__tests__/white-background-preview.test.ts index 8f277cc..bba89b9 100644 --- a/lib/__tests__/white-background-preview.test.ts +++ b/lib/__tests__/white-background-preview.test.ts @@ -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 () => { diff --git a/lib/auth.ts b/lib/auth.ts index 49005c2..49ddae8 100644 --- a/lib/auth.ts +++ b/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; diff --git a/lib/dingtalk-login.ts b/lib/dingtalk-login.ts index c60439e..eedf2cd 100644 --- a/lib/dingtalk-login.ts +++ b/lib/dingtalk-login.ts @@ -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, }, }) } diff --git a/lib/dingtalk-profile-token.ts b/lib/dingtalk-profile-token.ts new file mode 100644 index 0000000..a9b3053 --- /dev/null +++ b/lib/dingtalk-profile-token.ts @@ -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 diff --git a/lib/external-api.ts b/lib/external-api.ts index c6ae514..7e6ba1f 100644 --- a/lib/external-api.ts +++ b/lib/external-api.ts @@ -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, }, }); diff --git a/lib/performance-survey-image-processing.ts b/lib/performance-survey-image-processing.ts new file mode 100644 index 0000000..9ba1ca7 --- /dev/null +++ b/lib/performance-survey-image-processing.ts @@ -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 { + 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 { + 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 +} diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index 4c40103..dcd760b 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -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 | null = null +const profileRefreshInFlight = new Map>() +const profileRefreshStartedAt = new Map() + +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, diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index aa8c820..0c4abf0 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -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() +const performanceSurveyDepartmentCachePromises = new Map>() + +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 + 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 | 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() - 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() + 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 } diff --git a/lib/product-package-completion.ts b/lib/product-package-completion.ts index 535fa3c..f720015 100644 --- a/lib/product-package-completion.ts +++ b/lib/product-package-completion.ts @@ -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 + +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 + 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) { diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 1c35dd2..6c81f90 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -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() +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 { + 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( @@ -102,7 +179,7 @@ async function withRedisFallback( // 清理过期会话(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 { + 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 { + 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 { + 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 { ) } +export async function deleteOwnedUploadSession(uploadId: string, userId: string): Promise { + const session = await getOwnedUploadSession(uploadId, userId) + if (!session) { + return false + } + + await deleteUploadSession(uploadId) + return true +} + /** * 检查会话是否存在 */ diff --git a/middleware.ts b/middleware.ts index 99e211e..51bf454 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 { + 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) // 为重定向响应也添加缓存控制头 diff --git a/next.config.js b/next.config.js index 3bd46d2..6c77542 100644 --- a/next.config.js +++ b/next.config.js @@ -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时,不能使用通配符* diff --git a/package.json b/package.json index f50765f..7a947d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-source-files-status.ts b/scripts/check-source-files-status.ts index 25c6eca..644f759 100644 --- a/scripts/check-source-files-status.ts +++ b/scripts/check-source-files-status.ts @@ -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')) { diff --git a/scripts/cleanup-shadow-test-materials.ts b/scripts/cleanup-shadow-test-materials.ts index e40e720..7fe785a 100644 --- a/scripts/cleanup-shadow-test-materials.ts +++ b/scripts/cleanup-shadow-test-materials.ts @@ -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: { diff --git a/scripts/execute-migration.ps1 b/scripts/execute-migration.ps1 index ca07a66..3d7cedf 100644 --- a/scripts/execute-migration.ps1 +++ b/scripts/execute-migration.ps1 @@ -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 diff --git a/scripts/execute-migration.sh b/scripts/execute-migration.sh index 38495a8..8cb2882 100644 --- a/scripts/execute-migration.sh +++ b/scripts/execute-migration.sh @@ -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" diff --git a/scripts/query-3d-original-assets.ps1 b/scripts/query-3d-original-assets.ps1 index 1d637dd..a57c778 100644 --- a/scripts/query-3d-original-assets.ps1 +++ b/scripts/query-3d-original-assets.ps1 @@ -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 diff --git a/scripts/query-db.ps1 b/scripts/query-db.ps1 index 1faf442..ff1193c 100644 --- a/scripts/query-db.ps1 +++ b/scripts/query-db.ps1 @@ -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" diff --git a/scripts/query-system-stats.ts b/scripts/query-system-stats.ts index 852296b..5b4a7f8 100644 --- a/scripts/query-system-stats.ts +++ b/scripts/query-system-stats.ts @@ -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, }, }, }); diff --git a/setup-build-cache.ps1 b/setup-build-cache.ps1 index 99253c8..4111892 100644 --- a/setup-build-cache.ps1 +++ b/setup-build-cache.ps1 @@ -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 diff --git a/setup-build-server.ps1 b/setup-build-server.ps1 index e15c514..91056c0 100644 --- a/setup-build-server.ps1 +++ b/setup-build-server.ps1 @@ -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 diff --git a/setup-production-server.ps1 b/setup-production-server.ps1 index 61023ff..e1daef6 100644 --- a/setup-production-server.ps1 +++ b/setup-production-server.ps1 @@ -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