diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d2cc579 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# gongxiangp2 的 Codex 项目规则 + +适用范围:本文件仅对当前项目生效。 + +## 1. 项目背景 + +- 本项目是线上运行中的内部素材平台,稳定性优先于速度。 +- 当前默认以“新素材链路”为主,不把“新老素材兼容”作为默认核心门禁。 +- 只有在任务明确涉及历史数据、旧下载路径、旧存储迁移时,才重新拉高“老素材兼容”优先级。 + +## 2. 基本工作原则 + +- 默认使用中文说明,优先自然语言,少量必要代码示例。 +- 先做只读摸底,再决定是否修改。 +- 修改后必须验证,先跑最相关的验证,再视情况扩大范围。 +- 不触碰其他应用配置,不把项目规则写入全局 `Codex` 配置。 + +## 3. 项目内 Multi-agents 规则 + +当任务满足以下任一条件时,优先启用内置多代理: + +- 任务同时涉及前后端、接口与页面联动。 +- 任务涉及上线前审查、发布门禁、回归检查。 +- 任务需要同时做“功能正确性 + UI 统一性 + 发布风险”判断。 +- 任务跨越多个目录,单代理容易遗漏影响范围。 + +当用户明确写出以下任一表达时,默认必须真正进入多代理流程,而不是只解释角色定义: + +- “按项目的审查模式处理” +- “按项目的修复模式处理” +- “按项目的发布模式处理” +- “用 Multi-agents 处理” +- “启用项目团队” + +默认团队结构: + +- `orchestrator`:主控代理,负责拆解任务、分配角色、汇总结论。 +- `backend`:后端代理,负责 `app/api/**`、`lib/**`、`prisma/**` 风险审查与修复建议。 +- `frontend`:前端代理,负责 `app/**` 页面层、`components/**`、`hooks/**` 交互回归。 +- `ui_auditor`:UI 统一规范审查员,负责视觉系统和组件一致性审查。 +- `release_gate`:发布守门代理,负责按证据给出可上线/有条件上线/不可上线结论。 + +对外沟通规则: + +- 只有 `orchestrator` 直接和用户对话。 +- 其它子代理只负责内部执行、审查、汇总,不直接面向用户输出。 +- 用户默认把需求交给 `orchestrator`;由 `orchestrator` 自行决定是否派发、派给谁、何时汇总。 +- 除非用户明确要求查看子代理原始结果,否则对外只输出主控汇总后的结论。 + +按需附加角色: + +- `explorer`:跨文件侦察员,用于快速搜集上下文、文件归属和影响范围。 +- `worker`:定点执行代理,用于在职责明确后修改小范围文件。 + +## 4. 角色分工边界 + +- `backend` 默认优先处理:上传、下载、转码、权限、批量操作、后台统计、外部接口。 +- `frontend` 默认优先处理:管理后台页面、上传组件、分享页、首页、交互状态。 +- `ui_auditor` 默认不先改代码,先审查视觉问题;只有明确要求修 UI 时才参与改动建议。 +- `release_gate` 只做汇总和判定,不直接承担业务实现。 +- `worker` 只能接收已经明确边界的子任务,避免和主代理重复改动。 + +自动派单规则: + +- 用户只需要把任务交给 `orchestrator`,不需要手动指定团队成员。 +- `orchestrator` 根据任务类型自动决定是否启用团队,以及启用哪些角色。 +- 默认分配逻辑: + - 单一页面或纯 UI 小改动:`frontend` + `ui_auditor` + - 纯接口、下载、上传、转码、权限、批量操作:`backend` + - 前后端联动:`backend` + `frontend` + - 前后端联动且涉及视觉统一:`backend` + `frontend` + `ui_auditor` + - 上线前审查、高风险任务、改动范围大:`backend` + `frontend` + `ui_auditor` + `release_gate` +- 影响范围不清:先派 `explorer` 摸底,再决定后续角色 +- 用户不负责选人,角色编组由 `orchestrator` 内部完成。 + +高风险边界规则: + +- 下面这些任务默认视为高风险: + - 涉及数据库写入、删除、恢复、批量更新、批量删除 + - 涉及权限判断、角色判断、下载资格、管理员能力 + - 涉及上传、下载、转码、外部接口、批量操作 + - 涉及后台统计、日志、导出、审计记录 + - 涉及可能影响线上已有数据、用户可见结果或管理后台操作结果的改动 +- 高风险任务默认至少派出:`backend` +- 如果高风险任务同时影响页面或交互,再追加:`frontend` +- 如果高风险任务还影响视觉一致性,再追加:`ui_auditor` +- 如果任务准备上线、范围较大、或存在不确定性,再追加:`release_gate` +- 只要任务落入高风险边界,`orchestrator` 默认采用更保守的判断和验证策略。 + +UI 基线规则: + +- 当前项目的 UI 统一标准默认以首页为基线。 +- 首页对应文件:`app/page.tsx` +- `ui_auditor` 在审查其它页面时,优先对齐首页的视觉语言、主按钮风格、信息层级、间距节奏和状态反馈。 +- 如果某个后台页面或特殊业务页面需要偏离首页风格,必须说明原因和影响范围。 + +## 5. 输出要求 + +多代理任务的输出默认按下面结构收敛: + +- `发现的问题` +- `严重级别 P0 / P1 / P2` +- `影响范围` +- `修复建议` +- `验证清单` +- `最终结论` + +## 6. 建议先读的文档 + +- `项目文档.md` +- `docs/CODEX_MULTI_AGENTS.md` diff --git a/README.md b/README.md index f0f788c..d79d0b8 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,117 @@ -# 由壹点3D素材 +# 由壹点 3D 素材平台 -一个现代化的3D素材分享平台,采用苹果官方UI设计规范。 +当前仓库对应的是一个已经在线上运行的业务项目,不是演示性质的素材站。项目围绕素材上传、管理、下载、分享、权限控制和运维诊断构建,当前代码已经明显超出早期 README 里那种“注册登录 + 预览图 + 压缩包”的简单形态。 + +> 本 README 已于 2026-03-06 按当前代码结构重写,优先反映现状。 + +## 项目概览 + +- 基于 Next.js 14 App Router 的素材协作与分发系统 +- 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径 +- 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话 +- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路 +- 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布 + +## 当前核心能力 + +### 1. 用户与登录 + +- 账号密码登录 +- 钉钉登录与账号补全流程 +- NextAuth 会话管理 +- 个人资料页、个人素材、收藏与点赞 + +### 2. 素材浏览与分发 + +- 首页搜索、筛选、分页加载 +- 收藏、点赞、排行榜、分享页 +- 下载前校验与前端客户端下载兜底 +- 素材分享链接与外部下载接口 + +### 3. 素材上传与处理 + +- 分片上传、断点续传、上传会话管理 +- 支持多种素材类型上传,当前代码中已覆盖: + - 3D 原图 + - AI 原图 + - 实拍图 + - 视频截图 + - 任务页素材 + - 商品套包 + - 标准模特白底图 +- SKU 维度文件管理、排序、批量下载 +- 详情图 / 素材资产管理 +- 商品套包未完成上传的中止与回滚 +- 视频转码、封面 / 预览片段生成、转码队列与重试 + +### 4. 后台与权限 + +- 素材管理、用户管理、分类管理 +- 部门管理与部门素材权限控制 +- 数据统计、统计导出、素材操作日志 +- API Key、API 日志、系统设置、回收站、超级管理员相关能力 + +### 5. 运行与诊断 + +- 网络诊断页面与接口 +- 健康检查接口与内存检查接口 +- 快速性能检查接口 +- 内外网 URL 重写与下载链路适配 ## 技术栈 -- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS -- **认证**: NextAuth.js -- **数据库**: PostgreSQL + Prisma ORM -- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包) +- 前端:Next.js 14、React 18、TypeScript、Tailwind CSS +- 认证:NextAuth.js +- 数据库:PostgreSQL + Prisma ORM +- 对象存储:S3 / MinIO +- 本地文件:`uploads` 目录 +- 缓存 / 会话:Redis(可选,上传会话优先使用) +- 文件与媒体处理:`sharp`、`jszip`、`adm-zip`、`archiver` +- 测试:Jest + Testing Library -## 功能特性 +## 目录结构 -- ✅ 用户注册/登录系统 -- ✅ 素材上传(预览图 + 压缩包) -- ✅ 素材浏览与下载 -- ✅ 管理员后台管理 -- ✅ 用户管理 -- ✅ 苹果风格UI设计 +```text +gongxiangp2/ +├── app/ # 页面、App Router、API 路由 +│ ├── admin/ # 管理后台页面 +│ ├── api/ # 业务 API +│ ├── login/ # 登录页 +│ ├── register/ # 注册页 +│ ├── share/ # 分享页 +│ └── page.tsx # 首页 +├── components/ # 通用组件、后台组件、上传组件 +├── lib/ # 业务工具、上传/下载/转码/权限逻辑 +├── prisma/ # Prisma schema +├── scripts/ # 运维/修复/清理脚本 +├── docs/ # 网络、团队协作等项目文档 +├── uploads/ # 本地上传与处理中间文件 +└── README.md +``` + +## 数据模型概览 + +当前 Prisma 模型已经不止早期的 `User` / `Material` 两张表,核心模型包括但不限于: + +- `User` +- `Material` +- `MaterialSku` +- `MaterialAsset` +- `MaterialLog` +- `MaterialDownloadLog` +- `Department` +- `DepartmentMaterialPermission` +- `ApiKey` +- `ApiCallLog` +- `VideoTranscodeProgress` +- `SystemSettings` + +角色枚举当前包含: + +- `USER` +- `ADMIN` +- `DEPT_ADMIN` +- `SYSTEM_ADMIN` ## 快速开始 @@ -28,115 +123,148 @@ npm install ### 2. 配置环境变量 -复制 `.env.local` 文件并填入你的配置: +仓库当前没有维护独立的 `.env.example`,请自行准备本地环境变量文件,并且不要把真实密钥再次提交到仓库。 + +最小必需: ```bash -# Database -DATABASE_URL=your_postgresql_url +DATABASE_URL=postgresql://... +NEXTAUTH_SECRET=replace-this-with-a-strong-secret +``` -# NextAuth -NEXTAUTH_SECRET=your_secret_key +常用配置: + +```bash +# 站点地址 NEXTAUTH_URL=http://localhost:3000 +APP_PUBLIC_URL=http://localhost:3000 -# MinIO S3 Storage -S3_ENDPOINT=your_s3_endpoint -S3_BUCKET=your_bucket_name -S3_ACCESS_KEY_ID=your_access_key -S3_SECRET_ACCESS_KEY=your_secret_key -S3_REGION=us-east-1 -S3_PUBLIC_DOMAIN=your_public_domain -S3_FILE_PATH=your_file_path/ - -# Local storage +# 本地文件目录 UPLOAD_DIR=./uploads + +# S3 / MinIO +S3_ENDPOINT=http://127.0.0.1:9000 +S3_BUCKET=your-bucket +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +S3_REGION=us-east-1 +S3_PUBLIC_DOMAIN=https://your-s3-domain +S3_FILE_PATH=your-prefix/ + +# Redis(上传会话) +USE_REDIS=true +REDIS_URL=redis://127.0.0.1:6379 + +# 钉钉登录 +DINGTALK_CLIENT_ID=your-dingtalk-app-key +DINGTALK_CLIENT_SECRET=your-dingtalk-app-secret +NEXT_PUBLIC_DINGTALK_CLIENT_ID=your-dingtalk-app-key + +# 下载与外部接口 +DOWNLOAD_TOKEN_SECRET=replace-this +ADMIN_API_KEY=replace-this-if-needed + +# 内外网链路改写 +SERVER_IP=127.0.0.1 +MINIO_API_PORT=9000 + +# 视频转码(按需) +VIDEO_TRANSCODE_MAX_CONCURRENT=2 +VIDEO_TRANSCODE_TIMEOUT=3600000 +VIDEO_PREVIEW_RESOLUTION=720 +VIDEO_CLIP_DURATION=10 +VIDEO_PREVIEW_CRF=20 +VIDEO_CLIP_CRF=24 +VIDEO_MAX_TEMP_FILE_SIZE=10737418240 + +# AI 聊天(按需) +GEMINI_API_KEY=your-api-key +GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta +GEMINI_MODEL=gemini-2.0-flash-exp ``` ### 3. 初始化数据库 ```bash -# 生成 Prisma Client npm run prisma:generate - -# 推送数据库模式 npm run prisma:push ``` -### 4. 创建管理员账号 +### 4. 初始化管理员账号 -首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。 +首次启动后,可以先注册一个普通账号,再在数据库里把角色改成 `ADMIN`。 ```sql -UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com'; +UPDATE users +SET role = 'ADMIN' +WHERE email = 'your@email.com'; ``` -### 5. 启动开发服务器 +### 5. 启动开发环境 ```bash npm run dev ``` -访问 http://localhost:3000 +默认访问地址: -## 项目结构 - -``` -gongxiangp2/ -├── app/ # Next.js App Router -│ ├── api/ # API 路由 -│ │ ├── auth/ # 认证相关 -│ │ ├── materials/ # 素材相关 -│ │ └── admin/ # 管理员相关 -│ ├── login/ # 登录页面 -│ ├── register/ # 注册页面 -│ ├── admin/ # 管理员页面 -│ └── page.tsx # 首页 -├── components/ # React 组件 -│ ├── Header.tsx -│ └── UploadModal.tsx -├── lib/ # 工具库 -│ ├── prisma.ts # Prisma 客户端 -│ ├── auth.ts # NextAuth 配置 -│ └── s3.ts # S3 存储服务 -├── prisma/ # Prisma 配置 -│ └── schema.prisma # 数据库模式 -└── uploads/ # 本地上传文件存储 +```text +http://localhost:3000 ``` -## 数据库模型 +## 常用脚本 -### User (用户) -- id: UUID -- email: 邮箱 -- username: 用户名 -- password: 密码 (加密) -- role: 角色 (USER/ADMIN) +```bash +npm run dev +npm run build +npm run start +npm run lint +npm run test +npm run test:watch +npm run test:coverage +npm run prisma:generate +npm run prisma:push +npm run prisma:studio +``` -### Material (素材) -- id: UUID -- previewUrl: 预览图URL (S3) -- productModel: 商品型号 -- sceneName: 场景名称 -- authorName: 作者花名 -- zipFileName: 压缩包文件名 -- zipFilePath: 压缩包本地路径 -- userId: 用户ID (外键) +## 部署说明 -## 部署 - -### 生产环境构建 +### 本地或单机生产启动 ```bash npm run build -npm start +npm run start ``` -### 环境变量 +### Docker / Compose -确保在生产环境中设置所有必要的环境变量,特别是: -- `NEXTAUTH_SECRET`: 使用强随机字符串 -- `NEXTAUTH_URL`: 设置为生产域名 +仓库内已经包含以下部署文件: -## License +- `Dockerfile` +- `Dockerfile.base` +- `docker-compose.yml` +- `docker-compose.shadow.yml` -MIT +但当前线上环境并不只依赖容器本身,还依赖网络入口、FRP、域名与反向代理链路。因此如果要碰部署问题,建议连同项目文档一起看,而不是只看 Compose 文件。 +### 发布前重点核对 + +- 登录是否正常 +- 首页筛选、上传、下载、分享是否正常 +- 管理后台核心页签是否可用 +- 视频转码队列是否异常堆积 +- `/api/health` 是否正常 +- 如果改了网络或下载链路,再验证一次内外网访问 + +## 相关文档 + +- `docs/NETWORK_TOPOLOGY.md`:当前网络拓扑与故障排查 +- `docs/CODEX_MULTI_AGENTS.md`:项目内 Codex 团队协作规则 +- `部署前安全检查清单.md`:上线前检查项 +- `项目文档.md`:历史项目资料 + +## 说明 + +- 当前仓库 `package.json` 为 `private: true` +- 本仓库目前没有单独声明开源许可证,不应继续沿用旧 README 中的 `MIT` 说法 +- 项目已在线上运行,任何会影响上传、下载、权限、批量删除、资源链路的改动,都应先验证再发布 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 4e8e15b..cd0c7c1 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback, useRef } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' @@ -10,6 +10,7 @@ import MaterialManagement from '@/components/admin/MaterialManagement' import UserManagement from '@/components/admin/UserManagement' import CategoryManagement from '@/components/admin/CategoryManagement' import DepartmentManagement from '@/components/admin/DepartmentManagement' +import DataStatisticsTab from '@/components/admin/DataStatisticsTab' // 类型定义 interface User { @@ -96,9 +97,22 @@ interface Department { export default function AdminPage() { const { data: session, status } = useSession() const router = useRouter() + + const sessionUser = session?.user as { + role?: string + managedDepartment?: string | null + department?: string | null + isSuperAdmin?: boolean + email?: string | null + } | undefined + const currentUserRole = sessionUser?.role || '' + const canAccessAdminPage = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN' + const canManageSystemTabs = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' + const departmentScopeName = String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() || null + const canSelectDepartmentInStats = canManageSystemTabs // Tab状态 - const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'permissions' | 'superAdmins'>('materials') + const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'dataStats' | 'permissions' | 'superAdmins'>('dataStats') const [loading, setLoading] = useState(true) // 数据状态 @@ -141,42 +155,32 @@ export default function AdminPage() { setToast({ message, type }) }, []) - // 权限检查 - useEffect(() => { - if (status === 'unauthenticated') { - router.push('/login') - return + const fetchDepartments = useCallback(async () => { + try { + // 阶段三:获取包含层级关系的部门数据 + const response = await fetch('/api/departments?includeHierarchy=true') + if (response.ok) { + const data = await response.json() + setDepartments(data) + // 默认所有部门收起 + setCollapsedDepartments(new Set(data.map((d: Department) => d.id))) + } + } catch (error) { + console.error('Error fetching departments:', error) } - - if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN') { - router.push('/') - return - } - }, [status, session, router]) - - // 数据加载 - useEffect(() => { - if (status === 'authenticated') { - fetchData() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, activeTab, showRecycleBin]) - - // 切换回收站时清空选中状态和搜索词 - useEffect(() => { - setSelectedMaterials(new Set()) - setMaterialSearchTerm('') // 清空搜索词,避免状态混乱 - }, [showRecycleBin]) + }, []) const fetchData = useCallback(async () => { setLoading(true) try { - if (activeTab === 'materials') { + if (activeTab === 'dataStats' || !canManageSystemTabs) { + return + } else if (activeTab === 'materials') { setCurrentMaterialsPage(1) setDisplayedMaterialsCount(24) setHasMoreMaterials(true) - - const url = showRecycleBin + + const url = showRecycleBin ? `/api/materials?page=1&pageSize=${materialsPageSize}&deleted=true` : `/api/materials?page=1&pageSize=${materialsPageSize}` const response = await fetch(url) @@ -218,22 +222,40 @@ export default function AdminPage() { } finally { setLoading(false) } - }, [activeTab, showRecycleBin, materialsPageSize]) + }, [activeTab, canManageSystemTabs, fetchDepartments, showRecycleBin]) - const fetchDepartments = useCallback(async () => { - try { - // 阶段三:获取包含层级关系的部门数据 - const response = await fetch('/api/departments?includeHierarchy=true') - if (response.ok) { - const data = await response.json() - setDepartments(data) - // 默认所有部门收起 - setCollapsedDepartments(new Set(data.map((d: Department) => d.id))) - } - } catch (error) { - console.error('Error fetching departments:', error) + // 权限检查 + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login') + return } - }, []) + + if (status === 'authenticated' && !canAccessAdminPage) { + router.push('/') + return + } + }, [status, canAccessAdminPage, router]) + + useEffect(() => { + if (status === 'authenticated' && !canManageSystemTabs && activeTab !== 'dataStats') { + setActiveTab('dataStats') + } + }, [activeTab, canManageSystemTabs, status]) + + // 数据加载 + useEffect(() => { + if (status === 'authenticated') { + fetchData() + } + }, [status, fetchData]) + + // 切换回收站时清空选中状态和搜索词 + useEffect(() => { + void showRecycleBin + setSelectedMaterials(new Set()) + setMaterialSearchTerm('') // 清空搜索词,避免状态混乱 + }, [showRecycleBin]) // 加载更多素材 const loadMoreMaterials = useCallback(async () => { @@ -256,7 +278,7 @@ export default function AdminPage() { // 去重:过滤掉已经存在的素材,并同步更新 displayedMaterialsCount setMaterials(prev => { const existingIds = new Set(prev.map(m => m.id)) - const uniqueNewMaterials = newMaterials.filter((m: any) => !existingIds.has(m.id)) + const uniqueNewMaterials = newMaterials.filter((m: { id: string }) => !existingIds.has(m.id)) // 同步更新 displayedMaterialsCount(使用实际去重后的数量) setDisplayedMaterialsCount(prev.length + uniqueNewMaterials.length) return [...prev, ...uniqueNewMaterials] @@ -272,7 +294,7 @@ export default function AdminPage() { } finally { setIsLoadingMoreMaterials(false) } - }, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialsPageSize, materialSearchTerm]) + }, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialSearchTerm]) // 切换部门折叠状态 const toggleDepartmentCollapse = useCallback((departmentName: string) => { @@ -309,7 +331,7 @@ export default function AdminPage() { ) } - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !canAccessAdminPage) { return null } @@ -331,93 +353,128 @@ export default function AdminPage() {

系统管理

- + {canManageSystemTabs && ( + + )}
{/* Tabs */}
- - - - {(session.user as any)?.isSuperAdmin && ( + + {canManageSystemTabs && ( <> + + + + + + + {sessionUser?.isSuperAdmin && ( + <> + + + + + )} )}
+ {/* 数据统计 Tab */} + {activeTab === 'dataStats' && ( + + )} + {/* 素材管理 Tab */} - {activeTab === 'materials' && ( + {canManageSystemTabs && activeTab === 'materials' && ( diff --git a/app/api/admin/data-stats/export/route.ts b/app/api/admin/data-stats/export/route.ts new file mode 100644 index 0000000..2c4aefb --- /dev/null +++ b/app/api/admin/data-stats/export/route.ts @@ -0,0 +1,176 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { isDepartmentAdmin, isSystemAdmin } from '@/lib/department-permission' +import { getMaterialUploadStats } from '@/lib/admin-material-stats' +import { prisma } from '@/lib/prisma' + +interface StatsSessionUser { + role?: string | null + managedDepartment?: string | null + department?: string | null +} + +function toDateStart(dateString: string): Date { + return new Date(`${dateString}T00:00:00`) +} + +function toDateEnd(dateString: string): Date { + return new Date(`${dateString}T23:59:59.999`) +} + +function escapeCsvValue(value: unknown): string { + if (value === null || value === undefined) { + return '' + } + + const stringValue = String(value) + if (/[",\n]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"` + } + + return stringValue +} + +function formatDateTime(value: Date): string { + return value.toLocaleString('sv-SE', { + timeZone: 'Asia/Shanghai', + hour12: false, + }) +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + const sessionUser = session?.user as StatsSessionUser | undefined + const role = sessionUser?.role + const canViewAllDepartments = isSystemAdmin(role) + const canViewDepartmentScope = isDepartmentAdmin(role) + const canViewStats = canViewAllDepartments || canViewDepartmentScope + const scopeDepartmentName = canViewDepartmentScope + ? String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() + : null + + if (!session?.user || !canViewStats) { + return NextResponse.json({ error: '无权限' }, { status: 403 }) + } + + if (canViewDepartmentScope && !scopeDepartmentName) { + return NextResponse.json({ error: '当前账号未绑定部门,无法导出统计数据' }, { status: 400 }) + } + + const { searchParams } = new URL(request.url) + const startDate = searchParams.get('startDate') + const endDate = searchParams.get('endDate') + const departmentKey = searchParams.get('departmentKey') + + const stats = await getMaterialUploadStats({ + startDate, + endDate, + departmentKey, + scopeDepartmentName, + }) + + const selectedUserIds = Array.from(new Set(stats.selectedUsers.map((user) => user.userId))) + if (selectedUserIds.length === 0) { + const lines = ['素材ID,上传时间,产品型号,素材类型,店铺,场景,平台,品类,作者,上传账号,上传姓名,上传部门,下载次数,点赞数'] + const csvContent = `\uFEFF${lines.join('\n')}` + const rangeStart = startDate || 'all' + const rangeEnd = endDate || 'all' + const fileName = `material-details-${rangeStart}-${rangeEnd}.csv` + + return new NextResponse(csvContent, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + }, + }) + } + + const createdAtFilter: { gte?: Date; lte?: Date } = {} + if (startDate) { + createdAtFilter.gte = toDateStart(startDate) + } + if (endDate) { + createdAtFilter.lte = toDateEnd(endDate) + } + + const materials = await prisma.material.findMany({ + where: { + deletedAt: null, + userId: { in: selectedUserIds }, + ...(createdAtFilter.gte || createdAtFilter.lte ? { createdAt: createdAtFilter } : {}), + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + productModel: true, + materialType: true, + categoryName: true, + sceneName: true, + platformName: true, + productCategoryName: true, + authorName: true, + createdAt: true, + downloadCount: true, + likeCount: true, + user: { + select: { + username: true, + realName: true, + department: true, + departmentRef: { + select: { + name: true, + }, + }, + }, + }, + }, + }) + + const lines: string[] = [] + lines.push('素材ID,上传时间,产品型号,素材类型,店铺,场景,平台,品类,作者,上传账号,上传姓名,上传部门,下载次数,点赞数') + for (const material of materials) { + const departmentName = material.user.departmentRef?.name || material.user.department || '未分配部门' + lines.push( + [ + material.id, + formatDateTime(material.createdAt), + material.productModel, + material.materialType, + material.categoryName, + material.sceneName, + material.platformName, + material.productCategoryName, + material.authorName, + material.user.username, + material.user.realName || '', + departmentName, + material.downloadCount, + material.likeCount, + ] + .map(escapeCsvValue) + .join(',') + ) + } + + const csvContent = `\uFEFF${lines.join('\n')}` + const rangeStart = startDate || 'all' + const rangeEnd = endDate || 'all' + const fileName = `material-details-${rangeStart}-${rangeEnd}.csv` + + return new NextResponse(csvContent, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, + }, + }) + } catch (error) { + console.error('[Admin Data Stats Export] GET error:', error) + return NextResponse.json({ error: '导出 CSV 失败' }, { status: 500 }) + } +} diff --git a/app/api/admin/data-stats/route.ts b/app/api/admin/data-stats/route.ts new file mode 100644 index 0000000..8255abd --- /dev/null +++ b/app/api/admin/data-stats/route.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { isDepartmentAdmin, isSystemAdmin } from '@/lib/department-permission' +import { getMaterialUploadStats } from '@/lib/admin-material-stats' + +interface StatsSessionUser { + role?: string | null + managedDepartment?: string | null + department?: string | null +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + const sessionUser = session?.user as StatsSessionUser | undefined + + const role = sessionUser?.role + const canViewAllDepartments = isSystemAdmin(role) + const canViewDepartmentScope = isDepartmentAdmin(role) + const canViewStats = canViewAllDepartments || canViewDepartmentScope + const scopeDepartmentName = canViewDepartmentScope + ? String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() + : null + + if (!session?.user || !canViewStats) { + return NextResponse.json({ error: '无权限' }, { status: 403 }) + } + + if (canViewDepartmentScope && !scopeDepartmentName) { + return NextResponse.json({ error: '当前账号未绑定部门,无法查看统计数据' }, { status: 400 }) + } + + const { searchParams } = new URL(request.url) + const startDate = searchParams.get('startDate') + const endDate = searchParams.get('endDate') + const departmentKey = searchParams.get('departmentKey') + + const stats = await getMaterialUploadStats({ + startDate, + endDate, + departmentKey, + scopeDepartmentName, + }) + + return NextResponse.json({ + success: true, + data: stats, + meta: { + canViewAllDepartments, + scopeDepartmentName, + }, + }) + } catch (error) { + console.error('[Admin Data Stats] GET error:', error) + return NextResponse.json({ error: '获取数据统计失败' }, { status: 500 }) + } +} diff --git a/app/api/admin/material-operation-logs/route.ts b/app/api/admin/material-operation-logs/route.ts new file mode 100644 index 0000000..9877818 --- /dev/null +++ b/app/api/admin/material-operation-logs/route.ts @@ -0,0 +1,237 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { isSystemAdmin } from '@/lib/department-permission' +import { safeJsonParse } from '@/lib/safe-json' + +const VALID_ACTIONS = new Set(['ALL', 'UPLOAD', 'UPDATE', 'DELETE', 'DOWNLOAD', 'RESTORE']) + +function toDateStart(dateString: string): Date { + return new Date(`${dateString}T00:00:00`) +} + +function toDateEnd(dateString: string): Date { + return new Date(`${dateString}T23:59:59.999`) +} + +function normalizeKeyword(keyword: string | null): string { + return (keyword || '').trim() +} + +function includesKeyword(value: string | null | undefined, keyword: string): boolean { + if (!value) return false + return value.toLowerCase().includes(keyword.toLowerCase()) +} + +function getSnapshotMaterialInfo(changes: Record | null): { + productModel?: string + materialType?: string +} { + if (!changes) return {} + const snapshot = changes.materialSnapshot + if (!snapshot || typeof snapshot !== 'object') return {} + + const info = snapshot as Record + return { + productModel: typeof info.productModel === 'string' ? info.productModel : undefined, + materialType: typeof info.materialType === 'string' ? info.materialType : undefined, + } +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user || !isSystemAdmin((session.user as any).role)) { + return NextResponse.json({ error: '无权限' }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const requestedAction = (searchParams.get('action') || 'ALL').toUpperCase() + const action = VALID_ACTIONS.has(requestedAction) ? requestedAction : 'ALL' + + const page = Math.max(Number(searchParams.get('page') || '1'), 1) + const pageSize = Math.min(Math.max(Number(searchParams.get('pageSize') || '20'), 1), 100) + const startDate = searchParams.get('startDate') + const endDate = searchParams.get('endDate') + const keyword = normalizeKeyword(searchParams.get('keyword')) + + const materialLogWhere: any = {} + const downloadLogWhere: any = {} + + if (startDate || endDate) { + const timestampFilter: { gte?: Date; lte?: Date } = {} + if (startDate) { + timestampFilter.gte = toDateStart(startDate) + } + if (endDate) { + timestampFilter.lte = toDateEnd(endDate) + } + materialLogWhere.timestamp = timestampFilter + downloadLogWhere.downloadedAt = timestampFilter + } + + if (action === 'ALL') { + materialLogWhere.action = { not: 'DOWNLOAD' } + } else if (action !== 'DOWNLOAD') { + materialLogWhere.action = action + } + + const includeMaterialLogs = action !== 'DOWNLOAD' + const includeDownloadLogs = action === 'ALL' || action === 'DOWNLOAD' + const fetchLimit = keyword ? 5000 : page * pageSize + + const [materialLogTotal, downloadLogTotal, materialLogs, downloadLogs] = await Promise.all([ + includeMaterialLogs ? prisma.materialLog.count({ where: materialLogWhere }) : Promise.resolve(0), + includeDownloadLogs ? prisma.materialDownloadLog.count({ where: downloadLogWhere }) : Promise.resolve(0), + includeMaterialLogs + ? prisma.materialLog.findMany({ + where: materialLogWhere, + orderBy: { timestamp: 'desc' }, + take: fetchLimit, + include: { + user: { + select: { + id: true, + username: true, + realName: true, + department: true, + departmentId: true, + departmentRef: { + select: { + name: true, + }, + }, + }, + }, + }, + }) + : Promise.resolve([]), + includeDownloadLogs + ? prisma.materialDownloadLog.findMany({ + where: downloadLogWhere, + orderBy: { downloadedAt: 'desc' }, + take: fetchLimit, + include: { + user: { + select: { + id: true, + username: true, + realName: true, + department: true, + departmentId: true, + departmentRef: { + select: { + name: true, + }, + }, + }, + }, + }, + }) + : Promise.resolve([]), + ]) + + const materialIds = Array.from( + new Set([ + ...materialLogs.map((log) => log.materialId), + ...downloadLogs.map((log) => log.materialId), + ]) + ) + + const materials = materialIds.length + ? await prisma.material.findMany({ + where: { id: { in: materialIds } }, + select: { + id: true, + productModel: true, + materialType: true, + }, + }) + : [] + + const materialMap = new Map(materials.map((material) => [material.id, material])) + + const formattedMaterialLogs = materialLogs.map((log) => { + const parsedChanges = log.changes + ? safeJsonParse>(log.changes, {}, false) + : null + const relatedMaterial = materialMap.get(log.materialId) + const snapshotInfo = getSnapshotMaterialInfo(parsedChanges) + + return { + id: `material-${log.id}`, + source: 'material_log', + action: log.action, + timestamp: log.timestamp.toISOString(), + user: { + id: log.user.id, + username: log.user.username, + realName: log.user.realName, + departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门', + }, + material: { + id: log.materialId, + productModel: relatedMaterial?.productModel || snapshotInfo.productModel || '已删除素材', + materialType: relatedMaterial?.materialType || snapshotInfo.materialType || '未知类型', + }, + changes: parsedChanges, + } + }) + + const formattedDownloadLogs = downloadLogs.map((log) => ({ + id: `download-${log.id}`, + source: 'download_log', + action: 'DOWNLOAD', + timestamp: log.downloadedAt.toISOString(), + user: { + id: log.user.id, + username: log.user.username, + realName: log.user.realName, + departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门', + }, + material: { + id: log.materialId, + productModel: materialMap.get(log.materialId)?.productModel || '已删除素材', + materialType: materialMap.get(log.materialId)?.materialType || '未知类型', + }, + changes: null, + })) + + const mergedLogs = [...formattedMaterialLogs, ...formattedDownloadLogs].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ) + + const filteredLogs = keyword + ? mergedLogs.filter((log) => { + return ( + includesKeyword(log.user.username, keyword) || + includesKeyword(log.user.realName, keyword) || + includesKeyword(log.user.departmentName, keyword) || + includesKeyword(log.material.id, keyword) || + includesKeyword(log.material.productModel, keyword) || + includesKeyword(log.material.materialType, keyword) + ) + }) + : mergedLogs + + const offset = (page - 1) * pageSize + const paginatedLogs = filteredLogs.slice(offset, offset + pageSize) + const total = keyword ? filteredLogs.length : materialLogTotal + downloadLogTotal + + return NextResponse.json({ + success: true, + data: { + logs: paginatedLogs, + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }) + } catch (error) { + console.error('[Admin Material Operation Logs] GET 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 index 71753a7..3e3f709 100644 --- a/app/api/admin/materials/batch-delete/route.ts +++ b/app/api/admin/materials/batch-delete/route.ts @@ -5,6 +5,8 @@ import { prisma } from '@/lib/prisma' import { isSystemAdmin } from '@/lib/department-permission' import { deleteFiles } from '@/lib/file-cleanup' import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup' +import { createMaterialLog } from '@/lib/material-log' +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' // UUID 格式校验正则 const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @@ -28,6 +30,11 @@ export async function POST(request: NextRequest) { } const { ids, permanent } = await request.json() + const operatorId = (session.user as any).id + + if (!operatorId) { + return NextResponse.json({ error: '缺少操作人信息' }, { status: 401 }) + } // 校验 ids 是数组且非空 if (!Array.isArray(ids) || ids.length === 0) { @@ -45,6 +52,9 @@ export async function POST(request: NextRequest) { if (permanent) { // ========== 永久删除:物理删除文件 + 删除数据库记录 ========== console.log(`[BatchDelete] 开始永久删除 ${ids.length} 条素材...`) + + // 确保下载日志表已应用“删除素材后保留日志”策略 + await ensureMaterialDownloadLogsTable() // 1. 先查询所有素材(获取文件路径和素材类型) const materials = await prisma.material.findMany({ @@ -62,6 +72,35 @@ export async function POST(request: NextRequest) { for (const material of materials) { const materialType = (material as any).materialType || '' + + // 记录永久删除日志(先记录,避免数据库删除后日志级联删除前丢失上下文) + await createMaterialLog({ + materialId: material.id, + userId: operatorId, + action: 'DELETE', + changes: { + materialSnapshot: { + oldValue: null, + newValue: { + id: material.id, + productModel: material.productModel, + materialType: materialType || null, + }, + }, + deletedAt: { + oldValue: material.deletedAt ? material.deletedAt.toISOString() : null, + newValue: 'PERMANENTLY_DELETED', + }, + deleteMode: { + oldValue: material.deletedAt ? 'soft' : 'active', + newValue: 'permanent', + }, + materialType: { + oldValue: materialType || null, + newValue: materialType || null, + }, + }, + }) // 收集该素材的所有文件 const fileInfo = await collectMaterialFiles(material.id, material) @@ -128,7 +167,7 @@ export async function POST(request: NextRequest) { // 查询素材(需要处理任务号) const materials = await prisma.material.findMany({ where: { id: { in: ids } }, - select: { id: true, taskNo: true } + select: { id: true, taskNo: true, productModel: true, materialType: true } }) if (materials.length === 0) { @@ -136,17 +175,47 @@ export async function POST(request: NextRequest) { } const now = Date.now() + const deletedAt = new Date() + await Promise.all(materials.map((m, index) => prisma.material.update({ where: { id: m.id }, data: { - deletedAt: new Date(), + deletedAt, // 如果有任务号,加上删除时间戳后缀使其作废(防止重复) ...(m.taskNo ? { taskNo: `${m.taskNo}_DELETED_${now}_${index}` } : {}) } }) )) + await Promise.all( + materials.map((material) => + createMaterialLog({ + materialId: material.id, + userId: operatorId, + action: 'DELETE', + changes: { + materialSnapshot: { + oldValue: null, + newValue: { + id: material.id, + productModel: material.productModel, + materialType: material.materialType, + }, + }, + deletedAt: { + oldValue: null, + newValue: deletedAt.toISOString(), + }, + deleteMode: { + oldValue: 'active', + newValue: 'soft', + }, + }, + }) + ) + ) + console.log(`[BatchDelete] 软删除完成: ${materials.length} 条素材`) return NextResponse.json({ diff --git a/app/api/admin/video-transcode-queue/route.ts b/app/api/admin/video-transcode-queue/route.ts new file mode 100644 index 0000000..41c2df5 --- /dev/null +++ b/app/api/admin/video-transcode-queue/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { transcodeQueue } from '@/lib/video-transcode-queue' + +export const dynamic = 'force-dynamic' + +const DEFAULT_PREVIEW_LIMIT = 20 +const MAX_PREVIEW_LIMIT = 100 + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + const role = (session?.user as unknown as { role?: string } | null)?.role + if (!session || role !== 'ADMIN') { + return NextResponse.json({ error: '无权限' }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const limitParam = searchParams.get('limit') + let previewLimit = Number.parseInt(limitParam || '', 10) + if (Number.isNaN(previewLimit) || previewLimit <= 0) { + previewLimit = DEFAULT_PREVIEW_LIMIT + } else { + previewLimit = Math.min(previewLimit, MAX_PREVIEW_LIMIT) + } + + const status = transcodeQueue.getStatus() + + const processingPreview = status.processingIds.slice(0, previewLimit) + const waitingPreview = status.waiting.slice(0, previewLimit) + const progressSnapshot = Array.from(status.progress.entries()) + .slice(0, previewLimit) + .map(([materialId, value]) => ({ + materialId, + progress: value, + stage: status.stages.get(materialId) ?? null, + })) + + return NextResponse.json({ + queueLength: status.queueLength, + processingCount: status.processingCount, + maxConcurrent: status.maxConcurrent, + processingPreview, + waitingPreview, + previewLimit, + timestamp: new Date().toISOString(), + progress: progressSnapshot, + }) + } catch (error) { + console.error('[Admin] Video transcode queue status error:', error) + return NextResponse.json({ error: '获取转码队列状态失败' }, { status: 500 }) + } +} diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index ee753a2..40fc335 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -81,17 +81,24 @@ export async function GET(request: NextRequest) { if (isProductPackageType(material.materialType)) { // 任务-详情:使用预先生成的zip文件 - // 下载全部:使用 all.zip + // 下载全部:优先使用 all.zip;缺失时动态打包兜底 if (downloadType === 'all') { const allZipAsset = material.assets.find((a: any) => a.assetType === 'all'); - if (!allZipAsset) { + if (allZipAsset) { + console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`); + return await downloadSingleFile(allZipAsset); + } + + const fallbackAssets = getAssetsForDownload(material.assets || [], 'all'); + if (fallbackAssets.length === 0) { return NextResponse.json( { success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } }, { status: 404 } ); } - console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`); - return await downloadSingleFile(allZipAsset); + + console.warn('[external/download] all.zip 缺失,使用 main/sku/detail 动态打包兜底'); + return await downloadAsZip(fallbackAssets, material.taskNo || material.id, 'all'); } // 下载分类:使用 main.zip / sku.zip / detail.zip @@ -153,18 +160,38 @@ export async function GET(request: NextRequest) { { status: 400 } ); } else { - // 任务-3D原图/AI原图/实拍原图:下载zip文件 - const zipAssets = material.assets.filter((a: any) => a.assetType === 'zip'); - if (zipAssets.length === 0) { - return NextResponse.json( - { success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } }, - { status: 404 } - ); + // 任务-3D原图/AI原图/实拍原图:逐个尝试 zip 资产,失败时回退通用下载链路 + const zipAssets = (material.assets || []).filter((a: any) => a.assetType === 'zip'); + let lastNotFoundResponse: NextResponse | null = null; + + for (const zipAsset of zipAssets) { + const response = await downloadSingleFile(zipAsset); + if (response.status < 400) { + return response; + } + + if (response.status !== 404) { + return response; + } + + lastNotFoundResponse = response; + console.warn(`[external/download] 任务 zip 资产不可用,尝试下一个: ${zipAsset.fileUrl}`); } - - // 返回第一个zip文件 - const zipAsset = zipAssets[0]; - return await downloadSingleFile(zipAsset); + + console.warn('[external/download] zip 资产均失败,回退到通用下载链路'); + const fallbackResponse = await downloadNonProductPackageMaterial(material, downloadType, request); + if (fallbackResponse.status < 400) { + return fallbackResponse; + } + + if (lastNotFoundResponse) { + return lastNotFoundResponse; + } + + return NextResponse.json( + { success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } }, + { status: 404 } + ); } } @@ -205,14 +232,12 @@ function getAssetsForDownload(assets: any[], downloadType: string) { * ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24) */ async function downloadSingleFile(asset: any) { - const fileUrl = asset.fileUrl; - const fileName = asset.fileName || path.basename(fileUrl); - - // 判断是本地文件还是远程文件 - if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) { - // 本地文件 - const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, '')); + const normalizedFileUrl = normalizeAssetFileUrl(asset?.fileUrl); + const fileName = resolveAssetFileName(asset, normalizedFileUrl); + const localPath = resolveAssetLocalPath(asset?.fileUrl); + // 本地文件 + if (localPath) { if (!fs.existsSync(localPath)) { return NextResponse.json( { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } }, @@ -248,10 +273,12 @@ async function downloadSingleFile(asset: any) { 'Content-Length': fileStats.size.toString(), }, }); - } else { - // 远程文件(S3) + } + + // 远程文件(S3) + if (isHttpUrl(normalizedFileUrl)) { try { - const response = await fetch(fileUrl); + const response = await fetch(normalizedFileUrl); if (!response.ok) { return NextResponse.json( { success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } }, @@ -277,6 +304,11 @@ async function downloadSingleFile(asset: any) { ); } } + + return NextResponse.json( + { success: false, error: { code: 'INVALID_FILE_URL', message: '文件地址无效' } }, + { status: 400 } + ); } /** @@ -397,6 +429,56 @@ function isHttpUrl(url?: string | null): boolean { return /^https?:\/\//i.test(url.trim()); } +/** + * 规范化资产 fileUrl(统一斜杠并去除首尾空格) + */ +function normalizeAssetFileUrl(fileUrl?: string | null): string { + if (!fileUrl || typeof fileUrl !== 'string') return ''; + return fileUrl.trim().replace(/\\/g, '/'); +} + +/** + * 将资产 fileUrl 解析为本地绝对路径(仅针对非 http(s) 地址) + */ +function resolveAssetLocalPath(fileUrl?: string | null): string | null { + if (!fileUrl || typeof fileUrl !== 'string') return null; + + const normalizedUrl = normalizeAssetFileUrl(fileUrl); + if (!normalizedUrl || isHttpUrl(normalizedUrl)) { + return null; + } + + if (normalizedUrl.startsWith('/uploads/')) { + return path.join(process.cwd(), normalizedUrl.slice(1)); + } + + if (path.isAbsolute(fileUrl)) { + return fileUrl; + } + + const relativePath = normalizedUrl.replace(/^\.?\//, ''); + return path.join(process.cwd(), relativePath); +} + +/** + * 推断下载文件名 + */ +function resolveAssetFileName(asset: any, normalizedFileUrl: string): string { + if (asset?.fileName && typeof asset.fileName === 'string' && asset.fileName.trim()) { + return asset.fileName; + } + + if (isHttpUrl(normalizedFileUrl)) { + try { + return path.basename(new URL(normalizedFileUrl).pathname) || 'download-file'; + } catch { + return path.basename(normalizedFileUrl) || 'download-file'; + } + } + + return path.basename(normalizedFileUrl) || 'download-file'; +} + /** * 下载非任务-详情类型的素材 * 复用 /api/materials/download 的逻辑,但跳过权限检查 diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index b18a233..21ee8f0 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -22,7 +22,7 @@ import { TASK_STATUS, ASSET_TYPE, } from '@/lib/product-package'; -import { generateDownloadUrl, DownloadType } from '@/lib/download-token'; +import { generateDownloadUrl } from '@/lib/download-token'; import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; import { isProductPackageType } from '@/lib/material-type-mapper'; import path from 'path'; @@ -151,8 +151,8 @@ export async function GET(request: NextRequest) { }); } - // 生成资产列表(带下载链接) - const formatAsset = (asset: any, downloadType: DownloadType) => { + // 生成资产列表(仅返回资源信息,不返回分类型下载链接) + const formatAsset = (asset: any) => { try { // 验证资产数据 if (!asset || !asset.id) { @@ -172,15 +172,9 @@ export async function GET(request: NextRequest) { } const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request); - const downloadUrl = generateDownloadUrl(baseUrl, { - materialId: material.id, - downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType, - assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined, - }); return { id: asset.id, url: rewrittenUrl, - downloadUrl, fileName: asset.fileName || '', fileSize: Number(asset.fileSize || 0), ...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder || 0 }), @@ -198,6 +192,11 @@ export async function GET(request: NextRequest) { } }; + const allDownloadUrl = generateDownloadUrl(baseUrl, { + materialId: material.id, + downloadType: 'all', + }); + const responseData = { taskNo: material.taskNo, productModel: material.productModel, @@ -207,17 +206,16 @@ export async function GET(request: NextRequest) { createdAt: material.createdAt.toISOString(), updatedAt: material.updatedAt.toISOString(), assets: { - main: mainAssets.map((a: any) => formatAsset(a, 'main')), - sku: skuAssets.map((a: any) => formatAsset(a, 'sku')), - detail: detailAssets.map((a: any) => formatAsset(a, 'detail')), - psd: psdAssets.map((a: any) => formatAsset(a, 'psd')), + main: mainAssets.map((a: any) => formatAsset(a)), + sku: skuAssets.map((a: any) => formatAsset(a)), + detail: detailAssets.map((a: any) => formatAsset(a)), + psd: psdAssets.map((a: any) => formatAsset(a)), }, + // 兼容字段:给任务平台一个“单一”的下载链接(全部打包)。 + // 避免返回 main/sku/detail 等下载链接,导致对方做可用性校验时被 404(NO_ASSETS) 拦截。 + downloadUrl: allDownloadUrl, downloadUrls: { - all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), // 任务平台主要使用这个 - main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }), - sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }), - detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }), - psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : null, + all: allDownloadUrl, // 任务平台只需要这个 }, }; diff --git a/app/api/materials/[id]/assets/route.ts b/app/api/materials/[id]/assets/route.ts index 277a2e4..72236fa 100644 --- a/app/api/materials/[id]/assets/route.ts +++ b/app/api/materials/[id]/assets/route.ts @@ -10,6 +10,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { getAssetsByMaterialId, addAsset, deleteAsset } from '@/lib/product-package'; +import { createMaterialLog } from '@/lib/material-log'; /** * GET - 获取资产列表 @@ -117,6 +118,30 @@ export async function POST( sortOrder, }); + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + assetOperation: { + oldValue: null, + newValue: 'ADD', + }, + assetType: { + oldValue: null, + newValue: asset.assetType, + }, + assetFileName: { + oldValue: null, + newValue: asset.fileName || null, + }, + assetFileUrl: { + oldValue: null, + newValue: asset.fileUrl, + }, + }, + }); + return NextResponse.json({ success: true, data: asset, @@ -181,7 +206,13 @@ export async function DELETE( // 验证资产是否属于该素材 const asset = await prisma.materialAsset.findUnique({ where: { id: assetId }, - select: { materialId: true }, + select: { + id: true, + materialId: true, + assetType: true, + fileUrl: true, + fileName: true, + }, }); if (!asset || asset.materialId !== materialId) { @@ -194,6 +225,34 @@ export async function DELETE( // 删除资产 await deleteAsset(assetId); + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + assetOperation: { + oldValue: null, + newValue: 'DELETE', + }, + assetId: { + oldValue: asset.id, + newValue: asset.id, + }, + assetType: { + oldValue: asset.assetType, + newValue: asset.assetType, + }, + assetFileName: { + oldValue: asset.fileName || null, + newValue: null, + }, + assetFileUrl: { + oldValue: asset.fileUrl, + newValue: null, + }, + }, + }); + return NextResponse.json({ success: true, message: '删除成功', diff --git a/app/api/materials/[id]/assets/sort/route.ts b/app/api/materials/[id]/assets/sort/route.ts index d6cb0c7..0677ea9 100644 --- a/app/api/materials/[id]/assets/sort/route.ts +++ b/app/api/materials/[id]/assets/sort/route.ts @@ -8,6 +8,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { updateAssetSortOrder } from '@/lib/product-package'; +import { createMaterialLog } from '@/lib/material-log'; export async function PUT( request: NextRequest, @@ -63,7 +64,10 @@ export async function PUT( materialId, assetType, }, - select: { id: true }, + select: { + id: true, + sortOrder: true, + }, }); if (assets.length !== assetIds.length) { @@ -76,6 +80,58 @@ export async function PUT( // 更新排序 await updateAssetSortOrder(orders); + const oldOrder = [...assets] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((asset) => ({ assetId: asset.id, sortOrder: asset.sortOrder })); + + const newOrder = orders + .map((order: { assetId: string; sortOrder: number }) => ({ + assetId: order.assetId, + sortOrder: order.sortOrder, + })) + .sort((a, b) => a.sortOrder - b.sortOrder); + + const oldSortMap = new Map(assets.map((asset) => [asset.id, asset.sortOrder])); + const changedItems = newOrder + .map((item) => { + const oldSortOrder = oldSortMap.get(item.assetId); + if (oldSortOrder === undefined || oldSortOrder === item.sortOrder) return null; + return { + assetId: item.assetId, + oldSortOrder, + newSortOrder: item.sortOrder, + }; + }) + .filter( + (item): item is { assetId: string; oldSortOrder: number; newSortOrder: number } => item !== null + ); + + if (changedItems.length > 0) { + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + assetOperation: { + oldValue: null, + newValue: 'REORDER', + }, + assetType: { + oldValue: null, + newValue: assetType, + }, + assetOrder: { + oldValue: oldOrder, + newValue: newOrder, + }, + changedItems: { + oldValue: null, + newValue: changedItems, + }, + }, + }); + } + return NextResponse.json({ success: true, message: '排序更新成功', diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index efaa36d..e2a5507 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -7,6 +7,34 @@ import { isShareLinkAccess, } from '@/lib/department-permission'; import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation'; +import { createMaterialLog } from '@/lib/material-log'; + +function buildMaterialChanges(oldMaterial: any, newData: Record) { + const changes: Record = {}; + + const normalize = (value: any) => { + if (value instanceof Date) { + return value.toISOString(); + } + if (value === undefined) { + return null; + } + return value; + }; + + for (const [key, value] of Object.entries(newData)) { + if (key === 'updatedAt') continue; + + const oldValue = normalize(oldMaterial[key]); + const newValue = normalize(value); + + if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { + changes[key] = { oldValue, newValue }; + } + } + + return changes; +} // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -380,6 +408,16 @@ export async function PUT( where: { id: params.id }, data: updateData, }); + + const changes = buildMaterialChanges(material, updateData); + if (Object.keys(changes).length > 0) { + await createMaterialLog({ + materialId: params.id, + userId, + action: 'UPDATE', + changes, + }); + } return NextResponse.json({ success: true, diff --git a/app/api/materials/[id]/skus/[skuId]/download/route.ts b/app/api/materials/[id]/skus/[skuId]/download/route.ts index 7e1f4f8..25567c2 100644 --- a/app/api/materials/[id]/skus/[skuId]/download/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/download/route.ts @@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma' import { createReadStream, existsSync } from 'fs' import { stat } from 'fs/promises' import path from 'path' +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' // 下载单个 SKU 的压缩包 // ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24) @@ -15,6 +16,9 @@ export async function GET( { params }: { params: { id: string; skuId: string } } ) { try { + const { searchParams } = new URL(request.url) + const validateOnly = searchParams.get('validate') === '1' + const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) @@ -32,10 +36,15 @@ export async function GET( } const { skuId } = params + const materialId = params.id // 获取 SKU 信息 - const sku = await prisma.materialSku.findUnique({ - where: { id: skuId } + const sku = await prisma.materialSku.findFirst({ + where: { + id: skuId, + materialId, + deletedAt: null, + } }) if (!sku) { @@ -76,6 +85,20 @@ export async function GET( const fileName = `${sku.skuName}.zip` const fileStats = await stat(filePath) + if (validateOnly) { + fileStream.destroy() + return NextResponse.json({ + success: true, + fileName, + fileSize: fileStats.size, + contentType: 'application/zip', + }) + } + + recordSkuDownloadLog(materialId, userId).catch((error) => { + console.error('[SKU Download] 记录下载日志失败:', error) + }) + // 返回文件流 return new NextResponse(stream, { headers: { @@ -89,3 +112,24 @@ export async function GET( return NextResponse.json({ error: '下载失败' }, { status: 500 }) } } + +async function recordSkuDownloadLog(materialId: string, userId: string) { + await ensureMaterialDownloadLogsTable() + await Promise.all([ + prisma.materialDownloadLog.create({ + data: { + materialId, + userId, + downloadedAt: new Date(), + }, + }), + prisma.material.update({ + where: { id: materialId }, + data: { + downloadCount: { + increment: 1, + }, + }, + }), + ]) +} diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts index 97947d3..9991c17 100644 --- a/app/api/materials/[id]/skus/[skuId]/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -6,6 +6,7 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import fs from 'fs' import path from 'path' +import { createMaterialLog } from '@/lib/material-log' // 获取单个 SKU 详情 export async function GET( @@ -105,6 +106,64 @@ export async function PUT( data: updateData }) + const changes: Record = { + skuOperation: { + oldValue: null, + newValue: 'UPDATE', + }, + } + + if (updateData.skuName !== undefined && existingSku.skuName !== updatedSku.skuName) { + changes.skuName = { + oldValue: existingSku.skuName, + newValue: updatedSku.skuName, + } + } + + if (updateData.previewUrl !== undefined && (existingSku as any).previewUrl !== (updatedSku as any).previewUrl) { + changes.skuPreviewUrl = { + oldValue: (existingSku as any).previewUrl || null, + newValue: (updatedSku as any).previewUrl || null, + } + } + + if (updateData.fileName !== undefined && (existingSku as any).fileName !== (updatedSku as any).fileName) { + changes.skuFileName = { + oldValue: (existingSku as any).fileName || null, + newValue: (updatedSku as any).fileName || null, + } + } + + if (updateData.filePath !== undefined && existingSku.filePath !== updatedSku.filePath) { + changes.skuFilePath = { + oldValue: existingSku.filePath, + newValue: updatedSku.filePath, + } + } + + if (updateData.fileSize !== undefined && existingSku.fileSize.toString() !== updatedSku.fileSize.toString()) { + changes.skuFileSize = { + oldValue: existingSku.fileSize.toString(), + newValue: updatedSku.fileSize.toString(), + } + } + + if (updateData.sortOrder !== undefined && existingSku.sortOrder !== updatedSku.sortOrder) { + changes.skuSortOrder = { + oldValue: existingSku.sortOrder, + newValue: updatedSku.sortOrder, + } + } + + if (Object.keys(changes).length > 1) { + await createMaterialLog({ + materialId: updatedSku.materialId, + userId, + action: 'UPDATE', + changes, + }) + } + // 返回时统一字段命名 return NextResponse.json({ id: updatedSku.id, @@ -165,13 +224,39 @@ export async function DELETE( } // 软删除:只标记删除时间,不删除文件(文件保留在服务器,可通过回收站恢复) + const deletedAt = new Date() + await prisma.materialSku.update({ where: { id: skuId }, data: { - deletedAt: new Date() + deletedAt } as any }) + await createMaterialLog({ + materialId: existingSku.materialId, + userId, + action: 'UPDATE', + changes: { + skuOperation: { + oldValue: null, + newValue: 'DELETE', + }, + skuId: { + oldValue: existingSku.id, + newValue: existingSku.id, + }, + skuName: { + oldValue: existingSku.skuName, + newValue: existingSku.skuName, + }, + deletedAt: { + oldValue: existingSku.deletedAt ? existingSku.deletedAt.toISOString() : null, + newValue: deletedAt.toISOString(), + }, + }, + }) + return NextResponse.json({ success: true, message: 'SKU 已删除' }) } catch (error) { console.error('删除 SKU 失败:', error) diff --git a/app/api/materials/[id]/skus/batch-download/route.ts b/app/api/materials/[id]/skus/batch-download/route.ts index 71047ce..65d9a5f 100644 --- a/app/api/materials/[id]/skus/batch-download/route.ts +++ b/app/api/materials/[id]/skus/batch-download/route.ts @@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { createReadStream } from 'fs' import path from 'path' import JSZip from 'jszip' +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' // SKU批量下载API export async function POST( @@ -12,6 +12,22 @@ export async function POST( { params }: { params: { id: string } } ) { try { + const session = await getServerSession(authOptions) + if (!session?.user || !(session.user as any).id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const userId = (session.user as any).id + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { canDownload: true }, + }) + + if (!user?.canDownload) { + return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 }) + } + const materialId = params.id const body = await request.json() const { skuIds } = body @@ -95,6 +111,10 @@ export async function POST( const filename = `${material.productModel}_SKU批量下载_${Date.now()}.zip` + recordBatchDownloadLog(materialId, userId).catch((error) => { + console.error('[SKU Batch Download] 记录下载日志失败:', error) + }) + return new NextResponse(zipBuffer as any, { headers: { 'Content-Type': 'application/zip', @@ -111,3 +131,24 @@ export async function POST( } } +async function recordBatchDownloadLog(materialId: string, userId: string) { + await ensureMaterialDownloadLogsTable() + await Promise.all([ + prisma.materialDownloadLog.create({ + data: { + materialId, + userId, + downloadedAt: new Date(), + }, + }), + prisma.material.update({ + where: { id: materialId }, + data: { + downloadCount: { + increment: 1, + }, + }, + }), + ]) +} + diff --git a/app/api/materials/[id]/skus/reorder/route.ts b/app/api/materials/[id]/skus/reorder/route.ts index 7f47d07..845add4 100644 --- a/app/api/materials/[id]/skus/reorder/route.ts +++ b/app/api/materials/[id]/skus/reorder/route.ts @@ -4,6 +4,7 @@ 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' // 批量更新 SKU 排序 export async function PUT( @@ -46,6 +47,29 @@ export async function PUT( return NextResponse.json({ error: '请提供 SKU ID 列表' }, { status: 400 }) } + const existingSkus = await prisma.materialSku.findMany({ + where: { + id: { in: skuIds }, + materialId, + deletedAt: null, + }, + select: { + id: true, + skuName: true, + sortOrder: true, + }, + }) + + if (existingSkus.length !== skuIds.length) { + return NextResponse.json({ error: '部分 SKU 不存在或不属于该素材' }, { status: 400 }) + } + + const oldOrder = [...existingSkus] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((sku) => sku.id) + + const oldOrderMap = new Map(existingSkus.map((sku) => [sku.id, sku])) + // 批量更新排序 const updates = skuIds.map((skuId: string, index: number) => prisma.materialSku.update({ @@ -56,6 +80,41 @@ export async function PUT( await prisma.$transaction(updates) + const changedItems = skuIds + .map((skuId, index) => { + const previous = oldOrderMap.get(skuId) + if (!previous || previous.sortOrder === index) return null + return { + skuId, + skuName: previous.skuName, + oldSortOrder: previous.sortOrder, + newSortOrder: index, + } + }) + .filter((item): item is { skuId: string; skuName: string; oldSortOrder: number; newSortOrder: number } => item !== null) + + if (changedItems.length > 0) { + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + skuOperation: { + oldValue: null, + newValue: 'REORDER', + }, + skuOrder: { + oldValue: oldOrder, + newValue: skuIds, + }, + changedItems: { + oldValue: null, + newValue: changedItems, + }, + }, + }) + } + return NextResponse.json({ success: true, message: '排序已更新' }) } catch (error) { console.error('更新 SKU 排序失败:', error) diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts index 1f06eb7..ee62597 100644 --- a/app/api/materials/[id]/skus/route.ts +++ b/app/api/materials/[id]/skus/route.ts @@ -5,6 +5,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' +import { createMaterialLog } from '@/lib/material-log' // 获取素材的所有 SKU 列表 export async function GET( @@ -69,6 +70,8 @@ export async function POST( return NextResponse.json({ error: '请先登录' }, { status: 401 }) } + const userId = (session.user as any).id + const materialId = params.id // 检查素材是否存在且是标准模型类型 @@ -114,6 +117,34 @@ export async function POST( } as any // 临时类型断言,因为 Prisma Client 可能还未完全更新 }) + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + skuOperation: { + oldValue: null, + newValue: 'CREATE', + }, + skuId: { + oldValue: null, + newValue: sku.id, + }, + skuName: { + oldValue: null, + newValue: sku.skuName, + }, + skuFileName: { + oldValue: null, + newValue: (sku as any).fileName || null, + }, + skuSortOrder: { + oldValue: null, + newValue: sku.sortOrder, + }, + }, + }) + // 返回时转换为前端期望的字段名 return NextResponse.json({ id: sku.id, diff --git a/app/api/materials/[id]/white-background/route.ts b/app/api/materials/[id]/white-background/route.ts new file mode 100644 index 0000000..a7c6e57 --- /dev/null +++ b/app/api/materials/[id]/white-background/route.ts @@ -0,0 +1,192 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { promises as fs } from 'fs' +import path from 'path' +import { randomBytes } from 'crypto' +import JSZip from 'jszip' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { sanitizeFileName } from '@/lib/image-processor' +import { + STANDARD_MODEL_WHITE_BG_VIEWS, + getMissingWhiteBackgroundViews, + type StandardModelWhiteBgKey, +} from '@/lib/standard-model-white-background' +import { createMaterialLog } from '@/lib/material-log' + +export const runtime = 'nodejs' + +function generateUniqueFilename(prefix: string, ext: string): string { + const timestamp = Date.now() + const random = randomBytes(8).toString('hex') + return `${prefix}-${timestamp}-${random}${ext}` +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } }, +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user || !(session.user as any).id) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const materialId = params.id + const userId = (session.user as any).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: '只有标准模型类型支持上传白底图' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }) + + if (material.userId !== userId && user?.role !== 'ADMIN') { + return NextResponse.json({ error: '没有权限上传白底图' }, { status: 403 }) + } + + const formData = await request.formData() + const skuId = ((formData.get('skuId') as string) || '').trim() + const skuName = ((formData.get('skuName') as string) || '').trim() + + if (!skuId) { + return NextResponse.json({ error: '缺少SKU ID' }, { status: 400 }) + } + + const sku = await prisma.materialSku.findFirst({ + where: { + id: skuId, + materialId, + deletedAt: null, + }, + select: { id: true, skuName: true }, + }) + + if (!sku) { + return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 }) + } + const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => { + acc[view.key] = formData.get(view.fieldName) as File | null + return acc + }, {} as Partial>) + + const missingViews = getMissingWhiteBackgroundViews(whiteBackgroundImages) + if (missingViews.length > 0) { + const missingLabels = STANDARD_MODEL_WHITE_BG_VIEWS + .filter((view) => missingViews.includes(view.key)) + .map((view) => view.label) + return NextResponse.json( + { error: `白底图需一次上传完整6张,缺少:${missingLabels.join('、')}` }, + { status: 400 }, + ) + } + + const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId, 'white-background') + await fs.mkdir(uploadDir, { recursive: true }) + + const zip = new JSZip() + for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) { + const imageFile = whiteBackgroundImages[view.key] + if (!imageFile) { + throw new Error(`白底图缺失:${view.label}`) + } + + const ext = path.extname(imageFile.name) || '.jpg' + const normalizedName = sanitizeFileName(view.label).replace(/\s+/g, '-').toLowerCase() + const targetName = `${normalizedName}${ext}` + const imageBuffer = Buffer.from(await imageFile.arrayBuffer()) + zip.file(targetName, imageBuffer) + } + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }) + + const safeSkuPrefix = sanitizeFileName(skuName || sku.skuName || 'standard-model') + const whiteBgZipName = generateUniqueFilename(`${safeSkuPrefix}-white-bg`, '.zip') + const absolutePath = path.join(uploadDir, whiteBgZipName) + const relativePath = path.join('uploads', 'skus', materialId, 'white-background', whiteBgZipName) + + await fs.writeFile(absolutePath, zipBuffer) + + const skuAssetType = `standard-model-white-bg-zip:${skuId}` + + const existingAssets = await prisma.materialAsset.findMany({ + where: { + materialId, + assetType: skuAssetType, + }, + select: { id: true, fileUrl: true }, + }) + + for (const asset of existingAssets) { + if (!asset.fileUrl || asset.fileUrl.startsWith('http://') || asset.fileUrl.startsWith('https://')) { + continue + } + const oldPath = path.isAbsolute(asset.fileUrl) + ? asset.fileUrl + : path.join(process.cwd(), asset.fileUrl) + try { + await fs.unlink(oldPath) + } catch { + // 忽略旧文件不存在等清理异常 + } + } + + if (existingAssets.length > 0) { + await prisma.materialAsset.deleteMany({ + where: { + materialId, + assetType: skuAssetType, + }, + }) + } + + await prisma.materialAsset.create({ + data: { + materialId, + assetType: skuAssetType, + fileUrl: relativePath, + fileName: whiteBgZipName, + fileSize: BigInt(zipBuffer.length), + sortOrder: 0, + }, + }) + + await createMaterialLog({ + materialId, + userId, + action: 'UPDATE', + changes: { + whiteBackgroundZip: { + oldValue: existingAssets.length > 0 ? 'replaced' : null, + newValue: whiteBgZipName, + }, + }, + }) + + return NextResponse.json({ + success: true, + fileName: whiteBgZipName, + filePath: relativePath.replace(/\\/g, '/'), + fileSize: zipBuffer.length, + }) + } catch (error) { + console.error('上传白底图失败:', error) + return NextResponse.json({ error: '上传白底图失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 1030136..bbecbca 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -21,6 +21,7 @@ import { createDownloadError, } from '@/lib/download-errors'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; // GET - 下载素材 export async function GET(request: NextRequest) { @@ -61,6 +62,8 @@ export async function GET(request: NextRequest) { const downloadType = searchParams.get('type'); // all/main/sku/detail/psd const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载) + const validateOnly = searchParams.get('validate') === '1'; + if (!materialId) { return NextResponse.json( createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'), @@ -142,7 +145,7 @@ export async function GET(request: NextRequest) { // 任务-详情:处理分类下载 if (isProductPackageType(material.materialType) && downloadType) { - return handleProductPackageDownload(material, downloadType, assetId); + return handleProductPackageDownload(material, downloadType, assetId, validateOnly); } // 其他类型:返回zipFilePath对应的文件 @@ -158,7 +161,7 @@ export async function GET(request: NextRequest) { filePath = path.join(process.cwd(), filePath); } - return serveFile(filePath, material, downloadType || '源文件'); + return serveFile(filePath, material, downloadType || '源文件', validateOnly); } catch (error) { console.error('[Download] Error:', error); return NextResponse.json( @@ -169,7 +172,12 @@ export async function GET(request: NextRequest) { } // 处理任务-详情分类下载 -async function handleProductPackageDownload(material: any, downloadType: string, assetId?: string | null) { +async function handleProductPackageDownload( + material: any, + downloadType: string, + assetId?: string | null, + validateOnly: boolean = false +) { console.log(`[Download] ========== 任务-详情下载 ==========`); console.log(`[Download] materialId=${material.id}, downloadType=${downloadType}, assetId=${assetId}`); @@ -214,11 +222,11 @@ async function handleProductPackageDownload(material: any, downloadType: string, console.log(`[Download] 文件路径: ${absolutePath}`); console.log(`[Download] 文件名: ${asset.fileName}`); - return serveFile(absolutePath, material, downloadType === 'all' ? '全部' : downloadType); + return serveFile(absolutePath, material, downloadType === 'all' ? '全部' : downloadType, validateOnly); } // 通用文件服务函数 -async function serveFile(filePath: string, material: any, fileNameSuffix: string) { +async function serveFile(filePath: string, material: any, fileNameSuffix: string, validateOnly: boolean = false) { // R29修复:路径遍历保护 const { isPathSafe, isPathInAllowedDirs, getAllowedFileDirectories } = await import('@/lib/path-security'); @@ -272,6 +280,16 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string ].filter(Boolean); const filename = `${filenameParts.join('_')}${ext}`; + if (validateOnly) { + fileStream.destroy(); + return NextResponse.json({ + success: true, + fileName: filename, + fileSize: fileStats.size, + contentType: mimeType, + }); + } + console.log(`[Download] 开始下载: ${filename}, size=${fileStats.size} bytes`); // 修复:记录下载日志并更新下载次数(在后台异步执行) @@ -303,6 +321,8 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string // 记录下载日志(异步执行,不阻塞下载) async function recordDownloadLog(materialId: string) { try { + await ensureMaterialDownloadLogsTable(); + const session = await getServerSession(authOptions); if (!session?.user) { return; // 分享链接访问不记录日志 @@ -315,7 +335,6 @@ async function recordDownloadLog(materialId: string) { // 写入下载日志 prisma.materialDownloadLog.create({ data: { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, materialId, userId, downloadedAt: new Date(), diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index e1bbd4c..5b3c6ca 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -7,6 +7,7 @@ import { deleteFromS3, isS3Url } from '@/lib/s3'; import { deleteFiles } from '@/lib/file-cleanup'; import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { createMaterialLog } from '@/lib/material-log'; +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; import { normalizeUrl, normalizeMaterial } from '@/lib/utils'; import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite'; import { detectIsIntranet } from '@/lib/network-detection'; @@ -986,11 +987,13 @@ export async function DELETE(request: NextRequest) { // 软删除 if (!permanent) { + const deletedAt = new Date(); + // 注意:软删除时,文件仍然保留,以便恢复时可以重新使用 // 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时) await prisma.material.update({ where: { id }, - data: { deletedAt: new Date() }, + data: { deletedAt }, }); // 记录日志 @@ -999,9 +1002,21 @@ export async function DELETE(request: NextRequest) { userId, action: 'DELETE', changes: { - permanent: { - oldValue: false, - newValue: false, + materialSnapshot: { + oldValue: null, + newValue: { + id: material.id, + productModel: material.productModel, + materialType: material.materialType, + }, + }, + deletedAt: { + oldValue: material.deletedAt ? material.deletedAt.toISOString() : null, + newValue: deletedAt.toISOString(), + }, + deleteMode: { + oldValue: material.deletedAt ? 'deleted' : 'active', + newValue: 'soft', }, }, }); @@ -1011,15 +1026,30 @@ export async function DELETE(request: NextRequest) { // 永久删除 { + // 确保下载日志表已应用“删除素材后保留日志”策略 + await ensureMaterialDownloadLogsTable(); + // 记录日志 await createMaterialLog({ materialId: id, userId, action: 'DELETE', changes: { - permanent: { - oldValue: false, - newValue: true, + materialSnapshot: { + oldValue: null, + newValue: { + id: material.id, + productModel: material.productModel, + materialType: material.materialType, + }, + }, + deletedAt: { + oldValue: material.deletedAt ? material.deletedAt.toISOString() : null, + newValue: 'PERMANENTLY_DELETED', + }, + deleteMode: { + oldValue: material.deletedAt ? 'soft' : 'active', + newValue: 'permanent', }, }, }); diff --git a/app/api/materials/transcode/route.ts b/app/api/materials/transcode/route.ts index 2553a02..9045fb0 100644 --- a/app/api/materials/transcode/route.ts +++ b/app/api/materials/transcode/route.ts @@ -1,7 +1,7 @@ // 视频转码API - 完整版本(支持自动恢复) import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; -import { transcodeQueue, cleanupTranscodeResidues } from '@/lib/video-transcode-queue'; +import { transcodeQueue, cleanupTranscodeResidues, VIDEO_MATERIAL_TYPES } from '@/lib/video-transcode-queue'; import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'; export const dynamic = 'force-dynamic'; @@ -10,6 +10,23 @@ export const maxDuration = 3600; // 1小时 // 恢复标志:确保只执行一次 let recoveryChecked = false; +async function countInterruptedVideoTasks(): Promise { + try { + return await prisma.material.count({ + where: { + materialType: { in: VIDEO_MATERIAL_TYPES }, + videoProcessingStatus: 'processing', + videoSourceUrl: { not: null }, + deletedAt: null, + }, + }); + } catch (error) { + console.error('[Transcode] Failed to count interrupted video tasks:', error); + // 返回 -1 代表统计失败,后续逻辑会谨慎地触发兜底恢复 + return -1; + } +} + /** * R1修复:懒加载恢复中断的转码任务 * - 优先从新的 VideoTranscodeProgress 表恢复(持久化队列状态) @@ -28,14 +45,25 @@ async function ensureRecoveryCompleted(): Promise { // R1修复:优先使用新的持久化队列恢复 let recovered = await transcodeQueue.recoverOnStartup(executeTranscodeTask); - // 如果新表没有数据,降级到旧方法(向后兼容) + // 如果新表没有数据,并且存在真正被中断的任务,再降级到旧方法 if (recovered === 0) { - console.log('[Transcode] No tasks in new progress table, falling back to legacy recovery...'); - const { recovered: legacyRecovered, requeued } = await transcodeQueue.recoverInterruptedTasks( - executeTranscodeTask, - cleanupTranscodeResidues - ); - recovered = legacyRecovered + requeued; + const interruptedCount = await countInterruptedVideoTasks(); + + if (interruptedCount > 0 || interruptedCount === -1) { + if (interruptedCount > 0) { + console.log(`[Transcode] Found ${interruptedCount} interrupted processing tasks, running legacy recovery...`); + } else { + console.warn('[Transcode] Unable to verify interrupted tasks, running legacy recovery as a safeguard...'); + } + const { recovered: legacyRecovered, requeued } = await transcodeQueue.recoverInterruptedTasks( + executeTranscodeTask, + cleanupTranscodeResidues, + { videoTypes: VIDEO_MATERIAL_TYPES } + ); + recovered += legacyRecovered + requeued; + } else { + console.log('[Transcode] No interrupted processing tasks detected, skipping legacy fallback.'); + } } console.log(`[Transcode] Recovery completed: ${recovered} tasks recovered`); @@ -150,8 +178,7 @@ export async function POST(request: NextRequest) { } // 检查是否为视频类型 - const videoTypes = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频']; - if (!videoTypes.includes((material as any).materialType)) { + if (!VIDEO_MATERIAL_TYPES.includes((material as any).materialType)) { return NextResponse.json( { error: '此素材不是视频类型' }, { status: 400 } diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index f517a32..58e26e8 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -5,6 +5,34 @@ 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'; +import { createMaterialLog } from '@/lib/material-log'; + +function buildMaterialChanges(oldMaterial: any, newData: Record) { + const changes: Record = {}; + + const normalize = (value: any) => { + if (value instanceof Date) { + return value.toISOString(); + } + if (value === undefined) { + return null; + } + return value; + }; + + for (const [key, value] of Object.entries(newData)) { + if (key === 'updatedAt') continue; + + const oldValue = normalize(oldMaterial[key]); + const newValue = normalize(value); + + if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { + changes[key] = { oldValue, newValue }; + } + } + + return changes; +} // PUT /api/materials/update - 更新素材(包括预览图) export async function PUT(request: NextRequest) { @@ -147,6 +175,16 @@ export async function PUT(request: NextRequest) { }, }, }); + + const changes = buildMaterialChanges(material, updateData); + if (Object.keys(changes).length > 0) { + await createMaterialLog({ + materialId: id, + userId, + action: 'UPDATE', + changes, + }); + } const response = { ...updatedMaterial, diff --git a/app/api/materials/upload/3d-original/complete/route.ts b/app/api/materials/upload/3d-original/complete/route.ts index d587593..f02f4b9 100644 --- a/app/api/materials/upload/3d-original/complete/route.ts +++ b/app/api/materials/upload/3d-original/complete/route.ts @@ -9,6 +9,7 @@ import { cleanupOnError, saveAssets, } from '@/lib/task-material-processor'; +import { createMaterialLog } from '@/lib/material-log'; // POST - 完成3D原图上传 export async function POST(request: NextRequest) { @@ -119,6 +120,27 @@ export async function POST(request: NextRequest) { // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); + // 步骤6: 记录上传日志 + await createMaterialLog({ + materialId: materialId!, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-3D原图', + }, + previewCount: { + oldValue: 0, + newValue: uploadedPreviewUrls.length, + }, + zipFileName: { + oldValue: null, + newValue: zipInfo.fileName, + }, + }, + }); + console.log('[3D Original] Complete success!'); return NextResponse.json({ success: true, diff --git a/app/api/materials/upload/ai-original/complete/route.ts b/app/api/materials/upload/ai-original/complete/route.ts index 3653e4e..e98a72e 100644 --- a/app/api/materials/upload/ai-original/complete/route.ts +++ b/app/api/materials/upload/ai-original/complete/route.ts @@ -9,6 +9,7 @@ import { cleanupOnError, saveAssets, } from '@/lib/task-material-processor'; +import { createMaterialLog } from '@/lib/material-log'; // POST - 完成AI原图上传 export async function POST(request: NextRequest) { @@ -119,6 +120,27 @@ export async function POST(request: NextRequest) { // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); + // 步骤6: 记录上传日志 + await createMaterialLog({ + materialId: materialId!, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-AI原图', + }, + previewCount: { + oldValue: 0, + newValue: uploadedPreviewUrls.length, + }, + zipFileName: { + oldValue: null, + newValue: zipInfo.fileName, + }, + }, + }); + console.log('[AI Original] Complete success!'); return NextResponse.json({ success: true, diff --git a/app/api/materials/upload/product-package/abort/route.ts b/app/api/materials/upload/product-package/abort/route.ts new file mode 100644 index 0000000..853c86b --- /dev/null +++ b/app/api/materials/upload/product-package/abort/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { deleteUploadSession, getUploadSession } from '@/lib/upload-sessions' +import path from 'path' +import { rm, unlink } from 'fs/promises' + +interface AbortRequestBody { + materialId?: string + uploadIds?: string[] +} + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + const userId = (session?.user as { id?: string } | undefined)?.id + if (!userId) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const body = (await req.json()) as AbortRequestBody + const materialId = body.materialId?.trim() + const uploadIds = Array.isArray(body.uploadIds) + ? body.uploadIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + : [] + + if (!materialId) { + return NextResponse.json({ success: false, error: '缺少materialId' }, { status: 400 }) + } + + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + userId: true, + materialType: true, + previewUrl: true, + zipFilePath: true, + }, + }) + + if (!material) { + return NextResponse.json({ success: true, deleted: false, reason: 'material_not_found' }) + } + + if (material.userId !== userId) { + return NextResponse.json({ success: false, error: '无权限操作此素材' }, { status: 403 }) + } + + if (material.materialType !== '任务-详情') { + return NextResponse.json({ success: true, deleted: false, reason: 'not_task_detail' }) + } + + const assetsCount = await prisma.materialAsset.count({ where: { materialId } }) + if (assetsCount > 0 || material.previewUrl || material.zipFilePath) { + return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' }) + } + + for (const uploadId of uploadIds) { + const uploadSession = await getUploadSession(uploadId) + if (uploadSession?.fileName) { + const assembledPath = path.join(process.cwd(), 'uploads', 'assembled', `${uploadId}-${uploadSession.fileName}`) + await unlink(assembledPath).catch(() => undefined) + } + + const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId) + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + await deleteUploadSession(uploadId).catch(() => undefined) + } + + await prisma.material.delete({ where: { id: materialId } }) + + return NextResponse.json({ + success: true, + deleted: true, + materialId, + cleanedUploadIds: uploadIds.length, + }) + } catch (error) { + console.error('[product-package/abort] error:', error) + return NextResponse.json({ success: false, error: '回滚未完成素材失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index b2f01c1..da2238b 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -17,6 +17,7 @@ import { ASSET_TYPE, } from '@/lib/task-material-processor'; import { getUploadedFile } from '@/lib/upload-sessions'; +import { createMaterialLog } from '@/lib/material-log'; import path from 'path'; import { mkdir, writeFile, readFile } from 'fs/promises'; @@ -148,11 +149,12 @@ export async function POST(request: NextRequest) { if (asset.uploadId) { // 模式1:分片上传,从临时目录读取 const filePath = await getUploadedFile(asset.uploadId); - if (filePath) { - mainFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); - tempFiles.push(filePath); - uploadIds.push(asset.uploadId); + if (!filePath) { + throw new Error(`主图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`); } + mainFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); + tempFiles.push(filePath); + uploadIds.push(asset.uploadId); } else if (asset.fileUrl) { // 模式2:直接上传到S3,下载到临时目录 const tempPath = path.join(process.cwd(), 'uploads', 'temp', `main-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`); @@ -168,6 +170,8 @@ export async function POST(request: NextRequest) { mainFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize }); tempFiles.push(tempPath); + } else { + throw new Error(`主图资产参数无效: ${asset.fileName}`); } } } @@ -177,11 +181,12 @@ export async function POST(request: NextRequest) { for (const asset of assets.sku) { if (asset.uploadId) { const filePath = await getUploadedFile(asset.uploadId); - if (filePath) { - skuFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); - tempFiles.push(filePath); - uploadIds.push(asset.uploadId); + if (!filePath) { + throw new Error(`SKU图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`); } + skuFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); + tempFiles.push(filePath); + uploadIds.push(asset.uploadId); } else if (asset.fileUrl) { const tempPath = path.join(process.cwd(), 'uploads', 'temp', `sku-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`); await mkdir(path.dirname(tempPath), { recursive: true }); @@ -195,6 +200,8 @@ export async function POST(request: NextRequest) { skuFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize }); tempFiles.push(tempPath); + } else { + throw new Error(`SKU图资产参数无效: ${asset.fileName}`); } } } @@ -204,11 +211,12 @@ export async function POST(request: NextRequest) { for (const asset of assets.detail) { if (asset.uploadId) { const filePath = await getUploadedFile(asset.uploadId); - if (filePath) { - detailFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); - tempFiles.push(filePath); - uploadIds.push(asset.uploadId); + if (!filePath) { + throw new Error(`详情图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`); } + detailFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize }); + tempFiles.push(filePath); + uploadIds.push(asset.uploadId); } else if (asset.fileUrl) { const tempPath = path.join(process.cwd(), 'uploads', 'temp', `detail-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`); await mkdir(path.dirname(tempPath), { recursive: true }); @@ -222,6 +230,8 @@ export async function POST(request: NextRequest) { detailFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize }); tempFiles.push(tempPath); + } else { + throw new Error(`详情图资产参数无效: ${asset.fileName}`); } } } @@ -419,31 +429,35 @@ export async function POST(request: NextRequest) { if (psd.uploadId) { // 从临时目录获取PSD文件 const tempFilePath = await getUploadedFile(psd.uploadId); - if (tempFilePath) { - // 生成永久文件名和路径 - const timestamp = Date.now(); - const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_'); - const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`; - const permanentFilePath = path.join(psdDir, permanentFileName); - const relativePath = path.join('uploads', 'materials', permanentFileName); - - // 移动文件到永久目录 - try { - await rename(tempFilePath, permanentFilePath); - console.log(`[Product Package] PSD文件已移动: ${tempFilePath} -> ${permanentFilePath}`); - } catch (err) { - // 如果跨设备移动失败,使用复制+删除 - await copyFile(tempFilePath, permanentFilePath); - tempFiles.push(tempFilePath); // 标记为需要清理 - console.log(`[Product Package] PSD文件已复制: ${tempFilePath} -> ${permanentFilePath}`); - } - - psdPermanentPaths.push({ - filePath: relativePath, - fileName: psd.fileName, // 保留原始文件名 - fileSize: psd.fileSize, - }); + if (!tempFilePath) { + throw new Error(`PSD文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`); } + + // 生成永久文件名和路径 + const timestamp = Date.now(); + const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_'); + const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`; + const permanentFilePath = path.join(psdDir, permanentFileName); + const relativePath = path.join('uploads', 'materials', permanentFileName); + + // 移动文件到永久目录 + try { + await rename(tempFilePath, permanentFilePath); + console.log(`[Product Package] PSD文件已移动: ${tempFilePath} -> ${permanentFilePath}`); + } catch (err) { + // 如果跨设备移动失败,使用复制+删除 + await copyFile(tempFilePath, permanentFilePath); + tempFiles.push(tempFilePath); // 标记为需要清理 + console.log(`[Product Package] PSD文件已复制: ${tempFilePath} -> ${permanentFilePath}`); + } + + psdPermanentPaths.push({ + filePath: relativePath, + fileName: psd.fileName, // 保留原始文件名 + fileSize: psd.fileSize, + }); + } else if (!psd.fileUrl) { + throw new Error(`PSD资产参数无效: ${psd.fileName}`); } } console.log(`[Product Package] ${psdPermanentPaths.length} 个PSD文件已移动到永久目录`); @@ -492,26 +506,37 @@ export async function POST(request: NextRequest) { await cleanupTempFiles(tempFiles, uploadIds); // 记录日志 - await prisma.materialLog.create({ - data: { - materialId, - userId: (session.user as any).id, - action: 'UPLOAD', - changes: JSON.stringify({ - ...(taskNo && taskNo.trim() ? { taskNo } : {}), - assetCounts: { + await createMaterialLog({ + materialId, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-详情', + }, + taskNo: { + oldValue: null, + newValue: taskNo && taskNo.trim() ? taskNo : null, + }, + assetCounts: { + oldValue: null, + newValue: { main: mainFiles.length, sku: skuFiles.length, detail: detailFiles.length, psd: assets.psd?.length || 0, }, - zipSizes: { + }, + zipSizes: { + oldValue: null, + newValue: { main: zipInfos.main?.fileSize || 0, sku: zipInfos.sku?.fileSize || 0, detail: zipInfos.detail?.fileSize || 0, all: zipInfos.all.fileSize, }, - }), + }, }, }); diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index ab1c7e9..8e71fce 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma'; import { validateTaskNo, isTaskNoExists, TASK_STATUS } from '@/lib/product-package'; import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { deleteFiles } from '@/lib/file-cleanup'; +import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'; export async function POST(request: NextRequest) { try { @@ -76,6 +77,9 @@ export async function POST(request: NextRequest) { // 删除关联记录 await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType); + + // 确保下载日志表已应用“删除素材后保留日志”策略 + await ensureMaterialDownloadLogsTable(); // 从数据库永久删除 await prisma.material.delete({ diff --git a/app/api/materials/upload/real-photo/complete/route.ts b/app/api/materials/upload/real-photo/complete/route.ts index c4915f6..f8f49f5 100644 --- a/app/api/materials/upload/real-photo/complete/route.ts +++ b/app/api/materials/upload/real-photo/complete/route.ts @@ -9,6 +9,7 @@ import { cleanupOnError, saveAssets, } from '@/lib/task-material-processor'; +import { createMaterialLog } from '@/lib/material-log'; // POST - 完成实拍原图上传 export async function POST(request: NextRequest) { @@ -119,6 +120,27 @@ export async function POST(request: NextRequest) { // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); + // 步骤6: 记录上传日志 + await createMaterialLog({ + materialId: materialId!, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-实拍原图', + }, + previewCount: { + oldValue: 0, + newValue: uploadedPreviewUrls.length, + }, + zipFileName: { + oldValue: null, + newValue: zipInfo.fileName, + }, + }, + }); + console.log('[Real Photo] Complete success!'); return NextResponse.json({ success: true, diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 26562eb..fe26cf8 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -14,6 +14,7 @@ import { compressImage, sanitizeFileName } from '@/lib/image-processor'; import { processPdfUpload } from '@/lib/pdf-upload-handler'; import { deleteFiles } from '@/lib/file-cleanup'; import { deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; +import { createMaterialLog } from '@/lib/material-log'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -37,6 +38,134 @@ const SKU_TEMPLATE_PLACEHOLDER = (() => { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() +const VIDEO_TYPES = new Set(['视频', 'VIDEO', '主图视频', '安装视频', '种草视频']) +const AUDIO_TYPES = new Set(['音乐']) +const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp']) +const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac']) + +function normalizeExtension(ext: string): string { + if (!ext) return '' + return ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}` +} + +function detectExtFromMime(mime?: string | null): string | null { + if (!mime) return null + const map: Record = { + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/x-matroska': '.mkv', + 'video/x-msvideo': '.avi', + 'video/3gpp': '.3gp', + 'audio/mpeg': '.mp3', + 'audio/wav': '.wav', + 'audio/flac': '.flac', + 'audio/mp4': '.m4a', + 'audio/aac': '.aac', + } + return map[mime] || null +} + +async function detectExtFromFile(filePath?: string): Promise { + if (!filePath) return null + try { + const absolute = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath) + const handle = await fs.open(absolute, 'r') + try { + const buffer = Buffer.alloc(16) + await handle.read(buffer, 0, 16, 0) + const signature = buffer.toString('hex') + if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) { + return '.mp4' + } + if (buffer.slice(0, 4).toString('ascii') === 'RIFF') { + return '.avi' + } + if (signature.startsWith('1a45dfa3')) { + return '.mkv' + } + if (buffer.slice(0, 3).toString('ascii') === 'ID3') { + return '.mp3' + } + if (buffer.slice(0, 4).toString('ascii') === 'fLaC') { + return '.flac' + } + return null + } finally { + await handle.close() + } + } catch (error) { + console.warn('[Upload] Failed to detect extension from file:', filePath, error) + return null + } +} + +async function ensureMediaExtension(params: { + extCandidate: string + mimeType?: string | null + filePath?: string + fileBuffer?: Buffer | null + materialType: string +}): Promise { + const { extCandidate, mimeType, filePath, fileBuffer, materialType } = params + const targetSet = VIDEO_TYPES.has(materialType) ? VIDEO_EXTENSIONS : AUDIO_TYPES.has(materialType) ? AUDIO_EXTENSIONS : null + if (!targetSet) { + return extCandidate || '.zip' + } + + if (fileBuffer && fileBuffer.length >= 16) { + const signature = fileBuffer.toString('hex', 0, 16) + const ftyp = fileBuffer.slice(4, 8).toString('ascii') + const riff = fileBuffer.slice(0, 4).toString('ascii') + const id3 = fileBuffer.slice(0, 3).toString('ascii') + const flac = fileBuffer.slice(0, 4).toString('ascii') + + if (ftyp.startsWith('ftyp') && targetSet.has('.mp4')) return '.mp4' + if (riff === 'RIFF' && targetSet.has('.avi')) return '.avi' + if (signature.startsWith('1a45dfa3') && targetSet.has('.mkv')) return '.mkv' + if (id3 === 'ID3' && targetSet.has('.mp3')) return '.mp3' + if (flac === 'fLaC' && targetSet.has('.flac')) return '.flac' + } + + const normalized = normalizeExtension(extCandidate) + if (normalized && targetSet.has(normalized)) { + return normalized + } + + const mimeExt = detectExtFromMime(mimeType) + if (mimeExt && targetSet.has(mimeExt)) { + return mimeExt + } + + const detected = await detectExtFromFile(filePath) + if (detected && targetSet.has(detected)) { + return detected + } + + return VIDEO_TYPES.has(materialType) ? '.mp4' : '.mp3' +} + +function isVideoMaterial(materialType: string) { + return VIDEO_TYPES.has(materialType) +} + +function isAudioMaterial(materialType: string) { + return AUDIO_TYPES.has(materialType) +} + +function normalizeMaterialType(materialTypeRaw?: string | null): string { + const trimmed = (materialTypeRaw || '').trim() + + if (!trimmed) { + return '3D场景' + } + + if (trimmed.toLowerCase() === 'video') { + return 'VIDEO' + } + + return trimmed +} + // 生成唯一文件名 function generateUniqueFilename(prefix: string, ext: string): string { const timestamp = Date.now(); @@ -79,7 +208,8 @@ export async function POST(request: NextRequest) { 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 materialTypeRaw = formData.get('materialType') as string | null; + const materialType = normalizeMaterialType(materialTypeRaw); const rendererParam = formData.get('renderer') as string | null; // 统一处理renderer:3D场景必须有renderer,其他类型可选 const renderer = requiresRenderer(materialType) @@ -98,6 +228,7 @@ export async function POST(request: NextRequest) { categoryName, sceneName, materialType, + materialTypeRaw, }); // 使用统一的验证逻辑验证必填字段 @@ -330,11 +461,31 @@ export async function POST(request: NextRequest) { sourcePath = existingFilePath; const stat = await fs.stat(sourcePath); zipFileSize = BigInt(stat.size); - ext = path.extname(originalFileName || sourcePath) || '.zip'; + ext = path.extname(originalFileName || sourcePath); console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size); } - const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType); + const isVideoOrAudio = isVideoMaterial(materialType) || isAudioMaterial(materialType); + + if (isVideoMaterial(materialType)) { + ext = await ensureMediaExtension({ + extCandidate: ext, + mimeType: zipFile?.type, + filePath: sourcePath || existingFilePath, + fileBuffer: buffer, + materialType, + }); + } else if (isAudioMaterial(materialType)) { + ext = await ensureMediaExtension({ + extCandidate: ext, + mimeType: zipFile?.type, + filePath: sourcePath || existingFilePath, + fileBuffer: buffer, + materialType, + }); + } else { + ext = normalizeExtension(ext) || '.zip'; + } // 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件 const uploadDir = process.env.UPLOAD_DIR || './uploads'; @@ -473,7 +624,7 @@ export async function POST(request: NextRequest) { // 创建数据库记录(包含所有字段,确保数据不丢失) // 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放 const videoSourceUrl = - ['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName + isVideoMaterial(materialType) && zipFileName ? `/uploads/${zipFileName}` : null; @@ -602,9 +753,38 @@ export async function POST(request: NextRequest) { // taskNo写入失败不影响上传流程,只记录错误 } } + + // 记录上传日志 + await createMaterialLog({ + materialId: material.id, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + productModel: { + oldValue: null, + newValue: productModel, + }, + materialType: { + oldValue: null, + newValue: materialType, + }, + categoryName: { + oldValue: null, + newValue: categoryName, + }, + sceneName: { + oldValue: null, + newValue: sceneName, + }, + taskNo: { + oldValue: null, + newValue: taskNo || null, + }, + }, + }); // 验证文件是否存在(特别是音乐和视频文件) - if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) { + if (zipFilePath && (materialType === '音乐' || isVideoMaterial(materialType))) { try { const stats = await fs.stat(zipFilePath); console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size }); diff --git a/app/api/materials/upload/task-page/complete/route.ts b/app/api/materials/upload/task-page/complete/route.ts index 116aaa6..2cfffa5 100644 --- a/app/api/materials/upload/task-page/complete/route.ts +++ b/app/api/materials/upload/task-page/complete/route.ts @@ -9,6 +9,7 @@ import { cleanupOnError, saveAssets, } from '@/lib/task-material-processor'; +import { createMaterialLog } from '@/lib/material-log'; // POST - 完成任务-页面上传 export async function POST(request: NextRequest) { @@ -119,6 +120,27 @@ export async function POST(request: NextRequest) { // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); + // 步骤6: 记录上传日志 + await createMaterialLog({ + materialId: materialId!, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-页面', + }, + previewCount: { + oldValue: 0, + newValue: uploadedPreviewUrls.length, + }, + zipFileName: { + oldValue: null, + newValue: zipInfo.fileName, + }, + }, + }); + console.log('[Task Page] Complete success!'); return NextResponse.json({ success: true, diff --git a/app/api/materials/upload/video-screenshot/complete/route.ts b/app/api/materials/upload/video-screenshot/complete/route.ts index 315c6ba..4a92f0b 100644 --- a/app/api/materials/upload/video-screenshot/complete/route.ts +++ b/app/api/materials/upload/video-screenshot/complete/route.ts @@ -9,6 +9,7 @@ import { cleanupOnError, saveAssets, } from '@/lib/task-material-processor'; +import { createMaterialLog } from '@/lib/material-log'; // POST - 完成视频截图上传 export async function POST(request: NextRequest) { @@ -119,6 +120,27 @@ export async function POST(request: NextRequest) { // 步骤5: 清理临时文件 await cleanupTempFiles(tempFiles, uploadIds); + // 步骤6: 记录上传日志 + await createMaterialLog({ + materialId: materialId!, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: '任务-视频截图', + }, + previewCount: { + oldValue: 0, + newValue: uploadedPreviewUrls.length, + }, + zipFileName: { + oldValue: null, + newValue: zipInfo.fileName, + }, + }, + }); + console.log('[Video Screenshot] Complete success!'); return NextResponse.json({ success: true, diff --git a/app/page.tsx b/app/page.tsx index cba0f8c..bf4094a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,7 @@ import VideoPlayerModal from '@/components/VideoPlayerModal' import ToastBubble from '@/components/ToastBubble' import { useToast } from '@/hooks/useToast' import { parseDownloadError } from '@/lib/download-errors' +import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' @@ -106,9 +107,7 @@ export default function Home() { ] const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string }[]>([]) const [isTransitionLoading, setIsTransitionLoading] = useState(false) - // 修复:使用 Map 存储每个素材的下载进度,避免进度条跳动问题 const [downloadingId, setDownloadingId] = useState(null) - const downloadProgressMap = useRef>(new Map()) const [downloadProgress, setDownloadProgress] = useState(0) const [selectedMonth, setSelectedMonth] = useState('') const [months, setMonths] = useState>([]) @@ -1378,103 +1377,36 @@ export default function Home() { } } - const handleDownload = (id: string) => { - // 如果当前素材正在下载,忽略重复点击 - if (downloadingId === id) { - console.log(`[Download] 素材 ${id} 正在下载中,忽略重复点击`) - return + const handleDownloadByDirectLink = (id: string) => { + const runDownload = async () => { + if (downloadingId === id) { + return + } + + setDownloadingId(id) + setDownloadProgress(10) + + try { + const downloadUrl = `/api/materials/download?id=${encodeURIComponent(id)}` + await validateAndTriggerDownload(downloadUrl) + setDownloadProgress(100) + setMaterials(prev => + prev.map(item => + item.id === id ? { ...item, downloadCount: (item.downloadCount || 0) + 1 } : item + ) + ) + } catch (error) { + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + } finally { + setTimeout(() => { + setDownloadingId(null) + setDownloadProgress(0) + }, 300) + } } - setDownloadingId(id) - // 初始化该素材的下载进度为 0 - downloadProgressMap.current.set(id, 0) - setDownloadProgress(0) - - const xhr = new XMLHttpRequest() - - // 监听下载进度 - // 注意:直接使用 id 参数,不要使用 downloadingId 状态(闭包陷阱) - xhr.addEventListener('progress', (e) => { - if (e.lengthComputable) { - const percentComplete = Math.round((e.loaded / e.total) * 100) - // 更新进度(id 是闭包参数,值是正确的) - downloadProgressMap.current.set(id, percentComplete) - setDownloadProgress(percentComplete) - } - }) - - xhr.addEventListener('load', () => { - // 清理进度记录 - downloadProgressMap.current.delete(id) - - if (xhr.status === 200) { - try { - // 从响应头获取 Content-Type(视频、音频等不同类型) - const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream' - const blob = new Blob([xhr.response], { type: contentType }) - - // 从响应头获取文件名 - const contentDisposition = xhr.getResponseHeader('Content-Disposition') - let filename = 'material' // 默认文件名,扩展名从服务器获取 - if (contentDisposition) { - const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) - if (filenameMatch && filenameMatch[1]) { - filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) - } - } - - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - a.remove() - window.URL.revokeObjectURL(url) - - // 更新本地状态的下载次数 - setMaterials(prev => prev.map(m => - m.id === id ? { ...m, downloadCount: (m.downloadCount || 0) + 1 } : m - )) - } catch (error) { - console.error('下载处理失败:', error) - const { message } = parseDownloadError(error) - showToast(message, { variant: 'error' }) - } - } else { - // 当 responseType 为 arraybuffer 时,需要将响应转换为文本来解析错误 - try { - const decoder = new TextDecoder('utf-8') - const errorText = decoder.decode(xhr.response) - const error = errorText ? JSON.parse(errorText) : { error: '下载失败' } - const { message } = parseDownloadError(error) - showToast(message, { variant: 'error' }) - } catch { - const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' }) - showToast(message, { variant: 'error' }) - } - } - - // 清理下载状态 - setDownloadingId(null) - setDownloadProgress(0) - }) - - xhr.addEventListener('error', () => { - // 清理进度记录 - downloadProgressMap.current.delete(id) - - const { message } = parseDownloadError({ code: 'NETWORK_ERROR' }) - showToast(message, { variant: 'error' }) - - // 清理下载状态 - setDownloadingId(null) - setDownloadProgress(0) - }) - - xhr.open('GET', `/api/materials/download?id=${id}`) - xhr.responseType = 'arraybuffer' - xhr.send() + void runDownload() } const handleLike = async (id: string) => { @@ -1812,7 +1744,7 @@ export default function Home() { searchKeyword={searchKeyword} hasFilters={!!(searchKeyword || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform)} onAudioPlay={handleAudioPlay} - onDownload={handleDownload} + onDownload={handleDownloadByDirectLink} onLike={handleLike} onFavorite={handleFavorite} onShare={handleShare} diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 47a00ba..b70f9b0 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -15,6 +15,7 @@ import AudioVisualizer from '@/components/AudioVisualizer' import ProductPackageDetail from '@/components/detail/ProductPackageDetail' import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail' import { parseDownloadError } from '@/lib/download-errors' +import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, parseFetchError } from '@/lib/error-handler' interface Material { @@ -440,86 +441,35 @@ export default function SharePage() { } } - // 下载整个素材(非标准模型类型,或标准模型类型但未选中SKU时) - const handleDownload = () => { - if (!material) return - - // 如果是标准模型类型且有选中的SKU,则批量下载SKU - if (material.materialType === '标准模型' && selectedSkuIds.size > 0) { - handleBatchDownloadSkus() + const handleDownloadByDirectLink = () => { + if (!material) { return } - // 否则下载整个素材 - setDownloading(true) - setDownloadProgress(0) + if (material.materialType === '标准模型' && selectedSkuIds.size > 0) { + void handleBatchDownloadSkus() + return + } - const xhr = new XMLHttpRequest() - - xhr.addEventListener('progress', (e) => { - if (e.lengthComputable) { - const percentComplete = Math.round((e.loaded / e.total) * 100) - setDownloadProgress(percentComplete) + const runDownload = async () => { + setDownloading(true) + setDownloadProgress(10) + try { + const downloadUrl = `/api/materials/download?id=${encodeURIComponent(material.id)}` + await validateAndTriggerDownload(downloadUrl) + setDownloadProgress(100) + } catch (error) { + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + } finally { + setTimeout(() => { + setDownloading(false) + setDownloadProgress(0) + }, 300) } - }) + } - xhr.addEventListener('load', () => { - if (xhr.status === 200) { - try { - const blob = new Blob([xhr.response], { type: 'application/zip' }) - - const contentDisposition = xhr.getResponseHeader('Content-Disposition') - let filename = 'material.zip' - if (contentDisposition) { - const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) - if (filenameMatch && filenameMatch[1]) { - filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) - } - } - - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - // 延迟清理 blob URL,确保下载已经开始 - setTimeout(() => { - a.remove() - window.URL.revokeObjectURL(url) - }, 100) - } catch (error) { - console.error('下载处理失败:', error) - const { message } = parseDownloadError(error) - showToast(message, { variant: 'error' }) - } - } else { - // 当 responseType 为 arraybuffer 时,需要将响应转换为文本来解析错误 - try { - const decoder = new TextDecoder('utf-8') - const errorText = decoder.decode(xhr.response) - const errorData = errorText ? JSON.parse(errorText) : { error: '下载失败' } - const { message } = parseDownloadError(errorData) - showToast(message, { variant: 'error' }) - } catch { - const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' }) - showToast(message, { variant: 'error' }) - } - } - setDownloading(false) - setDownloadProgress(0) - }) - - xhr.addEventListener('error', () => { - const { message } = parseDownloadError({ code: 'NETWORK_ERROR' }) - showToast(message, { variant: 'error' }) - setDownloading(false) - setDownloadProgress(0) - }) - - xhr.open('GET', `/api/materials/download?id=${material.id}`) - xhr.responseType = 'arraybuffer' - xhr.send() + void runDownload() } if (loading) { @@ -582,65 +532,17 @@ export default function SharePage() { canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限 showToast={showToast} onDownload={async (type, assetId) => { - // 处理下载 - let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`; - if (assetId) { - downloadUrl += `&assetId=${assetId}`; - } - // 使用 fetch 下载,这样可以监听下载完成并重置状态 try { - const response = await fetch(downloadUrl); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: '下载失败' })); - const { message } = parseDownloadError(errorData); - throw new Error(message); + let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}` + if (assetId) { + directDownloadUrl += `&assetId=${assetId}` } - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - - // 根据类型和assetId生成文件名 - if (assetId && type === 'psd') { - // 单个PSD文件下载,从响应头获取文件名 - const contentDisposition = response.headers.get('Content-Disposition'); - if (contentDisposition) { - const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - if (fileNameMatch && fileNameMatch[1]) { - const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, '')); - a.download = fileName; - } else { - a.download = 'source.psd'; - } - } else { - a.download = 'source.psd'; - } - } else { - // 批量下载,使用ZIP格式 - const typeNames: Record = { - all: '全部', - main: '主图', - sku: 'SKU图', - detail: '详情图', - psd: 'PSD源文件', - }; - a.download = `${material.productModel}_${typeNames[type] || type}.zip`; - } - - document.body.appendChild(a); - a.click(); - // 延迟清理 blob URL,确保下载已经开始 - setTimeout(() => { - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - }, 100); - // 下载成功,通知组件重置状态 - return Promise.resolve(); + await validateAndTriggerDownload(directDownloadUrl) + return Promise.resolve() } catch (error) { - console.error('下载失败:', error); - const { message } = parseDownloadError(error); - showToast(message, { variant: 'error' }); - return Promise.reject(error); + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + return Promise.reject(error) } }} onBack={() => router.back()} @@ -663,49 +565,17 @@ export default function SharePage() { canDownloadSource={material.canDownloadSource ?? false} showToast={showToast} onDownload={async (type, assetId) => { - // 处理下载,使用与ProductPackageDetail相同的下载API - let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`; - if (assetId) { - downloadUrl += `&assetId=${assetId}`; - } try { - const response = await fetch(downloadUrl); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: '下载失败' })); - const { message } = parseDownloadError(errorData); - throw new Error(message); + let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}` + if (assetId) { + directDownloadUrl += `&assetId=${assetId}` } - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - - // 从响应头获取文件名 - const contentDisposition = response.headers.get('Content-Disposition'); - if (contentDisposition) { - const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - if (fileNameMatch && fileNameMatch[1]) { - const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, '')); - a.download = fileName; - } else { - a.download = `${material.productModel}_source.zip`; - } - } else { - a.download = `${material.productModel}_source.zip`; - } - - document.body.appendChild(a); - a.click(); - setTimeout(() => { - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - }, 100); - return Promise.resolve(); + await validateAndTriggerDownload(directDownloadUrl) + return Promise.resolve() } catch (error) { - console.error('下载失败:', error); - const { message } = parseDownloadError(error); - showToast(message, { variant: 'error' }); - return Promise.reject(error); + const { message } = parseDownloadError(error) + showToast(message, { variant: 'error' }) + return Promise.reject(error) } }} onBack={() => router.back()} @@ -1069,7 +939,7 @@ export default function SharePage() { {/* 操作按钮 - 统一样式 */}
+
+ + {isOpen && ( +
+
+ +
+ {format(visibleMonth, 'yyyy年MM月')} +
+ +
+ +
+ {WEEKDAY_LABELS.map((day) => ( +
{day}
+ ))} +
+ +
+ {dayCells.map((day) => { + const disabled = isDayDisabled(day) + const inCurrentMonth = isSameMonth(day, visibleMonth) + const isSelected = selectedDate ? isSameDay(day, selectedDate) : false + const isRangeStart = rangeStartDate ? isSameDay(day, rangeStartDate) : false + const isRangeEnd = rangeEndDate ? isSameDay(day, rangeEndDate) : false + const isInRange = rangeBounds ? isWithinInterval(day, rangeBounds) : false + + const dayClass = isSelected + ? 'bg-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500' + : isRangeStart || isRangeEnd + ? 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/60 dark:text-blue-100 dark:border-blue-800' + : isInRange + ? 'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/35 dark:text-blue-200 dark:border-blue-900/50' + : 'bg-white text-gray-800 border-transparent dark:bg-gray-900 dark:text-gray-200' + + return ( + + ) + })} +
+ +
+ + +
+
+ )} + + ) +} + +function HorizontalBarChart({ + items, + emptyText, + activeKey, + onSelect, + accent, +}: { + items: HorizontalBarDatum[] + emptyText: string + activeKey?: string + onSelect: (key: string) => void + accent: 'blue' | 'emerald' +}) { + const [animated, setAnimated] = useState(false) + + useEffect(() => { + setAnimated(false) + const frameId = requestAnimationFrame(() => setAnimated(true)) + return () => cancelAnimationFrame(frameId) + }, [items]) + + if (!items.length) { + return
{emptyText}
+ } + + const maxValue = Math.max(...items.map((item) => item.value), 1) + const barColorClass = BAR_SOLID_CLASS[accent] + const activeBarClass = accent === 'blue' + ? 'ring-2 ring-blue-300 dark:ring-blue-700' + : 'ring-2 ring-emerald-300 dark:ring-emerald-700' + return ( +
+
+ {items.map((item) => { + const targetHeight = Math.max((item.value / maxValue) * 88, 6) + const isActive = activeKey === item.key + + return ( + + ) + })} +
+
+ 点击柱子查看人员占比 +
+
+ ) +} + +export default function DataStatisticsTab({ + showToast, + canViewLogs = false, + canSelectDepartment = true, + departmentScopeName = null, +}: DataStatisticsTabProps) { + const today = new Date() + const initialStartDate = format(startOfMonth(today), DATE_FORMAT) + const initialEndDate = format(today, DATE_FORMAT) + + const [startDate, setStartDate] = useState(initialStartDate) + const [endDate, setEndDate] = useState(initialEndDate) + const [draftStartDate, setDraftStartDate] = useState(initialStartDate) + const [draftEndDate, setDraftEndDate] = useState(initialEndDate) + const [departmentKey, setDepartmentKey] = useState('') + + const [statsLoading, setStatsLoading] = useState(false) + const [statsData, setStatsData] = useState(null) + const [drilldown, setDrilldown] = useState(null) + const [dateFilterMode, setDateFilterMode] = useState('thisMonth') + + const [logAction, setLogAction] = useState('ALL') + const [logKeywordInput, setLogKeywordInput] = useState('') + const [logKeyword, setLogKeyword] = useState('') + const [logPage, setLogPage] = useState(1) + + const [logsLoading, setLogsLoading] = useState(false) + const [logsData, setLogsData] = useState(null) + const hasInitializedStatsRef = useRef(false) + const drilldownSectionRef = useRef(null) + const scrollToDrilldownSection = useCallback(() => { + requestAnimationFrame(() => { + drilldownSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }) + }, []) + + const fetchStats = useCallback(async (overrides: Partial = {}) => { + const query: StatsQueryState = { + startDate, + endDate, + departmentKey, + ...overrides, + } + + const normalizedStartDate = normalizeDateInputValue(query.startDate) + const normalizedEndDate = normalizeDateInputValue(query.endDate) + + if (!isValidDateInputValue(normalizedStartDate)) { + showToast('开始日期格式不正确,请输入 YYYY-MM-DD', 'error') + return + } + + if (!isValidDateInputValue(normalizedEndDate)) { + showToast('结束日期格式不正确,请输入 YYYY-MM-DD', 'error') + return + } + + if (normalizedStartDate && normalizedEndDate) { + const parsedStartDate = parse(normalizedStartDate, DATE_FORMAT, new Date()) + const parsedEndDate = parse(normalizedEndDate, DATE_FORMAT, new Date()) + if (isAfter(parsedStartDate, parsedEndDate)) { + showToast('开始日期不能晚于结束日期', 'error') + return + } + } + + setStatsLoading(true) + try { + const params = new URLSearchParams() + if (normalizedStartDate) params.append('startDate', normalizedStartDate) + if (normalizedEndDate) params.append('endDate', normalizedEndDate) + if (canSelectDepartment && query.departmentKey) params.append('departmentKey', query.departmentKey) + + const response = await fetch(`/api/admin/data-stats?${params.toString()}`) + if (!response.ok) { + const error = await response.json() + throw new Error(error?.error || '获取数据统计失败') + } + + const payload = await response.json() + const nextData: MaterialUploadStatsData = payload.data + + setStatsData(nextData) + + setDrilldown((previous) => { + if (previous?.source === 'department') { + const matched = nextData.departmentDistribution.items.find((item) => item.departmentKey === previous.key) + if (matched) return toDepartmentDrilldown(matched) + } + + if (previous?.source === 'materialType') { + const matched = nextData.materialTypeDistribution.items.find((item) => item.materialType === previous.key) + if (matched) return toMaterialTypeDrilldown(matched) + } + + if (nextData.departmentDistribution.items.length) { + return toDepartmentDrilldown(nextData.departmentDistribution.items[0]) + } + + if (nextData.materialTypeDistribution.items.length) { + return toMaterialTypeDrilldown(nextData.materialTypeDistribution.items[0]) + } + + return null + }) + } catch (error) { + console.error('[DataStatisticsTab] fetchStats error:', error) + showToast(error instanceof Error ? error.message : '获取数据统计失败', 'error') + } finally { + setStatsLoading(false) + } + }, [ + canSelectDepartment, + departmentKey, + endDate, + showToast, + startDate, + ]) + + const fetchLogs = useCallback(async () => { + if (!canViewLogs) { + return + } + + setLogsLoading(true) + try { + const params = new URLSearchParams({ + action: logAction, + page: String(logPage), + pageSize: '20', + }) + + if (startDate) params.append('startDate', startDate) + if (endDate) params.append('endDate', endDate) + if (logKeyword) params.append('keyword', logKeyword) + + const response = await fetch(`/api/admin/material-operation-logs?${params.toString()}`) + if (!response.ok) { + const error = await response.json() + throw new Error(error?.error || '获取日志失败') + } + + const payload = await response.json() + setLogsData(payload.data) + } catch (error) { + console.error('[DataStatisticsTab] fetchLogs error:', error) + showToast(error instanceof Error ? error.message : '获取日志失败', 'error') + } finally { + setLogsLoading(false) + } + }, [canViewLogs, endDate, logAction, logKeyword, logPage, showToast, startDate]) + + useEffect(() => { + if (hasInitializedStatsRef.current) { + return + } + + hasInitializedStatsRef.current = true + fetchStats() + }, [fetchStats]) + + useEffect(() => { + fetchLogs() + }, [fetchLogs]) + + const handleApplyFilters = async () => { + const normalizedStartDate = normalizeDateInputValue(draftStartDate) + const normalizedEndDate = normalizeDateInputValue(draftEndDate) + + if (!isValidDateInputValue(normalizedStartDate)) { + showToast('开始日期格式不正确,请输入 YYYY-MM-DD', 'error') + return + } + + if (!isValidDateInputValue(normalizedEndDate)) { + showToast('结束日期格式不正确,请输入 YYYY-MM-DD', 'error') + return + } + + if (normalizedStartDate && normalizedEndDate) { + const parsedStartDate = parse(normalizedStartDate, DATE_FORMAT, new Date()) + const parsedEndDate = parse(normalizedEndDate, DATE_FORMAT, new Date()) + if (isAfter(parsedStartDate, parsedEndDate)) { + showToast('开始日期不能晚于结束日期', 'error') + return + } + } + + setStartDate(normalizedStartDate) + setEndDate(normalizedEndDate) + setDraftStartDate(normalizedStartDate) + setDraftEndDate(normalizedEndDate) + setDateFilterMode('custom') + setLogPage(1) + + await fetchStats({ + startDate: normalizedStartDate, + endDate: normalizedEndDate, + }) + } + + const handleApplyPreset = async (preset: DatePresetKey) => { + const range = getPresetRange(preset) + + setStartDate(range.startDate) + setEndDate(range.endDate) + setDraftStartDate(range.startDate) + setDraftEndDate(range.endDate) + setDateFilterMode(preset) + setLogPage(1) + + await fetchStats({ + startDate: range.startDate, + endDate: range.endDate, + }) + } + + const handleExportCsv = async () => { + try { + const params = new URLSearchParams() + if (startDate) params.append('startDate', startDate) + if (endDate) params.append('endDate', endDate) + if (canSelectDepartment && departmentKey) params.append('departmentKey', departmentKey) + + const response = await fetch(`/api/admin/data-stats/export?${params.toString()}`) + if (!response.ok) { + const error = await response.json() + throw new Error(error?.error || '导出失败') + } + + const blob = await response.blob() + const downloadUrl = URL.createObjectURL(blob) + const fileName = parseFileNameFromHeader(response.headers.get('content-disposition')) + + const link = document.createElement('a') + link.href = downloadUrl + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(downloadUrl) + + showToast('CSV 导出成功') + } catch (error) { + console.error('[DataStatisticsTab] export error:', error) + showToast(error instanceof Error ? error.message : 'CSV 导出失败', 'error') + } + } + + const departmentBars = useMemo(() => { + if (!statsData) { + return [] as HorizontalBarDatum[] + } + + return statsData.departmentDistribution.items.map((item) => ({ + key: item.departmentKey, + label: item.departmentName, + value: item.uploadCount, + percentage: item.percentage, + subText: `${item.userCount} 人参与`, + })) + }, [statsData]) + + const materialTypeBars = useMemo(() => { + if (!statsData) { + return [] as HorizontalBarDatum[] + } + + return statsData.materialTypeDistribution.items.map((item) => ({ + key: item.materialType, + label: item.materialType, + value: item.uploadCount, + percentage: item.percentage, + subText: `${item.users.length} 人参与`, + })) + }, [statsData]) + + const handleDepartmentBarSelect = (key: string) => { + const selected = statsData?.departmentDistribution.items.find((item) => item.departmentKey === key) + if (!selected) { + return + } + + setDrilldown(toDepartmentDrilldown(selected)) + scrollToDrilldownSection() + } + + const handleMaterialTypeBarSelect = (key: string) => { + const selected = statsData?.materialTypeDistribution.items.find((item) => item.materialType === key) + if (!selected) { + return + } + + setDrilldown(toMaterialTypeDrilldown(selected)) + scrollToDrilldownSection() + } + + const drilldownUsers = useMemo(() => { + if (!drilldown) { + return [] as MaterialUserUploadStat[] + } + + return drilldown.users.map((user) => ({ + ...user, + percentage: drilldown.totalUploads > 0 + ? Number(((user.uploadCount / drilldown.totalUploads) * 100).toFixed(2)) + : 0, + })) + }, [drilldown]) + + const totalPages = logsData?.totalPages || 1 + const canGoPrev = logPage > 1 + const canGoNext = logPage < totalPages + + return ( +
+
+
+
+

数据统计看板

+

+ 统一按上方时间范围统计;点击分布项可直接跳转到下方人员明细。 +

+
+ + 时间范围:{startDate || '不限'} ~ {endDate || '不限'} + + + 部门筛选:{statsData?.selectedDepartmentName || '全部部门'} + +
+ {!canSelectDepartment && (departmentScopeName || statsData?.selectedDepartmentName) && ( +

+ 当前为 {departmentScopeName || statsData?.selectedDepartmentName} 部门范围 +

+ )} +
+ + +
+ +
+
+
+ {DATE_PRESETS.map((preset) => ( + + ))} + {dateFilterMode === 'custom' && ( + + 自定义日期 + + )} +
+ +
+ + + + + {canSelectDepartment ? ( +
+ + +
+ ) : ( +
+
+ 当前已按部门范围自动过滤,可直接看绩效分布 +
+
+ )} + +
+ +
+
+
+ +
+ + + +
+ +
+
+
+
+

部门上传分布(核心)

+

+ 统计范围:{statsData?.departmentDistribution.label || '-'} +

+
+
+ +
+ +
+
+ +
+
+
+

素材类型上传分布(核心)

+

+ 统计范围:{statsData?.materialTypeDistribution.label || '-'} +

+
+
+ +
+ +
+
+
+ +
+
+

+ {drilldown?.title || '人员素材占比(点击上方柱子查看)'} +

+ {drilldown && ( + + 合计 {drilldown.totalUploads} 条 + + )} +
+ +
+ {drilldownUsers.length ? ( +
+ {drilldownUsers.map((user) => { + const percentage = user.percentage || 0 + const width = Math.max(percentage, 3) + + return ( +
+
+
{getUserDisplayName(user)}
+
+ {user.uploadCount} 条 ({percentage.toFixed(2)}%) +
+
+
+
+
+
部门:{user.departmentName}
+
+ ) + })} +
+ ) : ( +
+ 请选择上方柱子查看对应人员占比 +
+ )} +
+
+ +
+
+
+

每日趋势(近31天)

+
+
+ {statsData?.dailyUploads.length ? ( + + + + + + + + + {statsData.dailyUploads.slice(-31).map((item) => ( + + + + + ))} + +
日期上传数
{item.bucket}{item.count}
+ ) : ( +
暂无每日趋势数据
+ )} +
+
+ +
+
+

月度趋势

+
+
+ {statsData?.monthlyUploads.length ? ( + + + + + + + + + {statsData.monthlyUploads.map((item) => ( + + + + + ))} + +
月份上传数
{item.bucket}{item.count}
+ ) : ( +
暂无月度趋势数据
+ )} +
+
+
+
+
+ + {canViewLogs && ( +
+
+

素材操作日志

+

+ 记录上传、修改(含字段前后值)、下载、删除等关键操作 +

+
+ +
+
+
+ + +
+ +
+ + setLogKeywordInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + setLogPage(1) + setLogKeyword(logKeywordInput.trim()) + } + }} + placeholder="可按素材型号、类型、用户名搜索" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white" + /> +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + {logsLoading ? ( + + + + ) : logsData?.logs.length ? ( + logsData.logs.map((log) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
时间操作素材操作人部门变更详情
加载日志中...
+ {new Date(log.timestamp).toLocaleString('zh-CN')} + + + {formatLogAction(log.action)} + + +
{log.material.productModel}
+
{log.material.materialType}
+
{log.user.realName || log.user.username}{log.user.departmentName}{formatChangeSummary(log)}
暂无日志数据
+
+ +
+
+ 共 {logsData?.total || 0} 条,当前第 {logsData?.page || 1} / {logsData?.totalPages || 1} 页 +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 389b65e..0041d88 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -259,6 +259,31 @@ export default function ProductPackageUploader({ return result.uploadId; }; + const MAX_FILE_CONCURRENCY = 2; + + const runWithConcurrency = async ( + items: TInput[], + limit: number, + worker: (item: TInput, index: number) => Promise + ): Promise => { + if (items.length === 0) return []; + + const safeLimit = Math.max(1, Math.min(limit, items.length)); + const results: TOutput[] = new Array(items.length); + let nextIndex = 0; + + const consume = async () => { + while (nextIndex < items.length) { + const current = nextIndex; + nextIndex += 1; + results[current] = await worker(items[current], current); + } + }; + + await Promise.all(Array.from({ length: safeLimit }, () => consume())); + return results; + }; + // 提交上传 const handleSubmit = async () => { // 验证必填字段(任务号改为可选,不提示) @@ -284,6 +309,10 @@ export default function ProductPackageUploader({ setProcessingStep(''); setError(''); + let createdMaterialId: string | null = null; + const uploadedFileIds: string[] = []; + let completed = false; + try { // 1. 初始化上传 setProcessingStep('初始化上传...'); @@ -307,6 +336,7 @@ export default function ProductPackageUploader({ const { data: initData } = await initResponse.json(); const materialId = initData.materialId; + createdMaterialId = materialId; // 2. 上传所有文件(并行上传以提高速度) const totalFiles = @@ -326,10 +356,10 @@ export default function ProductPackageUploader({ ): Promise> => { if (assets.length === 0) return []; - // 并行上传所有文件(使用分片上传) - const uploadPromises = assets.map(async (asset, index) => { + return runWithConcurrency(assets, MAX_FILE_CONCURRENCY, async (asset, index) => { try { const uploadId = await uploadFileWithChunks(asset.file); + uploadedFileIds.push(uploadId); // 只在上传完成时更新进度 updateProgress(); return { @@ -343,19 +373,17 @@ export default function ProductPackageUploader({ throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`); } }); - - // 等待所有文件上传完成 - return await Promise.all(uploadPromises); }; // 并行上传PSD文件(使用分片上传) const uploadPsdFiles = async (): Promise> => { if (psdFiles.length === 0) return []; - const uploadPromises = psdFiles.map(async (psdFileItem, index) => { + return runWithConcurrency(psdFiles, MAX_FILE_CONCURRENCY, async (psdFileItem, index) => { try { const psdFile = psdFileItem.file; const uploadId = await uploadFileWithChunks(psdFile); + uploadedFileIds.push(uploadId); // 只在上传完成时更新进度 updateProgress(); return { @@ -368,17 +396,20 @@ export default function ProductPackageUploader({ throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`); } }); - - return await Promise.all(uploadPromises); }; - // 并行上传所有类型的资产(主图、SKU图、详情图、PSD文件同时上传) - const [mainAssets, skuAssets, detailAssets, psdAssetsArray] = await Promise.all([ - uploadAssets(mainImages, 'main'), - uploadAssets(skuImages, 'sku'), - uploadAssets(detailImages, 'detail'), - uploadPsdFiles(), - ]); + // 顺序上传不同资产类型,避免一次性触发过多并发请求导致会话不稳定 + setProcessingStep('上传主图中...'); + const mainAssets = await uploadAssets(mainImages, 'main'); + + setProcessingStep('上传SKU图中...'); + const skuAssets = await uploadAssets(skuImages, 'sku'); + + setProcessingStep('上传详情图中...'); + const detailAssets = await uploadAssets(detailImages, 'detail'); + + setProcessingStep('上传PSD中...'); + const psdAssetsArray = await uploadPsdFiles(); // PSD资产格式:支持数组(complete接口支持数组格式) const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined; @@ -415,13 +446,22 @@ export default function ProductPackageUploader({ setUploadProgress(100); setProcessingStep('完成!'); + completed = true; // 清理预览URL const cleanup = () => { - mainImages.forEach(img => URL.revokeObjectURL(img.preview)); - skuImages.forEach(img => URL.revokeObjectURL(img.preview)); - detailImages.forEach(img => URL.revokeObjectURL(img.preview)); - psdFiles.forEach(img => URL.revokeObjectURL(img.preview)); + mainImages.forEach((img) => { + URL.revokeObjectURL(img.preview); + }); + skuImages.forEach((img) => { + URL.revokeObjectURL(img.preview); + }); + detailImages.forEach((img) => { + URL.revokeObjectURL(img.preview); + }); + psdFiles.forEach((img) => { + URL.revokeObjectURL(img.preview); + }); }; cleanup(); @@ -429,8 +469,24 @@ export default function ProductPackageUploader({ setProcessingStep(''); onSuccess(); }, 500); - } catch (error: any) { + } catch (error: unknown) { console.error('上传失败:', error); + + if (createdMaterialId && !completed) { + try { + await fetch('/api/materials/upload/product-package/abort', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + materialId: createdMaterialId, + uploadIds: uploadedFileIds, + }), + }); + } catch (abortError) { + console.error('[Upload] 失败回滚未完成素材失败:', abortError); + } + } + const { parseApiError } = await import('@/lib/error-handler'); const { message } = parseApiError(error); setError(message); @@ -487,9 +543,10 @@ export default function ProductPackageUploader({ {/* 主图上传 */}
-