diff --git a/.dockerignore b/.dockerignore index 44c69a8..324d1d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,19 @@ out build dist +# 忽略的项目目录(不属于本项目) +flnxi_sucai +gongxiangp2 +frp_* + +# Docker 缓存(保留 npm-packages.tgz 用于加速构建) +.docker-cache/* +!.docker-cache/npm-packages.tgz + +# 构建缓存(保留 node_modules 缓存) +.build-cache/* +!.build-cache/node_modules.tar.gz + # Git 相关 .git .gitignore diff --git a/.gitignore b/.gitignore index 73250a7..7e645a8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,11 @@ next-env.d.ts # Uploads /uploads +# 忽略的项目目录(不属于本项目) +/flnxi_sucai +/gongxiangp2 +/frp_* + +# Docker 缓存 +/.docker-cache + diff --git a/Dockerfile b/Dockerfile index 06b05fb..17b6999 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,15 +21,51 @@ RUN apk add --no-cache \ python3 \ make \ g++ \ - pkgconfig + pkgconfig \ + vips-dev \ + fftw-dev \ + build-base WORKDIR /app # 复制依赖文件 COPY package.json package-lock.json* ./ -RUN npm ci + +# 智能缓存策略: +# 1. 如果存在 .build-cache/node_modules.tar.gz,直接解压使用(10-20秒) +# 2. 否则执行 npm ci 下载(60-90秒) +COPY .build-cache/node_modules.tar.gz* /tmp/ +RUN --mount=type=cache,target=/root/.npm,id=npm-cache,sharing=locked \ + if [ -f /tmp/node_modules.tar.gz ]; then \ + echo "✅ 使用缓存的 node_modules(快速模式)" && \ + tar -xzf /tmp/node_modules.tar.gz && \ + chmod -R +x node_modules/.bin && \ + mkdir -p node_modules/canvas && \ + echo "🔧 重新安装 sharp(musl 版本)" && \ + npm config set registry https://registry.npmmirror.com && \ + npm install --os=linux --libc=musl --cpu=x64 --legacy-peer-deps sharp; \ + else \ + echo "⚠️ 缓存不存在,重新下载依赖(首次构建)" && \ + npm config set registry https://registry.npmmirror.com && \ + npm config set cache /root/.npm --global && \ + npm ci --prefer-offline --no-audit --ignore-optional --legacy-peer-deps && \ + mkdir -p node_modules/canvas && \ + echo "🔧 安装 sharp(musl 版本)" && \ + npm install --os=linux --libc=musl --cpu=x64 --legacy-peer-deps sharp; \ + fi # 构建阶段 FROM base AS builder +# 安装构建时需要的依赖(sharp需要) +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories +RUN apk add --no-cache \ + openssl3 \ + openssl-dev \ + vips-dev \ + fftw-dev \ + build-base \ + python3 \ + make \ + g++ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -52,7 +88,7 @@ ENV NEXT_TELEMETRY_DISABLED 1 # 使用阿里云镜像源加速 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories -# 安装运行时依赖(包括 canvas 所需的库) +# 安装运行时依赖(包括 canvas 所需的库、FFmpeg 和 Prisma 所需的 OpenSSL) RUN apk add --no-cache \ openssl3 \ openssl-dev \ @@ -61,7 +97,9 @@ RUN apk add --no-cache \ jpeg \ giflib \ librsvg \ - pixman + pixman \ + ffmpeg \ + vips RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -71,7 +109,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd -COPY --from=builder --chown=nextjs:nodejs /app/node_modules/canvas ./node_modules/canvas COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c474b9a..9420f8e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' -import EditMaterialModal from '@/components/EditMaterialModal' +import DepartmentPermissionsTab from '@/components/DepartmentPermissionsTab' +import SuperAdminsTab from '@/components/SuperAdminsTab' import { format } from 'date-fns' interface User { @@ -32,6 +33,9 @@ interface Material { renderer: string authorName: string createdAt: string + videoCoverUrl?: string + videoPreviewClipUrl?: string + videoPreview540Url?: string user: { username: string } @@ -55,26 +59,35 @@ interface Platform { count: number } +interface ProductCategory { + id: string + name: string + count: number +} + export default function AdminPage() { const { data: session, status } = useSession() const router = useRouter() - const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments'>('materials') + const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'permissions' | 'superAdmins'>('materials') const [materials, setMaterials] = useState([]) const [users, setUsers] = useState([]) const [categories, setCategories] = useState([]) const [scenes, setScenes] = useState([]) const [platforms, setPlatforms] = useState([]) + const [productCategories, setProductCategories] = useState([]) const [loading, setLoading] = useState(true) - const [editingMaterial, setEditingMaterial] = useState(null) const [editingCategory, setEditingCategory] = useState(null) const [editingScene, setEditingScene] = useState(null) const [editingPlatform, setEditingPlatform] = useState(null) + const [editingProductCategory, setEditingProductCategory] = useState(null) const [showAddCategoryModal, setShowAddCategoryModal] = useState(false) const [showAddSceneModal, setShowAddSceneModal] = useState(false) const [showAddPlatformModal, setShowAddPlatformModal] = useState(false) + const [showAddProductCategoryModal, setShowAddProductCategoryModal] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [newSceneName, setNewSceneName] = useState('') const [newPlatformName, setNewPlatformName] = useState('') + const [newProductCategoryName, setNewProductCategoryName] = useState('') const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null) // 编辑用户部门 @@ -92,9 +105,22 @@ export default function AdminPage() { // 素材搜索 const [materialSearchTerm, setMaterialSearchTerm] = useState('') + // 素材批量操作相关状态 + const [selectedMaterials, setSelectedMaterials] = useState>(new Set()) + const [showRecycleBin, setShowRecycleBin] = useState(false) + // 部门折叠状态 const [collapsedDepartments, setCollapsedDepartments] = useState>(new Set()) const [collapsedAdmins, setCollapsedAdmins] = useState>(new Set()) + + // 素材分页加载相关状态 + const [displayedMaterialsCount, setDisplayedMaterialsCount] = useState(24) // 当前显示的素材数量 + const [currentMaterialsPage, setCurrentMaterialsPage] = useState(1) // 当前加载到第几页 + const [hasMoreMaterials, setHasMoreMaterials] = useState(true) // 是否还有更多素材 + const [isLoadingMoreMaterials, setIsLoadingMoreMaterials] = useState(false) // 是否正在加载更多 + const loadMoreMaterialsRef = useRef(null) // 加载更多触发器ref + const materialsPageSize = 20 + const materialSearchDebounceRef = useRef(null) // 搜索防抖定时器 // Toast自动隐藏 useEffect(() => { @@ -108,6 +134,49 @@ export default function AdminPage() { setToast({ message, type }) } + // 局部搜索函数(不触发整页加载) + const searchMaterials = useCallback(async (searchTerm: string) => { + try { + const hasSearch = searchTerm.trim().length > 0 + const url = showRecycleBin + ? `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}&deleted=true${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}` + : `/api/materials?page=1&pageSize=${hasSearch ? 10000 : materialsPageSize}${hasSearch ? `&search=${encodeURIComponent(searchTerm.trim())}` : ''}` + 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(hasSearch ? false : materialsData.length >= materialsPageSize) + } + } catch (error) { + console.error('搜索素材失败:', error) + } + }, [showRecycleBin, materialsPageSize]) + + // 素材搜索处理函数(局部刷新,使用防抖) + const handleMaterialSearchChange = useCallback((value: string) => { + // 立即更新显示值(不触发搜索,只更新输入框显示) + setMaterialSearchTerm(value) + + // 清除之前的定时器 + if (materialSearchDebounceRef.current) { + clearTimeout(materialSearchDebounceRef.current) + } + + // 如果搜索框为空,立即重置数据(不防抖) + if (!value.trim()) { + searchMaterials('') + return + } + + // 设置新的定时器,500ms后触发搜索(局部刷新) + materialSearchDebounceRef.current = setTimeout(() => { + searchMaterials(value) + }, 500) + }, [searchMaterials]) + useEffect(() => { if (status === 'unauthenticated') { router.push('/login') @@ -125,17 +194,32 @@ export default function AdminPage() { fetchData() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, activeTab]) + }, [status, activeTab, showRecycleBin]) const fetchData = async () => { setLoading(true) try { if (activeTab === 'materials') { - const response = await fetch('/api/materials?pageSize=1000') + // 重置分页状态 + setCurrentMaterialsPage(1) + setDisplayedMaterialsCount(24) + setHasMoreMaterials(true) + + // 初始加载不使用搜索(搜索由handleMaterialSearchChange处理) + const url = showRecycleBin + ? `/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() // 处理新的分页API响应格式 - setMaterials(data.materials || data) + const materialsData = data.materials || data + setMaterials(materialsData) + // 设置显示数量为实际加载的素材数量 + setDisplayedMaterialsCount(materialsData.length) + setCurrentMaterialsPage(1) + // 根据返回数量判断是否还有更多 + setHasMoreMaterials(materialsData.length >= materialsPageSize) } } else if (activeTab === 'users') { const response = await fetch('/api/admin/users') @@ -146,10 +230,11 @@ export default function AdminPage() { // 加载部门列表 fetchDepartments() } else if (activeTab === 'categories') { - const [categoriesRes, scenesRes, platformsRes] = await Promise.all([ + const [categoriesRes, scenesRes, platformsRes, productCategoriesRes] = await Promise.all([ fetch('/api/categories'), fetch('/api/scenes'), - fetch('/api/platforms') + fetch('/api/platforms'), + fetch('/api/product-categories') ]) if (categoriesRes.ok) { const data = await categoriesRes.json() @@ -163,8 +248,19 @@ export default function AdminPage() { const data = await platformsRes.json() setPlatforms(data) } + if (productCategoriesRes.ok) { + const data = await productCategoriesRes.json() + setProductCategories(data) + } } else if (activeTab === 'departments') { + // 部门管理页:需要加载部门列表 fetchDepartments() + } else if (activeTab === 'permissions') { + // 部门权限配置页:也依赖部门列表,需要先加载部门 + fetchDepartments() + // 权限配置数据在组件内部加载 + } else if (activeTab === 'superAdmins') { + // 超级管理员数据在组件内部加载 } } catch (error) { console.error('Error fetching data:', error) @@ -185,6 +281,98 @@ export default function AdminPage() { } } + // 使用ref存储loadMoreMaterials函数和materials.length,避免在useEffect依赖项中包含它们 + const loadMoreMaterialsRefFn = useRef<() => Promise>() + const materialsLengthRef = useRef(0) + + // 加载更多素材(仅在非搜索模式下使用) + const loadMoreMaterials = useCallback(async () => { + if (isLoadingMoreMaterials || !hasMoreMaterials) return + + // 如果有搜索关键词,不执行分页加载 + if (materialSearchTerm.trim().length > 0) { + return + } + + setIsLoadingMoreMaterials(true) + try { + const nextPage = currentMaterialsPage + 1 + const url = showRecycleBin + ? `/api/materials?page=${nextPage}&pageSize=${materialsPageSize}&deleted=true` + : `/api/materials?page=${nextPage}&pageSize=${materialsPageSize}` + + const response = await fetch(url) + if (response.ok) { + const data = await response.json() + const newMaterials = data.materials || data + + if (newMaterials.length > 0) { + setMaterials(prev => { + const updated = [...prev, ...newMaterials] + materialsLengthRef.current = updated.length + // 加载新数据后,自动增加显示数量(显示新加载的数据) + setDisplayedMaterialsCount(prev => Math.min(prev + newMaterials.length, updated.length)) + return updated + }) + setCurrentMaterialsPage(nextPage) + setHasMoreMaterials(newMaterials.length >= materialsPageSize) + } else { + setHasMoreMaterials(false) + } + } + } catch (error) { + console.error('加载更多素材失败:', error) + } finally { + setIsLoadingMoreMaterials(false) + } + }, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialsPageSize, materialSearchTerm]) + + // 更新ref + useEffect(() => { + loadMoreMaterialsRefFn.current = loadMoreMaterials + materialsLengthRef.current = materials.length + }, [loadMoreMaterials, materials.length]) + + // 监听滚动,实现懒加载 + useEffect(() => { + if (activeTab !== 'materials') return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + // 使用ref访问最新的状态值,避免闭包问题 + const currentHasMore = hasMoreMaterials + const currentIsLoading = isLoadingMoreMaterials + const currentMaterialsLength = materialsLengthRef.current + const currentDisplayedCount = displayedMaterialsCount + + if (currentHasMore && !currentIsLoading) { + // 如果显示数量已经接近或超过已加载的数量,先加载更多数据 + if (currentDisplayedCount >= currentMaterialsLength - 5) { + // 使用ref访问最新的loadMoreMaterials函数 + if (loadMoreMaterialsRefFn.current) { + loadMoreMaterialsRefFn.current() + } + } else { + // 如果还有未显示的素材,直接增加显示数量 + setDisplayedMaterialsCount(prev => Math.min(prev + 24, currentMaterialsLength)) + } + } else if (!currentHasMore && currentDisplayedCount < currentMaterialsLength) { + // 如果没有更多数据,但还有未显示的素材,显示所有已加载的素材 + setDisplayedMaterialsCount(currentMaterialsLength) + } + } + }, + { threshold: 0.1 } + ) + + if (loadMoreMaterialsRef.current) { + observer.observe(loadMoreMaterialsRef.current) + } + + return () => observer.disconnect() + }, [activeTab, hasMoreMaterials, isLoadingMoreMaterials, displayedMaterialsCount]) + const handleEditUserDepartment = (user: User) => { setEditingUser(user) setSelectedDepartment(user.department || '') @@ -301,6 +489,70 @@ export default function AdminPage() { } } + // 批量删除素材 + const handleBatchDeleteMaterials = async () => { + const selectedIds = Array.from(selectedMaterials) + if (selectedIds.length === 0) { + showToast('请先选择要删除的素材', 'error') + return + } + + const confirmMessage = showRecycleBin + ? `确定要永久删除选中的 ${selectedIds.length} 条素材吗?此操作不可恢复!` + : `确定要删除选中的 ${selectedIds.length} 条素材吗?素材将移至回收站。` + + if (!confirm(confirmMessage)) return + + try { + const response = await fetch('/api/admin/materials/batch-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ids: selectedIds, + permanent: showRecycleBin, + }), + }) + + if (response.ok) { + setMaterials(prevMaterials => prevMaterials.filter(m => !selectedIds.includes(m.id))) + setSelectedMaterials(new Set()) + showToast(showRecycleBin ? '批量永久删除完成' : '批量删除完成') + } else { + showToast('批量操作失败', 'error') + } + } catch (error) { + console.error('Batch delete materials error:', error) + showToast('批量操作失败', 'error') + } + } + + // 切换素材选择状态 + const toggleMaterialSelection = (id: string) => { + setSelectedMaterials(prev => { + const newSet = new Set(prev) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + return newSet + }) + } + + // 全选/取消全选 + const toggleSelectAll = () => { + // materials数组已经是搜索结果,不需要再过滤 + if (materials.length > 0 && materials.every(m => selectedMaterials.has(m.id))) { + // 取消全选 + setSelectedMaterials(new Set()) + } else { + // 全选 + setSelectedMaterials(new Set(materials.map(m => m.id))) + } + } + const handleDeleteUser = async (id: string) => { if (!confirm('确定要删除这个用户吗?该用户的所有素材也会被删除。')) return @@ -692,6 +944,92 @@ export default function AdminPage() { } } + // 品类管理函数 + const handleAddProductCategory = async () => { + if (!newProductCategoryName.trim()) { + showToast('请输入品类名称', 'error') + return + } + + try { + const response = await fetch('/api/product-categories', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: newProductCategoryName.trim() }), + }) + + if (response.ok) { + const data = await response.json() + setProductCategories(prevCategories => [...prevCategories, { id: data.id, name: newProductCategoryName.trim(), count: 0 }]) + setShowAddProductCategoryModal(false) + setNewProductCategoryName('') + showToast('添加成功') + } else { + const data = await response.json() + showToast(data.error || '添加失败', 'error') + } + } catch (error) { + console.error('Error adding product category:', error) + showToast('添加失败', 'error') + } + } + + const handleUpdateProductCategory = async (id: string, newName: string) => { + if (!newName.trim()) { + showToast('品类名称不能为空', 'error') + return + } + + try { + const response = await fetch('/api/product-categories', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id, name: newName.trim() }), + }) + + if (response.ok) { + setProductCategories(prevCategories => + prevCategories.map(cat => + cat.id === id ? { ...cat, name: newName.trim() } : cat + ) + ) + setEditingProductCategory(null) + showToast('修改成功') + } else { + const data = await response.json() + showToast(data.error || '修改失败', 'error') + } + } catch (error) { + console.error('Error updating product category:', error) + showToast('修改失败', 'error') + } + } + + const handleDeleteProductCategory = async (id: string) => { + if (!confirm('确定要删除这个品类吗?')) return + + try { + const response = await fetch(`/api/product-categories?id=${id}`, { + method: 'DELETE', + }) + + if (response.ok) { + setProductCategories(prevCategories => prevCategories.filter(cat => cat.id !== id)) + showToast('删除成功') + } else { + const data = await response.json() + showToast(data.error || '删除失败', 'error') + } + } catch (error) { + console.error('Error deleting product category:', error) + showToast('删除失败', 'error') + } + } + // 设置部门管理员 const handleSetDepartmentManager = async (userId: string, departmentName: string) => { try { @@ -856,19 +1194,46 @@ export default function AdminPage() { > 部门管理 + {(session.user as any)?.isSuperAdmin && ( + <> + + + + )} {/* Materials Table */} {activeTab === 'materials' && (
- {/* 素材搜索框 */} -
+ {/* 素材搜索框和批量操作 */} +
+ {/* 搜索框 */}
setMaterialSearchTerm(e.target.value)} + onChange={(e) => handleMaterialSearchChange(e.target.value)} className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-blue-500 dark:focus:border-blue-400 focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-600 outline-none transition-all" /> {materialSearchTerm && ( )}
+ + {/* 批量操作工具栏 */} +
+
+ + {selectedMaterials.size > 0 && ( + + )} + +
+ + 共 {materials.length} 个{showRecycleBin ? '(回收站)' : ''} + +
-
- - - - - - - - - - - - - {materials - .filter(material => { - if (!materialSearchTerm) return true - const searchLower = materialSearchTerm.toLowerCase() - return ( - material.productModel.toLowerCase().includes(searchLower) || - material.categoryName.toLowerCase().includes(searchLower) || - material.sceneName.toLowerCase().includes(searchLower) || - material.authorName.toLowerCase().includes(searchLower) || - material.materialType.toLowerCase().includes(searchLower) - ) - }) - .map((material) => ( - - - - - - - - - ))} - -
- 预览图 - - 商品型号 - - 场景名称 - - 作者 - - 上传时间 - - 操作 -
- {material.sceneName} - -
+ {/* 卡片网格布局 */} +
+ {materials + .slice(0, displayedMaterialsCount) // 只显示前N个素材 + .map((material) => { + // 判断是否为视频类型 + const isVideo = material.materialType === '主图视频' || + material.materialType === '安装视频' || + material.materialType === '种草视频' + const posterUrl = material.videoCoverUrl || material.previewUrl + const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url + + // 获取素材类型角标颜色 + const getBadgeColor = (type: string) => { + switch (type) { + case '3DMAX': + case '3D场景': + return 'bg-blue-600/80' + case 'PSD': + case '详情页': + return 'bg-green-600/80' + case 'AI': + case '矢量文件': + return 'bg-amber-600/80' + case 'C4D': + case 'C4D模型': + return 'bg-purple-600/80' + case '主图视频': + case '安装视频': + case '种草视频': + return 'bg-red-600/80' + default: + return 'bg-black/60' + } + } + + return ( +
+ {/* 选择框 */} + + + {/* 预览图/视频 */} +
+ {isVideo && videoUrl ? ( +
-
- {material.sceneName} + + {/* 素材类型角标 */} + {material.materialType && ( +
+ {material.materialType} +
+ )} + + {/* 底部信息栏 */} +
+

+ {material.sceneName} +

+
+ {material.authorName} + + {format(new Date(material.createdAt), 'MM-dd')} + +
-
-
- {material.authorName} + + {/* 操作按钮(悬停显示) */} +
+ {showRecycleBin ? ( + <> + + + + ) : ( + <> + + + + )}
-
- @{material.user.username} -
-
- {format(new Date(material.createdAt), 'yyyy-MM-dd HH:mm')} - -
- - -
-
+
+
+ ) + })}
+ + {/* 加载更多触发器 */} + {(() => { + // materials数组已经是搜索结果,不需要再过滤 + // 显示触发器的条件:还有未显示的素材,或者还有更多数据可以加载 + const hasMoreToShow = displayedMaterialsCount < materials.length || hasMoreMaterials + return hasMoreToShow && ( +
+
+ {isLoadingMoreMaterials ? ( + <> + + + + + 加载中... + + ) : ( + 滚动加载更多... + )} +
+
+ ) + })()} + {materials.length === 0 && (
暂无素材 @@ -1602,6 +2115,100 @@ export default function AdminPage() { )}
+ + {/* 品类管理 */} +
+
+
+

品类管理(可选)

+

管理所有品类名称,用于标准模型分类

+
+ +
+
+
+ {productCategories.map((category) => ( +
+ {editingProductCategory?.id === category.id ? ( +
+ { + if (e.key === 'Enter') { + handleUpdateProductCategory(category.id, e.currentTarget.value) + } else if (e.key === 'Escape') { + setEditingProductCategory(null) + } + }} + autoFocus + /> +
+ + +
+
+ ) : ( +
+
+

+ {category.name} +

+ + {category.count} 个素材 + +
+
+ + +
+
+ )} +
+ ))} +
+ {productCategories.length === 0 && ( +
+ 暂无品类 +
+ )} +
+
)} @@ -1676,31 +2283,23 @@ export default function AdminPage() { )} - - {/* Edit Material Modal */} - {editingMaterial && ( - setEditingMaterial(null)} - onSuccess={async () => { - setEditingMaterial(null) - // 只重新获取素材列表,不刷新其他数据 - if (activeTab === 'materials') { - try { - const response = await fetch('/api/materials?pageSize=1000') - if (response.ok) { - const data = await response.json() - setMaterials(data.materials || data) - } - } catch (error) { - console.error('Error fetching materials:', error) - } - } - showToast('修改成功') - }} + {/* Permissions Tab */} + {activeTab === 'permissions' && (session.user as any)?.isSuperAdmin && ( + + )} + + {/* Super Admins Tab */} + {activeTab === 'superAdmins' && (session.user as any)?.isSuperAdmin && ( + )} + + {/* Add Category Modal */} {showAddCategoryModal && ( @@ -1855,6 +2454,57 @@ export default function AdminPage() { )} + {/* 新增品类模态框 */} + {showAddProductCategoryModal && ( +
+
+

+ 新增品类 +

+
+
+ + setNewProductCategoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddProductCategory() + } else if (e.key === 'Escape') { + setShowAddProductCategoryModal(false) + setNewProductCategoryName('') + } + }} + placeholder="请输入品类名称" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg focus:ring-2 focus:ring-gray-400 outline-none" + autoFocus + /> +
+
+ + +
+
+
+
+ )} + {/* 编辑用户部门模态框 */} {editingUser && (
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 6022cb4..af105a4 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' +import { useToast } from '@/hooks/useToast' +import ToastBubble from '@/components/ToastBubble' export default function SystemSettings() { const { data: session, status } = useSession() @@ -21,6 +23,7 @@ export default function SystemSettings() { const [loginLogoPreview, setLoginLogoPreview] = useState('') const [uploadingHeader, setUploadingHeader] = useState(false) const [uploadingLogin, setUploadingLogin] = useState(false) + const { toast, showToast } = useToast() // S3配置状态 const [s3Endpoint, setS3Endpoint] = useState('') @@ -66,7 +69,7 @@ export default function SystemSettings() { } } catch (error) { console.error('Error fetching settings:', error) - alert('加载设置失败') + showToast('加载设置失败', { variant: 'error' }) } finally { setLoading(false) } @@ -96,16 +99,18 @@ export default function SystemSettings() { }) if (response.ok) { - alert('保存成功!页面将自动刷新以应用新设置') + showToast('保存成功!页面将自动刷新以应用新设置', { variant: 'success', duration: 3000 }) // 刷新页面以应用新设置 + setTimeout(() => { window.location.reload() + }, 1000) } else { const error = await response.json() - alert(error.error || '保存失败') + showToast(error.error || '保存失败', { variant: 'error' }) } } catch (error) { console.error('Save settings error:', error) - alert('保存失败,请稍后重试') + showToast('保存失败,请稍后重试', { variant: 'error' }) } finally { setSaving(false) } @@ -117,13 +122,13 @@ export default function SystemSettings() { // 验证文件类型 if (!file.type.startsWith('image/')) { - alert('请选择图片文件') + showToast('请选择图片文件', { variant: 'error' }) return } // 验证文件大小(最大5MB) if (file.size > 5 * 1024 * 1024) { - alert('LOGO文件大小不能超过5MB') + showToast('LOGO文件大小不能超过5MB', { variant: 'error' }) return } @@ -142,14 +147,14 @@ export default function SystemSettings() { const data = await response.json() setHeaderLogoUrl(data.logoUrl) setHeaderLogoPreview(data.logoUrl) - alert('导航栏LOGO上传成功!') + showToast('导航栏LOGO上传成功!', { variant: 'success' }) } else { const error = await response.json() - alert(error.error || 'LOGO上传失败') + showToast(error.error || 'LOGO上传失败', { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) - alert('上传失败,请稍后重试') + showToast('上传失败,请稍后重试', { variant: 'error' }) } finally { setUploadingHeader(false) } @@ -161,13 +166,13 @@ export default function SystemSettings() { // 验证文件类型 if (!file.type.startsWith('image/')) { - alert('请选择图片文件') + showToast('请选择图片文件', { variant: 'error' }) return } // 验证文件大小(最大5MB) if (file.size > 5 * 1024 * 1024) { - alert('LOGO文件大小不能超过5MB') + showToast('LOGO文件大小不能超过5MB', { variant: 'error' }) return } @@ -186,14 +191,14 @@ export default function SystemSettings() { const data = await response.json() setLoginLogoUrl(data.logoUrl) setLoginLogoPreview(data.logoUrl) - alert('登录页LOGO上传成功!') + showToast('登录页LOGO上传成功!', { variant: 'success' }) } else { const error = await response.json() - alert(error.error || 'LOGO上传失败') + showToast(error.error || 'LOGO上传失败', { variant: 'error' }) } } catch (error) { console.error('Upload logo error:', error) - alert('上传失败,请稍后重试') + showToast('上传失败,请稍后重试', { variant: 'error' }) } finally { setUploadingLogin(false) } @@ -684,6 +689,11 @@ export default function SystemSettings() {
+ + {/* 统一的Toast提示 */} + {toast && toast.show && ( + + )} ) } diff --git a/app/api/admin/department-permissions/batch/route.ts b/app/api/admin/department-permissions/batch/route.ts new file mode 100644 index 0000000..a113260 --- /dev/null +++ b/app/api/admin/department-permissions/batch/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { isSuperAdmin } from '@/lib/department-permission'; + +/** + * POST - 批量更新权限配置 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const body = await request.json(); + const { department } = body || {}; + const permissions: Array<{ + materialType: string; + canView?: boolean; + canDownload?: boolean; + canShare?: boolean; + }> = body?.permissions || []; + + if (!department || !Array.isArray(permissions) || permissions.length === 0) { + return NextResponse.json( + { error: '部门名称和素材类型列表不能为空' }, + { status: 400 } + ); + } + const materialTypes = permissions.map((p) => p.materialType).filter(Boolean); + + // 检查是否有关联部门 + const existing = await prisma.departmentMaterialPermission.findFirst({ + where: { + department, + materialType: { in: materialTypes }, + linkedToDepartment: { not: null }, + }, + }); + + if (existing) { + return NextResponse.json( + { error: '该部门已关联到其他部门,请先取消关联' }, + { status: 400 } + ); + } + + // 批量更新权限配置 + const updates = permissions.map((perm) => + prisma.departmentMaterialPermission.upsert({ + where: { + department_materialType: { + department, + materialType: perm.materialType, + }, + }, + update: { + canView: perm.canView ?? false, + canDownload: perm.canDownload ?? false, + canShare: perm.canShare ?? true, + linkedToDepartment: null, + }, + create: { + department, + materialType: perm.materialType, + canView: perm.canView ?? false, + canDownload: perm.canDownload ?? false, + canShare: perm.canShare ?? true, + linkedToDepartment: null, + }, + }) + ); + + await Promise.all(updates); + + return NextResponse.json({ + message: '批量更新成功', + updatedCount: permissions.length, + }); + } catch (error) { + console.error('[Department Permissions Batch] Error:', error); + return NextResponse.json({ error: '批量更新失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/department-permissions/copy/route.ts b/app/api/admin/department-permissions/copy/route.ts new file mode 100644 index 0000000..9bd70db --- /dev/null +++ b/app/api/admin/department-permissions/copy/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { isSuperAdmin } from '@/lib/department-permission'; + +/** + * POST - 复制权限配置 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { sourceDepartment, targetDepartments } = await request.json(); + + if (!sourceDepartment || !targetDepartments || !Array.isArray(targetDepartments)) { + return NextResponse.json( + { error: '源部门和目标部门不能为空' }, + { status: 400 } + ); + } + + // 获取源部门的所有权限配置 + const sourcePermissions = await prisma.departmentMaterialPermission.findMany({ + where: { department: sourceDepartment }, + }); + + if (sourcePermissions.length === 0) { + return NextResponse.json( + { error: '源部门没有权限配置' }, + { status: 400 } + ); + } + + // 为每个目标部门复制权限 + for (const targetDept of targetDepartments) { + if (targetDept === sourceDepartment) { + continue; // 跳过自己 + } + + // 先删除目标部门的所有权限配置 + await prisma.departmentMaterialPermission.deleteMany({ + where: { department: targetDept }, + }); + + // 复制源部门的权限配置 + const newPermissions = sourcePermissions.map((perm) => ({ + department: targetDept, + materialType: perm.materialType, + canView: perm.canView, + canDownload: perm.canDownload, + canShare: perm.canShare, + linkedToDepartment: null, // 复制后不关联 + })); + + await prisma.departmentMaterialPermission.createMany({ + data: newPermissions, + }); + } + + return NextResponse.json({ + message: '权限复制成功', + copiedCount: targetDepartments.length, + }); + } catch (error) { + console.error('[Department Permissions Copy] Error:', error); + return NextResponse.json({ error: '复制权限失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/department-permissions/link/route.ts b/app/api/admin/department-permissions/link/route.ts new file mode 100644 index 0000000..414f39f --- /dev/null +++ b/app/api/admin/department-permissions/link/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getAllMaterialTypes, createDefaultPermissionsForDepartment, isSuperAdmin } from '@/lib/department-permission'; + +/** + * POST - 关联权限配置 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { targetDepartment, sourceDepartment } = await request.json(); + + if (!targetDepartment || !sourceDepartment) { + return NextResponse.json( + { error: '目标部门和源部门不能为空' }, + { status: 400 } + ); + } + + if (targetDepartment === sourceDepartment) { + return NextResponse.json( + { error: '不能关联到自己' }, + { status: 400 } + ); + } + + // 确保源部门有权限配置 + await createDefaultPermissionsForDepartment(sourceDepartment); + + // 确保目标部门有权限配置 + await createDefaultPermissionsForDepartment(targetDepartment); + + // 获取所有素材类型(从数据库动态获取) + const allMaterialTypes = await getAllMaterialTypes(); + + // 更新目标部门所有素材类型的关联 + for (const materialType of allMaterialTypes) { + await prisma.departmentMaterialPermission.upsert({ + where: { + department_materialType: { + department: targetDepartment, + materialType, + }, + }, + update: { + linkedToDepartment: sourceDepartment, + }, + create: { + department: targetDepartment, + materialType, + canView: false, + canDownload: false, + canShare: true, + linkedToDepartment: sourceDepartment, + }, + }); + } + + return NextResponse.json({ + message: '权限关联成功', + targetDepartment, + sourceDepartment, + }); + } catch (error) { + console.error('[Department Permissions Link] Error:', error); + return NextResponse.json({ error: '关联权限失败' }, { status: 500 }); + } +} + +/** + * DELETE - 取消关联权限配置 + */ +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const department = searchParams.get('department'); + + if (!department) { + return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); + } + + // 清除该部门所有权限配置的关联 + await prisma.departmentMaterialPermission.updateMany({ + where: { + department, + linkedToDepartment: { not: null }, + }, + data: { + linkedToDepartment: null, + }, + }); + + return NextResponse.json({ + message: '已取消关联', + department, + }); + } catch (error) { + console.error('[Department Permissions Unlink] Error:', error); + return NextResponse.json({ error: '取消关联失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/department-permissions/route.ts b/app/api/admin/department-permissions/route.ts new file mode 100644 index 0000000..ecbd607 --- /dev/null +++ b/app/api/admin/department-permissions/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { + getAllMaterialTypes, + getDepartmentPermissions, + createDefaultPermissionsForDepartment, + isSuperAdmin, +} from '@/lib/department-permission'; + +/** + * GET - 获取所有部门的权限配置 + */ +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const searchKeyword = searchParams.get('search') || ''; + + // 获取所有部门 + const departments = await prisma.department.findMany({ + orderBy: { name: 'asc' }, + }); + + // 过滤部门(如果有关键词) + const filteredDepartments = searchKeyword + ? departments.filter((dept) => dept.name.includes(searchKeyword)) + : departments; + + // 获取所有部门的权限配置 + const permissionsMap: Record> = {}; + const linkedDepartments: Record = {}; + + for (const dept of filteredDepartments) { + const permissions = await getDepartmentPermissions(dept.name); + permissionsMap[dept.name] = permissions; + + // 检查是否有关联部门 + const deptPermissions = await prisma.departmentMaterialPermission.findFirst({ + where: { + department: dept.name, + linkedToDepartment: { not: null }, + }, + }); + + if (deptPermissions?.linkedToDepartment) { + linkedDepartments[dept.name] = deptPermissions.linkedToDepartment; + } + } + + // 从数据库动态获取所有素材类型 + const materialTypes = await getAllMaterialTypes(); + + return NextResponse.json({ + departments: filteredDepartments.map((d) => d.name), + permissions: permissionsMap, + linkedDepartments, + materialTypes, + }); + } catch (error) { + console.error('[Department Permissions] GET Error:', error); + return NextResponse.json({ error: '获取权限配置失败' }, { status: 500 }); + } +} + +/** + * POST - 更新权限配置 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const body = await request.json(); + const { department, materialType, canView, canDownload, canShare } = body; + + if (!department || !materialType) { + return NextResponse.json({ error: '部门名称和素材类型不能为空' }, { status: 400 }); + } + + // 检查是否有关联部门(如果有关联,不允许直接修改) + const existing = await prisma.departmentMaterialPermission.findUnique({ + where: { + department_materialType: { + department, + materialType, + }, + }, + }); + + if (existing?.linkedToDepartment) { + return NextResponse.json( + { error: '该部门已关联到其他部门,请先取消关联' }, + { status: 400 } + ); + } + + // 更新或创建权限配置 + const permission = await prisma.departmentMaterialPermission.upsert({ + where: { + department_materialType: { + department, + materialType, + }, + }, + update: { + canView: canView ?? false, + canDownload: canDownload ?? false, + canShare: canShare ?? true, + linkedToDepartment: null, // 更新时清除关联 + }, + create: { + department, + materialType, + canView: canView ?? false, + canDownload: canDownload ?? false, + canShare: canShare ?? true, + linkedToDepartment: null, + }, + }); + + return NextResponse.json({ permission }); + } catch (error) { + console.error('[Department Permissions] POST Error:', error); + return NextResponse.json({ error: '更新权限配置失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/materials/batch-delete/route.ts b/app/api/admin/materials/batch-delete/route.ts new file mode 100644 index 0000000..03ea15b --- /dev/null +++ b/app/api/admin/materials/batch-delete/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// 批量删除素材(软删除或永久删除) +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session || (session.user as any)?.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }) + } + + const { ids, permanent } = await request.json() + + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 }) + } + + if (permanent) { + // 永久删除:从数据库中彻底删除 + await prisma.material.deleteMany({ + where: { + id: { in: ids } + } + }) + } else { + // 软删除:设置deletedAt字段 + await prisma.material.updateMany({ + where: { + id: { in: ids } + }, + data: { + deletedAt: new Date() + } + }) + } + + return NextResponse.json({ + success: true, + message: permanent ? '批量永久删除成功' : '批量删除成功' + }) + } catch (error) { + console.error('Batch delete materials error:', error) + return NextResponse.json({ error: '批量删除失败' }, { status: 500 }) + } +} diff --git a/app/api/admin/super-admins/route.ts b/app/api/admin/super-admins/route.ts new file mode 100644 index 0000000..3d2f900 --- /dev/null +++ b/app/api/admin/super-admins/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { isSuperAdmin, canAddSuperAdmin, getSuperAdminCount } from '@/lib/department-permission'; + +/** + * GET - 获取所有超级管理员列表 + */ +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const superAdmins = await prisma.user.findMany({ + where: { isSuperAdmin: true }, + select: { + id: true, + email: true, + username: true, + realName: true, + department: true, + role: true, + createdAt: true, + }, + orderBy: { createdAt: 'asc' }, + }); + + const count = await getSuperAdminCount(); + const canAdd = await canAddSuperAdmin(); + + return NextResponse.json({ + superAdmins, + count, + maxCount: 4, + canAdd, + }); + } catch (error) { + console.error('[Super Admins] GET Error:', error); + return NextResponse.json({ error: '获取超级管理员列表失败' }, { status: 500 }); + } +} + +/** + * POST - 添加超级管理员 + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { userId: targetUserId } = await request.json(); + + if (!targetUserId) { + return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 }); + } + + // 检查是否可以添加(最多4个) + if (!(await canAddSuperAdmin())) { + return NextResponse.json({ error: '超级管理员数量已达上限(最多4个)' }, { status: 400 }); + } + + // 检查目标用户是否存在 + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + }); + + if (!targetUser) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + } + + // 检查是否已经是超级管理员 + if (targetUser.isSuperAdmin) { + return NextResponse.json({ error: '该用户已经是超级管理员' }, { status: 400 }); + } + + // 检查是否是管理员角色 + if (targetUser.role !== 'ADMIN') { + return NextResponse.json({ error: '只有管理员才能被设置为超级管理员' }, { status: 400 }); + } + + // 设置为超级管理员 + const updatedUser = await prisma.user.update({ + where: { id: targetUserId }, + data: { isSuperAdmin: true }, + select: { + id: true, + email: true, + username: true, + realName: true, + department: true, + role: true, + isSuperAdmin: true, + createdAt: true, + }, + }); + + return NextResponse.json({ + message: '超级管理员添加成功', + user: updatedUser, + }); + } catch (error) { + console.error('[Super Admins] POST Error:', error); + return NextResponse.json({ error: '添加超级管理员失败' }, { status: 500 }); + } +} + +/** + * DELETE - 移除超级管理员 + */ +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = (session?.user as any)?.id; + + if (!session || !(await isSuperAdmin(userId))) { + return NextResponse.json({ error: '需要超级管理员权限' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const targetUserId = searchParams.get('userId'); + + if (!targetUserId) { + return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 }); + } + + // 不能移除自己 + if (targetUserId === userId) { + return NextResponse.json({ error: '不能移除自己的超级管理员权限' }, { status: 400 }); + } + + // 检查目标用户是否存在 + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + }); + + if (!targetUser) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + } + + // 检查是否是超级管理员 + if (!targetUser.isSuperAdmin) { + return NextResponse.json({ error: '该用户不是超级管理员' }, { status: 400 }); + } + + // 检查移除后是否至少还有1个超级管理员 + const count = await getSuperAdminCount(); + if (count <= 1) { + return NextResponse.json({ error: '至少需要保留1个超级管理员' }, { status: 400 }); + } + + // 移除超级管理员权限 + const updatedUser = await prisma.user.update({ + where: { id: targetUserId }, + data: { isSuperAdmin: false }, + select: { + id: true, + email: true, + username: true, + realName: true, + department: true, + role: true, + isSuperAdmin: true, + createdAt: true, + }, + }); + + return NextResponse.json({ + message: '超级管理员权限已移除', + user: updatedUser, + }); + } catch (error) { + console.error('[Super Admins] DELETE Error:', error); + return NextResponse.json({ error: '移除超级管理员失败' }, { status: 500 }); + } +} + diff --git a/app/api/admin/system-status/route.ts b/app/api/admin/system-status/route.ts new file mode 100644 index 0000000..096c6a6 --- /dev/null +++ b/app/api/admin/system-status/route.ts @@ -0,0 +1,241 @@ +/** + * 系统状态监控 API + * + * GET: 获取系统整体状态和统计数据 + * + * 参考:flnxi_sucai/app/api/admin/system-status/route.ts + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + // 检查管理员权限 + if (!session || (session.user as any).role !== 'ADMIN') { + return NextResponse.json( + { error: '无权限访问系统状态' }, + { status: 403 } + ) + } + + // 计算时间范围(30天前和7天前) + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + + // 并行查询基础数据 + let newUsersThisMonth: any, newMaterialsThisMonth: any, siteSettings: any, recentMaterials: any, totalUsers: number, totalMaterials: number, activeUsers: number, departmentStats: any[], roleStats: any[] + + try { + [newUsersThisMonth, newMaterialsThisMonth, siteSettings, recentMaterials, totalUsers, totalMaterials, activeUsers, departmentStats, roleStats] = await Promise.all([ + prisma.user.aggregate({ + _count: { id: true }, + where: { createdAt: { gte: thirtyDaysAgo } }, + }), + prisma.material.aggregate({ + _count: { id: true }, + where: { createdAt: { gte: thirtyDaysAgo }, deletedAt: null } as any, + }), + prisma.systemSettings.findFirst({ + select: { siteName: true }, + }), + prisma.material.findMany({ + take: 10, + orderBy: { createdAt: 'desc' }, + where: { deletedAt: null } as any, + select: { + id: true, + productModel: true, + createdAt: true, + user: { + select: { + username: true, + email: true, + }, + }, + }, + }), + prisma.user.count(), + prisma.material.count({ where: { deletedAt: null } as any }), + prisma.user.count({ where: { updatedAt: { gte: sevenDaysAgo } } }), + prisma.user.groupBy({ + by: ['department'], + _count: { id: true }, + }), + prisma.user.groupBy({ + by: ['role'], + _count: { id: true }, + }), + ]) + } catch (error: any) { + console.error('[SystemStatus] Database query error:', error) + // 降级处理 + newUsersThisMonth = { _count: { id: 0 } } + newMaterialsThisMonth = { _count: { id: 0 } } + siteSettings = null + recentMaterials = [] + totalUsers = 0 + totalMaterials = 0 + activeUsers = 0 + departmentStats = [] + roleStats = [] + } + + // 获取素材统计(兼容旧数据库) + let materialStats: any = {} + try { + // 尝试按素材类型分组统计 + const materialTypeStats = await prisma.material.groupBy({ + by: ['materialType'], + _count: { + id: true, + }, + where: { + deletedAt: null, // 只统计未删除的素材 + } as any, + }) + + const totalMaterials = materialTypeStats.reduce((sum, stat) => sum + stat._count.id, 0) + + materialStats = { + total: totalMaterials, + byType: materialTypeStats.reduce((acc, stat) => { + acc[stat.materialType] = stat._count.id + return acc + }, {} as Record), + } + + // 获取已删除素材数量 + try { + const deletedCount = await prisma.material.count({ + where: { + deletedAt: { not: null }, + } as any, + }) + materialStats.deleted = deletedCount + } catch { + // deletedAt 字段不存在,跳过 + materialStats.deleted = 0 + } + } catch (error: any) { + // 如果 deletedAt 字段不存在,降级统计 + if (error?.meta?.column?.includes('deletedAt')) { + const materialTypeStats = await prisma.material.groupBy({ + by: ['materialType'], + _count: { + id: true, + }, + }) + + const totalMaterials = materialTypeStats.reduce((sum, stat) => sum + stat._count.id, 0) + + materialStats = { + total: totalMaterials, + byType: materialTypeStats.reduce((acc, stat) => { + acc[stat.materialType] = stat._count.id + return acc + }, {} as Record), + deleted: 0, // 旧数据库没有软删除 + } + } else { + throw error + } + } + + // 获取下载统计(最近7天) + let downloadStats: any = {} + try { + const recentDownloads = await (prisma as any).materialDownloadLog.count({ + where: { + downloadedAt: { + gte: sevenDaysAgo, + }, + }, + }) + + const totalDownloads = await (prisma as any).materialDownloadLog.count() + + downloadStats = { + total: totalDownloads, + recent7Days: recentDownloads, + } + } catch (error: any) { + // 如果下载日志表不存在 + if (error?.meta?.table?.includes('material_download_logs')) { + downloadStats = { + total: 0, + recent7Days: 0, + } + } else { + throw error + } + } + + // 获取收藏统计 + let favoriteStats: any = {} + try { + const totalFavorites = await (prisma as any).materialFavorite.count() + favoriteStats = { + total: totalFavorites, + } + } catch (error: any) { + // 如果收藏表不存在 + if (error?.meta?.table?.includes('material_favorites')) { + favoriteStats = { + total: 0, + } + } else { + throw error + } + } + + // 获取存储统计(素材文件大小) + const storageStats = await prisma.material.aggregate({ + _sum: { + zipFileSize: true, + }, + }) + + const totalStorageBytes = Number(storageStats._sum.zipFileSize || 0) + const totalStorageGB = (totalStorageBytes / (1024 * 1024 * 1024)).toFixed(2) + + // 从roleStats计算管理员和普通用户数量 + const adminUsers = roleStats.find((r: any) => r.role === 'ADMIN')?._count?.id || 0 + const regularUsers = roleStats.find((r: any) => r.role === 'USER')?._count?.id || 0 + + // 系统信息 + const systemInfo = { + nodeVersion: process.version, + platform: process.platform, + uptime: process.uptime(), + memoryUsage: process.memoryUsage(), + timestamp: new Date().toISOString(), + } + + return NextResponse.json({ + users: { + total: totalUsers, + admin: adminUsers, + regular: regularUsers, + }, + materials: materialStats, + downloads: downloadStats, + favorites: favoriteStats, + storage: { + totalBytes: totalStorageBytes, + totalGB: parseFloat(totalStorageGB), + }, + system: systemInfo, + }) + } catch (error) { + console.error('[Admin] System status error:', error) + return NextResponse.json( + { error: '获取系统状态失败' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/admin/trash/route.ts b/app/api/admin/trash/route.ts new file mode 100644 index 0000000..8c3cb40 --- /dev/null +++ b/app/api/admin/trash/route.ts @@ -0,0 +1,93 @@ +/** + * 回收站 API - 获取已删除的素材 + * + * GET: 获取所有已删除的素材(仅管理员) + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session || (session.user as any).role !== 'ADMIN') { + return NextResponse.json( + { error: '无权限' }, + { status: 403 } + ) + } + + const { searchParams } = new URL(req.url) + const page = parseInt(searchParams.get('page') || '1') + const pageSize = parseInt(searchParams.get('pageSize') || '20') + const skip = (page - 1) * pageSize + + // 获取已删除的素材 + const [deletedMaterials, total] = await Promise.all([ + prisma.material.findMany({ + where: { + deletedAt: { not: null }, // 只返回已删除的素材 + }, + select: { + id: true, + userId: true, + productModel: true, + previewUrl: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + materialType: true, + renderer: true, + authorName: true, + createdAt: true, + deletedAt: true, + user: { + select: { + username: true, + }, + }, + }, + orderBy: { deletedAt: 'desc' }, + skip, + take: pageSize, + } as any), + prisma.material.count({ + where: { + deletedAt: { not: null }, + }, + } as any), + ]) + + // 应用 URL 重写 + const formatted = deletedMaterials.map((material: any) => + rewriteMaterialStorageUrlsForClient(material) + ) + + return NextResponse.json({ + materials: formatted, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }) + } catch (error: any) { + // 兼容旧数据库:如果 deletedAt 字段不存在 + if (error?.meta?.column?.includes('deletedAt')) { + return NextResponse.json( + { error: '回收站功能需要先运行数据库迁移' }, + { status: 400 } + ) + } + + console.error('[Admin Trash] GET error:', error) + return NextResponse.json( + { error: '获取回收站列表失败' }, + { status: 500 } + ) + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index b2caf47..4fe0ba1 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,20 +1,16 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// Get all users (admin only) +// GET /api/admin/users - 获取用户列表 export async function GET() { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json( - { error: '无权限' }, - { status: 403 } - ) + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }); } - + const users = await prisma.user.findMany({ select: { id: true, @@ -24,69 +20,57 @@ export async function GET() { isActive: true, canDownload: true, department: true, + managedDepartment: true, // 管理员负责的部门 createdAt: true, _count: { select: { - materials: true, + materials: { + where: { + deletedAt: null, + } as any, + }, }, }, }, orderBy: { createdAt: 'desc', }, - }) - - return NextResponse.json(users) + }); + + return NextResponse.json(users); } catch (error) { - console.error('Error fetching users:', error) - return NextResponse.json( - { error: '获取用户列表失败' }, - { status: 500 } - ) + console.error('Error fetching users:', error); + return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 }); } } -// Delete user (admin only) -export async function DELETE(req: NextRequest) { +// DELETE /api/admin/users - 删除用户 +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json( - { error: '无权限' }, - { status: 403 } - ) + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }); } - - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') - + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { - return NextResponse.json( - { error: '缺少用户ID' }, - { status: 400 } - ) + return NextResponse.json({ error: '缺少用户ID' }, { status: 400 }); } - - // Don't allow deleting self + + // 不能删除自己 if (id === (session.user as any).id) { - return NextResponse.json( - { error: '不能删除自己' }, - { status: 400 } - ) + return NextResponse.json({ error: '不能删除自己' }, { status: 400 }); } - + await prisma.user.delete({ where: { id }, - }) - - return NextResponse.json({ message: '删除成功' }) + }); + + return NextResponse.json({ message: '删除成功' }); } catch (error) { - console.error('Error deleting user:', error) - return NextResponse.json( - { error: '删除失败' }, - { status: 500 } - ) + console.error('Error deleting user:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); } } - diff --git a/app/api/admin/users/set-department-manager/route.ts b/app/api/admin/users/set-department-manager/route.ts index 40d3612..42fbd09 100644 --- a/app/api/admin/users/set-department-manager/route.ts +++ b/app/api/admin/users/set-department-manager/route.ts @@ -1,86 +1,111 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -export async function POST(req: NextRequest) { +/** + * 设置部门管理员 + * POST /api/admin/users/set-department-manager + */ +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + // 验证管理员权限 + const session = await getServerSession(authOptions); if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ); } - const { userId, departmentName } = await req.json() + const { userId, departmentName } = await request.json(); if (!userId || !departmentName) { - return NextResponse.json({ error: '用户ID和部门名称不能为空' }, { status: 400 }) + return NextResponse.json( + { error: '用户ID和部门名称不能为空' }, + { status: 400 } + ); } - // 检查该部门是否已经有管理员 + // 检查该部门是否已有管理员 const existingManager = await prisma.user.findFirst({ where: { managedDepartment: departmentName, NOT: { id: userId } } - }) + }); if (existingManager) { - return NextResponse.json({ - error: `部门"${departmentName}"已有管理员:${existingManager.username}` - }, { status: 400 }) + return NextResponse.json( + { error: `部门"${departmentName}"已有管理员:${existingManager.username}` }, + { status: 400 } + ); } - // 更新用户为部门管理员 + // 设置部门管理员(同时提升为管理员角色) const user = await prisma.user.update({ where: { id: userId }, - data: { + data: { managedDepartment: departmentName, - role: 'ADMIN' // 设置为管理员角色 + role: 'ADMIN' } - }) + }); - return NextResponse.json({ + return NextResponse.json({ message: '部门管理员设置成功', - user - }) + user + }); } catch (error) { - console.error('Set department manager error:', error) - return NextResponse.json({ error: '设置失败' }, { status: 500 }) + console.error('Set department manager error:', error); + return NextResponse.json( + { error: '设置失败' }, + { status: 500 } + ); } } -// 取消部门管理员 -export async function DELETE(req: NextRequest) { +/** + * 取消部门管理员 + * DELETE /api/admin/users/set-department-manager?userId=xxx + */ +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + // 验证管理员权限 + const session = await getServerSession(authOptions); if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ); } - const { searchParams } = new URL(req.url) - const userId = searchParams.get('userId') + const { searchParams } = new URL(request.url); + const userId = searchParams.get('userId'); if (!userId) { - return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 }) + return NextResponse.json( + { error: '用户ID不能为空' }, + { status: 400 } + ); } // 取消部门管理员 const user = await prisma.user.update({ where: { id: userId }, - data: { + data: { managedDepartment: null } - }) + }); - return NextResponse.json({ + return NextResponse.json({ message: '已取消部门管理员', - user - }) + user + }); } catch (error) { - console.error('Remove department manager error:', error) - return NextResponse.json({ error: '取消失败' }, { status: 500 }) + console.error('Remove department manager error:', error); + return NextResponse.json( + { error: '取消失败' }, + { status: 500 } + ); } } - diff --git a/app/api/admin/users/set-role/route.ts b/app/api/admin/users/set-role/route.ts index d57587f..2933517 100644 --- a/app/api/admin/users/set-role/route.ts +++ b/app/api/admin/users/set-role/route.ts @@ -1,62 +1,61 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +// 设置用户角色API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -export async function POST(req: NextRequest) { +// POST - 设置用户角色(仅管理员) +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - // 检查是否为管理员 - if (!session || (session.user as any)?.role !== 'ADMIN') { + const session = await getServerSession(authOptions); + if (!session || session.user?.role !== 'ADMIN') { return NextResponse.json( { error: '权限不足' }, { status: 403 } - ) + ); } - const { userId, role } = await req.json() + const { userId, role } = await request.json(); if (!userId || !role) { return NextResponse.json( { error: '参数错误' }, { status: 400 } - ) + ); } - // 验证角色值 if (role !== 'USER' && role !== 'ADMIN') { return NextResponse.json( { error: '角色值无效' }, { status: 400 } - ) + ); } - // 检查目标用户是否存在 - const targetUser = await prisma.user.findUnique({ + // 检查用户是否存在 + const user = await prisma.user.findUnique({ where: { id: userId }, - }) + }); - if (!targetUser) { + if (!user) { return NextResponse.json( { error: '用户不存在' }, { status: 404 } - ) + ); } // 不能修改自己的角色 - if (targetUser.email === session.user?.email) { + if (user.email === session.user?.email) { return NextResponse.json( { error: '不能修改自己的角色' }, { status: 400 } - ) + ); } - // 更新用户角色 + // 更新角色 const updatedUser = await prisma.user.update({ where: { id: userId }, data: { role }, - }) + }); return NextResponse.json({ success: true, @@ -66,13 +65,12 @@ export async function POST(req: NextRequest) { username: updatedUser.username, role: updatedUser.role, }, - }) + }); } catch (error) { - console.error('Error updating user role:', error) + console.error('Error updating user role:', error); return NextResponse.json( { error: '设置角色失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/admin/users/toggle-active/route.ts b/app/api/admin/users/toggle-active/route.ts index 713f2b5..a194542 100644 --- a/app/api/admin/users/toggle-active/route.ts +++ b/app/api/admin/users/toggle-active/route.ts @@ -1,52 +1,51 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +// 切换用户激活状态API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// 激活/禁用用户 -export async function POST(req: NextRequest) { +// POST - 切换用户激活状态(仅管理员) +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { return NextResponse.json( { error: '无权限' }, { status: 403 } - ) + ); } - const { userId, isActive } = await req.json() + const { userId, isActive } = await request.json(); if (!userId || typeof isActive !== 'boolean') { return NextResponse.json( { error: '参数错误' }, { status: 400 } - ) + ); } // 不能禁用自己 - if (userId === (session.user as any).id) { + if (userId === session.user.id) { return NextResponse.json( { error: '不能禁用自己' }, { status: 400 } - ) + ); } - // 更新用户激活状态 + // 更新激活状态 await prisma.user.update({ where: { id: userId }, data: { isActive }, - }) + }); return NextResponse.json({ message: isActive ? '用户已激活' : '用户已禁用', - }) + }); } catch (error) { - console.error('Toggle active error:', error) + console.error('Toggle active error:', error); return NextResponse.json( { error: '操作失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/admin/users/toggle-download/route.ts b/app/api/admin/users/toggle-download/route.ts index d8ff127..e2f5752 100644 --- a/app/api/admin/users/toggle-download/route.ts +++ b/app/api/admin/users/toggle-download/route.ts @@ -1,74 +1,72 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +// 切换用户下载权限API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// Toggle user download permission (admin only) -export async function POST(req: NextRequest) { +// POST - 切换用户下载权限(仅管理员) +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - // Check if user is admin - if (!session || (session.user as any).role !== 'ADMIN') { + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { return NextResponse.json( { error: '无权限' }, { status: 403 } - ) + ); } - const { userId } = await req.json() + const { userId } = await request.json(); if (!userId) { return NextResponse.json( { error: '缺少用户ID' }, { status: 400 } - ) + ); } - // Prevent admin from disabling their own download permission - if (userId === (session.user as any).id) { + // 不能修改自己的下载权限 + if (userId === session.user.id) { return NextResponse.json( { error: '不能修改自己的下载权限' }, { status: 400 } - ) + ); } - // Get current user + // 检查用户是否存在 const user = await prisma.user.findUnique({ where: { id: userId }, - }) + }); if (!user) { return NextResponse.json( { error: '用户不存在' }, { status: 404 } - ) + ); } - // Prevent disabling download for other admins + // 不能修改管理员的下载权限 if (user.role === 'ADMIN') { return NextResponse.json( { error: '不能修改管理员的下载权限' }, { status: 400 } - ) + ); } - // Toggle download permission + // 切换下载权限 const updatedUser = await prisma.user.update({ where: { id: userId }, data: { canDownload: !user.canDownload }, - }) + }); return NextResponse.json({ message: updatedUser.canDownload ? '已开启下载权限' : '已关闭下载权限', canDownload: updatedUser.canDownload, - }) + }); } catch (error) { - console.error('Error toggling download permission:', error) + console.error('Error toggling download permission:', error); return NextResponse.json( { error: '操作失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/admin/users/update-department/route.ts b/app/api/admin/users/update-department/route.ts index ae5958f..b5ca6a4 100644 --- a/app/api/admin/users/update-department/route.ts +++ b/app/api/admin/users/update-department/route.ts @@ -1,49 +1,54 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// 更新用户部门 -export async function POST(req: NextRequest) { +/** + * 更新用户部门 + * POST /api/admin/users/update-department + */ +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any)?.role !== 'ADMIN') { + // 验证管理员权限 + const session = await getServerSession(authOptions); + if (!session || session.user?.role !== 'ADMIN') { return NextResponse.json( { error: '权限不足' }, { status: 403 } - ) + ); } - const { userId, department } = await req.json() + const { userId, department } = await request.json(); - console.log('[Update Department] Request:', { userId, department }) + console.log('[Update Department] Request:', { userId, department }); if (!userId) { return NextResponse.json( { error: '缺少用户ID' }, { status: 400 } - ) + ); } + // 更新用户部门 const user = await prisma.user.update({ where: { id: userId }, - data: { department: department || '' }, - }) + data: { + department: department || '' + } + }); - console.log('[Update Department] Success:', { - userId: user.id, + console.log('[Update Department] Success:', { + userId: user.id, username: user.username, - department: user.department - }) + department: user.department + }); - return NextResponse.json(user) + return NextResponse.json(user); } catch (error) { - console.error('[Update Department] Error:', error) + console.error('[Update Department] Error:', error); return NextResponse.json( { error: '更新部门失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/admin/video-transcode-stats/route.ts b/app/api/admin/video-transcode-stats/route.ts new file mode 100644 index 0000000..e1bf136 --- /dev/null +++ b/app/api/admin/video-transcode-stats/route.ts @@ -0,0 +1,188 @@ +/** + * 视频转码统计 API + * + * GET: 获取视频转码队列状况、失败任务等统计数据 + * + * 参考:flnxi_sucai/app/api/admin/video-transcode-stats/route.ts + * 适配当前系统使用 materialType 字段 + */ + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + + // 检查管理员权限 + if (!session || (session.user as any).role !== 'ADMIN') { + return NextResponse.json( + { error: '无权限' }, + { status: 403 } + ) + } + + // 识别视频素材类型 + const videoMaterialTypes = ['主图视频', '安装视频', '种草视频', '视频'] + + // 获取视频素材统计(兼容旧数据库) + let videoStats: any = {} + try { + // 查询所有视频素材 + const videoMaterials = await prisma.material.findMany({ + where: { + AND: [ + { + materialType: { + in: videoMaterialTypes, + }, + }, + { + deletedAt: null, // 只统计未删除的素材 + } as any, + ], + } as any, + select: { + id: true, + materialType: true, + videoProcessingStatus: true, + transcodeLastError: true, + transcodeAttempts: true, + createdAt: true, + } as any, // 使用 as any 兼容旧数据库 + }) + + // 按转码状态分组统计 + const statusCounts = { + pending: 0, // 待转码 + processing: 0, // 转码中 + completed: 0, // 转码完成 + failed: 0, // 转码失败 + unknown: 0, // 状态未知 + } + + const failedTasks: any[] = [] + const recentTasks: any[] = [] + + videoMaterials.forEach((material: any) => { + const status = material.videoProcessingStatus || 'unknown' + + switch (status) { + case 'pending': + case 'queued': + statusCounts.pending++ + break + case 'processing': + case 'transcoding': + statusCounts.processing++ + break + case 'completed': + case 'success': + statusCounts.completed++ + break + case 'failed': + case 'error': + statusCounts.failed++ + failedTasks.push({ + id: material.id, + materialType: material.materialType, + error: material.transcodeLastError, + attempts: material.transcodeAttempts || 0, + createdAt: material.createdAt, + }) + break + default: + statusCounts.unknown++ + } + + // 收集最近的任务(最近24小时) + const oneDayAgo = new Date() + oneDayAgo.setDate(oneDayAgo.getDate() - 1) + + if (material.createdAt && new Date(material.createdAt) > oneDayAgo) { + recentTasks.push({ + id: material.id, + materialType: material.materialType, + status: material.videoProcessingStatus || 'unknown', + createdAt: material.createdAt, + }) + } + }) + + videoStats = { + total: videoMaterials.length, + byStatus: statusCounts, + byType: videoMaterialTypes.reduce((acc, type) => { + acc[type] = videoMaterials.filter((m: any) => m.materialType === type).length + return acc + }, {} as Record), + failedTasks: failedTasks.slice(0, 10), // 最多返回10个失败任务 + recentTasks: recentTasks.slice(0, 20), // 最多返回20个最近任务 + } + } catch (error: any) { + // 兼容旧数据库:如果视频相关字段不存在 + if (error?.meta?.column?.includes('videoProcessingStatus') || + error?.meta?.column?.includes('deletedAt')) { + console.warn('[Admin] Video fields not found, using fallback query') + + // 降级查询:只统计视频素材数量 + const videoMaterials = await prisma.material.findMany({ + where: { + materialType: { + in: videoMaterialTypes, + }, + }, + select: { + id: true, + materialType: true, + createdAt: true, + }, + }) + + videoStats = { + total: videoMaterials.length, + byStatus: { + pending: 0, + processing: 0, + completed: videoMaterials.length, // 假设都已完成 + failed: 0, + unknown: 0, + }, + byType: videoMaterialTypes.reduce((acc, type) => { + acc[type] = videoMaterials.filter(m => m.materialType === type).length + return acc + }, {} as Record), + failedTasks: [], + recentTasks: videoMaterials.slice(0, 20).map(m => ({ + id: m.id, + materialType: m.materialType, + status: 'completed', + createdAt: m.createdAt, + })), + } + } else { + throw error + } + } + + // 系统资源使用情况 + const systemResources = { + memory: process.memoryUsage(), + uptime: process.uptime(), + timestamp: new Date().toISOString(), + } + + return NextResponse.json({ + video: videoStats, + system: systemResources, + }) + } catch (error) { + console.error('[Admin] Video transcode stats error:', error) + return NextResponse.json( + { error: '获取转码统计失败' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/ai-chat/route.ts b/app/api/ai-chat/route.ts index 78c275f..43c4e8c 100644 --- a/app/api/ai-chat/route.ts +++ b/app/api/ai-chat/route.ts @@ -1,64 +1,71 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' +// AI聊天API - 使用Gemini API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; -export async function POST(req: NextRequest) { +// POST - 发送消息到AI +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); if (!session) { return NextResponse.json( { error: '未授权' }, { status: 401 } - ) + ); } - const { messages, model } = await req.json() + const { messages, model } = await request.json(); - const apiKey = process.env.GEMINI_API_KEY - const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta' - const modelName = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp' + const apiKey = process.env.GEMINI_API_KEY; + const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'; + const modelName = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'; if (!apiKey) { return NextResponse.json( { error: 'Gemini API未配置' }, { status: 500 } - ) + ); } - // 构建Gemini API请求 + // 转换消息格式为Gemini格式 const geminiMessages = messages.map((msg: any) => { + // 文本消息 if (typeof msg.content === 'string') { return { role: msg.role === 'assistant' ? 'model' : 'user', - parts: [{ text: msg.content }] - } - } else if (Array.isArray(msg.content)) { + parts: [{ text: msg.content }], + }; + } + + // 多模态消息(文本+图片) + if (Array.isArray(msg.content)) { const parts = msg.content.map((part: any) => { if (part.type === 'text') { - return { text: part.text } - } else if (part.type === 'image_url') { - // 提取base64图片数据 - const base64Match = part.image_url.url.match(/data:image\/([^;]+);base64,(.+)/) - if (base64Match) { + return { text: part.text }; + } + if (part.type === 'image_url') { + // 解析base64图片 + const match = part.image_url.url.match(/data:image\/([^;]+);base64,(.+)/); + if (match) { return { inlineData: { - mimeType: `image/${base64Match[1]}`, - data: base64Match[2] - } - } + mimeType: `image/${match[1]}`, + data: match[2], + }, + }; } } - return null - }).filter(Boolean) - + return null; + }).filter(Boolean); + return { role: msg.role === 'assistant' ? 'model' : 'user', - parts - } + parts, + }; } - return null - }).filter(Boolean) + + return null; + }).filter(Boolean); // 调用Gemini API const response = await fetch( @@ -78,39 +85,37 @@ export async function POST(req: NextRequest) { }, }), } - ) + ); if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - console.error('Gemini API error:', errorData) + const errorData = await response.json().catch(() => ({})); + console.error('Gemini API error:', errorData); return NextResponse.json( { error: errorData.error?.message || '调用Gemini API失败' }, { status: response.status } - ) + ); } - const data = await response.json() - return NextResponse.json(data) - + const data = await response.json(); + return NextResponse.json(data); } catch (error) { - console.error('AI chat error:', error) + console.error('AI chat error:', error); return NextResponse.json( { error: error instanceof Error ? error.message : '处理失败' }, { status: 500 } - ) + ); } } -// GET endpoint to check API configuration +// GET - 获取AI配置状态 export async function GET() { - const apiKey = process.env.GEMINI_API_KEY - const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta' - const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp' - + const apiKey = process.env.GEMINI_API_KEY; + const apiBaseUrl = process.env.GEMINI_API_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'; + const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp'; + return NextResponse.json({ configured: !!apiKey, apiBaseUrl, - model - }) + model, + }); } - diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 996c658..22953bc 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,7 @@ -import NextAuth from 'next-auth' -import { authOptions } from '@/lib/auth' +import NextAuth from 'next-auth'; +import { authOptions } from '@/lib/auth'; -const handler = NextAuth(authOptions) - -export { handler as GET, handler as POST } +// NextAuth 路由处理器 +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 7003f73..44f2549 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,37 +1,31 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import bcrypt from 'bcryptjs' +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import bcrypt from 'bcryptjs'; -export async function POST(req: NextRequest) { +// POST /api/auth/register - 用户注册 +export async function POST(request: NextRequest) { try { - const { email, username, department, password } = await req.json() - - // Validation + const { email, username, department, password } = await request.json(); + if (!email || !username || !password) { - return NextResponse.json( - { error: '所有字段都是必填的' }, - { status: 400 } - ) + return NextResponse.json({ error: '所有字段都是必填的' }, { status: 400 }); } - - // Check if user exists + + // 检查用户是否已存在 const existingUser = await prisma.user.findFirst({ where: { OR: [{ email }, { username }], }, - }) - + }); + if (existingUser) { - return NextResponse.json( - { error: '邮箱或用户名已存在' }, - { status: 400 } - ) + return NextResponse.json({ error: '邮箱或用户名已存在' }, { status: 400 }); } - - // Hash password - const hashedPassword = await bcrypt.hash(password, 10) - - // Create user + + // 加密密码 + const hashedPassword = await bcrypt.hash(password, 10); + + // 创建用户 const user = await prisma.user.create({ data: { email, @@ -47,18 +41,14 @@ export async function POST(req: NextRequest) { role: true, createdAt: true, }, - }) - + }); + return NextResponse.json( { message: '注册成功', user }, { status: 201 } - ) + ); } catch (error) { - console.error('Registration error:', error) - return NextResponse.json( - { error: '注册失败,请稍后再试' }, - { status: 500 } - ) + console.error('Registration error:', error); + return NextResponse.json({ error: '注册失败,请稍后再试' }, { status: 500 }); } } - diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index 0c4167b..e5d34b9 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -1,196 +1,178 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// 获取所有使用店铺 +// GET - 获取所有使用店铺分类 export async function GET() { try { const categories = await prisma.category.findMany({ - orderBy: { - name: 'asc', - }, - }) + orderBy: { name: 'asc' }, + }); - // 获取每个店铺下的素材数量 + // 统计每个分类的素材数量(只统计未删除的素材) const categoriesWithCount = await Promise.all( categories.map(async (category) => { - const count = await prisma.material.count({ - where: { - categoryName: category.name, - }, - }) + let count: number; + try { + count = await prisma.material.count({ + where: { + categoryName: category.name, + deletedAt: null, + } as any, + }); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + count = await prisma.material.count({ + where: { categoryName: category.name }, + }); + } else { + throw e; + } + } return { ...category, count, - } + }; }) - ) + ); - return NextResponse.json(categoriesWithCount) + return NextResponse.json(categoriesWithCount); } catch (error) { - console.error('Error fetching categories:', error) + console.error('Error fetching categories:', error); return NextResponse.json( { error: '获取店铺列表失败' }, { status: 500 } - ) + ); } } -// 添加新的使用店铺 -export async function POST(req: NextRequest) { +// POST - 创建新的使用店铺分类 +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); if (!session) { - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ) + return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - const { name } = await req.json() + const { name } = await request.json(); if (!name || !name.trim()) { return NextResponse.json( { error: '店铺名称不能为空' }, { status: 400 } - ) + ); } - // 检查店铺是否已存在 + // 检查是否已存在 const existing = await prisma.category.findUnique({ where: { name: name.trim() }, - }) + }); if (existing) { return NextResponse.json( { error: '该店铺已存在' }, { status: 400 } - ) + ); } - // 创建新店铺 const category = await prisma.category.create({ - data: { - name: name.trim(), - }, - }) + data: { name: name.trim() }, + }); - return NextResponse.json(category, { status: 201 }) + return NextResponse.json(category, { status: 201 }); } catch (error) { - console.error('Error creating category:', error) + console.error('Error creating category:', error); return NextResponse.json( { error: '创建店铺失败' }, { status: 500 } - ) + ); } } -// 更新店铺名称 -export async function PATCH(req: NextRequest) { +// PATCH - 更新使用店铺分类 +export async function PATCH(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json( - { error: '权限不足' }, - { status: 403 } - ) + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); } - const { id, name } = await req.json() + const { id, name } = await request.json(); if (!id || !name || !name.trim()) { - return NextResponse.json( - { error: '参数错误' }, - { status: 400 } - ) + return NextResponse.json({ error: '参数错误' }, { status: 400 }); } - // 检查新名称是否已存在(排除当前店铺) + // 检查新名称是否已被其他分类使用 const existing = await prisma.category.findFirst({ where: { name: name.trim(), NOT: { id }, }, - }) + }); if (existing) { return NextResponse.json( { error: '该店铺名称已存在' }, { status: 400 } - ) + ); } // 获取旧名称 const oldCategory = await prisma.category.findUnique({ where: { id }, - }) + }); if (!oldCategory) { - return NextResponse.json( - { error: '分类不存在' }, - { status: 404 } - ) + return NextResponse.json({ error: '分类不存在' }, { status: 404 }); } - // 更新店铺名称 - const category = await prisma.category.update({ + // 更新分类 + const updated = await prisma.category.update({ where: { id }, data: { name: name.trim() }, - }) + }); - // 同步更新所有使用该分类的素材 + // 更新所有使用该分类的素材 await prisma.material.updateMany({ where: { categoryName: oldCategory.name }, data: { categoryName: name.trim() }, - }) + }); - return NextResponse.json(category) + return NextResponse.json(updated); } catch (error) { - console.error('Error updating category:', error) - return NextResponse.json( - { error: '更新失败' }, - { status: 500 } - ) + console.error('Error updating category:', error); + return NextResponse.json({ error: '更新失败' }, { status: 500 }); } } -// 删除店铺 -export async function DELETE(req: NextRequest) { +// DELETE - 删除使用店铺分类 +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json( - { error: '权限不足' }, - { status: 403 } - ) + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); } - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); if (!id) { - return NextResponse.json( - { error: '缺少ID参数' }, - { status: 400 } - ) + return NextResponse.json({ error: '缺少ID参数' }, { status: 400 }); } - // 删除店铺 await prisma.category.delete({ where: { id }, - }) + }); - return NextResponse.json({ success: true }) + return NextResponse.json({ success: true }); } catch (error) { - console.error('Error deleting category:', error) - return NextResponse.json( - { error: '删除失败' }, - { status: 500 } - ) + console.error('Error deleting category:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); } } - diff --git a/app/api/danmaku/route.ts b/app/api/danmaku/route.ts index fd80938..81e0006 100644 --- a/app/api/danmaku/route.ts +++ b/app/api/danmaku/route.ts @@ -1,55 +1,56 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +// 弹幕API - 支持SSE实时推送和普通查询 +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// GET - 获取弹幕列表(支持SSE) +// GET - 获取弹幕列表(支持SSE实时推送) export async function GET(request: NextRequest) { try { - const searchParams = request.nextUrl.searchParams - const isSSE = searchParams.get('sse') === 'true' + const { searchParams } = new URL(request.url); + const isSSE = searchParams.get('sse') === 'true'; + // SSE模式 - 实时推送弹幕 if (isSSE) { - // Server-Sent Events 实时推送 - const encoder = new TextEncoder() + const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { - // 发送初始弹幕 - const initialDanmakus = await prisma.danmaku.findMany({ + // 首次发送最近50条弹幕 + const recentDanmaku = await prisma.danmaku.findMany({ orderBy: { createdAt: 'desc' }, - take: 50 - }) + take: 50, + }); controller.enqueue( - encoder.encode(`data: ${JSON.stringify(initialDanmakus)}\n\n`) - ) + encoder.encode(`data: ${JSON.stringify(recentDanmaku)}\n\n`) + ); - // 轮询新弹幕 + // 每秒检查新弹幕 const interval = setInterval(async () => { try { - const latestDanmaku = await prisma.danmaku.findMany({ + const newDanmaku = await prisma.danmaku.findMany({ orderBy: { createdAt: 'desc' }, - take: 1 - }) - - if (latestDanmaku.length > 0) { + take: 1, + }); + + if (newDanmaku.length > 0) { controller.enqueue( - encoder.encode(`data: ${JSON.stringify(latestDanmaku[0])}\n\n`) - ) + encoder.encode(`data: ${JSON.stringify(newDanmaku[0])}\n\n`) + ); } } catch (error) { - console.error('SSE error:', error) + console.error('SSE error:', error); } - }, 1000) + }, 1000); - // 清理 + // 客户端断开连接时清理 request.signal.addEventListener('abort', () => { - clearInterval(interval) - controller.close() - }) - } - }) + clearInterval(interval); + controller.close(); + }); + }, + }); return new NextResponse(stream, { headers: { @@ -57,77 +58,77 @@ export async function GET(request: NextRequest) { 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, - }) + }); } - // 普通GET请求 - const limit = parseInt(searchParams.get('limit') || '50') - const danmakus = await prisma.danmaku.findMany({ + // 普通模式 - 返回弹幕列表 + const limit = parseInt(searchParams.get('limit') || '50'); + const danmaku = await prisma.danmaku.findMany({ orderBy: { createdAt: 'desc' }, - take: limit - }) + take: limit, + }); - return NextResponse.json(danmakus) + return NextResponse.json(danmaku); } catch (error) { - console.error('Get danmaku error:', error) + console.error('Get danmaku error:', error); return NextResponse.json( { error: '获取弹幕失败' }, { status: 500 } - ) + ); } } // POST - 发送弹幕 export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); if (!session) { return NextResponse.json( { error: '请先登录' }, { status: 401 } - ) + ); } - const { content } = await request.json() + const { content } = await request.json(); + // 验证弹幕内容 if (!content || content.trim().length === 0) { return NextResponse.json( { error: '弹幕内容不能为空' }, { status: 400 } - ) + ); } if (content.length > 100) { return NextResponse.json( { error: '弹幕内容不能超过100字' }, { status: 400 } - ) + ); } - // 生成随机匿名用户名 - const randomNames = [ - '神秘访客', '路过的设计师', '热心网友', '匿名用户', + // 随机匿名昵称 + const anonymousNames = [ + '神秘访客', '路过的设计师', '热心网友', '匿名用户', '悄悄路过', '神秘嘉宾', '隐身大佬', '低调看客', '默默围观', '潜水员', '匿名网友', '神秘人士' - ] - const anonymousName = randomNames[Math.floor(Math.random() * randomNames.length)] - + ]; + const randomName = anonymousNames[Math.floor(Math.random() * anonymousNames.length)]; + + // 创建弹幕 const danmaku = await prisma.danmaku.create({ data: { content: content.trim(), - username: anonymousName, // 使用匿名名称 - userId: (session.user as any).id - } - }) + username: randomName, + userId: session.user.id, + }, + }); - return NextResponse.json(danmaku) + return NextResponse.json(danmaku); } catch (error) { - console.error('Send danmaku error:', error) + console.error('Send danmaku error:', error); return NextResponse.json( { error: '发送弹幕失败' }, { status: 500 } - ) + ); } } - diff --git a/app/api/departments/route.ts b/app/api/departments/route.ts index e348e1e..c7bf053 100644 --- a/app/api/departments/route.ts +++ b/app/api/departments/route.ts @@ -1,107 +1,87 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { createDefaultPermissionsForDepartment } from '@/lib/department-permission'; -// Get all departments +// GET /api/departments - 获取部门列表 export async function GET() { try { const departments = await prisma.department.findMany({ - orderBy: { - createdAt: 'asc', - }, - }) - - return NextResponse.json(departments) + orderBy: { createdAt: 'asc' }, + }); + + return NextResponse.json(departments); } catch (error) { - console.error('Error fetching departments:', error) - return NextResponse.json( - { error: '获取部门列表失败' }, - { status: 500 } - ) + console.error('Error fetching departments:', error); + return NextResponse.json({ error: '获取部门列表失败' }, { status: 500 }); } } -// Create a new department (admin only) -export async function POST(req: NextRequest) { +// POST /api/departments - 创建部门 +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json( - { error: '无权限' }, - { status: 403 } - ) + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }); } - - const { name } = await req.json() - + + const { name } = await request.json(); + if (!name || name.trim() === '') { - return NextResponse.json( - { error: '部门名称不能为空' }, - { status: 400 } - ) + return NextResponse.json({ error: '部门名称不能为空' }, { status: 400 }); } - - // Check if department already exists + + // 检查部门是否已存在 const existing = await prisma.department.findUnique({ where: { name }, - }) - + }); + if (existing) { - return NextResponse.json( - { error: '部门已存在' }, - { status: 400 } - ) + return NextResponse.json({ error: '部门已存在' }, { status: 400 }); } - + const department = await prisma.department.create({ data: { name }, - }) - - return NextResponse.json(department, { status: 201 }) + }); + + // 自动创建默认权限配置 + try { + await createDefaultPermissionsForDepartment(name); + } catch (error) { + console.error('Error creating default permissions for department:', error); + // 不阻止部门创建,只记录错误 + } + + return NextResponse.json(department, { status: 201 }); } catch (error) { - console.error('Error creating department:', error) - return NextResponse.json( - { error: '创建部门失败' }, - { status: 500 } - ) + console.error('Error creating department:', error); + return NextResponse.json({ error: '创建部门失败' }, { status: 500 }); } } -// Delete a department (admin only) -export async function DELETE(req: NextRequest) { +// DELETE /api/departments - 删除部门 +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json( - { error: '无权限' }, - { status: 403 } - ) + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }); } - - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') - + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { - return NextResponse.json( - { error: '缺少部门ID' }, - { status: 400 } - ) + return NextResponse.json({ error: '缺少部门ID' }, { status: 400 }); } - + await prisma.department.delete({ where: { id }, - }) - - return NextResponse.json({ message: '删除成功' }) + }); + + return NextResponse.json({ message: '删除成功' }); } catch (error) { - console.error('Error deleting department:', error) - return NextResponse.json( - { error: '删除失败' }, - { status: 500 } - ) + console.error('Error deleting department:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); } } - diff --git a/app/api/health/route.ts b/app/api/health/route.ts index bc61870..4f5ac0b 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,14 +1,15 @@ -import { NextResponse } from 'next/server' +// 健康检查API +import { NextResponse } from 'next/server'; -// Health check endpoint - 用于网络检测 +// GET - 健康检查 export async function GET() { - return NextResponse.json({ + return NextResponse.json({ status: 'ok', - timestamp: new Date().toISOString() - }) + timestamp: new Date().toISOString(), + }); } +// HEAD - 健康检查(轻量级) export async function HEAD() { - return new NextResponse(null, { status: 200 }) + return new NextResponse(null, { status: 200 }); } - diff --git a/app/api/materials/[id]/retry-transcode/route.ts b/app/api/materials/[id]/retry-transcode/route.ts new file mode 100644 index 0000000..468a7da --- /dev/null +++ b/app/api/materials/[id]/retry-transcode/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// 重试转码API +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const materialId = params.id + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + userId: true, + videoProcessingStatus: true, + videoSourceUrl: true + } + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + // 检查权限:只有素材上传者或管理员可以重试转码 + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }) + + if (material.userId !== session.user.id && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限重试转码' }, { status: 403 }) + } + + // 检查是否有视频源文件 + if (!material.videoSourceUrl) { + return NextResponse.json({ error: '该素材没有视频源文件' }, { status: 400 }) + } + + // 更新转码状态为processing,重置错误信息 + await prisma.material.update({ + where: { id: materialId }, + data: { + videoProcessingStatus: 'processing', + transcodeLastError: null, + transcodeAttempts: 0 + } + }) + + // TODO: 这里应该触发实际的转码任务 + // 由于转码服务是后台运行的,这里只是更新状态 + // 实际的转码任务会由后台服务自动检测并处理 + + return NextResponse.json({ + success: true, + message: '已重新提交转码任务' + }) + } catch (error) { + console.error('重试转码失败:', error) + return NextResponse.json({ error: '重试转码失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index b2c4775..9e10fde 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -1,7 +1,35 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { + canUserViewMaterial, + isShareLinkAccess, +} from '@/lib/department-permission'; +import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation'; + +// 解析 displayImages 字段 +function parseDisplayImages(displayImages: any): string[] { + if (!displayImages) return []; + + if (Array.isArray(displayImages)) { + return displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0); + } + + if (typeof displayImages === 'string') { + try { + const parsed = JSON.parse(displayImages); + if (Array.isArray(parsed)) { + return parsed.filter((img: any) => typeof img === 'string' && img.trim().length > 0); + } + } catch (e) { + console.warn('[materials/[id]] Failed to parse displayImages JSON, fallback to single value:', e); + } + return displayImages.trim() ? [displayImages] : []; + } + + return []; +} // GET /api/materials/[id] - 获取单个素材详情 export async function GET( @@ -9,52 +37,166 @@ export async function GET( { params }: { params: { id: string } } ) { try { - const material = await prisma.material.findUnique({ - where: { id: params.id }, - include: { - user: { + let material; + + try { + material = await prisma.material.findFirst({ + where: { + id: params.id, + deletedAt: null, + }, + select: { + id: true, + userId: true, + productModel: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + materialType: true, + renderer: true, + authorName: true, + previewUrl: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + displayImages: true, + createdAt: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + user: { + select: { + id: true, + username: true, + }, + }, + } as any, + }); + } catch (error: any) { + // 兼容旧版本数据库(没有 deletedAt 字段) + if (error?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); + material = await prisma.material.findFirst({ + where: { + id: params.id, + }, select: { id: true, - username: true, + userId: true, + productModel: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + materialType: true, + renderer: true, + authorName: true, + previewUrl: true, + zipFileName: true, + zipFileSize: true, + downloadCount: true, + likeCount: true, + displayImages: true, + createdAt: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + user: { + select: { + id: true, + username: true, + }, + }, + } as any, + }); + } else { + throw error; + } + } + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); + } + + // 权限检查:如果不是分享链接访问,检查查看权限 + const isShare = isShareLinkAccess(request); + if (!isShare) { + const session = await getServerSession(authOptions); + if (session?.user) { + const userRole = (session.user as any).role; + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; + const userId = (session.user as any).id; + + // 超级管理员不受限制 + const { isSuperAdmin } = await import('@/lib/department-permission'); + if (!(await isSuperAdmin(userId))) { + const canView = await canUserViewMaterial( + userDepartment, + userRole, + managedDepartment, + (material as any).materialType, + userId + ); + + if (!canView) { + return NextResponse.json( + { error: '无权限查看该素材' }, + { status: 403 } + ); } } + } else { + // 未登录用户且不是分享链接,不允许查看 + return NextResponse.json( + { error: '请先登录' }, + { status: 401 } + ); } - }) - - if (!material) { - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) } - - // 返回素材信息(转换BigInt为字符串) + + // 类型断言,确保 user 字段存在 + const materialWithUser = material as any; + return NextResponse.json({ - id: material.id, - userId: material.userId, - productModel: material.productModel, - categoryName: material.categoryName, - sceneName: material.sceneName, - materialType: material.materialType, - renderer: material.renderer, - authorName: material.authorName, - previewUrl: material.previewUrl, - zipFileName: material.zipFileName, - zipFileSize: material.zipFileSize.toString(), - downloadCount: material.downloadCount, - likeCount: material.likeCount, - displayImages: material.displayImages, - createdAt: material.createdAt, + id: materialWithUser.id, + userId: materialWithUser.userId, + productModel: materialWithUser.productModel, + categoryName: materialWithUser.categoryName, + sceneName: materialWithUser.sceneName, + platformName: materialWithUser.platformName || '', + productCategoryName: materialWithUser.productCategoryName || '', + materialType: materialWithUser.materialType, + renderer: materialWithUser.renderer, + authorName: materialWithUser.authorName, + previewUrl: materialWithUser.previewUrl, + zipFileName: materialWithUser.zipFileName, + zipFileSize: materialWithUser.zipFileSize.toString(), + downloadCount: materialWithUser.downloadCount, + likeCount: materialWithUser.likeCount, + displayImages: parseDisplayImages(materialWithUser.displayImages || null), + createdAt: materialWithUser.createdAt, + videoSourceUrl: materialWithUser.videoSourceUrl || null, + videoPreview540Url: materialWithUser.videoPreview540Url || null, + videoPreviewClipUrl: materialWithUser.videoPreviewClipUrl || null, + videoCoverUrl: materialWithUser.videoCoverUrl || null, + videoDuration: materialWithUser.videoDuration || null, + videoProcessingStatus: materialWithUser.videoProcessingStatus || null, user: { - username: material.user.username - } - }) - } catch (error: any) { - console.error('获取素材失败:', error) - return NextResponse.json( - { error: '获取素材失败' }, - { status: 500 } - ) + username: materialWithUser.user?.username || '', + }, + }); + } catch (error) { + console.error('获取素材失败:', error); + return NextResponse.json({ error: '获取素材失败' }, { status: 500 }); } } @@ -64,61 +206,97 @@ export async function PUT( { params }: { params: { id: string } } ) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); if (!session?.user) { - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ) + return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - - const body = await request.json() - const { productModel, categoryName, sceneName, materialType, renderer, authorName } = body - - // 验证必填字段 - if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) { - return NextResponse.json( - { error: '缺少必填字段' }, - { status: 400 } - ) + + const { + productModel, + categoryName, + sceneName, + platformName, + productCategoryName, + materialType, + renderer, + authorName + } = await request.json(); + + // 使用统一的验证逻辑 + // 基础必填字段验证 + if (!productModel || !materialType || !authorName) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }); } - - // 获取素材信息 + + if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { + return NextResponse.json({ error: '请填写所有必填字段(店铺、场景)' }, { status: 400 }); + } + + // 标准模型必须选择品类 + if (requiresProductCategoryName(materialType) && !productCategoryName) { + return NextResponse.json({ error: '标准模型类型必须选择品类' }, { status: 400 }); + } + + // renderer 只在3D场景类型时必填 + if (requiresRenderer(materialType) && !renderer) { + return NextResponse.json({ error: '3D场景类型必须指定渲染器' }, { status: 400 }); + } + + // 检查素材是否存在 const material = await prisma.material.findUnique({ - where: { id: params.id } - }) - + where: { id: params.id }, + }); + if (!material) { - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } - - // 权限检查:只有素材上传者或管理员可以编辑 - const userFromSession = session.user as any - if (material.userId !== userFromSession.id && userFromSession.role !== 'ADMIN') { - return NextResponse.json( - { error: '您没有权限编辑此素材' }, - { status: 403 } - ) + + // 检查权限:素材所有者、普通管理员或超级管理员都可以编辑 + const user = session.user as any; + const userId = user.id; + const userRole = user.role; + + // 检查是否是超级管理员 + const { isSuperAdmin } = await import('@/lib/department-permission'); + const isUserSuperAdmin = await isSuperAdmin(userId); + + if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) { + return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 }); } - - // 更新素材信息 + + // 更新素材(包含所有字段,确保数据不丢失) + const updateData: any = { + productModel, + // 使用统一的验证逻辑判断字段是否需要更新 + categoryName: requiresCategoryAndScene(materialType) ? categoryName : (categoryName !== undefined ? categoryName : material.categoryName), + sceneName: requiresCategoryAndScene(materialType) ? sceneName : (sceneName !== undefined ? sceneName : material.sceneName), + platformName: platformName !== undefined && platformName !== null ? platformName : (material.platformName || ''), + materialType, + authorName, + updatedAt: new Date(), + }; + + // 标准模型必须更新 productCategoryName + if (requiresProductCategoryName(materialType)) { + updateData.productCategoryName = productCategoryName || material.productCategoryName; + } else { + updateData.productCategoryName = productCategoryName !== undefined ? productCategoryName : material.productCategoryName; + } + + // 使用统一的验证逻辑更新renderer + if (requiresRenderer(materialType)) { + // 3D场景类型:必须提供renderer,如果没有提供则使用默认值或保留原值 + updateData.renderer = renderer || material.renderer || getDefaultRenderer(materialType); + } else { + // 非3D场景类型,保持原renderer值(如果有) + updateData.renderer = material.renderer; + } + const updatedMaterial = await prisma.material.update({ where: { id: params.id }, - data: { - productModel, - categoryName, - sceneName, - materialType, - renderer, - authorName, - updatedAt: new Date() - } - }) - + data: updateData, + }); + return NextResponse.json({ success: true, message: '素材信息更新成功', @@ -127,17 +305,15 @@ export async function PUT( productModel: updatedMaterial.productModel, categoryName: updatedMaterial.categoryName, sceneName: updatedMaterial.sceneName, + platformName: updatedMaterial.platformName, + productCategoryName: updatedMaterial.productCategoryName, materialType: updatedMaterial.materialType, renderer: updatedMaterial.renderer, - authorName: updatedMaterial.authorName - } - }) - } catch (error: any) { - console.error('更新素材失败:', error) - return NextResponse.json( - { error: '更新素材失败' }, - { status: 500 } - ) + authorName: updatedMaterial.authorName, + }, + }); + } catch (error) { + console.error('更新素材失败:', error); + return NextResponse.json({ error: '更新素材失败' }, { status: 500 }); } } - diff --git a/app/api/materials/[id]/skus/[skuId]/download/route.ts b/app/api/materials/[id]/skus/[skuId]/download/route.ts new file mode 100644 index 0000000..87920ee --- /dev/null +++ b/app/api/materials/[id]/skus/[skuId]/download/route.ts @@ -0,0 +1,71 @@ +'use server' + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import fs from 'fs' +import path from 'path' + +// 下载单个 SKU 的压缩包 +export async function GET( + request: NextRequest, + { params }: { params: { id: string; skuId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + // 检查用户下载权限 + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { canDownload: true } + }) + + if (!user?.canDownload) { + return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 }) + } + + const { skuId } = params + + // 获取 SKU 信息 + const sku = await prisma.materialSku.findUnique({ + where: { id: skuId } + }) + + if (!sku) { + return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) + } + + if (!sku.filePath) { + return NextResponse.json({ error: 'SKU 没有可下载的文件' }, { status: 404 }) + } + + // 构建文件路径 + const filePath = path.join(process.cwd(), 'uploads', sku.filePath) + + // 检查文件是否存在 + if (!fs.existsSync(filePath)) { + console.error(`SKU 文件不存在: ${filePath}`) + return NextResponse.json({ error: '文件不存在' }, { status: 404 }) + } + + // 读取文件 + const fileBuffer = fs.readFileSync(filePath) + const fileName = `${sku.skuName}.zip` + + // 返回文件流 + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + 'Content-Length': fileBuffer.length.toString() + } + }) + } catch (error) { + console.error('下载 SKU 失败:', error) + return NextResponse.json({ error: '下载失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts new file mode 100644 index 0000000..ae9747d --- /dev/null +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -0,0 +1,162 @@ +'use server' + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import fs from 'fs' +import path from 'path' + +// 获取单个 SKU 详情 +export async function GET( + request: NextRequest, + { params }: { params: { id: string; skuId: string } } +) { + try { + const { skuId } = params + + const sku = await prisma.materialSku.findUnique({ + where: { id: skuId } + }) + + if (!sku) { + return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) + } + + return NextResponse.json({ + ...sku, + fileSize: sku.fileSize.toString() + }) + } catch (error) { + console.error('获取 SKU 详情失败:', error) + return NextResponse.json({ error: '获取 SKU 详情失败' }, { status: 500 }) + } +} + +// 更新 SKU +export async function PUT( + request: NextRequest, + { params }: { params: { id: string; skuId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const { skuId } = params + + // 检查 SKU 是否存在,并获取关联的素材信息用于权限检查 + const existingSku = await prisma.materialSku.findUnique({ + where: { id: skuId }, + include: { + material: { + select: { + userId: true // 获取素材的上传者ID + } + } + } + }) + + if (!existingSku) { + return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) + } + + // 检查权限:只有素材上传者或管理员可以编辑 SKU + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }) + + if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限编辑此 SKU' }, { status: 403 }) + } + + const body = await request.json() + const { skuName, filePath, fileSize, sortOrder } = body + + // 构建更新数据 + const updateData: any = {} + if (skuName !== undefined) updateData.skuName = skuName + if (filePath !== undefined) updateData.filePath = filePath + if (fileSize !== undefined) updateData.fileSize = BigInt(fileSize) + if (sortOrder !== undefined) updateData.sortOrder = sortOrder + + const updatedSku = await prisma.materialSku.update({ + where: { id: skuId }, + data: updateData + }) + + return NextResponse.json({ + ...updatedSku, + fileSize: updatedSku.fileSize.toString() // 返回时使用正确的字段名 + }) + } catch (error) { + console.error('更新 SKU 失败:', error) + return NextResponse.json({ error: '更新 SKU 失败' }, { status: 500 }) + } +} + +// 删除 SKU +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string; skuId: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const { skuId } = params + + // 检查 SKU 是否存在,并获取关联的素材信息用于权限检查 + const existingSku = await prisma.materialSku.findUnique({ + where: { id: skuId }, + include: { + material: { + select: { + userId: true // 获取素材的上传者ID + } + } + } + }) + + if (!existingSku) { + return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) + } + + // 检查权限:只有素材上传者或管理员可以删除 SKU + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }) + + if (existingSku.material.userId !== session.user.id && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限删除此 SKU' }, { status: 403 }) + } + + // 删除本地压缩包文件 + if (existingSku.filePath) { + const fullPath = path.join(process.cwd(), 'uploads', existingSku.filePath) + if (fs.existsSync(fullPath)) { + try { + fs.unlinkSync(fullPath) + console.log(`已删除 SKU 压缩包: ${fullPath}`) + } catch (err) { + console.error(`删除 SKU 压缩包失败: ${fullPath}`, err) + } + } + } + + // 删除 SKU 记录 + await prisma.materialSku.delete({ + where: { id: skuId } + }) + + return NextResponse.json({ success: true, message: 'SKU 已删除' }) + } catch (error) { + console.error('删除 SKU 失败:', error) + return NextResponse.json({ error: '删除 SKU 失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/[id]/skus/reorder/route.ts b/app/api/materials/[id]/skus/reorder/route.ts new file mode 100644 index 0000000..01fe4e7 --- /dev/null +++ b/app/api/materials/[id]/skus/reorder/route.ts @@ -0,0 +1,63 @@ +'use server' + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// 批量更新 SKU 排序 +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const materialId = params.id + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { id: true, userId: true } + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + // 检查权限:素材上传者或管理员可以调整排序 + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }) + + if (material.userId !== session.user.id && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限调整排序' }, { status: 403 }) + } + + const body = await request.json() + const { skuIds } = body // 按新顺序排列的 SKU ID 数组 + + if (!Array.isArray(skuIds) || skuIds.length === 0) { + return NextResponse.json({ error: '请提供 SKU ID 列表' }, { status: 400 }) + } + + // 批量更新排序 + const updates = skuIds.map((skuId: string, index: number) => + prisma.materialSku.update({ + where: { id: skuId }, + data: { sortOrder: index } + }) + ) + + await prisma.$transaction(updates) + + return NextResponse.json({ success: true, message: '排序已更新' }) + } catch (error) { + console.error('更新 SKU 排序失败:', error) + return NextResponse.json({ error: '更新排序失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts new file mode 100644 index 0000000..9c5e4d5 --- /dev/null +++ b/app/api/materials/[id]/skus/route.ts @@ -0,0 +1,110 @@ +'use server' + +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// 获取素材的所有 SKU 列表 +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const materialId = params.id + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { id: true, materialType: true } + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + // 获取 SKU 列表,按排序顺序 + const skus = await prisma.materialSku.findMany({ + where: { materialId }, + orderBy: { sortOrder: 'asc' } + }) + + // 转换 BigInt 为字符串(JSON 序列化需要) + const skusWithStringSize = skus.map(sku => ({ + ...sku, + fileSize: sku.fileSize.toString() // 使用正确的字段名 + })) + + return NextResponse.json({ + skus: skusWithStringSize, + total: skus.length + }) + } catch (error) { + console.error('获取 SKU 列表失败:', error) + return NextResponse.json({ error: '获取 SKU 列表失败' }, { status: 500 }) + } +} + +// 添加新的 SKU +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const materialId = params.id + + // 检查素材是否存在且是标准模型类型 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { id: true, materialType: true } + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + if (material.materialType !== '标准模型') { + return NextResponse.json({ error: '只有标准模型类型才能添加 SKU' }, { status: 400 }) + } + + const body = await request.json() + const { skuName, filePath, fileSize } = body + + // 验证必填字段(只需要 skuName 和 filePath) + if (!skuName || !filePath) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }) + } + + // 获取当前最大排序值 + const maxSortOrder = await prisma.materialSku.aggregate({ + where: { materialId }, + _max: { sortOrder: true } + }) + + const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1 + + // 创建 SKU(只使用模型中存在的字段) + const sku = await prisma.materialSku.create({ + data: { + materialId, + skuName, + filePath, + fileSize: BigInt(fileSize || 0), + sortOrder: newSortOrder + } + }) + + return NextResponse.json({ + ...sku, + fileSize: sku.fileSize.toString() // 返回时使用正确的字段名 + }, { status: 201 }) + } catch (error) { + console.error('添加 SKU 失败:', error) + return NextResponse.json({ error: '添加 SKU 失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/[id]/skus/upload/route.ts b/app/api/materials/[id]/skus/upload/route.ts new file mode 100644 index 0000000..823d64a --- /dev/null +++ b/app/api/materials/[id]/skus/upload/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' +import { existsSync } from 'fs' + +// SKU文件上传API +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const materialId = params.id + + // 检查素材是否存在且是标准模型类型 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { id: true, materialType: true, userId: true } + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + if (material.materialType !== '标准模型') { + return NextResponse.json({ error: '只有标准模型类型才能上传 SKU' }, { status: 400 }) + } + + // 检查权限 + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true } + }) + + if (material.userId !== session.user.id && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限上传 SKU' }, { status: 403 }) + } + + const formData = await request.formData() + const skuName = formData.get('skuName') as string + const file = formData.get('file') as File + + if (!skuName || !file) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }) + } + + // 创建上传目录 + const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId) + if (!existsSync(uploadDir)) { + await mkdir(uploadDir, { recursive: true }) + } + + // 生成文件名 + const timestamp = Date.now() + const ext = path.extname(file.name) + const fileName = `${skuName}_${timestamp}${ext}` + const filePath = path.join(uploadDir, fileName) + + // 保存文件 + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + await writeFile(filePath, buffer) + + // 相对路径(用于数据库存储) + const relativePath = path.join('skus', materialId, fileName) + + // 获取当前最大排序值 + const maxSortOrder = await prisma.materialSku.aggregate({ + where: { materialId }, + _max: { sortOrder: true } + }) + + const newSortOrder = (maxSortOrder._max.sortOrder ?? -1) + 1 + + // 创建 SKU 记录 + const sku = await prisma.materialSku.create({ + data: { + materialId, + skuName, + filePath: relativePath, + fileSize: BigInt(file.size), + sortOrder: newSortOrder + } + }) + + return NextResponse.json({ + ...sku, + fileSize: sku.fileSize.toString() + }, { status: 201 }) + } catch (error) { + console.error('上传 SKU 失败:', error) + return NextResponse.json({ error: '上传 SKU 失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index c4dfbfa..78a7ec4 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -1,112 +1,476 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { readFile, access } from 'fs/promises' -import path from 'path' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { access, stat } from 'fs/promises'; +import { createReadStream } from 'fs'; +import { Readable } from 'stream'; +import path from 'path'; +import { lookup, extension } from 'mime-types'; +import JSZip from 'jszip'; +import { + canUserDownloadMaterial, + isShareLinkAccess, +} from '@/lib/department-permission'; -export async function GET(req: NextRequest) { - try { - // Check if user is logged in - const session = await getServerSession(authOptions) - if (!session) { - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ) - } +function isHttpUrl(url?: string | null): boolean { + if (!url || typeof url !== 'string') return false; + return /^https?:\/\//i.test(url.trim()); +} - // Check if user has download permission (skip check for admins) - const userId = (session.user as any).id - const userRole = (session.user as any).role - - if (userRole !== 'ADMIN') { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { canDownload: true } - }) +// 动态打包displayImages为ZIP(用于模特类素材) +async function buildDynamicZipFromDisplayImages(material: any) { + const displayImages = Array.isArray(material.displayImages) + ? material.displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0) + : []; - if (!user || !user.canDownload) { - return NextResponse.json( - { error: '您的下载权限已被限制,请联系管理员' }, - { status: 403 } - ) + if (displayImages.length === 0) { + console.warn('[Download API] No displayImages available for dynamic zip, materialId:', material.id); + return NextResponse.json( + { error: '该素材没有可下载的文件(可能只包含图片)' }, + { status: 404 } + ); + } + + console.log(`[Download API] Building ZIP from displayImages for material ${material.id}, total: ${displayImages.length}`); + + const zip = new JSZip(); + let addedCount = 0; + + for (let i = 0; i < displayImages.length; i++) { + const imageUrl = displayImages[i]; + try { + const response = await fetch(imageUrl); + if (!response.ok) { + console.warn(`[Download API] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`); + continue; } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + if (!buffer || buffer.length === 0) { + console.warn(`[Download API] Empty buffer for display image index=${i} url=${imageUrl}, skipping`); + continue; + } + + const urlObj = new URL(imageUrl); + const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_'); + zip.file(filename, buffer); + addedCount++; + } catch (error) { + console.error(`[Download API] Error while adding display image index=${i} url=${imageUrl} to zip:`, error); } + } - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') + if (addedCount === 0) { + console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id); + return NextResponse.json( + { error: '该素材没有可下载的文件(可能只包含图片)' }, + { status: 404 } + ); + } - if (!id) { - return NextResponse.json( - { error: '缺少素材ID' }, - { status: 400 } - ) - } + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); - const material = await prisma.material.findUnique({ - where: { id }, - }) + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '展示图合集', + ].filter(Boolean); - if (!material) { - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) - } + const filename = `${filenameParts.join('_')}.zip`; - // 增加下载次数 - await prisma.material.update({ - where: { id }, - data: { - downloadCount: { - increment: 1 + console.log( + '[Download API] Dynamic ZIP built from displayImages, materialId:', + material.id, + 'size:', + (zipBuffer.length / 1024 / 1024).toFixed(2), + 'MB' + ); + + return new NextResponse(zipBuffer as any, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': zipBuffer.length.toString(), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); +} + +// GET - 下载素材 +export async function GET(request: NextRequest) { + try { + const isShare = isShareLinkAccess(request); + + // 如果不是分享链接,需要登录 + if (!isShare) { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const userId = session.user.id; + const userRole = session.user.role; + + // 检查用户级别的下载权限 + if (userRole !== 'ADMIN') { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { canDownload: true }, + }); + + if (!user || !user.canDownload) { + return NextResponse.json( + { error: '您的下载权限已被限制,请联系管理员' }, + { status: 403 } + ); } } - }) - - // Read file from local storage - // 确保使用绝对路径 - let filePath = material.zipFilePath - if (!path.isAbsolute(filePath)) { - filePath = path.join(process.cwd(), filePath) } - - console.log('Attempting to download file:', filePath) - + + const { searchParams } = new URL(request.url); + const materialId = searchParams.get('id'); + + if (!materialId) { + return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); + } + + // 查询素材 + let material: any; + try { + material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + zipFilePath: true, + zipFileName: true, + productModel: true, + categoryName: true, + sceneName: true, + productCategoryName: true, + authorName: true, + materialType: true, + downloadCount: true, + displayImages: true, + }, + }); + } catch (error: any) { + if (error?.meta?.column?.includes('deletedAt')) { + console.warn('[Download API] deletedAt field not found, retrying without deletedAt'); + material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + zipFilePath: true, + zipFileName: true, + productModel: true, + categoryName: true, + sceneName: true, + productCategoryName: true, + authorName: true, + materialType: true, + downloadCount: true, + displayImages: true, + }, + }); + } else { + throw error; + } + } + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); + } + + // 权限检查:如果不是分享链接,检查部门级别的下载权限 + if (!isShare) { + const session = await getServerSession(authOptions); + if (session?.user) { + const userRole = (session.user as any).role; + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; + const userId = (session.user as any).id; + + // 超级管理员不受限制 + const { isSuperAdmin } = await import('@/lib/department-permission'); + if (!(await isSuperAdmin(userId))) { + const canDownload = await canUserDownloadMaterial( + userDepartment, + userRole, + managedDepartment, + material.materialType, + userId + ); + + if (!canDownload) { + return NextResponse.json( + { error: '无权限下载该素材' }, + { status: 403 } + ); + } + } + } + } + + // 增加下载次数(仅非分享链接时) + if (!isShare) { + try { + await prisma.material.update({ + where: { id: materialId }, + data: { downloadCount: { increment: 1 } }, + }); + } catch (error: any) { + if (error?.meta?.column?.includes('deletedAt')) { + console.warn('[Download API] deletedAt field not found in update, retrying without deletedAt'); + await prisma.material.update({ + where: { id: materialId }, + data: { downloadCount: { increment: 1 } }, + }); + } else { + throw error; + } + } + } + + // 检查是否有ZIP文件;若无,则处理S3直链(音频等存放在zipFileName的场景),否则再尝试动态打包 + if (!material.zipFilePath || material.zipFilePath.trim() === '') { + const zipUrl = (material.zipFileName || '').trim(); + + // 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL + if (zipUrl && isHttpUrl(zipUrl)) { + console.log('[Download API] zipFilePath empty, proxy S3 url from zipFileName:', zipUrl); + + const urlObj = new URL(zipUrl); + const extFromUrl = path.extname(urlObj.pathname) || ''; + const mimeType = lookup(extFromUrl) || 'application/octet-stream'; + + // 生成下载文件名(与本地下载保持一致的命名逻辑) + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '源文件', + ].filter(Boolean); + const finalExt = extFromUrl || '.bin'; + const filename = `${filenameParts.join('_')}${finalExt}`; + + try { + const response = await fetch(zipUrl); + if (!response.ok || !response.body) { + console.error('[Download API] Failed to fetch S3 url from zipFileName:', zipUrl, 'status:', response.status); + return NextResponse.json( + { error: '文件不存在或无法访问' }, + { status: 404 } + ); + } + + // 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回 + const nodeStream = Readable.fromWeb(response.body as any); + const stream = new ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(chunk)); + nodeStream.on('end', () => controller.close()); + nodeStream.on('error', (err) => controller.error(err)); + }, + cancel() { + nodeStream.destroy(); + }, + }); + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + // 不设置 Content-Length,因 S3 直链已被代理流式返回 + }, + }); + } catch (error) { + console.error('[Download API] Error proxying S3 url from zipFileName:', zipUrl, error); + return NextResponse.json( + { error: '文件下载失败,请稍后再试' }, + { status: 500 } + ); + } + } + + console.log('[Download API] Material has no zipFilePath, try dynamic zip if supported. type=', material.materialType); + + // 支持动态打包的素材类型 + const supportedTypes = [ + '模特', + '实拍图片', + '买家秀图片', + '小红书种草', + '主图模版', + 'SKU模版', + '品牌VI灵感', + ]; + + if ( + supportedTypes.includes(material.materialType) && + Array.isArray(material.displayImages) && + material.displayImages.length > 0 + ) { + return buildDynamicZipFromDisplayImages(material); + } + + return NextResponse.json( + { error: '该素材没有可下载的文件(可能只包含图片)' }, + { status: 404 } + ); + } + + // 处理文件路径 + const isOldMaterial = + material.zipFilePath.includes('103.118.245.6') || + material.zipFilePath.startsWith('http'); + + let filePath = material.zipFilePath; + + if (!isOldMaterial) { + if (!path.isAbsolute(filePath)) { + filePath = path.join(process.cwd(), filePath); + } + } + + console.log('Attempting to download file:', filePath, '(Old material:', isOldMaterial, ')'); + // 检查文件是否存在 try { - await access(filePath) + await access(filePath); } catch (error) { - console.error('File not found:', filePath) + console.error('File not found:', filePath, 'error:', error); + + // 如果本地文件不存在,尝试动态打包 + const supportedTypes = [ + '模特', + '实拍图片', + '买家秀图片', + '小红书种草', + '主图模版', + 'SKU模版', + '品牌VI灵感', + ]; + + if ( + supportedTypes.includes(material.materialType) && + Array.isArray(material.displayImages) && + material.displayImages.length > 0 + ) { + console.log('[Download API] Local file missing, fallback to dynamic zip from displayImages, materialId:', material.id); + return buildDynamicZipFromDisplayImages(material); + } + return NextResponse.json( { error: '文件不存在,可能已被删除' }, { status: 404 } - ) + ); } - - const fileBuffer = await readFile(filePath) - // 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名 - // 获取原文件扩展名 - const originalExt = path.extname(material.zipFileName) // 例如 .zip 或 .7z - const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}` - - console.log('Download filename:', newFileName) + // 获取文件大小 + const fileStats = await stat(filePath); + const fileSize = fileStats.size; - return new NextResponse(fileBuffer as any, { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${encodeURIComponent(newFileName)}"`, - 'Content-Length': fileBuffer.length.toString(), + // 确定文件扩展名和MIME类型 + const originalExt = material.zipFileName ? path.extname(material.zipFileName) : ''; + const actualExt = path.extname(filePath); + const ext = originalExt || actualExt; + const mimeType = lookup(ext || filePath) || 'application/octet-stream'; + const mimeExt = extension(mimeType); + const finalExt = ext || (mimeExt ? `.${mimeExt}` : ''); + + // 生成下载文件名 + const filenameParts = [ + material.productModel || 'unknown', + material.categoryName || '', + material.sceneName || '', + material.productCategoryName || '', + '源文件', + ].filter(Boolean); + + const filename = `${filenameParts.join('_')}${finalExt || '.bin'}`; + + console.log('Download filename:', filename, 'size:', fileSize, 'mime:', mimeType); + + // 处理Range请求(断点续传) + const rangeHeader = request.headers.get('range'); + + if (rangeHeader) { + const rangePrefix = 'bytes='; + if (!rangeHeader.startsWith(rangePrefix)) { + return new NextResponse('Range header format is invalid', { status: 416 }); + } + + const rangeParts = rangeHeader.replace(rangePrefix, '').split('-'); + const start = parseInt(rangeParts[0], 10); + const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1; + + if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) { + return new NextResponse('Range header out of bounds', { status: 416 }); + } + + const chunkSize = end - start + 1; + const fileStream = createReadStream(filePath, { start, end }); + + const stream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => controller.enqueue(chunk)); + fileStream.on('end', () => controller.close()); + fileStream.on('error', (err) => controller.error(err)); + }, + cancel() { + fileStream.destroy(); + }, + }); + + return new NextResponse(stream, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); + } + + // 正常下载 + const fileStream = createReadStream(filePath); + + const stream = new ReadableStream({ + start(controller) { + fileStream.on('data', (chunk) => controller.enqueue(chunk)); + fileStream.on('end', () => controller.close()); + fileStream.on('error', (err) => controller.error(err)); }, - }) + cancel() { + fileStream.destroy(); + }, + }); + + return new NextResponse(stream, { + headers: { + 'Accept-Ranges': 'bytes', + 'Content-Length': fileSize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }, + }); } catch (error) { - console.error('Download error:', error) + console.error('Download error:', error); return NextResponse.json( { error: '下载失败: ' + (error instanceof Error ? error.message : '未知错误') }, { status: 500 } - ) + ); } } - diff --git a/app/api/materials/favorite/route.ts b/app/api/materials/favorite/route.ts new file mode 100644 index 0000000..6d08bb7 --- /dev/null +++ b/app/api/materials/favorite/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// POST - 收藏/取消收藏 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const { materialId } = await request.json(); + + if (!materialId) { + return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); + } + + const userId = session.user.id; + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + }); + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); + } + + // 检查是否已收藏 + const existingFavorite = await prisma.materialFavorite.findFirst({ + where: { + materialId, + userId, + }, + }); + + if (existingFavorite) { + // 取消收藏 + await prisma.materialFavorite.delete({ + where: { + id: existingFavorite.id, + }, + }); + + return NextResponse.json({ + message: '已取消收藏', + isFavorited: false, + }); + } else { + // 收藏 + await prisma.materialFavorite.create({ + data: { + materialId, + userId, + }, + }); + + return NextResponse.json({ + message: '已收藏', + isFavorited: true, + }); + } + } catch (error: any) { + // 兼容旧版本数据库(没有material_favorites表) + if (error?.meta?.table?.includes('material_favorites')) { + return NextResponse.json( + { error: '收藏功能需要先运行数据库迁移' }, + { status: 400 } + ); + } + + console.error('[Materials] FAVORITE error:', error); + return NextResponse.json({ error: '操作失败' }, { status: 500 }); + } +} diff --git a/app/api/materials/like/route.ts b/app/api/materials/like/route.ts index 460440c..538d28a 100644 --- a/app/api/materials/like/route.ts +++ b/app/api/materials/like/route.ts @@ -1,93 +1,69 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// 点赞/取消点赞 -export async function POST(req: NextRequest) { +// POST - 点赞/取消点赞 +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); if (!session) { - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ) + return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - const { materialId } = await req.json() + const { materialId } = await request.json(); if (!materialId) { - return NextResponse.json( - { error: '缺少素材ID' }, - { status: 400 } - ) + return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); } - const userId = (session.user as any).id + const userId = session.user.id; // 检查是否已点赞 - const existingLike = await prisma.materialLike.findUnique({ + const existingLike = await prisma.materialLike.findFirst({ where: { - materialId_userId: { - materialId, - userId - } - } - }) + materialId, + userId, + }, + }); if (existingLike) { // 取消点赞 await prisma.materialLike.delete({ - where: { - id: existingLike.id - } - }) + where: { id: existingLike.id }, + }); - // 减少点赞次数 await prisma.material.update({ where: { id: materialId }, - data: { - likeCount: { - decrement: 1 - } - } - }) + data: { likeCount: { decrement: 1 } }, + }); - return NextResponse.json({ + return NextResponse.json({ liked: false, - message: '已取消点赞' - }) + message: '已取消点赞', + }); } else { - // 添加点赞 + // 点赞 await prisma.materialLike.create({ data: { materialId, - userId - } - }) + userId, + }, + }); - // 增加点赞次数 await prisma.material.update({ where: { id: materialId }, - data: { - likeCount: { - increment: 1 - } - } - }) + data: { likeCount: { increment: 1 } }, + }); - return NextResponse.json({ + return NextResponse.json({ liked: true, - message: '点赞成功' - }) + message: '点赞成功', + }); } } catch (error) { - console.error('Like error:', error) - return NextResponse.json( - { error: '操作失败' }, - { status: 500 } - ) + console.error('Like error:', error); + return NextResponse.json({ error: '操作失败' }, { status: 500 }); } } - diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts new file mode 100644 index 0000000..8148d06 --- /dev/null +++ b/app/api/materials/restore/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { createMaterialLog } from '@/lib/material-log'; + +// POST /api/materials/restore - 恢复已删除的素材 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }); + } + + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); + } + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ + where: { id }, + }); + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); + } + + // 恢复素材(将 deletedAt 设为 null) + await prisma.material.update({ + where: { id }, + data: { + deletedAt: null, + }, + }); + + // 记录操作日志 + const userId = (session.user as any).id; + await createMaterialLog({ + materialId: id, + userId, + action: 'RESTORE', + changes: { + deletedAt: { + oldValue: material.deletedAt?.toISOString() || null, + newValue: null, + }, + }, + }); + + return NextResponse.json({ message: '恢复成功' }); + } catch (error: any) { + // 兼容旧版本数据库(没有 deletedAt 字段) + if (error?.meta?.column?.includes('deletedAt')) { + return NextResponse.json({ error: '恢复功能需要先运行数据库迁移' }, { status: 400 }); + } + + console.error('[Materials] RESTORE error:', error); + return NextResponse.json({ error: '恢复失败' }, { status: 500 }); + } +} diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 83707b3..5f02784 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -5,6 +5,10 @@ import { prisma } from '@/lib/prisma'; import { deleteFromS3, isS3Url } from '@/lib/s3'; import { createMaterialLog } from '@/lib/material-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; +import { + getUserViewableMaterialTypes, + isShareLinkAccess, +} from '@/lib/department-permission'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -50,11 +54,11 @@ export async function GET(request: NextRequest) { // 单个素材查询(通过uploadId) const uploadId = searchParams.get('uploadId'); if (uploadId) { - const material = await prisma.material.findFirst({ + const material: any = await prisma.material.findFirst({ where: { uploadId, deletedAt: null, - }, + } as any, select: { id: true, userId: true, @@ -62,6 +66,8 @@ export async function GET(request: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, renderer: true, authorName: true, @@ -70,7 +76,6 @@ export async function GET(request: NextRequest) { downloadCount: true, likeCount: true, createdAt: true, - uploadId: true, videoSourceUrl: true, displayImages: true, videoPreview540Url: true, @@ -84,13 +89,41 @@ export async function GET(request: NextRequest) { avatar: true, }, }, - }, + } as any, }); if (!material) { return NextResponse.json({ material: null }); } + // 权限检查:如果不是分享链接访问,检查查看权限 + const isShare = isShareLinkAccess(request); + if (!isShare && session?.user) { + const userRole = (session.user as any).role; + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; + const userId = (session.user as any).id; + + // 超级管理员不受限制 + const { isSuperAdmin, canUserViewMaterial } = await import('@/lib/department-permission'); + if (!(await isSuperAdmin(userId))) { + const canView = await canUserViewMaterial( + userDepartment, + userRole, + managedDepartment, + material.materialType, + userId + ); + + if (!canView) { + return NextResponse.json( + { error: '无权限查看该素材' }, + { status: 403 } + ); + } + } + } + // 检查点赞和收藏状态 let isLiked = false; let isFavorited = false; @@ -107,12 +140,10 @@ export async function GET(request: NextRequest) { // 检查收藏 try { - const favorite = await prisma.materialFavorite.findUnique({ + const favorite = await prisma.materialFavorite.findFirst({ where: { - materialId_userId: { - materialId, - userId, - }, + materialId, + userId, }, }); isFavorited = !!favorite; @@ -123,8 +154,18 @@ export async function GET(request: NextRequest) { } } + // 处理previewUrl的fallback逻辑(与反编译文件一致) + let previewUrl = material.previewUrl || material.videoCoverUrl || ''; + if (!previewUrl && material.displayImages) { + const displayImagesArray = parseDisplayImages(material.displayImages); + if (displayImagesArray.length > 0) { + previewUrl = displayImagesArray[0]; + } + } + const normalizedMaterial = normalizeMaterial({ ...material, + previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked, @@ -155,7 +196,7 @@ export async function GET(request: NextRequest) { const where: any = {}; // 软删除过滤 - if (searchParams.get('showDeleted') === 'true') { + if (searchParams.get('showDeleted') === 'true' || searchParams.get('deleted') === 'true') { where.deletedAt = { not: null }; } else { where.deletedAt = null; @@ -209,6 +250,67 @@ export async function GET(request: NextRequest) { }; } + // 权限过滤:如果不是分享链接访问,且用户不是超级管理员,则过滤素材类型 + const isShare = isShareLinkAccess(request); + if (!isShare && session?.user) { + const userRole = (session.user as any).role; + const userDepartment = (session.user as any).department; + const managedDepartment = (session.user as any).managedDepartment; + + // 超级管理员不受限制 + const userId = (session.user as any).id; + const { isSuperAdmin } = await import('@/lib/department-permission'); + if (!(await isSuperAdmin(userId))) { + const viewableTypes = await getUserViewableMaterialTypes( + userDepartment, + userRole, + managedDepartment, + userId + ); + + // 如果用户没有查看权限的素材类型,添加过滤条件 + if (viewableTypes.length > 0) { + // 如果已经指定了materialType筛选,需要检查是否有权限 + if (materialType) { + const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType]; + const hasPermission = types.some((t) => viewableTypes.includes(t)); + if (!hasPermission) { + // 没有权限查看该类型,返回空结果 + return NextResponse.json({ + materials: [], + totalPages: 0, + totalCount: 0, + currentPage: page, + }); + } + } else { + // 没有指定materialType,过滤掉没有权限的类型 + // 需要处理类型映射 + const allViewableTypes = new Set(); + viewableTypes.forEach((type) => { + allViewableTypes.add(type); + // 添加映射的类型 + if (MATERIAL_TYPE_MAP[type]) { + MATERIAL_TYPE_MAP[type].forEach((mappedType) => { + allViewableTypes.add(mappedType); + }); + } + }); + + where.materialType = { in: Array.from(allViewableTypes) }; + } + } else { + // 用户没有任何查看权限,返回空结果 + return NextResponse.json({ + materials: [], + totalPages: 0, + totalCount: 0, + currentPage: page, + }); + } + } + } + // 获取总数 let totalCount: number; try { @@ -227,7 +329,7 @@ export async function GET(request: NextRequest) { const totalPages = Math.ceil(totalCount / pageSize); // 排序 - const orderBy: any = { createdAt: sortOrder }; + let orderBy: any = { createdAt: sortOrder }; if (sortBy === 'downloadCount') { orderBy = { downloadCount: sortOrder }; } @@ -246,6 +348,8 @@ export async function GET(request: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, renderer: true, authorName: true, @@ -269,10 +373,10 @@ export async function GET(request: NextRequest) { avatar: true, }, }, - }, + } as any, }; - // 综合排序不分页 + // comprehensive排序时,不设置orderBy、skip、take,获取所有数据后在内存中排序 if (sortBy !== 'comprehensive') { query.orderBy = orderBy; query.skip = (page - 1) * pageSize; @@ -280,6 +384,24 @@ export async function GET(request: NextRequest) { } materials = await prisma.material.findMany(query); + + // comprehensive排序:按下载量和创建时间综合排序 + if (sortBy === 'comprehensive') { + materials.sort((a, b) => { + // 综合排序:下载量权重70%,创建时间权重30% + const downloadScoreA = (a.downloadCount || 0) * 0.7; + const downloadScoreB = (b.downloadCount || 0) * 0.7; + const timeScoreA = new Date(a.createdAt).getTime() * 0.3; + const timeScoreB = new Date(b.createdAt).getTime() * 0.3; + const scoreA = downloadScoreA + timeScoreA; + const scoreB = downloadScoreB + timeScoreB; + return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; + }); + + // 分页 + const startIndex = (page - 1) * pageSize; + materials = materials.slice(startIndex, startIndex + pageSize); + } } catch (e: any) { if (e?.meta?.column?.includes('deletedAt')) { console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter'); @@ -296,6 +418,8 @@ export async function GET(request: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, renderer: true, authorName: true, @@ -319,11 +443,28 @@ export async function GET(request: NextRequest) { avatar: true, }, }, - }, - orderBy, - skip: (page - 1) * pageSize, - take: pageSize, + } as any, + ...(sortBy !== 'comprehensive' ? { + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + } : {}), }); + + // comprehensive排序处理 + if (sortBy === 'comprehensive') { + materials.sort((a, b) => { + const downloadScoreA = (a.downloadCount || 0) * 0.7; + const downloadScoreB = (b.downloadCount || 0) * 0.7; + const timeScoreA = new Date(a.createdAt).getTime() * 0.3; + const timeScoreB = new Date(b.createdAt).getTime() * 0.3; + const scoreA = downloadScoreA + timeScoreA; + const scoreB = downloadScoreB + timeScoreB; + return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; + }); + const startIndex = (page - 1) * pageSize; + materials = materials.slice(startIndex, startIndex + pageSize); + } } catch (e2: any) { if (e2?.meta?.column?.includes('transcode')) { console.warn('[Materials API] Transcode fields not found, using fallback query'); @@ -336,6 +477,8 @@ export async function GET(request: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, renderer: true, authorName: true, @@ -356,11 +499,30 @@ export async function GET(request: NextRequest) { username: true, }, }, - }, - orderBy, - skip: (page - 1) * pageSize, - take: pageSize, - })).map(m => ({ + } as any, + ...(sortBy !== 'comprehensive' ? { + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + } : {}), + })); + + // comprehensive排序处理 + if (sortBy === 'comprehensive') { + materials.sort((a, b) => { + const downloadScoreA = (a.downloadCount || 0) * 0.7; + const downloadScoreB = (b.downloadCount || 0) * 0.7; + const timeScoreA = new Date(a.createdAt).getTime() * 0.3; + const timeScoreB = new Date(b.createdAt).getTime() * 0.3; + const scoreA = downloadScoreA + timeScoreA; + const scoreB = downloadScoreB + timeScoreB; + return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; + }); + const startIndex = (page - 1) * pageSize; + materials = materials.slice(startIndex, startIndex + pageSize); + } + + materials = materials.map(m => ({ ...m, transcodeLastError: null, transcodeAttempts: 0, @@ -380,6 +542,8 @@ export async function GET(request: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, renderer: true, authorName: true, @@ -400,19 +564,38 @@ export async function GET(request: NextRequest) { username: true, }, }, - }, - orderBy, - skip: (page - 1) * pageSize, - take: pageSize, - })).map(m => ({ - ...m, - transcodeLastError: null, - transcodeAttempts: 0, - })); - } else { - throw e; - } - } + } as any, + ...(sortBy !== 'comprehensive' ? { + orderBy, + skip: (page - 1) * pageSize, + take: pageSize, + } : {}), + })); + + // comprehensive排序处理 + if (sortBy === 'comprehensive') { + materials.sort((a, b) => { + const downloadScoreA = (a.downloadCount || 0) * 0.7; + const downloadScoreB = (b.downloadCount || 0) * 0.7; + const timeScoreA = new Date(a.createdAt).getTime() * 0.3; + const timeScoreB = new Date(b.createdAt).getTime() * 0.3; + const scoreA = downloadScoreA + timeScoreA; + const scoreB = downloadScoreB + timeScoreB; + return sortOrder === 'desc' ? scoreB - scoreA : scoreA - scoreB; + }); + const startIndex = (page - 1) * pageSize; + materials = materials.slice(startIndex, startIndex + pageSize); + } + + materials = materials.map(m => ({ + ...m, + transcodeLastError: null, + transcodeAttempts: 0, + })); + } else { + throw e; + } + } // 批量查询点赞和收藏状态 const likedSet = new Set(); @@ -451,8 +634,18 @@ export async function GET(request: NextRequest) { // 格式化返回数据 const formattedMaterials = materials.map(material => { + // 处理previewUrl的fallback逻辑(与反编译文件一致) + let previewUrl = material.previewUrl || material.videoCoverUrl || ''; + if (!previewUrl && material.displayImages) { + const displayImagesArray = parseDisplayImages(material.displayImages); + if (displayImagesArray.length > 0) { + previewUrl = displayImagesArray[0]; + } + } + const formatted = { ...material, + previewUrl, // 使用处理后的previewUrl zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked: likedSet.has(material.id), @@ -468,6 +661,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ materials: formattedMaterials, totalPages, + total: totalCount, // 添加total字段以兼容前端 totalCount, currentPage: page, }); @@ -559,32 +753,32 @@ export async function DELETE(request: NextRequest) { // 收集所有文件路径 const filesToDelete: string[] = []; - if (material.zipFilePath) { - filesToDelete.push(material.zipFilePath); + if ((material as any).zipFilePath) { + filesToDelete.push((material as any).zipFilePath); } - if (material.videoSourceUrl && material.videoSourceUrl !== material.zipFilePath) { - filesToDelete.push(material.videoSourceUrl); + if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath) { + filesToDelete.push((material as any).videoSourceUrl); } - if (material.previewUrl) { - filesToDelete.push(material.previewUrl); + if ((material as any).previewUrl) { + filesToDelete.push((material as any).previewUrl); } - if (material.videoPreview540Url) { - filesToDelete.push(material.videoPreview540Url); + if ((material as any).videoPreview540Url) { + filesToDelete.push((material as any).videoPreview540Url); } - if (material.videoPreviewClipUrl) { - filesToDelete.push(material.videoPreviewClipUrl); + if ((material as any).videoPreviewClipUrl) { + filesToDelete.push((material as any).videoPreviewClipUrl); } - if (material.videoCoverUrl) { - filesToDelete.push(material.videoCoverUrl); + if ((material as any).videoCoverUrl) { + filesToDelete.push((material as any).videoCoverUrl); } - if (Array.isArray(material.displayImages) && material.displayImages.length > 0) { - filesToDelete.push(...material.displayImages.filter(img => img && img.trim())); + if (Array.isArray((material as any).displayImages) && (material as any).displayImages.length > 0) { + filesToDelete.push(...(material as any).displayImages.filter((img: string) => img && img.trim())); } // 去重 diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 22da512..e1f8c66 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -1,21 +1,45 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// Get single material for sharing (公开访问,无需登录) -export async function GET(req: NextRequest) { - try { - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') - - if (!id) { - return NextResponse.json( - { error: '缺少素材ID' }, - { status: 400 } - ) +// 解析 displayImages 字段 +function parseDisplayImages(displayImages: any): string[] { + if (!displayImages) return []; + + if (Array.isArray(displayImages)) { + return displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0); + } + + if (typeof displayImages === 'string') { + try { + const parsed = JSON.parse(displayImages); + if (Array.isArray(parsed)) { + return parsed.filter((img: any) => typeof img === 'string' && img.trim().length > 0); + } + } catch (e) { + console.warn('[materials/share] Failed to parse displayImages JSON, fallback to single value:', e); } + return displayImages.trim() ? [displayImages] : []; + } + + return []; +} +// GET /api/materials/share - 获取分享素材信息(无需登录,但登录用户会返回点赞和收藏状态) +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ error: '缺少素材ID' }, { status: 400 }); + } + + // 获取当前登录用户(如果有) + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + const material = await prisma.material.findUnique({ where: { id }, select: { @@ -24,6 +48,8 @@ export async function GET(req: NextRequest) { productModel: true, categoryName: true, sceneName: true, + platformName: true, + productCategoryName: true, materialType: true, authorName: true, zipFileName: true, @@ -31,30 +57,56 @@ export async function GET(req: NextRequest) { downloadCount: true, likeCount: true, displayImages: true, - createdAt: true - } - }) - + createdAt: true, + userId: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + videoProcessingStatus: true, + } as any, + }); + if (!material) { - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } - - // 转换BigInt为字符串 - const materialData = { + + // 如果用户已登录,检查点赞和收藏状态 + let isLiked = false; + let isFavorited = false; + + if (userId) { + const [like, favorite] = await Promise.all([ + prisma.materialLike.findFirst({ + where: { + materialId: id, + userId, + }, + }), + prisma.materialFavorite.findFirst({ + where: { + materialId: id, + userId, + }, + }), + ]); + + isLiked = !!like; + isFavorited = !!favorite; + } + + const response = { ...material, - zipFileSize: material.zipFileSize.toString() - } - - return NextResponse.json(materialData) + zipFileSize: material.zipFileSize.toString(), + displayImages: parseDisplayImages(material.displayImages || null), + isLiked, + isFavorited, + }; + + return NextResponse.json(response); } catch (error) { - console.error('Error fetching material for share:', error) - return NextResponse.json( - { error: '获取素材失败' }, - { status: 500 } - ) + console.error('Error fetching material for share:', error); + return NextResponse.json({ error: '获取素材失败' }, { status: 500 }); } } - diff --git a/app/api/materials/stats/route.ts b/app/api/materials/stats/route.ts index b2ba9fb..0993deb 100644 --- a/app/api/materials/stats/route.ts +++ b/app/api/materials/stats/route.ts @@ -1,14 +1,38 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; -// GET - 获取素材统计数据 +// GET - 获取素材统计数据(只统计未删除的素材) export async function GET() { try { - // 获取总数 - const total = await prisma.material.count(); + // 获取总数(只统计未删除的素材) + let total: number; + let materials: Array<{ createdAt: Date }>; - // 获取所有素材的创建时间 - const materials = await prisma.material.findMany({ + try { + total = await prisma.material.count({ + where: { + deletedAt: null, + } as any, + }); + + // 获取所有未删除素材的创建时间 + materials = await prisma.material.findMany({ + where: { + deletedAt: null, + } as any, + select: { + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + console.warn('[Materials Stats] deletedAt field not found, using all materials'); + total = await prisma.material.count(); + materials = await prisma.material.findMany({ select: { createdAt: true, }, @@ -16,6 +40,10 @@ export async function GET() { createdAt: 'desc', }, }); + } else { + throw e; + } + } // 按月份统计 const monthMap = new Map(); diff --git a/app/api/materials/transcode/route.ts b/app/api/materials/transcode/route.ts new file mode 100644 index 0000000..2aa1866 --- /dev/null +++ b/app/api/materials/transcode/route.ts @@ -0,0 +1,198 @@ +// 视频转码API - 完整版本(支持自动恢复) +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { transcodeQueue, cleanupTranscodeResidues } from '@/lib/video-transcode-queue'; +import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 3600; // 1小时 + +// 恢复标志:确保只执行一次 +let recoveryChecked = false; + +/** + * 懒加载恢复中断的转码任务 + */ +async function ensureRecoveryCompleted(): Promise { + if (recoveryChecked) { + return; + } + + recoveryChecked = true; + + try { + console.log('[Transcode] Starting recovery check...'); + const { recovered, requeued } = await transcodeQueue.recoverInterruptedTasks( + executeTranscodeTask, + cleanupTranscodeResidues + ); + console.log(`[Transcode] Recovery completed: ${recovered} tasks recovered, ${requeued} tasks requeued`); + } catch (error) { + console.error('[Transcode] Recovery failed:', error); + // 恢复失败不影响正常流程,重置标志以便下次重试 + recoveryChecked = false; + } +} + +// GET - 查询转码状态 +export async function GET(request: NextRequest) { + // 懒加载恢复:第一次调用时自动恢复中断的任务(包括 GET 请求) + await ensureRecoveryCompleted(); + + try { + const { searchParams } = new URL(request.url); + const materialId = searchParams.get('materialId'); + + if (!materialId) { + return NextResponse.json( + { error: '缺少素材ID' }, + { status: 400 } + ); + } + + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + videoProcessingStatus: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoDuration: true, + transcodeLastError: true, + transcodeAttempts: true, + } as any, + }); + + if (!material) { + return NextResponse.json( + { error: '素材不存在' }, + { status: 404 } + ); + } + + // 获取转码队列状态 + const queueStatus = transcodeQueue.getStatus(); + const isProcessing = queueStatus.processingIds.includes(materialId); + const waitingIndex = queueStatus.waiting.findIndex(id => id === materialId); + const waitingTotal = queueStatus.waiting.length; + + return NextResponse.json({ + materialId: material.id, + status: material.videoProcessingStatus || 'pending', + hasPreview540: !!material.videoPreview540Url, + hasClip: !!material.videoPreviewClipUrl, + hasCover: !!material.videoCoverUrl, + duration: material.videoDuration, + attempts: material.transcodeAttempts || 0, + lastError: material.transcodeLastError, + progress: isProcessing ? queueStatus.progress.get(materialId) || null : null, + stage: isProcessing ? queueStatus.stages.get(materialId) || null : null, + queue: { + waitingIndex: waitingIndex >= 0 ? waitingIndex + 1 : null, + waitingTotal: waitingTotal, + processingIds: queueStatus.processingIds, + maxConcurrent: queueStatus.maxConcurrent, + }, + }); + } catch (error) { + console.error('[Transcode] GET error:', error); + return NextResponse.json( + { error: '查询转码状态失败' }, + { status: 500 } + ); + } +} + +// POST - 启动转码任务 +export async function POST(request: NextRequest) { + // 懒加载恢复:第一次调用时自动恢复中断的任务 + await ensureRecoveryCompleted(); + + try { + const { materialId, forceRetry } = await request.json(); + + if (!materialId) { + return NextResponse.json( + { error: '缺少素材ID' }, + { status: 400 } + ); + } + + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + videoSourceUrl: true, + videoCoverUrl: true, + videoProcessingStatus: true, + materialType: true, + } as any, + }); + + if (!material) { + return NextResponse.json( + { error: '素材不存在' }, + { status: 404 } + ); + } + + // 检查是否为视频类型 + const videoTypes = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频']; + if (!videoTypes.includes((material as any).materialType)) { + return NextResponse.json( + { error: '此素材不是视频类型' }, + { status: 400 } + ); + } + + // 检查是否有源视频 + if (!(material as any).videoSourceUrl) { + return NextResponse.json( + { error: '未找到源视频文件' }, + { status: 400 } + ); + } + + // 检查转码状态 + if ((material as any).videoProcessingStatus === 'processing' && !forceRetry) { + return NextResponse.json( + { error: '转码任务正在进行中', materialId }, + { status: 409 } + ); + } + + if ((material as any).videoProcessingStatus === 'done' && !forceRetry) { + return NextResponse.json( + { message: '转码已完成', materialId }, + { status: 200 } + ); + } + + // 判断是否需要生成封面图(用户未上传时才生成) + const needsCoverImage = !(material as any).videoCoverUrl; + + // 加入转码队列 + await transcodeQueue.add( + materialId, + (material as any).videoSourceUrl, + needsCoverImage, + executeTranscodeTask + ); + + console.log(`[Transcode] Task queued for material ${materialId}`); + + return NextResponse.json({ + message: '转码任务已启动', + materialId, + status: 'processing', + queueStatus: transcodeQueue.getStatus(), + }); + } catch (error) { + console.error('[Transcode] POST error:', error); + return NextResponse.json( + { error: '转码任务启动失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 7dc20de..a521928 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -1,93 +1,128 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { uploadToS3, deleteFromS3 } from '@/lib/s3' -import { processImage, getProcessedFileName } from '@/lib/image-processor' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { deleteFromS3, uploadToS3 } from '@/lib/s3'; +import { compressImage, sanitizeFileName } from '@/lib/image-processor'; +import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation'; -export async function PUT(req: NextRequest) { +// PUT /api/materials/update - 更新素材(包括预览图) +export async function PUT(request: NextRequest) { try { - const session = await getServerSession(authOptions) - - if (!session || (session.user as any).role !== 'ADMIN') { - return NextResponse.json( - { error: '无权限' }, - { status: 403 } - ) + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - - const formData = await req.formData() - const id = formData.get('id') as string - const productModel = formData.get('productModel') as string - const categoryName = formData.get('categoryName') as string - const sceneName = formData.get('sceneName') as string - const materialType = (formData.get('materialType') as string) || '3DMAX' - const renderer = (formData.get('renderer') as string) || 'CR' - const authorName = formData.get('authorName') as string - const previewImage = formData.get('previewImage') as File | null - - // Validation - if (!id || !productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) { - return NextResponse.json( - { error: '所有字段都是必填的' }, - { status: 400 } - ) + const formData = await request.formData(); + const id = formData.get('id') as string; + const productModel = formData.get('productModel') as string; + const categoryName = formData.get('categoryName') as string; + const sceneName = formData.get('sceneName') as string; + const platformName = formData.get('platformName') as string | null; + const productCategoryName = formData.get('productCategoryName') as string | null; + const materialType = (formData.get('materialType') as string) || '3D场景'; + const renderer = formData.get('renderer') as string | null; + const authorName = formData.get('authorName') as string; + const previewImage = formData.get('previewImage') as File | null; + + // 使用统一的验证逻辑 + // 基础必填字段验证 + if (!id || !productModel || !materialType || !authorName) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }); } - - // Get existing material - const existingMaterial = await prisma.material.findUnique({ + + if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { + return NextResponse.json({ error: '请填写所有必填字段(店铺、场景)' }, { status: 400 }); + } + + // 标准模型必须选择品类 + if (requiresProductCategoryName(materialType) && !productCategoryName) { + return NextResponse.json({ error: '标准模型类型必须选择品类' }, { status: 400 }); + } + + // renderer 只在3D场景类型时必填 + if (requiresRenderer(materialType) && !renderer) { + return NextResponse.json({ error: '3D场景类型必须指定渲染器' }, { status: 400 }); + } + + // 检查素材是否存在 + const material = await prisma.material.findUnique({ where: { id }, - }) - - if (!existingMaterial) { - return NextResponse.json( - { error: '素材不存在' }, - { status: 404 } - ) + }); + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }); } - - let previewUrl = existingMaterial.previewUrl - - // If new preview image is uploaded + + // 检查权限:素材所有者、普通管理员或超级管理员都可以编辑 + const user = session.user as any; + const userId = user.id; + const userRole = user.role; + + // 检查是否是超级管理员 + const { isSuperAdmin } = await import('@/lib/department-permission'); + const isUserSuperAdmin = await isSuperAdmin(userId); + + if (material.userId !== userId && userRole !== 'ADMIN' && !isUserSuperAdmin) { + return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 }); + } + + let previewUrl = material.previewUrl; + + // 如果上传了新的预览图 if (previewImage && previewImage.size > 0) { - // Delete old preview image from S3 + // 删除旧的预览图 try { - await deleteFromS3(existingMaterial.previewUrl) + await deleteFromS3(material.previewUrl); } catch (error) { - console.error('Error deleting old preview:', error) + console.error('Error deleting old preview:', error); } - - // 处理预览图:压缩到300KB以内,转换为webP,缩放到600px - const originalBuffer = Buffer.from(await previewImage.arrayBuffer()) - const processedBuffer = await processImage(originalBuffer, { + + // 压缩并上传新的预览图 + const imageBuffer = Buffer.from(await previewImage.arrayBuffer()); + const compressedBuffer = await compressImage(imageBuffer, { maxSizeKB: 300, maxWidth: 600, format: 'webp', - }) - - // Upload processed preview image to S3 - const processedFileName = getProcessedFileName(previewImage.name) - const previewFileName = `preview-${Date.now()}-${processedFileName}` - previewUrl = await uploadToS3( - processedBuffer, - previewFileName, - 'image/webp' - ) + }); + + const sanitizedName = sanitizeFileName(previewImage.name); + const fileName = `preview-${Date.now()}-${sanitizedName}`; + previewUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp'); } - - // Update material record - const material = await prisma.material.update({ + + // 更新素材(包含所有字段,确保数据不丢失) + const updateData: any = { + previewUrl, + productModel, + // 使用统一的验证逻辑判断字段是否需要更新 + categoryName: requiresCategoryAndScene(materialType) ? (categoryName || material.categoryName) : (categoryName !== undefined && categoryName !== null ? categoryName : material.categoryName), + sceneName: requiresCategoryAndScene(materialType) ? (sceneName || material.sceneName) : (sceneName !== undefined && sceneName !== null ? sceneName : material.sceneName), + platformName: platformName !== undefined && platformName !== null ? platformName : (material.platformName || ''), + materialType, + authorName, + }; + + // 标准模型必须更新 productCategoryName + if (requiresProductCategoryName(materialType)) { + updateData.productCategoryName = productCategoryName || material.productCategoryName; + } else { + updateData.productCategoryName = productCategoryName !== undefined && productCategoryName !== null ? productCategoryName : material.productCategoryName; + } + + // 使用统一的验证逻辑更新renderer + if (requiresRenderer(materialType)) { + // 3D场景类型:必须提供renderer,如果没有提供则使用默认值或保留原值 + updateData.renderer = renderer || material.renderer || getDefaultRenderer(materialType); + } else { + // 非3D场景类型,保持原renderer值(如果有) + updateData.renderer = material.renderer; + } + + const updatedMaterial = await prisma.material.update({ where: { id }, - data: { - previewUrl, - productModel, - categoryName, - sceneName, - materialType, - renderer, - authorName, - }, + data: updateData, include: { user: { select: { @@ -95,24 +130,19 @@ export async function PUT(req: NextRequest) { }, }, }, - }) - - // 转换BigInt为字符串 - const materialData = { - ...material, - zipFileSize: material.zipFileSize.toString() - } - + }); + + const response = { + ...updatedMaterial, + zipFileSize: updatedMaterial.zipFileSize.toString(), + }; + return NextResponse.json( - { message: '更新成功', material: materialData }, + { message: '更新成功', material: response }, { status: 200 } - ) + ); } catch (error) { - console.error('Update error:', error) - return NextResponse.json( - { error: '更新失败,请稍后再试' }, - { status: 500 } - ) + console.error('Update error:', error); + return NextResponse.json({ error: '更新失败,请稍后再试' }, { status: 500 }); } } - diff --git a/app/api/materials/upload/chunk/route.ts b/app/api/materials/upload/chunk/route.ts new file mode 100644 index 0000000..5abacb6 --- /dev/null +++ b/app/api/materials/upload/chunk/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getUploadSession, updateChunk } from '@/lib/upload-sessions' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' + +// Configure route to handle large files +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 // 3 hours timeout + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const formData = await req.formData() + const uploadId = formData.get('uploadId') as string + const chunkIndex = parseInt(formData.get('chunkIndex') as string) + const chunk = formData.get('chunk') as File + const fileName = formData.get('fileName') as string + + if (!uploadId || isNaN(chunkIndex) || !chunk) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) + } + + // 获取上传会话(使用统一的接口,自动选择Redis或内存Map) + const sessionData = await getUploadSession(uploadId) + if (!sessionData) { + return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) + } + + // 验证用户 + if (sessionData.userId !== (session.user as any).id) { + return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) + } + + // 保存块到临时文件 + const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId) + await mkdir(tempDir, { recursive: true }) + + const chunkFilePath = path.join(tempDir, `chunk-${chunkIndex}`) + const chunkBuffer = Buffer.from(await chunk.arrayBuffer()) + await writeFile(chunkFilePath, chunkBuffer) + + // 记录已上传的块(使用统一的接口,自动选择Redis或内存Map) + await updateChunk(uploadId, chunkIndex, chunkFilePath) + + // 重新获取会话以获取最新的块数量 + const updatedSession = await getUploadSession(uploadId) + const uploadedChunksCount = updatedSession?.chunks.size || sessionData.chunks.size + 1 + + console.log(`[UploadChunk] Saved chunk ${chunkIndex}/${sessionData.totalChunks} for ${uploadId}`) + + return NextResponse.json({ + success: true, + uploadedChunks: uploadedChunksCount, + totalChunks: sessionData.totalChunks, + }) + } catch (error) { + console.error('[UploadChunk] Error:', error) + return NextResponse.json( + { error: '上传块失败' }, + { status: 500 } + ) + } +} + diff --git a/app/api/materials/upload/cleanup/route.ts b/app/api/materials/upload/cleanup/route.ts new file mode 100644 index 0000000..624fdf8 --- /dev/null +++ b/app/api/materials/upload/cleanup/route.ts @@ -0,0 +1,266 @@ +// 清理过期或失败的临时上传文件 +// 用于清理:1. 过期的上传会话对应的临时文件 2. 失败的上传临时文件 3. 用户取消上传的临时文件 +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getUploadSession } from '@/lib/upload-sessions' +import { promises as fs } from 'fs' +import path from 'path' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 // 5分钟 + +/** + * 清理单个上传会话的临时文件 + */ +async function cleanupUploadSession(uploadId: string, uploadDir: string): Promise<{ + success: boolean + deletedFiles: number + freedSpace: number + error?: string +}> { + const tempDir = path.join(uploadDir, 'temp', uploadId) + let deletedFiles = 0 + let freedSpace = 0 + + try { + // 检查目录是否存在 + try { + await fs.access(tempDir) + } catch { + // 目录不存在,说明已经清理过了 + return { success: true, deletedFiles: 0, freedSpace: 0 } + } + + // 读取目录中的所有文件 + const files = await fs.readdir(tempDir) + + for (const file of files) { + const filePath = path.join(tempDir, file) + try { + const stat = await fs.stat(filePath) + if (stat.isFile()) { + await fs.unlink(filePath) + deletedFiles++ + freedSpace += stat.size + } + } catch (err) { + console.warn(`[Cleanup] 删除文件失败: ${filePath}`, err) + } + } + + // 删除目录本身 + try { + await fs.rmdir(tempDir) + } catch (err) { + console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, err) + } + + return { success: true, deletedFiles, freedSpace } + } catch (error: any) { + return { + success: false, + deletedFiles, + freedSpace, + error: error.message || '清理失败' + } + } +} + +/** + * GET - 清理过期的临时上传文件 + * 参数: + * - uploadId: 可选,指定要清理的上传ID + * - expiredOnly: 可选,是否只清理过期的(默认true) + * - maxAge: 可选,最大年龄(小时),默认8小时 + */ +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + // 检查是否为管理员(可选,如果需要限制权限) + // const user = await prisma.user.findUnique({ where: { id: session.user.id } }) + // if (user?.role !== 'SUPER_ADMIN') { + // return NextResponse.json({ error: '权限不足' }, { status: 403 }) + // } + + const { searchParams } = new URL(req.url) + const uploadId = searchParams.get('uploadId') + const expiredOnly = searchParams.get('expiredOnly') !== 'false' + const maxAgeHours = parseInt(searchParams.get('maxAge') || '8', 10) + const maxAge = maxAgeHours * 60 * 60 * 1000 // 转换为毫秒 + + const uploadDir = process.env.UPLOAD_DIR || './uploads' + const tempBaseDir = path.join(uploadDir, 'temp') + + // 确保临时目录存在 + try { + await fs.access(tempBaseDir) + } catch { + return NextResponse.json({ + message: '临时目录不存在,无需清理', + cleaned: 0, + freedSpace: 0 + }) + } + + let cleaned = 0 + let totalFreedSpace = 0 + const errors: string[] = [] + + if (uploadId) { + // 清理指定的上传ID + const result = await cleanupUploadSession(uploadId, uploadDir) + if (result.success) { + cleaned = result.deletedFiles + totalFreedSpace = result.freedSpace + } else { + errors.push(result.error || '清理失败') + } + } else { + // 清理所有过期或无效的临时文件 + const now = Date.now() + const tempDirs = await fs.readdir(tempBaseDir) + + for (const dir of tempDirs) { + const uploadIdFromDir = dir + const tempDir = path.join(tempBaseDir, dir) + + try { + // 检查目录 + const stat = await fs.stat(tempDir) + if (!stat.isDirectory()) { + continue + } + + // 读取元数据文件(如果存在) + const metaPath = path.join(tempDir, 'meta.json') + let createdAt = stat.mtimeMs // 使用目录修改时间作为fallback + + try { + const metaContent = await fs.readFile(metaPath, 'utf-8') + const meta = JSON.parse(metaContent) + if (meta.createdAt) { + createdAt = meta.createdAt + } + } catch { + // 元数据文件不存在,使用目录修改时间 + } + + // 检查是否过期 + const age = now - createdAt + const isExpired = age > maxAge + + // 检查会话是否存在 + const session = await getUploadSession(uploadIdFromDir) + const sessionExists = !!session + + // 决定是否清理 + let shouldCleanup = false + if (expiredOnly) { + // 只清理过期的 + shouldCleanup = isExpired + } else { + // 清理所有:过期的 或 会话不存在的 + shouldCleanup = isExpired || !sessionExists + } + + if (shouldCleanup) { + const result = await cleanupUploadSession(uploadIdFromDir, uploadDir) + if (result.success) { + cleaned += result.deletedFiles + totalFreedSpace += result.freedSpace + console.log(`[Cleanup] 清理上传会话: ${uploadIdFromDir}, 文件数: ${result.deletedFiles}, 释放空间: ${formatBytes(result.freedSpace)}`) + } else { + errors.push(`${uploadIdFromDir}: ${result.error}`) + } + } + } catch (err: any) { + errors.push(`${dir}: ${err.message || '读取失败'}`) + } + } + } + + return NextResponse.json({ + message: '清理完成', + cleaned, + freedSpace: totalFreedSpace, + freedSpaceFormatted: formatBytes(totalFreedSpace), + errors: errors.length > 0 ? errors : undefined + }) + } catch (error: any) { + console.error('[Cleanup] Error:', error) + return NextResponse.json( + { error: '清理失败: ' + (error.message || '未知错误') }, + { status: 500 } + ) + } +} + +/** + * POST - 清理指定的上传会话(用于用户取消上传时调用) + */ +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const { uploadId } = await req.json() + + if (!uploadId) { + return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 }) + } + + // 验证会话归属 + const uploadSession = await getUploadSession(uploadId) + if (uploadSession && uploadSession.userId !== (session.user as any).id) { + return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 }) + } + + const uploadDir = process.env.UPLOAD_DIR || './uploads' + const result = await cleanupUploadSession(uploadId, uploadDir) + + if (result.success) { + // 删除上传会话 + try { + const { deleteUploadSession } = await import('@/lib/upload-sessions') + await deleteUploadSession(uploadId) + } catch (err) { + console.warn('[Cleanup] 删除上传会话失败:', err) + } + + return NextResponse.json({ + message: '清理成功', + deletedFiles: result.deletedFiles, + freedSpace: result.freedSpace, + freedSpaceFormatted: formatBytes(result.freedSpace) + }) + } else { + return NextResponse.json( + { error: result.error || '清理失败' }, + { status: 500 } + ) + } + } catch (error: any) { + console.error('[Cleanup] Error:', error) + return NextResponse.json( + { error: '清理失败: ' + (error.message || '未知错误') }, + { status: 500 } + ) + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] +} + diff --git a/app/api/materials/upload/complete/route.ts b/app/api/materials/upload/complete/route.ts new file mode 100644 index 0000000..fc0af0f --- /dev/null +++ b/app/api/materials/upload/complete/route.ts @@ -0,0 +1,114 @@ +// 分片上传完成合并接口 +// 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径 +// 供前端在第二步调用正式上传接口时复用,避免重复上传大文件 +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import path from 'path' +import fs from 'fs/promises' +import { createWriteStream, createReadStream } from 'fs' +import { pipeline } from 'stream/promises' +import { getUploadSession, deleteUploadSession } from '@/lib/upload-sessions' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 10800 // 最长3小时 + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const { uploadId, originalFileName } = await req.json() + + if (!uploadId) { + return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 }) + } + + const uploadDir = process.env.UPLOAD_DIR || './uploads' + const tempDir = path.join(uploadDir, 'temp', uploadId) + + // 读取会话 + const uploadSession = await getUploadSession(uploadId) + if (!uploadSession) { + return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) + } + + // 会话归属校验 + if (uploadSession.userId !== (session.user as any).id) { + return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) + } + + const totalChunks = uploadSession.totalChunks + const fileSize = uploadSession.fileSize + + // 收集分片文件 + const chunkFiles = [] + for (let i = 0; i < totalChunks; i++) { + const chunkPath = path.join(tempDir, `chunk-${i}`) + chunkFiles.push(chunkPath) + } + + // 校验分片是否齐全 + for (const chunkPath of chunkFiles) { + try { + await fs.access(chunkPath) + } catch { + return NextResponse.json({ error: '分片不完整,请重新上传' }, { status: 400 }) + } + } + + // 合并文件 + await fs.mkdir(tempDir, { recursive: true }) + const mergedFileName = originalFileName || uploadSession.fileName || `upload-${uploadId}` + const mergedPath = path.join(tempDir, `merged-${mergedFileName}`) + + const mergedStream = createWriteStream(mergedPath) + for (const chunkPath of chunkFiles) { + const chunkStream = createReadStream(chunkPath) + await pipeline(chunkStream, mergedStream, { end: false }) + } + // 手动结束写入并等待关闭 + mergedStream.end() + await new Promise((resolve, reject) => { + mergedStream.on('close', () => resolve()) + mergedStream.on('error', (err) => reject(err)) + }) + + // 校验大小 + const stat = await fs.stat(mergedPath) + if (stat.size !== fileSize) { + return NextResponse.json( + { error: `合并后的文件大小不匹配,期望 ${fileSize} 实际 ${stat.size}` }, + { status: 400 } + ) + } + + // 清理分片,保留合并后的文件供后续正式上传使用 + await Promise.all( + chunkFiles.map(async (file) => { + try { + await fs.unlink(file) + } catch (err) { + console.warn('[UploadComplete] 清理分片失败(忽略):', err) + } + }) + ) + + // 不立刻删除会话,方便后续调试。交由调用方完成正式入库后再清理。 + // 返回合并结果 + return NextResponse.json({ + message: '分片合并完成', + existingFilePath: mergedPath, + fileName: mergedFileName, + fileSize: stat.size, + uploadId, + }) + } catch (error) { + console.error('[UploadComplete] Error:', error) + return NextResponse.json({ error: '合并分片失败' }, { status: 500 }) + } +} + diff --git a/app/api/materials/upload/init/route.ts b/app/api/materials/upload/init/route.ts new file mode 100644 index 0000000..5facc8b --- /dev/null +++ b/app/api/materials/upload/init/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { saveUploadSession } from '@/lib/upload-sessions' +import { randomBytes } from 'crypto' +import { mkdir, writeFile } from 'fs/promises' +import path from 'path' + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const { fileName, fileSize, totalChunks } = await req.json() + + if (!fileName || !fileSize || !totalChunks) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) + } + + // 生成上传ID + const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}` + + // 创建临时目录 + const uploadDir = process.env.UPLOAD_DIR || '/app/uploads' + const tempDir = path.join(uploadDir, 'temp', uploadId) + await mkdir(tempDir, { recursive: true }) + // 写入元数据,方便在Redis丢失时回溯 + const metaPath = path.join(tempDir, 'meta.json') + const metaPayload = { + fileName, + fileSize, + totalChunks, + userId: (session.user as any).id, + createdAt: Date.now(), + } + await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8') + + // 保存会话(使用统一的接口,自动选择Redis或内存Map) + await saveUploadSession(uploadId, { + fileName, + fileSize, + totalChunks, + chunks: new Map(), + createdAt: Date.now(), + userId: (session.user as any).id, + }) + + console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${totalChunks}`) + + return NextResponse.json({ uploadId }) + } catch (error) { + console.error('[UploadInit] Error:', error) + return NextResponse.json( + { error: '初始化上传失败' }, + { status: 500 } + ) + } +} + diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 445a153..b36f52f 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -1,341 +1,448 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { uploadToS3 } from '@/lib/s3' -import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor' -import { writeFile, mkdir } from 'fs/promises' -import path from 'path' -import { randomBytes } from 'crypto' +// 素材上传API - 支持多种素材类型 +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { uploadToS3 } from '@/lib/s3'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { randomBytes } from 'crypto'; +import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'; +import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'; +import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation'; -// 生成唯一文件名(避免并发冲突) -function generateUniqueFileName(prefix: string, ext: string): string { - const timestamp = Date.now() - const randomId = randomBytes(8).toString('hex') // 16个字符的随机字符串 - return `${prefix}-${timestamp}-${randomId}${ext}` +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const maxDuration = 10800; // 3小时 + +// 音频素材占位符预览图(SVG格式) +const AUDIO_PLACEHOLDER = (() => { + const svg = `音频素材` + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` +})() + +// 生成唯一文件名 +function generateUniqueFilename(prefix: string, ext: string): string { + const timestamp = Date.now(); + const random = randomBytes(8).toString('hex'); + return `${prefix}-${timestamp}-${random}${ext}`; } -// Configure route to handle large files -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -// Increase timeout for large file uploads -export const maxDuration = 10800 // 3 hours timeout (10800 seconds) - -export async function POST(req: NextRequest) { +// POST - 上传素材 +export async function POST(request: NextRequest) { try { - console.log('[Upload] Starting upload process...') - - const session = await getServerSession(authOptions) - console.log('[Upload] Session checked:', !!session) + console.log('[Upload] Starting upload process...'); + const session = await getServerSession(authOptions); if (!session) { return NextResponse.json( { error: '请先登录' }, { status: 401 } - ) + ); } - console.log('[Upload] Parsing form data...') - const formData = await req.formData() - - const previewImage = formData.get('previewImage') as File - const displayImage = formData.get('displayImage') as File // PSD素材的展示图片 - const modelImages = formData.getAll('modelImages') as File[] // 模特类型的多张展示图片 - const zipFile = formData.get('zipFile') as File - const productModel = formData.get('productModel') as string - const categoryName = formData.get('categoryName') as string - const sceneName = formData.get('sceneName') as string - const materialType = (formData.get('materialType') as string) || '3DMAX' - const renderer = (formData.get('renderer') as string) || 'CR' + const formData = await request.formData(); + const previewImage = formData.get('previewImage') as File | null; // 第一张预览图(主预览图) + const previewImages = formData.getAll('previewImages') as File[]; // 其他预览图(作为展示图) + const displayImage = formData.get('displayImage') as File | null; + const modelImages = formData.getAll('modelImages') as File[]; + const displayImagesFromForm = formData.getAll('displayImages') as File[]; + const zipFile = formData.get('zipFile') as File | null; + // 支持分片上传后复用服务器已合并的文件 + const existingFilePath = (formData.get('existingFilePath') as string) || ''; + const originalFileName = (formData.get('originalFileName') as string) || ''; + const productModel = formData.get('productModel') as string; + const categoryName = formData.get('categoryName') as string; + const sceneName = formData.get('sceneName') as string; + const platformName = (formData.get('platformName') as string) || ''; + const productCategoryName = (formData.get('productCategoryName') as string) || ''; + const materialType = (formData.get('materialType') as string) || '3D场景'; + const rendererParam = formData.get('renderer') as string | null; + // 统一处理renderer:3D场景必须有renderer,其他类型可选 + const renderer = requiresRenderer(materialType) + ? (rendererParam || getDefaultRenderer(materialType)) + : (rendererParam || ''); console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, - previewImageSize: previewImage?.size, + previewImagesCount: previewImages.length, hasDisplayImage: !!displayImage, - displayImageSize: displayImage?.size, modelImagesCount: modelImages.length, hasZipFile: !!zipFile, - zipFileSize: zipFile?.size, + hasExistingFilePath: !!existingFilePath, productModel, categoryName, sceneName, - materialType - }) + materialType, + }); - console.log('[Upload] Material type:', materialType) - - // Validation - 基础字段验证 - if (!productModel || !categoryName || !sceneName) { - console.log('[Upload] Validation failed - missing required fields') + // 使用统一的验证逻辑验证必填字段 + if (!productModel) { return NextResponse.json( - { error: '产品型号、分类和场景都是必填的' }, + { error: '产品型号是必填的' }, { status: 400 } - ) + ); + } + + if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { + return NextResponse.json( + { error: '请填写所有必填字段(店铺、场景)' }, + { status: 400 } + ); } - // 素材文件验证(模特类型素材文件可选) - if (materialType !== '模特' && !zipFile) { - console.log('[Upload] Validation failed - missing zip file') + // 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件) + const requiresMainFile = !['模特', '标准模型', '买家秀图片', '小红书种草'].includes(materialType); + const hasMainFile = !!zipFile || !!existingFilePath; + if (requiresMainFile && !hasMainFile) { return NextResponse.json( { error: '请上传素材文件' }, { status: 400 } - ) + ); } - // 对于PSD类型,必须有展示图片 - if (materialType === 'PSD' && !displayImage) { - console.log('[Upload] Validation failed - PSD requires display image') - return NextResponse.json( - { error: 'PSD素材必须上传展示图片' }, - { status: 400 } - ) - } - - // 对于模特类型,必须有至少一张展示图片 - if (materialType === '模特' && modelImages.length === 0) { - console.log('[Upload] Validation failed - Model requires model images') - return NextResponse.json( - { error: '模特素材必须上传至少一张展示图片' }, - { status: 400 } - ) - } - - // 对于非PSD和非模特类型,必须有预览图 - if (materialType !== 'PSD' && materialType !== '模特' && !previewImage) { - console.log('[Upload] Validation failed - non-PSD/Model requires preview image') - return NextResponse.json( - { error: '请上传预览图' }, - { status: 400 } - ) - } - - // 获取用户信息作为作者花名 - const userId = (session.user as any).id - console.log('[Upload] Fetching user info... Session user ID:', userId, 'Type:', typeof userId) + // 获取用户信息 const user = await prisma.user.findUnique({ - where: { id: userId }, + where: { id: session.user.id }, select: { username: true }, - }) + }); if (!user) { - console.log('[Upload] User not found for ID:', userId) return NextResponse.json( { error: '用户不存在' }, { status: 404 } - ) + ); } - const authorName = user.username - console.log('[Upload] User found:', authorName) + let previewUrl = ''; + const displayImages: string[] = []; - let previewUrl: string = '' - - // 处理预览图(仅非PSD和非模特类型) - if (materialType !== 'PSD' && materialType !== '模特' && previewImage) { - // 验证预览图大小(限制50MB) - const maxPreviewSize = 50 * 1024 * 1024 // 50MB - if (previewImage.size > maxPreviewSize) { - console.log(`[Upload] Preview image too large: ${(previewImage.size / 1024 / 1024).toFixed(2)}MB`) + // 处理预览图(第一张作为主预览图) + if (previewImage && !['详情页', '模特'].includes(materialType)) { + if (previewImage.size > 50 * 1024 * 1024) { return NextResponse.json( - { error: `预览图过大,请使用小于50MB的图片(当前:${(previewImage.size / 1024 / 1024).toFixed(2)}MB)` }, + { error: `预览图过大,请使用小于50MB的图片` }, { status: 400 } - ) + ); } - // 验证预览图格式 - const allowedImageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'] - if (!allowedImageTypes.includes(previewImage.type)) { - console.log(`[Upload] Invalid image type: ${previewImage.type}`) - return NextResponse.json( - { error: `不支持的图片格式:${previewImage.type},请使用 JPG、PNG、WebP 或 GIF 格式` }, - { status: 400 } - ) - } - - // 处理预览图:压缩到300KB以内,转换为webP,缩放适配300x400显示 - console.log(`[Upload] Processing preview image... (${previewImage.type}, ${(previewImage.size / 1024).toFixed(2)}KB)`) - try { - const originalBuffer = Buffer.from(await previewImage.arrayBuffer()) - const processedBuffer = await processImage(originalBuffer, { - maxSizeKB: 300, - maxWidth: 600, - maxHeight: 800, - format: 'webp', - }) - console.log('[Upload] Preview image processed successfully') - - // Upload processed preview image to S3 - console.log('[Upload] Uploading preview image to S3...') - const processedFileName = getProcessedFileName(previewImage.name) - const fileExt = path.extname(processedFileName) - const previewFileName = generateUniqueFileName('preview', fileExt) - previewUrl = await uploadToS3(processedBuffer, previewFileName, 'image/webp') - console.log('[Upload] Preview image uploaded to S3:', previewUrl) - } catch (imageError) { - console.error('[Upload] Preview image processing error:', imageError) - return NextResponse.json( - { error: `预览图处理失败:${imageError instanceof Error ? imageError.message : '未知错误'}` }, - { status: 500 } - ) - } + console.log('[Upload] Processing preview image (1/1)...'); + const buffer = Buffer.from(await previewImage.arrayBuffer()); + const filename = generateUniqueFilename('preview', path.extname(previewImage.name)); + console.log('[Upload] Uploading preview image to S3...'); + previewUrl = await uploadToS3(buffer, filename, previewImage.type); + console.log('[Upload] Preview image uploaded:', previewUrl); } - // Handle PSD display images (切割和上传展示图片) - let displayImages: string[] = [] - if (materialType === 'PSD' && displayImage) { - console.log('[Upload] Processing PSD display image...') - try { - // 读取展示图片 - const displayImageBuffer = Buffer.from(await displayImage.arrayBuffer()) + // 处理其他预览图(作为展示图)- 并行处理以提高速度 + if (previewImages.length > 0 && !['详情页', '模特'].includes(materialType)) { + console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`); + + // 验证所有图片大小 + for (let i = 0; i < previewImages.length; i++) { + if (previewImages[i].size > 50 * 1024 * 1024) { + return NextResponse.json( + { error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` }, + { status: 400 } + ); + } + } + + // 并行处理所有预览图 + const uploadPromises = previewImages.map(async (image, index) => { + console.log(`[Upload] Processing preview image ${index + 2}/${previewImages.length + 1}...`); + const buffer = Buffer.from(await image.arrayBuffer()); + const filename = generateUniqueFilename(`preview-${index + 2}`, path.extname(image.name)); + console.log(`[Upload] Uploading preview image ${index + 2} to S3...`); + const url = await uploadToS3(buffer, filename, image.type); + console.log(`[Upload] Preview image ${index + 2} uploaded:`, url); + return url; + }); + + // 等待所有预览图上传完成 + const urls = await Promise.all(uploadPromises); + displayImages.push(...urls); + console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`); + } + + // 处理展示图片(详情页) + if (materialType === '详情页' && displayImage) { + const buffer = Buffer.from(await displayImage.arrayBuffer()); + const filename = generateUniqueFilename('display', path.extname(displayImage.name)); + const url = await uploadToS3(buffer, filename, displayImage.type); + displayImages.push(url); + + if (!previewUrl) { + previewUrl = url; + } + console.log('[Upload] Display image uploaded:', url); + } + + // 处理模特图片 + if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) { + for (let i = 0; i < modelImages.length; i++) { + const image = modelImages[i]; + const buffer = Buffer.from(await image.arrayBuffer()); + const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name)); + const url = await uploadToS3(buffer, filename, image.type); + displayImages.push(url); - // 切割图片(按1500px高度) - console.log('[Upload] Slicing display image (max height: 1500px)...') - const slicedBuffers = await sliceImage(displayImageBuffer, 1500) - console.log(`[Upload] Image sliced into ${slicedBuffers.length} parts`) - - // 上传每个切片到S3 - for (let i = 0; i < slicedBuffers.length; i++) { - const sliceFileName = generateUniqueFileName(`display-${i + 1}`, '.webp') - const sliceUrl = await uploadToS3(slicedBuffers[i], sliceFileName, 'image/webp') - displayImages.push(sliceUrl) - console.log(`[Upload] Display image slice ${i + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl) + if (i === 0 && !previewUrl) { + previewUrl = url; } - - // 如果没有预览图,使用第一张展示图片作为预览图 - if (!previewImage && displayImages.length > 0) { - previewUrl = displayImages[0] - console.log('[Upload] Using first display image as preview:', previewUrl) - } - } catch (displayImageError) { - console.error('[Upload] Display image processing error:', displayImageError) - return NextResponse.json( - { error: `PSD展示图片处理失败:${displayImageError instanceof Error ? displayImageError.message : '未知错误'}` }, - { status: 500 } - ) + console.log(`[Upload] Model image ${i + 1} uploaded:`, url); } } - // Handle Model display images (模特类型的多图处理) - if (materialType === '模特' && modelImages.length > 0) { - console.log(`[Upload] Processing ${modelImages.length} model images...`) - try { - // 处理每张模特图片 - for (let i = 0; i < modelImages.length; i++) { - const modelImage = modelImages[i] - console.log(`[Upload] Processing model image ${i + 1}/${modelImages.length}...`) - - // 读取图片 - const imageBuffer = Buffer.from(await modelImage.arrayBuffer()) - - // 切割图片(按1500px高度) - const slicedBuffers = await sliceImage(imageBuffer, 1500) - console.log(`[Upload] Model image ${i + 1} sliced into ${slicedBuffers.length} parts`) + // 处理通用展示图(反编译:买家秀/小红书/主图模版/SKU模版/品牌VI 等) + if (displayImagesFromForm.length > 0) { + for (let i = 0; i < displayImagesFromForm.length; i++) { + const image = displayImagesFromForm[i]; + const buffer = Buffer.from(await image.arrayBuffer()); + const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name)); + const url = await uploadToS3(buffer, filename, image.type); + displayImages.push(url); + if (!previewUrl) { + previewUrl = url; + } + console.log(`[Upload] Display image ${i + 1} uploaded:`, url); + } + } - // 上传每个切片到S3 - for (let j = 0; j < slicedBuffers.length; j++) { - const sliceFileName = generateUniqueFileName(`model-${i + 1}-${j + 1}`, '.webp') - const sliceUrl = await uploadToS3(slicedBuffers[j], sliceFileName, 'image/webp') - displayImages.push(sliceUrl) - console.log(`[Upload] Model image ${i + 1} slice ${j + 1}/${slicedBuffers.length} uploaded to S3:`, sliceUrl) + // 处理素材文件(主文件:3D场景/PSD/视频源等) + let zipFileName = ''; + let zipFilePath = ''; + let zipFileSize = BigInt(0); + + if (zipFile || existingFilePath) { + let ext = ''; + let buffer: Buffer | null = null; + let sourcePath = ''; + + if (zipFile) { + ext = path.extname(zipFile.name); + buffer = Buffer.from(await zipFile.arrayBuffer()); + zipFileSize = BigInt(buffer.length); + console.log('[Upload] Using uploaded file buffer'); + } else { + // 使用分片合并后的服务器侧文件 + sourcePath = existingFilePath; + const stat = await fs.stat(sourcePath); + zipFileSize = BigInt(stat.size); + ext = path.extname(originalFileName || sourcePath) || '.zip'; + console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size); + } + + const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType); + + // 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件 + const uploadDir = process.env.UPLOAD_DIR || './uploads'; + await fs.mkdir(uploadDir, { recursive: true }); + + // 根据类型决定文件名前缀 + const basePrefix = isVideoOrAudio + ? materialType === '音乐' + ? 'audio-source' + : 'video' + : 'material'; + + zipFileName = generateUniqueFilename(basePrefix, ext || '.bin'); + zipFilePath = path.join(uploadDir, zipFileName); + + if (sourcePath && !zipFile) { + // 分片上传场景:直接移动合并后的大文件,避免重复读写 50MB+ 文件 + await fs.rename(sourcePath, zipFilePath); + // 已经移动到最终位置,不再额外清理 + sourcePath = ''; + console.log('[Upload] Moved merged file to final path (no re-copy):', zipFilePath); + } else if (buffer) { + // 直传场景:使用内存中的 buffer 写入 + await fs.writeFile(zipFilePath, buffer); + console.log('[Upload] File saved locally:', zipFilePath); + } else { + throw new Error('未找到可写入的素材文件数据'); + } + + // 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化 + if (materialType === '音乐' && zipFile && buffer && isAudioFile(zipFile.type)) { + try { + console.log('[Upload] Audio file detected, parsing metadata and extracting cover image...'); + const audioMetadata = await parseAudioMetadata(buffer, zipFile.type); + + if (audioMetadata) { + console.log('[Upload] Audio metadata parsed:', { + duration: audioMetadata.duration, + durationFormatted: audioMetadata.durationFormatted, + format: audioMetadata.format, + hasPicture: !!audioMetadata.picture, + }); + + // 如果音频文件包含封面图,提取并上传到 MinIO 作为预览 + if (audioMetadata.picture && !previewUrl) { + try { + const pictureBuffer = audioMetadata.picture.data; + const pictureFormat = audioMetadata.picture.format || 'image/jpeg'; + const pictureExt = pictureFormat.includes('png') ? '.png' : '.jpg'; + const pictureFilename = generateUniqueFilename('audio-cover', pictureExt); + const pictureUrl = await uploadToS3(pictureBuffer, pictureFilename, pictureFormat); + previewUrl = pictureUrl; + console.log('[Upload] Audio cover image extracted and uploaded:', pictureUrl); + } catch (coverError) { + console.error('[Upload] Failed to upload audio cover image:', coverError); + } + } } + } catch (audioError) { + console.error('[Upload] Failed to parse audio metadata:', audioError); + // 音频解析失败不影响上传,继续处理 } + } - // 使用第一张图片作为预览图 - if (displayImages.length > 0) { - previewUrl = displayImages[0] - console.log('[Upload] Using first model image as preview:', previewUrl) + // 如果是复用服务器合并文件,用后清理以节省空间 + if (sourcePath) { + try { + await fs.unlink(sourcePath); + console.log('[Upload] 已清理临时合并文件:', sourcePath); + + // 同时清理整个临时目录(如果为空) + const tempDir = path.dirname(sourcePath); + try { + const files = await fs.readdir(tempDir); + if (files.length === 0) { + await fs.rmdir(tempDir); + console.log('[Upload] 已清理空临时目录:', tempDir); + } + } catch (dirError) { + // 忽略目录清理错误 + } + } catch (cleanupError) { + console.warn('[Upload] 清理临时合并文件失败(忽略):', cleanupError); } - } catch (modelImageError) { - console.error('[Upload] Model image processing error:', modelImageError) - return NextResponse.json( - { error: `模特图片处理失败:${modelImageError instanceof Error ? modelImageError.message : '未知错误'}` }, - { status: 500 } - ) } } - // Save zip/PSD file locally (if exists - 模特类型可以没有素材文件) - let zipFileName = '' - let zipFilePath = '' - let zipFileSize = BigInt(0) - - if (!zipFile) { - console.log('[Upload] No zip file provided (Model type with images only), skipping file save...') - } else { - console.log('[Upload] Saving file locally...') - const uploadDir = process.env.UPLOAD_DIR || './uploads' - await mkdir(uploadDir, { recursive: true }) - - const zipFileExt = path.extname(zipFile.name) - zipFileName = generateUniqueFileName('material', zipFileExt) - zipFilePath = path.join(uploadDir, zipFileName) - - // 读取并保存素材文件 - const zipBuffer = Buffer.from(await zipFile.arrayBuffer()) - await writeFile(zipFilePath, zipBuffer) - zipFileSize = BigInt(zipBuffer.length) // 记录文件大小(字节) - console.log('[Upload] File saved:', zipFilePath, `(${(Number(zipFileSize) / 1024 / 1024).toFixed(2)} MB)`) + // 音频素材:如果没有预览图,使用占位符 + if (materialType === '音乐' && (!previewUrl || previewUrl.trim() === '')) { + previewUrl = AUDIO_PLACEHOLDER + console.log('[Upload] Audio material without cover image, using audio placeholder') } - - // Create material record - console.log('[Upload] Creating database record...') + + // 创建数据库记录(包含所有字段,确保数据不丢失) + // 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放 + const videoSourceUrl = + ['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName + ? `/uploads/${zipFileName}` + : null; + const material = await prisma.material.create({ data: { previewUrl, productModel, categoryName, sceneName, + platformName, + productCategoryName, materialType, renderer, - authorName, + authorName: user.username, zipFileName, zipFilePath, zipFileSize, - displayImages, // PSD/模特展示图片URLs数组 - userId: (session.user as any).id, - }, + displayImages, + videoSourceUrl, // 视频类型时保存视频URL + videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态 + userId: session.user.id, + } as any, include: { user: { - select: { - username: true, - }, + select: { username: true }, }, }, - }) - console.log('[Upload] Database record created:', material.id) + }); - // 转换BigInt为字符串以支持JSON序列化 - const materialData = { - ...material, - zipFileSize: material.zipFileSize.toString() + console.log('[Upload] Database record created:', material.id); + + // 验证文件是否存在(特别是音乐和视频文件) + if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) { + try { + const stats = await fs.stat(zipFilePath); + console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size }); + } catch (fileError: any) { + console.error('[Upload] ⚠️ File not found after upload:', { + path: zipFilePath, + error: fileError.message, + materialId: material.id, + }); + // 文件不存在,但记录已创建,这会导致播放失败 + // 这里只记录错误,不阻止流程,因为可能是其他问题 + } } - console.log('[Upload] Upload completed successfully!') - return NextResponse.json( - { message: '上传成功', material: materialData }, - { status: 201 } - ) - } catch (error) { - console.error('Upload error:', error) - - // Log detailed error information - if (error instanceof Error) { - console.error('Error name:', error.name) - console.error('Error message:', error.message) - console.error('Error stack:', error.stack) + // 清理上传会话(如果使用了分片上传) + const uploadId = formData.get('uploadId') as string | null; + if (uploadId) { + try { + const { deleteUploadSession } = await import('@/lib/upload-sessions'); + await deleteUploadSession(uploadId); + console.log('[Upload] 已清理上传会话:', uploadId); + } catch (cleanupError) { + console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError); + } } - - // Return more specific error message for debugging - const errorMessage = error instanceof Error ? error.message : '上传失败,请稍后再试' - + + // 如果是视频类型素材,上传完成后立即入队触发后台转码(异步) + if (videoSourceUrl) { + const needsCoverImage = !previewUrl || previewUrl.trim() === ''; + + try { + // 入队转码任务(使用静态导入,确保模块加载成功) + await enqueueVideoTranscodeTask(material.id, videoSourceUrl, needsCoverImage); + + console.log('[Upload] ✅ Video transcode task enqueued successfully:', material.id); + } catch (e: any) { + // 入队失败时,记录详细错误信息到数据库 + const errorMsg = e?.message || String(e) || '未知错误'; + const errorStack = e?.stack ? `\n堆栈: ${e.stack.substring(0, 1000)}` : ''; + const fullErrorMsg = `入队失败: ${errorMsg}${errorStack}`; + + console.error('[Upload] ❌ Failed to enqueue video transcode task:', { + materialId: material.id, + videoSourceUrl, + error: errorMsg, + stack: e?.stack, + }); + + // 更新数据库状态,记录错误信息,但保持 pending 状态以便手动重试 + try { + await prisma.material.update({ + where: { id: material.id }, + data: { + videoProcessingStatus: 'pending', // 保持 pending,允许手动重试 + transcodeLastError: fullErrorMsg.substring(0, 1000), // 记录详细错误 + } as any, + }); + console.log('[Upload] ⚠️ Updated material status with enqueue error (status: pending for manual retry)'); + } catch (dbError) { + console.error('[Upload] ❌ Failed to update material status:', dbError); + } + } + } + return NextResponse.json( - { - error: '上传失败,请稍后再试', - details: process.env.NODE_ENV === 'development' ? errorMessage : undefined + { + message: '上传成功', + material: { + ...material, + zipFileSize: material.zipFileSize.toString(), + }, }, + { status: 201 } + ); + } catch (error) { + console.error('Upload error:', error); + return NextResponse.json( + { error: '上传失败,请稍后再试' }, { status: 500 } - ) + ); } } - diff --git a/app/api/materials/viewable-types/route.ts b/app/api/materials/viewable-types/route.ts new file mode 100644 index 0000000..5bd54fc --- /dev/null +++ b/app/api/materials/viewable-types/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { getUserViewableMaterialTypes } from '@/lib/department-permission'; + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const user = session.user as any; + const types = await getUserViewableMaterialTypes( + user.department, + user.role, + user.managedDepartment, + user.id + ); + + return NextResponse.json({ materialTypes: types }); + } catch (error) { + console.error('[Viewable Material Types] Error:', error); + return NextResponse.json({ error: '获取可见素材类型失败' }, { status: 500 }); + } +} + diff --git a/app/api/network-diagnose/route.ts b/app/api/network-diagnose/route.ts new file mode 100644 index 0000000..b8e8b1a --- /dev/null +++ b/app/api/network-diagnose/route.ts @@ -0,0 +1,127 @@ +// 网络诊断API - 用于测试上传/下载速度 +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; // 最长60秒 + +// 生成测试数据 +function generateTestData(size = 10 * 1024 * 1024): Uint8Array { + const data = new Uint8Array(size); + for (let i = 0; i < data.length; i++) { + data[i] = i % 251; // 使用简单的模式填充 + } + return data; +} + +// GET - 下载测试(返回测试数据) +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const mode = searchParams.get('mode') || 'download'; + + // 下载速度测试 + if (mode === 'download') { + const testData = generateTestData(); + return new Response(testData as any, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': testData.byteLength.toString(), + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + }); + } + + // S3存储测试 + if (mode === 'storage') { + const s3Url = getS3PublicUrl(); + const startTime = Date.now(); + + try { + const response = await fetch(s3Url, { + method: 'GET', + redirect: 'manual', + cache: 'no-store', + }); + + const duration = Date.now() - startTime; + + return NextResponse.json({ + ok: response.ok, + status: response.status, + durationMs: duration, + url: s3Url, + contentLength: response.headers.get('content-length'), + location: response.headers.get('location'), + }); + } catch (error: any) { + return NextResponse.json( + { + ok: false, + error: error?.message || String(error), + url: s3Url, + }, + { status: 500 } + ); + } + } + + // Ping测试 + if (mode === 'ping') { + return NextResponse.json({ + ok: true, + message: 'pong', + timestamp: new Date().toISOString(), + }); + } + + // 默认返回测试数据 + const testData = generateTestData(); + return new Response(testData as any, { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': testData.byteLength.toString(), + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + }); +} + +// POST - 上传测试(接收测试数据) +export async function POST(request: NextRequest) { + try { + const data = await request.arrayBuffer(); + return NextResponse.json({ + ok: true, + receivedBytes: data.byteLength, + timestamp: new Date().toISOString(), + }); + } catch (error: any) { + return NextResponse.json( + { + ok: false, + error: error?.message || String(error), + }, + { status: 500 } + ); + } +} + +// 获取S3公网URL +function getS3PublicUrl(): string { + const s3Domain = process.env.S3_PUBLIC_DOMAIN || process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL; + + if (!s3Domain) { + return 'https://s3.yydd.ai'; + } + + // 确保URL格式正确 + if (/^https?:\/\//i.test(s3Domain)) { + return s3Domain.replace(/\/$/, ''); + } + + return `https://${s3Domain.replace(/\/$/, '')}`; +} diff --git a/app/api/network-status/route.ts b/app/api/network-status/route.ts index ba9e942..f0ef564 100644 --- a/app/api/network-status/route.ts +++ b/app/api/network-status/route.ts @@ -1,105 +1,100 @@ import { NextRequest, NextResponse } from 'next/server' -/** - * 判断IP是否为内网地址 - */ -function isIntranetIP(ip: string): boolean { - if (!ip) return false - - // 处理可能存在的端口号 - const cleanIP = ip.split(':')[0] - - // localhost - if (cleanIP === '127.0.0.1' || cleanIP === 'localhost' || cleanIP === '::1') { +// 判断是否为内网IP地址 +function isInternalIP(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { return true } - // IPv4 内网IP段判断 + // 判断是否为内网IP段 + // 10.0.0.0 - 10.255.255.255 + // 172.16.0.0 - 172.31.255.255 + // 192.168.0.0 - 192.168.255.255 const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ - const match = cleanIP.match(ipv4Regex) + const match = hostname.match(ipv4Regex) if (match) { - const [, a, b, c, d] = match.map(Number) + const [, a, b] = match.map(Number) - // 验证IP有效性 - if (a > 255 || b > 255 || c > 255 || d > 255) { - return false - } - - // 10.0.0.0 - 10.255.255.255 (Class A) + // 10.x.x.x if (a === 10) return true - // 172.16.0.0 - 172.31.255.255 (Class B) + // 172.16.x.x - 172.31.x.x if (a === 172 && b >= 16 && b <= 31) return true - // 192.168.0.0 - 192.168.255.255 (Class C) + // 192.168.x.x if (a === 192 && b === 168) return true - - // 169.254.0.0 - 169.254.255.255 (Link-local) - if (a === 169 && b === 254) return true } return false } -/** - * 获取客户端真实IP地址 - */ -function getClientIP(request: NextRequest): string { - // 优先级:x-real-ip > x-forwarded-for > connection - const xRealIP = request.headers.get('x-real-ip') - if (xRealIP) return xRealIP - - const xForwardedFor = request.headers.get('x-forwarded-for') - if (xForwardedFor) { - // x-forwarded-for 可能包含多个IP,取第一个(客户端真实IP) - return xForwardedFor.split(',')[0].trim() - } - - const cfConnectingIP = request.headers.get('cf-connecting-ip') // Cloudflare - if (cfConnectingIP) return cfConnectingIP - - const trueClientIP = request.headers.get('true-client-ip') // Akamai - if (trueClientIP) return trueClientIP - - // 如果都没有,返回默认值 - return 'unknown' -} - -/** - * GET /api/network-status - * 返回用户的网络环境状态 - */ +// 网络状态检测API +// GET: 检测用户是否在内网,以及是否访问公网域名 export async function GET(request: NextRequest) { try { - const clientIP = getClientIP(request) - const isIntranet = isIntranetIP(clientIP) + // 获取客户端IP(从请求头中获取,如果通过代理) + const forwarded = request.headers.get('x-forwarded-for') + const realIp = request.headers.get('x-real-ip') + const clientIp = forwarded?.split(',')[0]?.trim() || realIp || 'unknown' // 获取当前访问的域名 - const hostname = request.headers.get('host') || '' - const isPublicDomain = hostname.includes('yydd.ai') || hostname.includes('3d.yydd.ai') + const host = request.headers.get('host') || '' + const hostname = host.split(':')[0] // 移除端口号 - console.log(`[Network Status] IP: ${clientIP}, isIntranet: ${isIntranet}, hostname: ${hostname}`) + // 严格判断:只有当clientIp确实是内网IP(不是unknown)时,才认为用户在内网 + // 同时hostname也必须是内网IP,双重验证确保准确性 + const clientIsIntranet = clientIp !== 'unknown' && isInternalIP(clientIp) + const hostnameIsIntranet = isInternalIP(hostname) - return NextResponse.json({ + // 用户在内网:clientIp是内网IP 或 hostname是内网IP(双重验证) + const isIntranet = clientIsIntranet || hostnameIsIntranet + + // 判断是否为公网域名(非内网IP的域名,且不是localhost) + // 公网域名特征:不是IP地址格式,且不是localhost/127.0.0.1 + const isPublicDomain = !hostnameIsIntranet && + hostname !== 'localhost' && + hostname !== '127.0.0.1' && + !hostname.match(/^192\.168\./) && + !hostname.match(/^10\./) && + !hostname.match(/^172\.(1[6-9]|2[0-9]|3[01])\./) && + // 确保hostname不是IP格式(是域名) + !hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + + return NextResponse.json({ success: true, isIntranet, isPublicDomain, - // 不返回完整IP地址(隐私保护) - ipMasked: clientIP.split('.').slice(0, 2).join('.') + '.xxx.xxx', - message: isIntranet - ? '检测到内网访问' - : '检测到公网访问', - intranetUrl: isIntranet && isPublicDomain - ? 'http://192.168.58.20:4320' - : null + clientIp, + hostname, + timestamp: new Date().toISOString() }) } catch (error) { console.error('[Network Status] Error:', error) - return NextResponse.json({ - success: false, - error: '网络状态检测失败' - }, { status: 500 }) + return NextResponse.json( + { + success: false, + error: '网络检测失败' + }, + { status: 500 } + ) } } +export async function POST(request: NextRequest) { + try { + // 接收测试数据并立即返回,用于测速 + const data = await request.arrayBuffer() + + return NextResponse.json({ + status: 'ok', + receivedBytes: data.byteLength, + timestamp: new Date().toISOString() + }) + } catch (error) { + return NextResponse.json( + { error: '网络测试失败' }, + { status: 500 } + ) + } +} diff --git a/app/api/network/test-download/route.ts b/app/api/network/test-download/route.ts new file mode 100644 index 0000000..7763810 --- /dev/null +++ b/app/api/network/test-download/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' + +// 下载测速API +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const size = body.size || 1024 * 1024 // 默认1MB + + // 生成测试数据 + const buffer = Buffer.alloc(size, 0) + + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': size.toString() + } + }) + } catch (error) { + console.error('下载测速失败:', error) + return NextResponse.json({ error: '下载测速失败' }, { status: 500 }) + } +} diff --git a/app/api/network/test-storage/route.ts b/app/api/network/test-storage/route.ts new file mode 100644 index 0000000..4a21957 --- /dev/null +++ b/app/api/network/test-storage/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import { testS3Connection } from '@/lib/s3' + +// 存储连通性测试API +export async function GET() { + try { + // 测试S3连接 + const s3Connected = await testS3Connection() + + if (s3Connected) { + return NextResponse.json({ + success: true, + message: 'S3存储服务连接正常' + }) + } else { + return NextResponse.json({ + success: false, + message: 'S3存储服务连接失败' + }, { status: 500 }) + } + } catch (error) { + console.error('存储连通性测试失败:', error) + return NextResponse.json({ + success: false, + message: '存储服务连接失败' + }, { status: 500 }) + } +} diff --git a/app/api/network/test-upload/route.ts b/app/api/network/test-upload/route.ts new file mode 100644 index 0000000..402fd02 --- /dev/null +++ b/app/api/network/test-upload/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' + +// 上传测速API +export async function POST(request: NextRequest) { + try { + const formData = await request.formData() + const file = formData.get('file') as File + + if (!file) { + return NextResponse.json({ error: '缺少文件' }, { status: 400 }) + } + + // 读取文件内容(模拟上传处理) + await file.arrayBuffer() + + return NextResponse.json({ + success: true, + size: file.size, + message: '上传测试完成' + }) + } catch (error) { + console.error('上传测速失败:', error) + return NextResponse.json({ error: '上传测速失败' }, { status: 500 }) + } +} diff --git a/app/api/platforms/route.ts b/app/api/platforms/route.ts index ae74303..0c73c9f 100644 --- a/app/api/platforms/route.ts +++ b/app/api/platforms/route.ts @@ -1,162 +1,227 @@ -import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// GET - 获取所有平台分类(包含数量统计) +// GET - 获取所有平台分类 export async function GET(request: NextRequest) { try { const platforms = await prisma.platform.findMany({ - orderBy: { createdAt: 'asc' } - }) + orderBy: { createdAt: 'asc' }, + }); - // 获取每个平台的素材数量 + // 统计每个平台的素材数量(只统计未删除的素材) const platformsWithCount = await Promise.all( platforms.map(async (platform) => { - const count = await prisma.material.count({ - where: { platformName: platform.name } - }) + let count: number; + try { + count = await prisma.material.count({ + where: { + platformName: platform.name, + deletedAt: null, + } as any, + }); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + count = await prisma.material.count({ + where: { platformName: platform.name }, + }); + } else { + throw e; + } + } + return { id: platform.id, name: platform.name, - count - } + count, + }; }) - ) + ); - return NextResponse.json(platformsWithCount) + return NextResponse.json(platformsWithCount); } catch (error) { - console.error('Error fetching platforms:', error) - return NextResponse.json({ error: '获取平台分类失败' }, { status: 500 }) + console.error('Error fetching platforms:', error); + return NextResponse.json( + { error: '获取平台分类失败' }, + { status: 500 } + ); } } -// POST - 创建新平台分类 +// POST - 创建新的平台分类 export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ); } - const { name } = await request.json() + const { name } = await request.json(); if (!name || !name.trim()) { - return NextResponse.json({ error: '平台分类名称不能为空' }, { status: 400 }) + return NextResponse.json( + { error: '平台分类名称不能为空' }, + { status: 400 } + ); } // 检查是否已存在 const existing = await prisma.platform.findUnique({ - where: { name: name.trim() } - }) + where: { name: name.trim() }, + }); if (existing) { - return NextResponse.json({ error: '该平台分类已存在' }, { status: 400 }) + return NextResponse.json( + { error: '该平台分类已存在' }, + { status: 400 } + ); } const platform = await prisma.platform.create({ - data: { name: name.trim() } - }) + data: { name: name.trim() }, + }); - return NextResponse.json(platform, { status: 201 }) + return NextResponse.json(platform, { status: 201 }); } catch (error) { - console.error('Error creating platform:', error) - return NextResponse.json({ error: '创建平台分类失败' }, { status: 500 }) + console.error('Error creating platform:', error); + return NextResponse.json( + { error: '创建平台分类失败' }, + { status: 500 } + ); } } // PUT - 更新平台分类 export async function PUT(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ); } - const { id, name } = await request.json() + const { id, name } = await request.json(); if (!id || !name || !name.trim()) { - return NextResponse.json({ error: '平台分类ID和名称不能为空' }, { status: 400 }) + return NextResponse.json( + { error: '平台分类ID和名称不能为空' }, + { status: 400 } + ); } - // 检查是否存在其他同名平台 + // 检查新名称是否已被其他分类使用 const existing = await prisma.platform.findFirst({ - where: { + where: { name: name.trim(), - NOT: { id } - } - }) + NOT: { id }, + }, + }); if (existing) { - return NextResponse.json({ error: '该平台分类名称已被使用' }, { status: 400 }) + return NextResponse.json( + { error: '该平台分类名称已被使用' }, + { status: 400 } + ); } // 获取旧名称 - const oldPlatform = await prisma.platform.findUnique({ where: { id } }) + const oldPlatform = await prisma.platform.findUnique({ + where: { id }, + }); + if (!oldPlatform) { - return NextResponse.json({ error: '平台分类不存在' }, { status: 404 }) + return NextResponse.json( + { error: '平台分类不存在' }, + { status: 404 } + ); } - // 更新平台分类 - const platform = await prisma.platform.update({ + // 更新分类 + const updated = await prisma.platform.update({ where: { id }, - data: { name: name.trim() } - }) + data: { name: name.trim() }, + }); - // 更新所有使用该平台的素材 + // 更新所有使用该分类的素材 await prisma.material.updateMany({ where: { platformName: oldPlatform.name }, - data: { platformName: name.trim() } - }) + data: { platformName: name.trim() }, + }); - return NextResponse.json(platform) + return NextResponse.json(updated); } catch (error) { - console.error('Error updating platform:', error) - return NextResponse.json({ error: '更新平台分类失败' }, { status: 500 }) + console.error('Error updating platform:', error); + return NextResponse.json( + { error: '更新平台分类失败' }, + { status: 500 } + ); } } // DELETE - 删除平台分类 export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); + if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ); } - const { searchParams } = new URL(request.url) - const id = searchParams.get('id') + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); if (!id) { - return NextResponse.json({ error: '平台分类ID不能为空' }, { status: 400 }) + return NextResponse.json( + { error: '平台分类ID不能为空' }, + { status: 400 } + ); } - // 检查是否有素材使用该平台 - const platform = await prisma.platform.findUnique({ where: { id } }) + // 检查是否有素材使用该分类 + const platform = await prisma.platform.findUnique({ + where: { id }, + }); + if (!platform) { - return NextResponse.json({ error: '平台分类不存在' }, { status: 404 }) + return NextResponse.json( + { error: '平台分类不存在' }, + { status: 404 } + ); } const materialCount = await prisma.material.count({ - where: { platformName: platform.name } - }) + where: { platformName: platform.name }, + }); if (materialCount > 0) { - return NextResponse.json({ - error: `无法删除:有 ${materialCount} 个素材正在使用该平台分类` - }, { status: 400 }) + return NextResponse.json( + { error: `无法删除:有 ${materialCount} 个素材正在使用该平台分类` }, + { status: 400 } + ); } - // 删除平台分类 - await prisma.platform.delete({ where: { id } }) + await prisma.platform.delete({ + where: { id }, + }); - return NextResponse.json({ message: '平台分类已删除' }) + return NextResponse.json({ message: '平台分类已删除' }); } catch (error) { - console.error('Error deleting platform:', error) - return NextResponse.json({ error: '删除平台分类失败' }, { status: 500 }) + console.error('Error deleting platform:', error); + return NextResponse.json( + { error: '删除平台分类失败' }, + { status: 500 } + ); } } - diff --git a/app/api/product-categories/route.ts b/app/api/product-categories/route.ts new file mode 100644 index 0000000..23f4351 --- /dev/null +++ b/app/api/product-categories/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// GET /api/product-categories - 获取产品类别列表 +export async function GET() { + try { + const categories = await prisma.productCategory.findMany({ + orderBy: { name: 'asc' }, + }); + + // 统计每个类别的素材数量 + const categoriesWithCount = await Promise.all( + categories.map(async (category) => { + const count = await prisma.material.count({ + where: { + productCategoryName: category.name, + deletedAt: null, + }, + }); + return { ...category, count }; + }) + ); + + return NextResponse.json(categoriesWithCount); + } catch (error) { + console.error('获取品类列表失败:', error); + return NextResponse.json({ error: '获取品类列表失败' }, { status: 500 }); + } +} + +// POST /api/product-categories - 创建产品类别 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const { name } = await request.json(); + + if (!name || !name.trim()) { + return NextResponse.json({ error: '品类名称不能为空' }, { status: 400 }); + } + + // 检查是否已存在 + const existing = await prisma.productCategory.findUnique({ + where: { name: name.trim() }, + }); + + if (existing) { + return NextResponse.json({ error: '该品类已存在' }, { status: 400 }); + } + + const category = await prisma.productCategory.create({ + data: { name: name.trim() }, + }); + + return NextResponse.json(category, { status: 201 }); + } catch (error) { + console.error('创建品类失败:', error); + return NextResponse.json({ error: '创建品类失败' }, { status: 500 }); + } +} + +// PUT /api/product-categories - 更新产品类别 +export async function PUT(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); + } + + const { id, name } = await request.json(); + + if (!id || !name || !name.trim()) { + return NextResponse.json({ error: '参数错误' }, { status: 400 }); + } + + // 检查新名称是否已被其他类别使用 + const existing = await prisma.productCategory.findFirst({ + where: { + name: name.trim(), + NOT: { id }, + }, + }); + + if (existing) { + return NextResponse.json({ error: '该品类名称已存在' }, { status: 400 }); + } + + // 获取旧名称 + const oldCategory = await prisma.productCategory.findUnique({ + where: { id }, + }); + + if (!oldCategory) { + return NextResponse.json({ error: '品类不存在' }, { status: 404 }); + } + + // 更新类别 + const updated = await prisma.productCategory.update({ + where: { id }, + data: { name: name.trim() }, + }); + + // 更新所有使用该类别的素材 + await prisma.material.updateMany({ + where: { productCategoryName: oldCategory.name }, + data: { productCategoryName: name.trim() }, + }); + + return NextResponse.json(updated); + } catch (error) { + console.error('更新品类失败:', error); + return NextResponse.json({ error: '更新失败' }, { status: 500 }); + } +} + +// DELETE /api/product-categories - 删除产品类别 +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ error: '缺少ID参数' }, { status: 400 }); + } + + await prisma.productCategory.delete({ + where: { id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('删除品类失败:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); + } +} diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts new file mode 100644 index 0000000..7afb594 --- /dev/null +++ b/app/api/proxy-media/route.ts @@ -0,0 +1,148 @@ +// 媒体资源代理 API - 解决混合内容问题 +// 将 HTTP 资源通过 HTTPS 代理提供给前端 +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// 允许的域名白名单 +const ALLOWED_DOMAINS = [ + 's3.1080p.hk', + 's3.yydd.ai', + 'localhost', + '127.0.0.1', +]; + +// 允许的文件扩展名 +const ALLOWED_EXTENSIONS = [ + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', // 图片 + '.mp4', '.webm', '.ogg', // 视频 + '.mp3', '.wav', '.ogg', '.m4a', // 音频 +]; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get('url'); + + if (!url) { + return NextResponse.json( + { error: '缺少 url 参数' }, + { status: 400 } + ); + } + + // 解析 URL + let targetUrl: URL; + try { + targetUrl = new URL(url); + } catch { + return NextResponse.json( + { error: '无效的 URL' }, + { status: 400 } + ); + } + + // 检查域名是否在白名单中 + const hostname = targetUrl.hostname.toLowerCase(); + const isAllowed = ALLOWED_DOMAINS.some(domain => + hostname === domain || hostname.endsWith(`.${domain}`) + ); + + if (!isAllowed) { + return NextResponse.json( + { error: '不允许的域名' }, + { status: 403 } + ); + } + + // 检查文件扩展名 + const pathname = targetUrl.pathname.toLowerCase(); + const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => + pathname.endsWith(ext) + ); + + if (!hasAllowedExtension) { + return NextResponse.json( + { error: '不允许的文件类型' }, + { status: 403 } + ); + } + + // 检查是否有 Range 请求(用于视频播放) + const rangeHeader = request.headers.get('range'); + + // 构建请求头 + const fetchHeaders: HeadersInit = { + 'User-Agent': 'Mozilla/5.0 (compatible; MediaProxy/1.0)', + }; + + // 如果有 Range 请求,转发给源服务器 + if (rangeHeader) { + fetchHeaders['Range'] = rangeHeader; + } + + // 获取资源 + const response = await fetch(targetUrl.toString(), { + headers: fetchHeaders, + // 设置超时 + signal: AbortSignal.timeout(30000), // 30秒超时 + }); + + if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的 + return NextResponse.json( + { error: `资源获取失败: ${response.status}` }, + { status: response.status } + ); + } + + // 获取内容类型 + const contentType = response.headers.get('content-type') || 'application/octet-stream'; + const contentLength = response.headers.get('content-length'); + const contentRange = response.headers.get('content-range'); + const acceptRanges = response.headers.get('accept-ranges'); + + // 获取响应体 + const buffer = await response.arrayBuffer(); + + // 构建响应头 + const responseHeaders: HeadersInit = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + }; + + // 添加 Range 相关头(如果存在) + if (contentLength) { + responseHeaders['Content-Length'] = contentLength; + } + if (contentRange) { + responseHeaders['Content-Range'] = contentRange; + } + if (acceptRanges) { + responseHeaders['Accept-Ranges'] = acceptRanges; + } + + // 返回资源 + return new NextResponse(buffer, { + status: response.status, // 可能是 200 或 206 + headers: responseHeaders, + }); + } catch (error: any) { + console.error('[Proxy Media] Error:', error); + + if (error.name === 'AbortError' || error.name === 'TimeoutError') { + return NextResponse.json( + { error: '请求超时' }, + { status: 504 } + ); + } + + return NextResponse.json( + { error: '代理请求失败' }, + { status: 500 } + ); + } +} + diff --git a/app/api/rankings/route.ts b/app/api/rankings/route.ts index f063c97..ae9d17f 100644 --- a/app/api/rankings/route.ts +++ b/app/api/rankings/route.ts @@ -1,16 +1,20 @@ -import { NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; -// 获取排行榜数据 +// GET /api/rankings - 获取排行榜数据 export async function GET() { try { - // 1. 贡献排行榜(上传素材数量最多) + // 上传榜 - 按素材数量排序(只统计未删除的素材) const contributors = await prisma.user.findMany({ select: { username: true, _count: { select: { - materials: true, + materials: { + where: { + deletedAt: null, + } as any, + }, }, }, }, @@ -20,10 +24,30 @@ export async function GET() { }, }, take: 10, - }) - - // 2. 下载排行榜(素材被下载次数最多的作者) - const downloadRankings = await prisma.material.groupBy({ + }); + + // 下载榜 - 按下载次数排序(只统计未删除的素材) + let downloadRankings; + try { + downloadRankings = await prisma.material.groupBy({ + by: ['userId'], + where: { + deletedAt: null, + } as any, + _sum: { + downloadCount: true, + }, + orderBy: { + _sum: { + downloadCount: 'desc', + }, + }, + take: 10, + }); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + downloadRankings = await prisma.material.groupBy({ by: ['userId'], _sum: { downloadCount: true, @@ -34,23 +58,47 @@ export async function GET() { }, }, take: 10, - }) - - const downloadUsers = await Promise.all( + }); + } else { + throw e; + } + } + + const downloads = await Promise.all( downloadRankings.map(async (item) => { const user = await prisma.user.findUnique({ where: { id: item.userId }, select: { username: true }, - }) + }); return { username: user?.username || '未知用户', count: item._sum.downloadCount || 0, - } + }; }) - ) - - // 3. 点赞排行榜(素材被点赞次数最多的作者) - const likeRankings = await prisma.material.groupBy({ + ); + + // 点赞榜 - 按点赞次数排序(只统计未删除的素材) + let likeRankings; + try { + likeRankings = await prisma.material.groupBy({ + by: ['userId'], + where: { + deletedAt: null, + } as any, + _sum: { + likeCount: true, + }, + orderBy: { + _sum: { + likeCount: 'desc', + }, + }, + take: 10, + }); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + likeRankings = await prisma.material.groupBy({ by: ['userId'], _sum: { likeCount: true, @@ -61,35 +109,35 @@ export async function GET() { }, }, take: 10, - }) - - const likeUsers = await Promise.all( + }); + } else { + throw e; + } + } + + const likes = await Promise.all( likeRankings.map(async (item) => { const user = await prisma.user.findUnique({ where: { id: item.userId }, select: { username: true }, - }) + }); return { username: user?.username || '未知用户', count: item._sum.likeCount || 0, - } + }; }) - ) - + ); + return NextResponse.json({ contributors: contributors.map((user) => ({ username: user.username, count: user._count.materials, })), - downloads: downloadUsers, - likes: likeUsers, - }) + downloads, + likes, + }); } catch (error) { - console.error('Error fetching rankings:', error) - return NextResponse.json( - { error: '获取排行榜失败' }, - { status: 500 } - ) + console.error('Error fetching rankings:', error); + return NextResponse.json({ error: '获取排行榜失败' }, { status: 500 }); } } - diff --git a/app/api/restore-data/route.ts b/app/api/restore-data/route.ts index 2385ab3..35fa031 100644 --- a/app/api/restore-data/route.ts +++ b/app/api/restore-data/route.ts @@ -1,3 +1,4 @@ +// 数据恢复API - 从S3和本地文件恢复素材数据 import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import https from 'https'; @@ -5,27 +6,29 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; -// 简单的密码hash函数(临时使用) -function hashPassword(password: string): string { - return crypto.createHash('sha256').update(password).digest('hex'); -} - -// 配置 const UPLOADS_DIR = '/app/uploads'; -const S3_BUCKET_URL = 'https://s3.1080p.hk/jsxiangche/?max-keys=2000'; -const S3_PUBLIC_URL = 'https://s3.1080p.hk/jsxiangche/'; - -// 默认配置 const ADMIN_EMAIL = 'admin'; const ADMIN_USERNAME = 'admin'; const ADMIN_PASSWORD = 'admin123456'; const DEFAULT_CATEGORY = '默认店铺'; const DEFAULT_SCENE = '默认场景'; -const DEFAULT_RENDERER = 'CR'; -const DEFAULT_MATERIAL_TYPE = '3DMAX'; -// 解析S3 XML -function parseS3XML(xml: string): string[] { +// 从S3获取预览图列表 +async function fetchS3PreviewList(): Promise { + return new Promise((resolve, reject) => { + https.get('https://s3.1080p.hk/jsxiangche/?max-keys=2000', (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + const keys = parseS3ListResponse(data); + resolve(keys.filter(key => key.includes('preview-'))); + }); + }).on('error', reject); + }); +} + +// 解析S3列表响应 +function parseS3ListResponse(xml: string): string[] { const keys: string[] = []; const keyRegex = /([^<]+)<\/Key>/g; let match; @@ -35,27 +38,18 @@ function parseS3XML(xml: string): string[] { return keys; } -// 获取S3预览图列表 -async function getS3PreviewFiles(): Promise { - return new Promise((resolve, reject) => { - https.get(S3_BUCKET_URL, (res) => { - let data = ''; - res.on('data', (chunk) => { data += chunk; }); - res.on('end', () => { - const allKeys = parseS3XML(data); - const previewFiles = allKeys.filter(key => key.includes('preview-')); - resolve(previewFiles); - }); - }).on('error', reject); - }); +// 从文件名提取时间戳 +function extractTimestamp(filename: string): number | null { + const match = filename.match(/-(\d{13})-/); + return match ? parseInt(match[1]) : null; } -// 获取本地素材文件 -function getLocalMaterialFiles(uploadsDir: string): string[] { +// 扫描本地素材文件 +function scanLocalMaterials(dir: string): string[] { try { - const files = fs.readdirSync(uploadsDir); - return files.filter(f => - f.startsWith('zip-') && (f.endsWith('.zip') || f.endsWith('.rar') || f.endsWith('.7z')) + return fs.readdirSync(dir).filter(file => + file.startsWith('zip-') && + (file.endsWith('.zip') || file.endsWith('.rar') || file.endsWith('.7z')) ); } catch (error: any) { console.error('读取本地文件失败:', error.message); @@ -63,192 +57,184 @@ function getLocalMaterialFiles(uploadsDir: string): string[] { } } -// 提取时间戳 -function extractTimestamp(filename: string): number | null { - const match = filename.match(/-(\d{13})-/); - return match ? parseInt(match[1]) : null; +// 匹配预览图和素材文件 +function matchPreviewsWithMaterials( + previews: string[], + materials: string[] +): Array<{ previewFile: string | null; materialFile: string; timestamp: number | null }> { + const matches: Array<{ previewFile: string | null; materialFile: string; timestamp: number | null }> = []; + + for (const material of materials) { + const materialTimestamp = extractTimestamp(material); + if (!materialTimestamp) continue; + + // 查找时间戳相近的预览图(允许1秒误差) + const matchedPreview = previews.find(preview => { + const previewTimestamp = extractTimestamp(preview); + if (!previewTimestamp) return false; + return Math.abs(previewTimestamp - materialTimestamp) < 1000; + }); + + matches.push({ + previewFile: matchedPreview || null, + materialFile: material, + timestamp: materialTimestamp, + }); + } + + return matches; } -// 提取原始名称 -function extractOriginalName(filename: string): string { +// 从文件名提取产品名称 +function extractProductName(filename: string): string { const parts = filename.split('-'); if (parts.length >= 3) { - const namePart = parts.slice(2).join('-'); - return namePart.replace(/\.(zip|rar|7z)$/i, ''); + return parts.slice(2).join('-').replace(/\.(zip|rar|7z)$/i, ''); } return filename; } -// 匹配文件 -interface Match { - previewFile: string | null; - materialFile: string; - timestamp: number; -} - -function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] { - const matches: Match[] = []; - - for (const materialFile of materialFiles) { - const materialTimestamp = extractTimestamp(materialFile); - if (!materialTimestamp) continue; - - const matchedPreview = previewFiles.find(previewFile => { - const previewTimestamp = extractTimestamp(previewFile); - if (!previewTimestamp) return false; - return Math.abs(previewTimestamp - materialTimestamp) < 1000; - }); - - matches.push({ - previewFile: matchedPreview || null, - materialFile: materialFile, - timestamp: materialTimestamp - }); - } - - return matches; -} - -export async function POST(req: NextRequest) { +// POST - 执行数据恢复 +export async function POST(request: NextRequest) { try { - const output: string[] = []; - - output.push('🚀 开始数据恢复...\n'); - - // 创建管理员用户 - output.push('📝 步骤1: 创建管理员用户'); - let admin; + let adminUser; + const logs: string[] = []; + + logs.push('🚀 开始数据恢复...\n'); + + // 步骤1: 创建管理员用户 + logs.push('📝 步骤1: 创建管理员用户'); try { - const hashedPassword = hashPassword(ADMIN_PASSWORD); - admin = await prisma.user.create({ + const hashedPassword = crypto.createHash('sha256').update(ADMIN_PASSWORD).digest('hex'); + adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL, username: ADMIN_USERNAME, password: hashedPassword, role: 'ADMIN', isActive: true, - canDownload: true - } + canDownload: true, + }, }); - output.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`); + logs.push(`✅ 创建管理员用户: ${ADMIN_USERNAME}`); } catch (error: any) { if (error.code === 'P2002') { - admin = await prisma.user.findUnique({ - where: { email: ADMIN_EMAIL } - }); - output.push(`ℹ️ 管理员用户已存在`); + adminUser = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } }); + logs.push(`ℹ️ 管理员用户已存在`); } else { throw error; } } - - if (!admin) { + + if (!adminUser) { throw new Error('无法创建或找到管理员用户'); } - - // 创建默认分类 - output.push('\n📝 步骤2: 创建默认分类和场景'); + + // 步骤2: 创建默认分类和场景 + logs.push('\n📝 步骤2: 创建默认分类和场景'); try { await prisma.category.create({ data: { name: DEFAULT_CATEGORY } }); - output.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`); + logs.push(`✅ 创建默认分类: ${DEFAULT_CATEGORY}`); } catch (error: any) { if (error.code === 'P2002') { - output.push(`ℹ️ 默认分类已存在`); + logs.push(`ℹ️ 默认分类已存在`); } } - - // 创建默认场景 + try { await prisma.scene.create({ data: { name: DEFAULT_SCENE } }); - output.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`); + logs.push(`✅ 创建默认场景: ${DEFAULT_SCENE}`); } catch (error: any) { if (error.code === 'P2002') { - output.push(`ℹ️ 默认场景已存在`); + logs.push(`ℹ️ 默认场景已存在`); } } - - // 获取S3预览图 - output.push('\n📝 步骤3: 获取S3预览图列表'); - const previewFiles = await getS3PreviewFiles(); - output.push(`✅ 从S3找到 ${previewFiles.length} 个预览图`); - - // 获取本地素材文件 - output.push('\n📝 步骤4: 扫描本地素材文件'); - const materialFiles = getLocalMaterialFiles(UPLOADS_DIR); - output.push(`✅ 从本地找到 ${materialFiles.length} 个素材文件`); - - // 匹配文件 - output.push('\n📝 步骤5: 匹配预览图和素材文件'); - const matches = matchFiles(previewFiles, materialFiles); + + // 步骤3: 获取S3预览图列表 + logs.push('\n📝 步骤3: 获取S3预览图列表'); + const s3Previews = await fetchS3PreviewList(); + logs.push(`✅ 从S3找到 ${s3Previews.length} 个预览图`); + + // 步骤4: 扫描本地素材文件 + logs.push('\n📝 步骤4: 扫描本地素材文件'); + const localMaterials = scanLocalMaterials(UPLOADS_DIR); + logs.push(`✅ 从本地找到 ${localMaterials.length} 个素材文件`); + + // 步骤5: 匹配预览图和素材文件 + logs.push('\n📝 步骤5: 匹配预览图和素材文件'); + const matches = matchPreviewsWithMaterials(s3Previews, localMaterials); const withPreview = matches.filter(m => m.previewFile).length; const withoutPreview = matches.length - withPreview; - output.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`); - - // 恢复素材数据 - output.push('\n📝 步骤6: 恢复素材数据到数据库'); - const HOST_UPLOADS_DIR = '/mnt/PoolOne/apps/youyidian-3d/uploads'; + logs.push(`✅ 总计 ${matches.length} 个素材 (${withPreview} 个有预览图, ${withoutPreview} 个无预览图)`); + + // 步骤6: 恢复素材数据到数据库 + logs.push('\n📝 步骤6: 恢复素材数据到数据库'); let successCount = 0; let failCount = 0; - + for (const match of matches) { try { - const originalName = extractOriginalName(match.materialFile); - const containerPath = path.join(UPLOADS_DIR, match.materialFile); - const hostPath = path.join(HOST_UPLOADS_DIR, match.materialFile); - const stats = fs.statSync(containerPath); - + const productName = extractProductName(match.materialFile); + const localPath = path.join(UPLOADS_DIR, match.materialFile); + const dockerPath = path.join('/mnt/PoolOne/apps/youyidian-3d/uploads', match.materialFile); + const stats = fs.statSync(localPath); + await prisma.material.create({ data: { - previewUrl: match.previewFile ? `${S3_PUBLIC_URL}${match.previewFile}` : '', - productModel: originalName.substring(0, 50), + previewUrl: match.previewFile ? `https://s3.1080p.hk/jsxiangche/${match.previewFile}` : '', + productModel: productName.substring(0, 50), categoryName: DEFAULT_CATEGORY, sceneName: DEFAULT_SCENE, - materialType: DEFAULT_MATERIAL_TYPE, - renderer: DEFAULT_RENDERER, + materialType: '3D场景', + renderer: 'CR', authorName: ADMIN_USERNAME, zipFileName: match.materialFile, - zipFilePath: hostPath, + zipFilePath: dockerPath, zipFileSize: BigInt(stats.size), downloadCount: 0, likeCount: 0, - userId: admin.id - } + userId: adminUser.id, + }, }); - + successCount++; if (successCount % 10 === 0) { - output.push(`✅ 已恢复: ${successCount}/${matches.length}`); + logs.push(`✅ 已恢复: ${successCount}/${matches.length}`); } } catch (error: any) { failCount++; - output.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`); + logs.push(`❌ 恢复失败: ${match.materialFile} - ${error.message}`); } } - - output.push(`\n📊 恢复完成统计:`); - output.push(` ✅ 成功: ${successCount} 个`); - output.push(` ❌ 失败: ${failCount} 个`); - output.push(` 📁 总计: ${matches.length} 个文件`); - - output.push(`\n🎉 数据恢复完成!`); - output.push(`\n📌 默认信息:`); - output.push(` 管理员账号: ${ADMIN_USERNAME}`); - output.push(` 管理员密码: ${ADMIN_PASSWORD}`); - output.push(` 默认分类: ${DEFAULT_CATEGORY}`); - output.push(` 默认场景: ${DEFAULT_SCENE}`); - output.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`); - - return NextResponse.json({ - success: true, - message: output.join('\n'), - stats: { successCount, failCount, total: matches.length } + + logs.push(`\n📊 恢复完成统计:`); + logs.push(` ✅ 成功: ${successCount} 个`); + logs.push(` ❌ 失败: ${failCount} 个`); + logs.push(` 📁 总计: ${matches.length} 个文件`); + logs.push(`\n🎉 数据恢复完成!`); + logs.push(`\n📌 默认信息:`); + logs.push(` 管理员账号: ${ADMIN_USERNAME}`); + logs.push(` 管理员密码: ${ADMIN_PASSWORD}`); + logs.push(` 默认分类: ${DEFAULT_CATEGORY}`); + logs.push(` 默认场景: ${DEFAULT_SCENE}`); + logs.push(`\n⚠️ 提示: 请登录后台修改素材的SPU、分类、场景等信息`); + + return NextResponse.json({ + success: true, + message: logs.join('\n'), + stats: { + successCount, + failCount, + total: matches.length, + }, }); - } catch (error: any) { - return NextResponse.json({ - success: false, - message: error.message - }, { status: 500 }); + return NextResponse.json( + { + success: false, + message: error.message, + }, + { status: 500 } + ); } } - diff --git a/app/api/scenes/route.ts b/app/api/scenes/route.ts index 28b576d..164765b 100644 --- a/app/api/scenes/route.ts +++ b/app/api/scenes/route.ts @@ -1,208 +1,182 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; -// 获取所有场景类别 -export async function GET(req: NextRequest) { +// GET - 获取所有场景类别 +export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(req.url) - const categoryName = searchParams.get('category') + const { searchParams } = new URL(request.url); + const category = searchParams.get('category'); const scenes = await prisma.scene.findMany({ - orderBy: { - name: 'asc', - }, - }) + orderBy: { name: 'asc' }, + }); - // 获取每个场景类别下的素材数量 - // 如果指定了店铺分类,只统计该店铺下的场景数量 + // 统计每个场景的素材数量(只统计未删除的素材) const scenesWithCount = await Promise.all( scenes.map(async (scene) => { - const whereCondition: any = { - sceneName: scene.name, - } - - // 如果选择了店铺,只统计该店铺下的场景数量 - if (categoryName) { - whereCondition.categoryName = categoryName + const where: any = { sceneName: scene.name }; + if (category) { + where.categoryName = category; + } + + let count: number; + try { + where.deletedAt = null; + count = await prisma.material.count({ where } as any); + } catch (e: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (e?.meta?.column?.includes('deletedAt')) { + delete where.deletedAt; + count = await prisma.material.count({ where }); + } else { + throw e; + } } - const count = await prisma.material.count({ - where: whereCondition, - }) - return { ...scene, count, - } + }; }) - ) + ); - return NextResponse.json(scenesWithCount) + return NextResponse.json(scenesWithCount); } catch (error) { - console.error('Error fetching scenes:', error) + console.error('Error fetching scenes:', error); return NextResponse.json( { error: '获取场景类别列表失败' }, { status: 500 } - ) + ); } } -// 添加新场景类别 -export async function POST(req: NextRequest) { +// POST - 创建新的场景类别 +export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); if (!session) { - return NextResponse.json( - { error: '请先登录' }, - { status: 401 } - ) + return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - const { name } = await req.json() + const { name } = await request.json(); if (!name || !name.trim()) { return NextResponse.json( { error: '场景类别名称不能为空' }, { status: 400 } - ) + ); } - // 检查场景类别是否已存在 + // 检查是否已存在 const existing = await prisma.scene.findUnique({ where: { name: name.trim() }, - }) + }); if (existing) { return NextResponse.json( { error: '该场景类别已存在' }, { status: 400 } - ) + ); } - // 创建新场景类别 const scene = await prisma.scene.create({ - data: { - name: name.trim(), - }, - }) + data: { name: name.trim() }, + }); - return NextResponse.json(scene, { status: 201 }) + return NextResponse.json(scene, { status: 201 }); } catch (error) { - console.error('Error creating scene:', error) + console.error('Error creating scene:', error); return NextResponse.json( { error: '创建场景类别失败' }, { status: 500 } - ) + ); } } -// 更新场景类别名称 -export async function PATCH(req: NextRequest) { +// PATCH - 更新场景类别 +export async function PATCH(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json( - { error: '权限不足' }, - { status: 403 } - ) + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); } - const { id, name } = await req.json() + const { id, name } = await request.json(); if (!id || !name || !name.trim()) { - return NextResponse.json( - { error: '参数错误' }, - { status: 400 } - ) + return NextResponse.json({ error: '参数错误' }, { status: 400 }); } - // 检查新名称是否已存在(排除当前场景) + // 检查新名称是否已被其他分类使用 const existing = await prisma.scene.findFirst({ where: { name: name.trim(), NOT: { id }, }, - }) + }); if (existing) { return NextResponse.json( { error: '该场景类别已存在' }, { status: 400 } - ) + ); } // 获取旧名称 const oldScene = await prisma.scene.findUnique({ where: { id }, - }) + }); if (!oldScene) { - return NextResponse.json( - { error: '分类不存在' }, - { status: 404 } - ) + return NextResponse.json({ error: '分类不存在' }, { status: 404 }); } - // 更新场景类别名称 - const scene = await prisma.scene.update({ + // 更新分类 + const updated = await prisma.scene.update({ where: { id }, data: { name: name.trim() }, - }) + }); - // 同步更新所有使用该场景的素材 + // 更新所有使用该分类的素材 await prisma.material.updateMany({ where: { sceneName: oldScene.name }, data: { sceneName: name.trim() }, - }) + }); - return NextResponse.json(scene) + return NextResponse.json(updated); } catch (error) { - console.error('Error updating scene:', error) - return NextResponse.json( - { error: '更新失败' }, - { status: 500 } - ) + console.error('Error updating scene:', error); + return NextResponse.json({ error: '更新失败' }, { status: 500 }); } } -// 删除场景类别 -export async function DELETE(req: NextRequest) { +// DELETE - 删除场景类别 +export async function DELETE(request: NextRequest) { try { - const session = await getServerSession(authOptions) + const session = await getServerSession(authOptions); - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json( - { error: '权限不足' }, - { status: 403 } - ) + if (!session || session.user?.role !== 'ADMIN') { + return NextResponse.json({ error: '权限不足' }, { status: 403 }); } - const { searchParams } = new URL(req.url) - const id = searchParams.get('id') + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); if (!id) { - return NextResponse.json( - { error: '缺少ID参数' }, - { status: 400 } - ) + return NextResponse.json({ error: '缺少ID参数' }, { status: 400 }); } - // 删除场景类别 await prisma.scene.delete({ where: { id }, - }) + }); - return NextResponse.json({ success: true }) + return NextResponse.json({ success: true }); } catch (error) { - console.error('Error deleting scene:', error) - return NextResponse.json( - { error: '删除失败' }, - { status: 500 } - ) + console.error('Error deleting scene:', error); + return NextResponse.json({ error: '删除失败' }, { status: 500 }); } } - diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index 2044b81..0d90cfe 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -1,101 +1,106 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' +// 系统设置API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { proxyHttpUrl } from '@/lib/url-rewrite'; // GET - 获取系统设置 export async function GET() { try { - // 获取或创建系统设置 - let settings = await prisma.systemSettings.findFirst() - + let settings = await prisma.systemSettings.findFirst(); + + // 如果没有设置,创建默认设置 if (!settings) { - // 如果不存在,创建默认设置 settings = await prisma.systemSettings.create({ data: { headerBgColor: '#1f2937', headerLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', loginLogoUrl: 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', - siteName: '由壹点3D素材' - } - }) + siteName: '由壹点3D素材', + }, + }); } - return NextResponse.json(settings) + // 将 HTTP URL 转换为通过代理的 URL(解决混合内容问题) + const response = { + ...settings, + headerLogoUrl: proxyHttpUrl(settings.headerLogoUrl) || settings.headerLogoUrl, + loginLogoUrl: proxyHttpUrl(settings.loginLogoUrl) || settings.loginLogoUrl, + }; + + return NextResponse.json(response); } catch (error) { - console.error('Get system settings error:', error) + console.error('Get system settings error:', error); return NextResponse.json( { error: '获取系统设置失败' }, { status: 500 } - ) + ); } } // PUT - 更新系统设置(仅管理员) export async function PUT(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); if (!session || session.user.role !== 'ADMIN') { return NextResponse.json( { error: '无权限' }, { status: 403 } - ) + ); } - const body = await request.json() - const { - headerBgColor, - siteName, - headerLogoUrl, + const { + headerBgColor, + siteName, + headerLogoUrl, loginLogoUrl, s3Endpoint, s3AccessKey, s3SecretKey, s3Bucket, s3Region, - s3PublicUrl - } = body + s3PublicUrl, + } = await request.json(); - // 获取或创建系统设置 - let settings = await prisma.systemSettings.findFirst() + let settings = await prisma.systemSettings.findFirst(); - if (!settings) { - // 创建 + if (settings) { + // 更新现有设置 + const updateData: any = {}; + if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor; + if (siteName !== undefined) updateData.siteName = siteName; + if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl; + if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl; + if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint; + if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey; + if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey; + if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket; + if (s3Region !== undefined) updateData.s3Region = s3Region; + if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl; + + settings = await prisma.systemSettings.update({ + where: { id: settings.id }, + data: updateData, + }); + } else { + // 创建新设置 settings = await prisma.systemSettings.create({ data: { headerBgColor: headerBgColor || '#1f2937', headerLogoUrl: headerLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', loginLogoUrl: loginLogoUrl || 'http://s3.1080p.hk/shuzhou/2025/05/18/8ebadf0a7cc34eaea00e8619b8efb827.png', - siteName: siteName || '由壹点3D素材' - } - }) - } else { - // 更新 - const updateData: any = {} - if (headerBgColor !== undefined) updateData.headerBgColor = headerBgColor - if (siteName !== undefined) updateData.siteName = siteName - if (headerLogoUrl !== undefined) updateData.headerLogoUrl = headerLogoUrl - if (loginLogoUrl !== undefined) updateData.loginLogoUrl = loginLogoUrl - if (s3Endpoint !== undefined) updateData.s3Endpoint = s3Endpoint - if (s3AccessKey !== undefined) updateData.s3AccessKey = s3AccessKey - if (s3SecretKey !== undefined) updateData.s3SecretKey = s3SecretKey - if (s3Bucket !== undefined) updateData.s3Bucket = s3Bucket - if (s3Region !== undefined) updateData.s3Region = s3Region - if (s3PublicUrl !== undefined) updateData.s3PublicUrl = s3PublicUrl - - settings = await prisma.systemSettings.update({ - where: { id: settings.id }, - data: updateData - }) + siteName: siteName || '由壹点3D素材', + }, + }); } - return NextResponse.json(settings) + return NextResponse.json(settings); } catch (error) { - console.error('Update system settings error:', error) + console.error('Update system settings error:', error); return NextResponse.json( { error: '更新系统设置失败' }, { status: 500 } - ) + ); } } diff --git a/app/api/system-settings/upload-logo/route.ts b/app/api/system-settings/upload-logo/route.ts index 11a87f4..4c19e30 100644 --- a/app/api/system-settings/upload-logo/route.ts +++ b/app/api/system-settings/upload-logo/route.ts @@ -1,90 +1,84 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { uploadToS3 } from '@/lib/s3' +// 上传系统Logo API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { uploadToS3 } from '@/lib/s3'; -// POST - 上传LOGO(仅管理员) +// POST - 上传Logo(仅管理员) export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); if (!session || session.user.role !== 'ADMIN') { return NextResponse.json( { error: '无权限' }, { status: 403 } - ) + ); } - // 将NextRequest转换为Node.js IncomingMessage - const formData = await request.formData() - const logoFile = formData.get('logo') as File - const logoType = formData.get('type') as string // 'header' or 'login' + const formData = await request.formData(); + const logo = formData.get('logo') as File; + const type = formData.get('type') as string; - if (!logoFile) { + if (!logo) { return NextResponse.json( { error: '请选择LOGO文件' }, { status: 400 } - ) + ); } - if (!logoType || !['header', 'login'].includes(logoType)) { + if (!type || !['header', 'login'].includes(type)) { return NextResponse.json( { error: '请指定LOGO类型(header或login)' }, { status: 400 } - ) + ); } // 验证文件类型 - if (!logoFile.type.startsWith('image/')) { + if (!logo.type.startsWith('image/')) { return NextResponse.json( { error: '只支持图片格式' }, { status: 400 } - ) + ); } - // 转换File为Buffer - const bytes = await logoFile.arrayBuffer() - const buffer = Buffer.from(bytes) + // 转换为Buffer + const arrayBuffer = await logo.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); // 生成文件名 - const timestamp = Date.now() - const ext = logoFile.name.split('.').pop() - const filename = `${logoType}-logo-${timestamp}.${ext}` + const timestamp = Date.now(); + const ext = logo.name.split('.').pop(); + const filename = `${type}-logo-${timestamp}.${ext}`; // 上传到S3 - const s3Url = await uploadToS3(buffer, filename, logoFile.type) + const logoUrl = await uploadToS3(buffer, filename, logo.type); - // 更新系统设置 - let settings = await prisma.systemSettings.findFirst() + // 更新数据库 + let settings = await prisma.systemSettings.findFirst(); + const fieldName = type === 'header' ? 'headerLogoUrl' : 'loginLogoUrl'; - const fieldName = logoType === 'header' ? 'headerLogoUrl' : 'loginLogoUrl' - - if (!settings) { - settings = await prisma.systemSettings.create({ - data: { - [fieldName]: s3Url - } - }) - } else { + if (settings) { settings = await prisma.systemSettings.update({ where: { id: settings.id }, - data: { - [fieldName]: s3Url - } - }) + data: { [fieldName]: logoUrl }, + }); + } else { + settings = await prisma.systemSettings.create({ + data: { [fieldName]: logoUrl }, + }); } return NextResponse.json({ success: true, - logoUrl: s3Url, - type: logoType - }) + logoUrl, + type, + }); } catch (error) { - console.error('Upload logo error:', error) + console.error('Upload logo error:', error); return NextResponse.json( { error: '上传LOGO失败' }, { status: 500 } - ) + ); } } diff --git a/app/api/upload/preview/route.ts b/app/api/upload/preview/route.ts new file mode 100644 index 0000000..6dbe9c7 --- /dev/null +++ b/app/api/upload/preview/route.ts @@ -0,0 +1,50 @@ +// 上传预览图API +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { uploadToS3 } from '@/lib/s3'; + +// POST - 上传预览图 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json( + { error: '请先登录' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json( + { error: '请选择文件' }, + { status: 400 } + ); + } + + // 验证文件类型 + if (!file.type.startsWith('image/')) { + return NextResponse.json( + { error: '只能上传图片文件' }, + { status: 400 } + ); + } + + // 转换为Buffer + const buffer = Buffer.from(await file.arrayBuffer()); + + // 上传到S3 + const url = await uploadToS3(buffer, file.name, file.type); + + return NextResponse.json({ url }); + } catch (error) { + console.error('上传预览图失败:', error); + return NextResponse.json( + { error: '上传失败,请稍后重试' }, + { status: 500 } + ); + } +} diff --git a/app/api/uploads/[...path]/route.ts b/app/api/uploads/[...path]/route.ts new file mode 100644 index 0000000..49b8ebc --- /dev/null +++ b/app/api/uploads/[...path]/route.ts @@ -0,0 +1,100 @@ +// 文件访问API - 提供本地上传文件的访问 +import { NextRequest } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { lookup as mimeTypeLookup } from 'mime-types'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +// GET - 获取文件 +export async function GET( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + const pathSegments = params.path || []; + + // 健康检查 + if (pathSegments.length === 1 && pathSegments[0] === 'health') { + return new Response('OK', { status: 200 }); + } + + const uploadDir = process.env.UPLOAD_DIR || '/app/uploads'; + const relativePath = path.join(...pathSegments); + const filePath = path.resolve(uploadDir, relativePath); + const baseDir = path.resolve(uploadDir); + + // 安全检查:防止路径遍历攻击 + if (!filePath.startsWith(baseDir)) { + return new Response('Forbidden', { status: 403 }); + } + + try { + const stats = await fs.promises.stat(filePath); + + if (!stats.isFile()) { + return new Response('Not Found', { status: 404 }); + } + + const range = request.headers.get('range'); + const contentType = mimeTypeLookup(path.extname(filePath)) || 'application/octet-stream'; + + // 支持Range请求(用于视频流) + if (range) { + const fileSize = stats.size; + const parts = range.match(/bytes=(\d*)-(\d*)/); + const start = parts && parts[1] ? parseInt(parts[1], 10) : 0; + const end = parts && parts[2] ? parseInt(parts[2], 10) : fileSize - 1; + + // 验证Range + if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) { + return new Response('Invalid Range', { status: 416 }); + } + + const stream = fs.createReadStream(filePath, { start, end }); + + return new Response(stream as any, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': String(end - start + 1), + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } + + // 普通请求 + const stream = fs.createReadStream(filePath); + + return new Response(stream as any, { + status: 200, + headers: { + 'Content-Length': String(stats.size), + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch (error: any) { + if (error.code === 'ENOENT') { + return new Response('Not Found', { status: 404 }); + } + return new Response('Server Error', { status: 500 }); + } +} + +// HEAD - 检查文件是否存在 +export async function HEAD( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + const pathSegments = params.path || []; + + // 健康检查 + if (pathSegments.length === 1 && pathSegments[0] === 'health') { + return new Response(null, { status: 200 }); + } + + return new Response(null, { status: 404 }); +} diff --git a/app/api/user/avatar/route.ts b/app/api/user/avatar/route.ts new file mode 100644 index 0000000..051ec1d --- /dev/null +++ b/app/api/user/avatar/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { uploadToS3 } from '@/lib/s3'; +import { normalizeUrl } from '@/lib/utils'; +import { compressImage, sanitizeFileName } from '@/lib/image-processor'; +import path from 'path'; + +// POST /api/user/avatar - 上传用户头像 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + + console.log('[UserAvatar] Session:', { userId, hasSession: !!session, hasUser: !!session?.user }); + + if (!userId) { + console.log('[UserAvatar] No user ID in session'); + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const formData = await request.formData(); + const avatar = formData.get('avatar') as File | null; + + if (!avatar) { + console.log('[UserAvatar] No avatar file provided'); + return NextResponse.json({ error: '请选择头像文件' }, { status: 400 }); + } + + console.log('[UserAvatar] File info:', { name: avatar.name, type: avatar.type, size: avatar.size }); + + // 验证文件类型 + if (!['image/jpeg', 'image/jpg', 'image/png', 'image/webp'].includes(avatar.type)) { + return NextResponse.json( + { error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' }, + { status: 400 } + ); + } + + // 验证文件大小(5MB) + if (avatar.size > 5 * 1024 * 1024) { + return NextResponse.json( + { error: `头像过大,请使用小于5MB的图片(当前:${(avatar.size / 1024 / 1024).toFixed(2)}MB)` }, + { status: 400 } + ); + } + + // 压缩图片 + const imageBuffer = Buffer.from(await avatar.arrayBuffer()); + const compressedBuffer = await compressImage(imageBuffer, { + maxSizeKB: 100, + maxWidth: 200, + maxHeight: 200, + format: 'webp', + }); + + // 上传到S3 + const sanitizedName = sanitizeFileName(avatar.name); + const ext = path.extname(sanitizedName) || '.webp'; + const fileName = `avatar-${userId}-${Date.now()}${ext}`; + + console.log('[UserAvatar] Uploading to S3:', fileName, `(${(compressedBuffer.length / 1024).toFixed(2)}KB)`); + + const avatarUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp'); + + console.log('[UserAvatar] S3 URL:', avatarUrl); + + // 更新用户头像 + console.log('[UserAvatar] Updating user:', userId); + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { avatar: avatarUrl }, + }); + + console.log('[UserAvatar] User updated:', { id: updatedUser.id, avatar: updatedUser.avatar }); + + // 规范化URL + const normalizedUrl = normalizeUrl(avatarUrl); + + return NextResponse.json({ avatarUrl: normalizedUrl }); + } catch (error) { + console.error('[UserAvatar] POST error:', error); + return NextResponse.json({ error: '上传头像失败' }, { status: 500 }); + } +} diff --git a/app/api/user/favorites/route.ts b/app/api/user/favorites/route.ts new file mode 100644 index 0000000..d401216 --- /dev/null +++ b/app/api/user/favorites/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { normalizeMaterial } from '@/lib/utils'; + +// 强制动态渲染 +export const dynamic = 'force-dynamic'; + +// 解析 displayImages 字段 +function parseDisplayImages(displayImages: any): string[] { + if (!displayImages) return []; + + if (Array.isArray(displayImages)) { + return displayImages.filter(img => typeof img === 'string' && img.trim().length > 0); + } + + if (typeof displayImages === 'string') { + try { + const parsed = JSON.parse(displayImages); + if (Array.isArray(parsed)) { + return parsed.filter(img => typeof img === 'string' && img.trim().length > 0); + } + } catch (e) { + console.warn('[user/favorites] Failed to parse displayImages JSON, fallback to single value:', e); + } + return displayImages.trim() ? [displayImages] : []; + } + + return []; +} + +// GET - 获取当前用户的收藏列表 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const userId = session.user.id; + + // 查询收藏记录,包含素材信息 + const favorites = await prisma.materialFavorite.findMany({ + where: { + userId, + }, + include: { + material: { + select: { + id: true, + productModel: true, + previewUrl: true, + displayImages: true, + materialType: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + renderer: true, + downloadCount: true, + likeCount: true, + createdAt: true, + userId: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoProcessingStatus: true, + videoDuration: true, + zipFileName: true, + zipFileSize: true, + deletedAt: true, + } as any, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // 提取素材,过滤已删除的 + let materials = favorites + .map((fav) => fav.material) + .filter((material) => !!material && !material.deletedAt) + .map((material: any) => { + const zipFileSize = typeof material.zipFileSize === 'bigint' + ? material.zipFileSize.toString() + : material.zipFileSize ?? null; + + const formatted = { + ...material, + zipFileSize, + displayImages: parseDisplayImages(material.displayImages || null), + isFavorited: true, + isLiked: false, + }; + + return normalizeMaterial(formatted); + }); + + // 批量查询点赞状态 + if (materials.length > 0) { + const materialIds = materials.map((m) => m.id); + const likes = await prisma.materialLike.findMany({ + where: { + userId, + materialId: { in: materialIds }, + }, + select: { materialId: true }, + }); + + const likedSet = new Set(likes.map((like) => like.materialId)); + materials.forEach((material) => { + material.isLiked = likedSet.has(material.id); + }); + } + + return NextResponse.json(materials); + } catch (error: any) { + // 兼容旧版本数据库(没有material_favorites表) + if (error?.meta?.table?.includes('material_favorites')) { + return NextResponse.json( + { error: '收藏功能需要先运行数据库迁移' }, + { status: 400 } + ); + } + + console.error('[UserFavorites] GET error:', error); + return NextResponse.json( + { error: '获取收藏失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/user/materials/route.ts b/app/api/user/materials/route.ts new file mode 100644 index 0000000..3304d40 --- /dev/null +++ b/app/api/user/materials/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { normalizeMaterial } from '@/lib/utils'; + +// 解析 displayImages 字段 +function parseDisplayImages(displayImages: any): string[] { + if (!displayImages) return []; + + if (Array.isArray(displayImages)) { + return displayImages.filter(img => typeof img === 'string' && img.trim().length > 0); + } + + if (typeof displayImages === 'string') { + try { + const parsed = JSON.parse(displayImages); + if (Array.isArray(parsed)) { + return parsed.filter(img => typeof img === 'string' && img.trim().length > 0); + } + } catch (e) { + console.warn('[user/materials] Failed to parse displayImages JSON, fallback to single value:', e); + } + return displayImages.trim() ? [displayImages] : []; + } + + return []; +} + +// GET - 获取当前用户上传的素材 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const userId = session.user.id; + + const materials = await prisma.material.findMany({ + where: { + userId, + deletedAt: null, + }, + select: { + id: true, + userId: true, + productModel: true, + previewUrl: true, + displayImages: true, + materialType: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + renderer: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoProcessingStatus: true, + videoDuration: true, + zipFileName: true, + zipFileSize: true, + } as any, + orderBy: { + createdAt: 'desc', + }, + }); + + const formattedMaterials = materials.map((material: any) => { + const zipFileSize = typeof material.zipFileSize === 'bigint' + ? material.zipFileSize.toString() + : material.zipFileSize ?? null; + + const formatted = { + ...material, + zipFileSize, + displayImages: parseDisplayImages(material.displayImages || null), + }; + + return normalizeMaterial(formatted); + }); + + return NextResponse.json(formattedMaterials); + } catch (error: any) { + // 兼容旧版本数据库(没有deletedAt字段) + if (error?.meta?.column?.includes('deletedAt')) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const userId = session.user.id; + + const materials = await prisma.material.findMany({ + where: { + userId, + }, + select: { + id: true, + userId: true, + productModel: true, + previewUrl: true, + displayImages: true, + materialType: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + renderer: true, + downloadCount: true, + likeCount: true, + createdAt: true, + videoSourceUrl: true, + videoPreview540Url: true, + videoPreviewClipUrl: true, + videoCoverUrl: true, + videoProcessingStatus: true, + videoDuration: true, + zipFileName: true, + zipFileSize: true, + } as any, + orderBy: { + createdAt: 'desc', + }, + }); + + const formattedMaterials = materials.map((material: any) => { + const zipFileSize = typeof material.zipFileSize === 'bigint' + ? material.zipFileSize.toString() + : material.zipFileSize ?? null; + + const formatted = { + ...material, + zipFileSize, + displayImages: Array.isArray(material.displayImages) + ? material.displayImages + : material.displayImages + ? [material.displayImages] + : [], + }; + + return normalizeMaterial(formatted); + }); + + return NextResponse.json(formattedMaterials); + } catch (fallbackError) { + console.error('[UserMaterials] Fallback query error:', fallbackError); + return NextResponse.json( + { error: '获取素材列表失败' }, + { status: 500 } + ); + } + } + + console.error('[UserMaterials] GET error:', error); + return NextResponse.json( + { error: '获取素材列表失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/user/profile/route.ts b/app/api/user/profile/route.ts new file mode 100644 index 0000000..bd0f308 --- /dev/null +++ b/app/api/user/profile/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// GET - 获取当前用户信息 +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + + if (!userId) { + return NextResponse.json({ error: '未登录' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + email: true, + avatar: true, + department: true, + realName: true, + phone: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }); + } + + return NextResponse.json(user); + } catch (error) { + console.error('[UserProfile] GET error:', error); + return NextResponse.json( + { error: '获取用户信息失败' }, + { status: 500 } + ); + } +} diff --git a/app/complete-profile/page.tsx b/app/complete-profile/page.tsx index f92771e..5b38834 100644 --- a/app/complete-profile/page.tsx +++ b/app/complete-profile/page.tsx @@ -54,7 +54,7 @@ function CompleteProfileContent() { } // 从URL参数获取用户信息 - const userInfoParam = searchParams.get('userInfo') + const userInfoParam = searchParams?.get('userInfo') if (userInfoParam) { try { const info = JSON.parse(decodeURIComponent(userInfoParam)) diff --git a/app/dingtalk-callback/page.tsx b/app/dingtalk-callback/page.tsx index 2d16bd8..f9e1e1f 100644 --- a/app/dingtalk-callback/page.tsx +++ b/app/dingtalk-callback/page.tsx @@ -13,8 +13,8 @@ function DingtalkCallbackContent() { useEffect(() => { const handleDingtalkLogin = async () => { try { - const code = searchParams.get('code') - const state = searchParams.get('state') || 'default' + const code = searchParams?.get('code') + const state = searchParams?.get('state') || 'default' if (!code) { throw new Error('授权失败:缺少授权码') diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 360abb8..ca8ac81 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' +import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName } from '@/lib/material-validation' interface Material { id: string @@ -11,6 +12,8 @@ interface Material { productModel: string categoryName: string sceneName: string + platformName?: string | null + productCategoryName?: string | null materialType: string renderer: string authorName: string @@ -28,15 +31,36 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [productModel, setProductModel] = useState('') const [categoryName, setCategoryName] = useState('') const [sceneName, setSceneName] = useState('') + const [platformName, setPlatformName] = useState('') + const [productCategoryName, setProductCategoryName] = useState('') const [materialType, setMaterialType] = useState('') const [renderer, setRenderer] = useState('') const [authorName, setAuthorName] = useState('') const [categories, setCategories] = useState>([]) const [scenes, setScenes] = useState>([]) + const [platforms, setPlatforms] = useState>([]) + const [productCategories, setProductCategories] = useState>([]) const materialTypes = [ - '3DMAX', 'PSD', 'AI', 'CAD', 'C4D', '材质', '模特', '标准饰品', '其他' + '3D场景', + '详情页', + '材质', + '模特', + '实拍图片', + '买家秀图片', + '小红书种草', + '主图模版', + 'SKU模版', + '主图视频', + '安装视频', + '种草视频', + '音乐', + 'C4D模型', + '标准饰品', + '标准模型', + '品牌VI灵感', + '矢量文件' ] useEffect(() => { @@ -49,6 +73,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) fetchMaterial() fetchCategories() fetchScenes() + fetchPlatforms() + fetchProductCategories() } }, [status, params.id]) @@ -71,6 +97,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setProductModel(data.productModel || '') setCategoryName(data.categoryName || '') setSceneName(data.sceneName || '') + setPlatformName(data.platformName || '') + setProductCategoryName(data.productCategoryName || '') setMaterialType(data.materialType || '') setRenderer(data.renderer || '') setAuthorName(data.authorName || '') @@ -106,15 +134,56 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) } } + const fetchPlatforms = async () => { + try { + const response = await fetch('/api/platforms') + if (response.ok) { + const data = await response.json() + setPlatforms(data) + } + } catch (error) { + console.error('获取平台分类失败:', error) + } + } + + const fetchProductCategories = async () => { + try { + const response = await fetch('/api/product-categories') + if (response.ok) { + const data = await response.json() + setProductCategories(data) + } + } catch (error) { + console.error('获取品类分类失败:', error) + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') setSuccess('') - if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) { + // 基础必填字段验证 + if (!productModel || !materialType || !authorName) { setError('请填写所有必填字段') return } + + // 使用统一的验证逻辑 + if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { + setError('请填写所有必填字段(店铺、场景)') + return + } + + if (requiresProductCategoryName(materialType) && !productCategoryName) { + setError('标准模型类型必须选择品类') + return + } + + if (requiresRenderer(materialType) && !renderer) { + setError('3D场景类型必须指定渲染器') + return + } setSubmitting(true) @@ -126,6 +195,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) productModel, categoryName, sceneName, + platformName: platformName || null, + productCategoryName: productCategoryName || null, materialType, renderer, authorName @@ -228,6 +299,38 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) +
+ + +
+ +
+ + +
+