From cd6e40d9d21bd769e41d4aac4903eb6f09a45e3e Mon Sep 17 00:00:00 2001 From: chenpei Date: Tue, 27 Jan 2026 09:33:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E4=BC=A0=E8=BE=93=E6=80=A7=E8=83=BD=E5=92=8C=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=20-=20TCP=E7=BC=93=E5=86=B2=E5=8C=BA+=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E6=8E=A7=E5=88=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动: 1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题 2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET 3. 动态并发控制 - 根据网络环境自动调整 4. Nginx超时增加到120s - 支持大分片上传 5. 上传进度修复 - 修复剩余时间计算 6. 新增任务-页面上传器和详情组件 7. 统一部署文档为deploy-remote-build.ps1 问题修复: - 公网用户上传从200-900KB/s提升到预期3-5MB/s - 内网用户上传更稳定,无连接重置错误 - UploadProgress组件剩余时间计算修正 Co-Authored-By: Claude --- .claude/Claude-命令速查.md | 339 ++ .claude/settings.json | 7 + .claude/settings.local.json | 48 + .claude/skills/cleanup-old.md | 52 + .claude/skills/cleanup-temp.md | 45 + .claude/skills/material-status.md | 50 + .claude/skills/system-health.md | 73 + .gitignore | 13 + .../任务-详情素材逻辑说明与迁移计划.md | 548 -- .kiro/steering/项目记忆库.md | 176 +- AGENTS.md | 161 - CLAUDE.md | 322 ++ Dockerfile | 8 +- app/admin/api-keys/page.tsx | 14 +- app/api/admin/regenerate-pdf-preview/route.ts | 98 + app/api/admin/regenerate-preview/route.ts | 152 + app/api/danmaku/route.ts | 101 +- app/api/external/download/route.ts | 66 +- app/api/external/product-assets/route.ts | 11 +- app/api/health/memory/route.ts | 52 + app/api/materials/[id]/route.ts | 55 +- app/api/materials/download/route-old.ts | 1375 ----- app/api/materials/route.ts | 72 +- app/api/materials/update/route.ts | 15 + app/api/materials/upload/chunk/route.ts | 74 +- app/api/materials/upload/complete/route.ts | 10 +- .../upload/product-package/complete/route.ts | 64 +- app/api/materials/upload/route.ts | 37 +- .../upload/task-page/complete/route.ts | 138 + .../materials/upload/task-page/init/route.ts | 104 + app/api/performance/quick/route.ts | 39 +- app/api/proxy-media/route.ts | 29 + app/api/users/list/route.ts | 44 + app/edit/[id]/page.tsx | 38 +- app/page.tsx | 63 +- app/share/[id]/page.tsx | 91 +- check_cuda.py | 5 + components/EditMaterialModal.tsx | 39 +- components/Header.tsx | 75 +- components/NetworkDiagnoseModal.tsx | 30 +- components/SearchableUserSelect.tsx | 228 + components/UploadModal.tsx | 50 +- components/detail/TaskPageDetail.tsx | 285 + components/home/SearchBar.tsx | 2 +- components/upload/AIOriginalUploader.tsx | 2 +- components/upload/ProductPackageUploader.tsx | 37 +- components/upload/RealPhotoUploader.tsx | 2 +- components/upload/TaskPageUploader.tsx | 614 +++ components/upload/ThreeDOriginalUploader.tsx | 2 +- components/upload/UploadProgress.tsx | 85 +- components/upload/VideoScreenshotUploader.tsx | 31 +- components/upload/batch-upload-handlers.ts | 3 +- components/upload/file-upload-handlers.ts | 2 +- components/upload/special-batch-uploaders.ts | 8 +- components/upload/upload-constants.ts | 2 +- components/upload/upload-utils.ts | 17 +- deploy-remote-build.ps1.backup2 | 905 ---- docker-compose.shadow.yml | 14 + docker-compose.yml | 43 + docs/分片上传优化-内外网自适应-20260124.md | 438 ++ docs/单个chunk慢的根本原因排查.md | 396 ++ docs/深度内存泄漏分析报告.md | 419 ++ docs/深度风险排查报告-20260123.md | 198 + docs/网络架构全景分析报告.md | 898 ++++ docs/部署规范.md | 221 + hooks/useBatchFilesUpload.ts | 3 +- lib/chunk-upload-config.ts | 201 + lib/chunk-upload.ts | 254 +- lib/material-type-mapper.ts | 10 +- lib/memory-monitor.ts | 96 + lib/network-bandwidth-monitor.ts | 17 +- lib/network.ts | 18 +- lib/pdf-processor.ts | 20 +- lib/task-material-processor.ts | 92 +- lib/upload-sessions-redis.ts | 61 +- lib/upload-sessions.ts | 5 +- middleware.ts | 4 + package-lock.json | 7 + package.json | 1 + scripts/README.md | 152 +- scripts/diagnose-pdf.js | 181 + .../migrate-product-package-old-materials.ts | 2 +- scripts/regenerate-brand-previews.ts | 54 + scripts/test-chunk-upload.ts | 153 + scripts/test-external-api.ps1 | 102 - test_cuda.py | 5 + test_port.py | 6 + test_port2.py | 6 + tsconfig.json | 2 +- 系统架构风险评估-快速索引.md | 140 + 系统深度架构侦察与风险评估报告.md | 4686 +++++++++++++++++ 项目文档.md | 8 +- 92 files changed, 12329 insertions(+), 3592 deletions(-) create mode 100644 .claude/Claude-命令速查.md create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 .claude/skills/cleanup-old.md create mode 100644 .claude/skills/cleanup-temp.md create mode 100644 .claude/skills/material-status.md create mode 100644 .claude/skills/system-health.md delete mode 100644 .kiro/steering/任务-详情素材逻辑说明与迁移计划.md delete mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/api/admin/regenerate-pdf-preview/route.ts create mode 100644 app/api/admin/regenerate-preview/route.ts create mode 100644 app/api/health/memory/route.ts delete mode 100644 app/api/materials/download/route-old.ts create mode 100644 app/api/materials/upload/task-page/complete/route.ts create mode 100644 app/api/materials/upload/task-page/init/route.ts create mode 100644 app/api/users/list/route.ts create mode 100644 check_cuda.py create mode 100644 components/SearchableUserSelect.tsx create mode 100644 components/detail/TaskPageDetail.tsx create mode 100644 components/upload/TaskPageUploader.tsx delete mode 100644 deploy-remote-build.ps1.backup2 create mode 100644 docs/分片上传优化-内外网自适应-20260124.md create mode 100644 docs/单个chunk慢的根本原因排查.md create mode 100644 docs/深度内存泄漏分析报告.md create mode 100644 docs/深度风险排查报告-20260123.md create mode 100644 docs/网络架构全景分析报告.md create mode 100644 docs/部署规范.md create mode 100644 lib/chunk-upload-config.ts create mode 100644 lib/memory-monitor.ts create mode 100644 scripts/diagnose-pdf.js create mode 100644 scripts/regenerate-brand-previews.ts create mode 100644 scripts/test-chunk-upload.ts delete mode 100644 scripts/test-external-api.ps1 create mode 100644 test_cuda.py create mode 100644 test_port.py create mode 100644 test_port2.py create mode 100644 系统架构风险评估-快速索引.md create mode 100644 系统深度架构侦察与风险评估报告.md diff --git a/.claude/Claude-命令速查.md b/.claude/Claude-命令速查.md new file mode 100644 index 0000000..07a0ec1 --- /dev/null +++ b/.claude/Claude-命令速查.md @@ -0,0 +1,339 @@ +# Claude Code 命令速查 + +本文档按命令来源分类,整理了所有可用命令。 + +--- + +## 目录 + +1. [Claude 自带命令](#1-claude-自带命令) +2. [已安装插件命令](#2-已安装插件命令) +3. [插件命令 (MCP)](#3-插件命令-mcp) +4. [项目技能 (Skills)](#4-项目技能-skills) +5. [自定义命令 (Slash Commands)](#5-自定义命令-slash-commands) + +--- + +## 1. Claude 自带命令 + +### 1.1 基础控制 + +| 命令 | 功能 | +|------|------| +| `/help` | 获取使用帮助信息 | +| `/status` | 查看账户和系统状态 | +| `/release-notes` | 查看最新发布说明 | +| `/exit` / `/quit` | 退出 REPL 交互模式 | + +### 1.2 会话管理 + +| 命令 | 功能 | +|------|------| +| `/clear` | 清除对话历史 | +| `/clear reset` | 完全重置对话 | +| `/clear new` | 开始新对话 | +| `/compact` | 压缩对话历史但保留摘要 | +| `/resume` | 恢复之前的对话 | + +### 1.3 上下文查看 + +| 命令 | 功能 | +|------|------| +| `/context` | 可视化当前上下文使用情况 | +| `/cost` | 显示当前会话的费用和持续时间 | + +### 1.4 账户管理 + +| 命令 | 功能 | +|------|------| +| `/login` | 使用 Anthropic 账户登录 | +| `/logout` | 登出当前账户 | +| `/upgrade` | 升级到 Max 计划 | + +### 1.5 项目与目录 + +| 命令 | 功能 | 示例 | +|------|------|------| +| `/add-dir ` | 添加新的工作目录 | `/add-dir /path/to/project` | +| `/init` | 使用 CLAUDE.md 初始化项目 | `/init` | + +### 1.6 导入导出 + +| 命令 | 功能 | +|------|------| +| `/export` | 导出当前对话到文件 | +| `/memory` | 编辑 Claude 记忆文件 | + +### 1.7 配置管理 + +| 命令 | 功能 | +|------|------| +| `/config` | 打开设置页面 | +| `/config theme` | 打开主题设置 | +| `/permissions` | 管理工具权限规则 | +| `/output-style` | 设置输出样式 | +| `/output-style:new` | 创建自定义输出样式 | +| `/hooks` | 管理钩子配置 | + +### 1.8 模型选择 + +| 命令 | 功能 | +|------|------| +| `/model` | 查看可用模型 | +| `/model ` | 切换指定模型 | + +**模型选择指导**: + +| 模型 | 适用场景 | +|------|----------| +| **Opus** | 复杂推理、创意写作、复杂代码重构 | +| **Sonnet** | 日常编程、代码审查、技术文档 | +| **Haiku** | 简单查询、快速响应、基础编程 | + +### 1.9 诊断工具 + +| 命令 | 功能 | +|------|------| +| `/doctor` | 诊断安装和设置问题 | +| `/ide` | 管理 IDE 集成状态 | + +### 1.10 智能体和任务 + +| 命令 | 功能 | +|------|------| +| `/agents` | 管理智能体配置和创建专门化 AI 助手 | +| `/bashes` | 列出和管理后台任务 | + +### 1.11 GitHub 集成 + +| 命令 | 功能 | +|------|------| +| `/install-github-app` | 为仓库设置 Claude GitHub Actions | +| `/pr-comments` | 从 GitHub 拉取请求获取评论 | +| `/review` | 审查拉取请求 | + +### 1.12 安全与审查 + +| 命令 | 功能 | +|------|------| +| `/security-review` | 对当前分支的待处理更改进行完整安全审查 | +| `/privacy-settings` | 查看和更新隐私设置 | + +### 1.13 终端与界面 + +| 命令 | 功能 | +|------|------| +| `/terminal-setup` | 为换行和视觉提示启用终端键绑定 | +| `/vim` | 在 Vim 和普通编辑模式之间切换 | +| `/statusline` | 设置 Claude Code 的状态行 UI | +| `/todos` | 列出当前待办事项 | + +### 1.14 安装迁移 + +| 命令 | 功能 | +|------|------| +| `/migrate-installer` | 从全局 npm 安装迁移到本地安装 | + +### 1.15 反馈 + +| 命令 | 功能 | +|------|------| +| `/feedback` | 提交关于 Claude Code 的反馈 | +| `/feedback bug` | 报告错误 | + +--- + +## 2. 已安装插件命令 + +### 2.1 superpowers 插件 (v4.1.1) + +**用途**:创意工作流程管理(头脑风暴 → 计划 → 执行) + +| 命令 | 功能 | 使用场景 | +|------|------|----------| +| `/brainstorm` | 头脑风暴 | 创建功能、构建组件、添加功能前必须使用 | +| `/write-plan` | 创建详细实施计划 | 将头脑风暴的结果转化为可执行的小任务 | +| `/execute-plan` | 分批执行计划 | 带审查检查点,逐步执行计划 | + +**推荐工作流**: +``` +/brainstorm → /write-plan → /execute-plan +``` + +### 2.2 claude-mem 插件 (v9.0.6) + +**用途**:基于文档发现的计划执行 + +| 命令 | 功能 | 使用场景 | +|------|------|----------| +| `/make-plan` | 创建带文档发现的实施计划 | 在实施前先发现和阅读相关文档 | +| `/do` | 使用子代理执行计划 | 部署子代理执行各个阶段的工作 | + +**特点**: +- 强制先阅读文档再实施 +- 防止"发明"不存在的 API +- 每个阶段都有验证检查清单 + +--- + +## 3. 插件命令 (MCP) + +### 3.1 MCP 管理 + +| 命令 | 功能 | +|------|------| +| `/mcp` | 管理 MCP 服务器 | + +### 3.2 MCP 命令格式 + +``` +/mcp____ +``` + +**示例**: + +| 命令 | 功能 | +|------|------| +| `/mcp__github__create_issue` | 创建 GitHub Issue | +| `/mcp__github__list_prs` | 列出 Pull Requests | +| `/mcp__database__query` | 执行数据库查询 | +| `/mcp__docker__list_containers` | 列出 Docker 容器 | + +--- + +## 4. 项目技能 (Skills) + +**存放位置**:`.claude/skills/` + +### 4.1 系统维护 + +| 命令 | 功能 | 说明 | +|------|------|------| +| `/cleanup-temp` | 清理临时文件 | 清理 `uploads/temp/`、`uploads/assembled/` 中的临时文件,保留正常数据 | +| `/cleanup-old` | 清理旧镜像 | 清理旧的 Docker 镜像,保留构建缓存 | +| `/system-health` | 系统健康检查 | 检查 Redis、数据库、存储、网络状态 | + +### 4.2 素材管理 + +| 命令 | 功能 | 说明 | +|------|------|------| +| `/material-status` | 查询素材状态 | 支持按作者、类型、日期、任务号查询 | + +--- + +## 5. 自定义命令 (Slash Commands) + +### 5.1 命令存放位置 + +**项目级命令**(团队共享): +``` +.claude/commands/ +``` + +**个人级命令**(个人使用): +``` +~/.claude/commands/ +``` + +### 5.2 命令文件结构 + +```markdown +--- +description: "命令描述" +argument-hint: "<参数>" +allowed-tools: ["Read", "Edit", "Bash"] +model: "claude-3-sonnet" +--- + +# 命令说明 + +请执行以下操作: + +@$1 +``` + +### 5.3 参数占位符 + +| 占位符 | 功能 | +|--------|------| +| `$ARGUMENTS` | 捕获所有参数 | +| `$1`, `$2` | 访问特定位置参数 | +| `${1:-default}` | 带默认值的参数 | + +### 5.4 文件引用 + +``` +@src/config.js # 直接引用文件内容 +@$1 # 引用参数指定的文件 +@src/utils/*.js # 引用多个文件 +``` + +### 5.5 Bash 命令集成 + +``` +!npm test # 执行测试 +!eslint $1 # 代码检查 +!git add . && git commit -m "消息" # 复合命令 +``` + +### 5.6 Front Matter 配置选项 + +```yaml +--- +# 命令描述 +description: "执行特定功能的命令" + +# 参数提示 +argument-hint: "<必需参数> [可选参数]" + +# 允许的工具 +allowed-tools: + - "Read" + - "Edit" + - "Bash" + - "Glob" + +# 指定模型 +model: "claude-3-sonnet" + +# 扩展思考模式 +thinking: true + +# 权限模式 +permission-mode: "standard" +--- +``` + +--- + +## 快捷技巧 + +### 常用命令组合 + +``` +# 开发前检查 +/doctor → /context → /status + +# 清理和重置 +/compact → /clear new + +# 系统维护 +/system-health → /cleanup-temp → /cleanup-old + +# 创意工作流程(使用 superpowers 插件) +/brainstorm → /write-plan → /execute-plan +``` + +### 自动化工作流 + +``` +# 代码审查流程 +/lint && /test && /review + +# 完整部署 +/build && /test && /deploy +``` + +--- + +**提示**:输入 `/help` 可以随时获取完整命令列表和详细帮助。 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a5b082c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(dir \"C:\\\\Users\\\\54972\\\\.claude\\\\plugins\" /b)" + ] + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1d9e071 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,48 @@ +{ + "permissions": { + "allow": [ + "Bash(ssh:*)", + "Bash(curl:*)", + "Bash(Select-String:*)", + "Bash(pwsh -Command:*)", + "Bash(pwsh:*)", + "Bash(npm install:*)", + "Bash(npm run build)", + "Bash(powershell -File:*)", + "Bash(powershell.exe:*)", + "Bash(ls:*)", + "Bash(scp:*)", + "Bash(docker ps:*)", + "Bash(npx tsx:*)", + "Bash(claude plugin search:*)", + "Bash(claude plugin list)", + "Bash(node -e:*)", + "Bash(while true)", + "Bash(done)", + "Bash(npx prisma db execute:*)", + "Bash(dir \"C:\\\\Users\\\\54972\\\\.claude\" /b)", + "Bash(dir \"C:\\\\Users\\\\54972\\\\.claude\\\\plugins\\\\cache\\\\superpowers-marketplace\\\\superpowers\\\\4.1.1\" /b)", + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''docker logs --tail 100 youyidian-3d-material 2>&1 | grep UploadChunk''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''docker logs --tail 50 youyidian-3d-material 2>&1''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''docker logs --tail 500 youyidian-3d-material 2>&1 | grep -i upload''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''cat /etc/frp/frpc.ini''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''find /etc -name \"\"*frp*\"\" 2>/dev/null | head -20''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''cat /etc/frp/frpc.toml''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''ping -c 4 192.168.63.125 2>&1 || echo \"\"Cannot ping user IP\"\"''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''ip addr show 2>&1 | head -30''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\":*)", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''docker exec youyidian-3d-material sh -c \"\"ulimit -a\"\"''\")", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"ssh -o StrictHostKeyChecking=no root@192.168.58.20 ''ss -tunlp | grep 4320''\")", + "Bash(powershell -ExecutionPolicy Bypass -File .deploy-remote-build.ps1:*)", + "Bash(Select-Object -First 50)", + "Bash(cmd:*)", + "Bash(\"C:\\\\Program Files\\\\PuTTY\\\\plink.exe\" -batch -pw \"Jsjj@8888\" \"root@192.168.42.134\" \"docker ps --format ''{{.Names}}''\")", + "Bash(powershell -Command \"[System.IO.File]::ReadAllBytes\\(''D:\\\\AI Code\\\\gongxiangp2\\\\deploy-remote-build.ps1''\\)[0..10] -join '',''\")", + "Bash(powershell:*)", + "Bash(cmd.exe /c \"cd /d D:\\\\AI Code\\\\gongxiangp2 && powershell -ExecutionPolicy Bypass -File analyze-braces.ps1\")", + "Bash(Select-Object -Last 30)" + ] + } +} diff --git a/.claude/skills/cleanup-old.md b/.claude/skills/cleanup-old.md new file mode 100644 index 0000000..448bb47 --- /dev/null +++ b/.claude/skills/cleanup-old.md @@ -0,0 +1,52 @@ +--- +name: 清理旧版本镜像 +description: 清理旧的 Docker 镜像和缓存,保留构建缓存 +allowed-tools: + - Bash(docker *) + - Bash(ssh *) +language: chinese +--- + +你是旧版本清理助手。 + +## 清理目标 + +只清理以下内容,**保留构建缓存**: + +### 可清理 +- 旧版本的 Docker 镜像(非当前 tag) +- 悬空镜像(dangling images) +- 旧容器的日志 +- 6 小时前的构建中间层 + +### 绝对不保留构建缓存 + +❌ `--build-cache` 相关的任何内容 +❌ 用于加速构建的缓存层 + +## 清理位置 + +| 位置 | 命令 | +|------|------| +| 构建服务器 (192.168.42.134) | `ssh root@192.168.42.134` | +| 生产服务器 (192.168.58.20) | `ssh root@192.168.58.20` | + +## 操作步骤 + +1. 连接到服务器 +2. 列出当前镜像和缓存占用 +3. 识别可清理的内容(排除构建缓存) +4. 展示清理计划 +5. 用户确认后执行 +6. 报告释放的空间 + +## 输出格式 + +``` +扫描结果: +- 当前镜像占用:XX GB +- 可清理旧镜像:XX 个(XX GB) +- 构建缓存:保留(XX GB) + +确认清理?[Y/n] +``` diff --git a/.claude/skills/cleanup-temp.md b/.claude/skills/cleanup-temp.md new file mode 100644 index 0000000..c20025e --- /dev/null +++ b/.claude/skills/cleanup-temp.md @@ -0,0 +1,45 @@ +--- +name: 清理临时文件 +description: 清理上传过程中的临时文件,保留正常数据 +allowed-tools: + - Bash + - Read +language: chinese +--- + +你是临时文件清理助手。 + +## 清理目标 + +只清理以下**临时文件**,绝不删除正常数据: + +### 本地临时目录 +- `uploads/temp/` - 分片上传的临时文件 +- `uploads/assembled/` - 已合并的临时文件(可清理) +- `uploads/cleanup/` - 待清理的文件 + +### 判断标准 +- `uploads/temp/` 中的文件:创建时间超过 2 小时 +- `uploads/assembled/` 中的文件:创建时间超过 1 小时 +- 确认文件路径包含 `temp`、`chunk`、`tmp` 关键词 + +## 绝对不删除 + +❌ `uploads/materials/` - 正常素材文件 +❌ `uploads/` 下的其他目录 +❌ 任何不属于 `temp`、`assembled`、`cleanup` 的文件 + +## 操作步骤 + +1. 先列出要清理的文件,让用户确认 +2. 统计文件数量和占用空间 +3. 用户确认后再执行删除 +4. 报告清理结果 + +## 输出格式 + +``` +清理扫描结果: +- 临时文件:XX 个(XX MB) +- 确认删除?[Y/n] +``` diff --git a/.claude/skills/material-status.md b/.claude/skills/material-status.md new file mode 100644 index 0000000..5a313ea --- /dev/null +++ b/.claude/skills/material-status.md @@ -0,0 +1,50 @@ +--- +name: 查询素材状态 +description: 通用素材状态查询工具(支持多种条件) +allowed-tools: + - Bash(npx tsx *) + - Read +language: chinese +--- + +你是素材状态查询助手。 + +## 功能 + +根据用户提供的条件查询素材信息,支持多种查询方式。 + +## 查询条件 + +可以组合使用以下条件: + +| 条件 | 说明 | 示例 | +|------|------|------| +| 作者 | 按作者姓名搜索 | `艾叶` | +| 类型 | 按素材类型搜索 | `品牌灵感`、`3D场景` | +| 日期范围 | 按上传时间搜索 | `2024-01-01 至 2024-01-31` | +| 任务号 | 按任务号搜索 | `TASK123456789012` | +| 状态 | 按处理状态搜索 | `processing`、`completed` | + +## 输出格式 + +``` +📊 查询结果(共 XX 条) + +条件:[列出查询条件] + +统计: +- 总数:XX +- 按类型分布:... +- 按作者分布:... + +详情(前 10 条): +- ID: XXX | 类型: XXX | 作者: XXX | 状态: XXX +... +``` + +## 使用方法 + +用户直接描述查询需求,例如: +- "查艾叶的所有素材" +- "查品牌灵感类型的素材" +- "查任务号 TASK123 开头的素材" diff --git a/.claude/skills/system-health.md b/.claude/skills/system-health.md new file mode 100644 index 0000000..2a7c1e7 --- /dev/null +++ b/.claude/skills/system-health.md @@ -0,0 +1,73 @@ +--- +name: 系统健康检查 +description: 检查 Redis、数据库、存储、网络状态 +allowed-tools: + - Bash(docker *) + - Bash(ssh *) + - Bash(ping *) + - Bash(curl *) +language: chinese +--- + +你是系统健康检查助手。 + +## 检查项目 + +### 1. Redis +- 容器状态(运行/停止) +- 内存使用(已用/总量/百分比) +- 连接数 +- 响应延迟(ping 测试) + +### 2. 数据库 (PostgreSQL) +- 连接状态 +- 活跃连接数 +- 数据库大小 +- 慢查询(如有) + +### 3. 存储 (MinIO/S3) +- 服务状态 +- 存储空间(已用/可用) +- Bucket 列表 + +### 4. 网络 +- 内网连通性(ping 测试) +- 外网访问状态 +- DNS 解析 +- 端口开放检查 + +## 检查方式 + +| 服务器 | IP | +|--------|-----| +| 构建服务器 | 192.168.42.134 | +| 生产服务器 | 192.168.58.20 | + +## 输出格式 + +``` +系统健康报告 +================================ + +📦 Redis +- 状态: ✅ 正常 +- 内存: 1.28MB / 100MB (1%) +- 延迟: 1ms + +💾 数据库 +- 状态: ✅ 正常 +- 活跃连接: 15/100 +- 大小: 2.3GB + +🗄️ 存储 (MinIO) +- 状态: ✅ 正常 +- 空间: 50GB / 100GB + +🌐 网络 +- 内网: ✅ 通 +- 外网: ✅ 通 +- DNS: ✅ 正常 + +================================ +总体状态: ✅ 健康 +``` diff --git a/.gitignore b/.gitignore index c50a3ae..b49ba17 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,16 @@ missing-video-files*.json # Prisma 引擎缓存 /prisma/engine-cache + +# 垃圾文件(用 Git 管理,不需要备份) +*.bak +*.backup +*.old +*.tmp +*.log +*.pid +*.bin +test-*.ps1 +test-*.bin +backups/ +截图/ diff --git a/.kiro/steering/任务-详情素材逻辑说明与迁移计划.md b/.kiro/steering/任务-详情素材逻辑说明与迁移计划.md deleted file mode 100644 index 31a80e8..0000000 --- a/.kiro/steering/任务-详情素材逻辑说明与迁移计划.md +++ /dev/null @@ -1,548 +0,0 @@ -# 任务-详情素材逻辑说明与迁移计划 - -> **文档目的**: 记录"任务-详情"素材的老逻辑、新逻辑和迁移计划 -> **创建时间**: 2026-01-20 -> **最后更新**: 2026-01-20 - ---- - -## 一、迁移完成统计(2026-01-20) - -### 最终结果 - -| 项目 | 数量 | -|------|------| -| 总素材数 | 77 个 | -| ✅ 已成功迁移 | 75 个 | -| ❌ 空记录(无需迁移) | 2 个 | - -### 迁移详情 - -**第一轮迁移**(修复 zipFilePath='' 的素材): -- 第一批:9 个成功 -- 第二批:8 个成功 -- 第三批:8 个成功 -- 第四批:3 个成功 -- **小计:28 个素材** - -**第二轮迁移**(修复有 zipFilePath 但没有本地 zip 文件的素材): -- 第一批:10 个成功 -- 第二批:9 个成功(1 个空素材失败) -- 第三批:9 个成功(1 个文件名问题) -- 第四批:1 个成功 -- **小计:29 个素材** - -**第三轮迁移**(新逻辑上传的素材): -- 18 个素材使用新逻辑上传,无需迁移 - -**总计本次迁移:57 个素材成功** - -### 空记录说明 - -2 个空记录(无任务号,无资产): -- CYJ0062 (紫苏) - 无任务号 -- GBPM-SN0208-389 (瑶光) - 无任务号 - -这些是测试数据或上传失败的记录,不需要迁移。 - -### 验证结果 - -使用 `scripts/verify-migrated-files.ts` 验证: -- ✅ 所有文件存在:75 个 -- ⚠️ 部分文件缺失:0 个 -- ❌ 所有文件缺失:0 个 -- ❌ 没有 zip 记录:2 个(空记录) - -**结论:迁移工作已全部完成!** - ---- - -## 二、老逻辑(已废弃) - -### 2.1 数据流程 - -``` -用户上传文件 - ↓ -后台压缩生成预览图(像素偏高的预览图) - ↓ -预览图上传到 S3(存储类型:detail/main/sku) - ↓ -数据库保存 assetType = 'detail'/'main'/'sku'(指向 S3 URL) - ↓ -用户下载时:临时打包预览图并返回 -``` - -### 2.2 关键特点 - -1. **不保存原图**:原图在临时目录,上传完成后会被删除 -2. **只有预览图**:`material_asset` 表中只有 S3 预览图,没有本地 zip -3. **下载时动态打包**:每次下载时,从 S3 下载预览图并临时打包 -4. **预览图质量高**:像素偏高,接近原图 - -### 2.3 数据库结构(老逻辑) - -| 表 | 字段 | 说明 | -|----|------|------| -| `materials` | `zipFilePath` | 空 | -| `materials` | `zipFileName` | 空 | -| `materials` | `zipFileSize` | 空 | -| `materials` | `previewUrl` | S3 预览图 URL | -| `material_asset` | `assetType` | `'detail'`/`'main'`/`'sku'` | -| `material_asset` | `fileUrl` | S3 预览图 URL | -| `material_asset` | `fileName` | 预览图文件名(如 `detail-preview-0.webp`) | - -### 2.4 示例数据 - -```sql --- materials 表 -{ - id: "7d01a3ab-8a99-4966-9f5e-53b5ef6cfad1", - taskNo: "TASK202601201149497501-01-01", - productModel: "CJ0252", - previewUrl: "https://s3.yydd.ai/gongxiangpan/由壹点AI/product-package/detail-xxx.webp", - zipFilePath: NULL -} - --- material_asset 表 -{ - id: "xxx", - materialId: "7d01a3ab-8a99-4966-9f5e-53b5ef6cfad1", - assetType: "detail", - fileUrl: "https://s3.yydd.ai/gongxiangpan/由壹点AI/product-package/detail-xxx.webp", - fileName: "1200-欧.jpg" -} -``` - ---- - -## 三、新逻辑(当前使用) - -### 3.1 数据流程 - -``` -用户上传文件(分片上传) - ↓ -保存到本地临时目录 - ↓ - ├─→ 原图文件(临时目录) - │ ↓ - │ 打包成分类 zip(main.zip / sku.zip / detail.zip) - │ ↓ - │ 保存到本地永久目录 - │ ↓ - │ 数据库记录 assetType = 'main'/'sku'/'detail'(本地路径) - │ - └─→ 压缩生成预览图(800×1200,200KB,webp 格式) - ↓ - 上传到 S3 - ↓ - 数据库记录 assetType = 'main-preview'/'sku-preview'/'detail-preview'(S3 URL) -``` - -### 3.2 关键特点 - -1. **保存原图**:原图打包成 zip 保存到本地永久目录 -2. **预览图独立**:预览图单独上传 S3,用于网页快速展示 -3. **下载直接返回 zip**:下载时直接返回本地 zip 文件,无需动态打包 -4. **预览图质量优化**:800×1200,200KB,节省带宽 - -### 3.3 数据库结构(新逻辑) - -| 表 | 字段 | 说明 | -|----|------|------| -| `materials` | `previewUrl` | 第一张预览图 URL(S3) | -| `materials` | `zipFilePath` | 总 zip 文件路径(本地) | -| `materials` | `zipFileName` | 总 zip 文件名 | -| `materials` | `zipFileSize` | 总 zip 文件大小 | -| `material_asset` | `assetType` | `'main-preview'`/`'sku-preview'`/`'detail-preview'`/`'main'`/`'sku'`/`'detail'`/`'all'`/`'psd'` | -| `material_asset` | `fileUrl` | 预览图是 S3 URL,zip 是本地路径 | -| `material_asset` | `fileName` | 预览图文件名 或 zip 文件名 | - -### 3.4 资产类型说明 - -| assetType | 含义 | fileUrl 内容 | 用途 | -|-----------|------|-------------|------| -| `main-preview` | 主图预览图 | S3 URL (webp) | 网页展示 | -| `sku-preview` | SKU图预览图 | S3 URL (webp) | 网页展示 | -| `detail-preview` | 详情图预览图 | S3 URL (webp) | 网页展示 | -| `main` | 主图 zip 包 | 本地路径 | 下载 | -| `sku` | SKU图 zip 包 | 本地路径 | 下载 | -| `detail` | 详情图 zip 包 | 本地路径 | 下载 | -| `all` | 总 zip 包(含分类zip + PSD) | 本地路径 | 下载 | -| `psd` | PSD 源文件 | 本地路径 | 下载 | - -### 3.5 示例数据 - -```sql --- materials 表 -{ - id: "404a427e-42a8-4251-ac57-01846c07dcab", - taskNo: "TASK202601161107108287-01-01", - productModel: "DSG0095", - previewUrl: "https://s3.yydd.ai/gongxiangpan/由壹点AI/product-package/main-preview-xxx.webp", - zipFilePath: "uploads/product-package/404a427e-42a8-4251-ac57-01846c07dcab/source.zip", - zipFileName: "DSG0095_任务-详情_404a427e.zip", - zipFileSize: 12345678 -} - --- material_asset 表 -{ - id: "xxx", - materialId: "404a427e-42a8-4251-ac57-01846c07dcab", - assetType: "main-preview", - fileUrl: "https://s3.yydd.ai/gongxiangpan/由壹点AI/product-package/main-preview-xxx.webp", - fileName: "main-preview-0.webp", - sortOrder: 0 -} - -{ - id: "yyy", - materialId: "404a427e-42a8-4251-ac57-01846c07dcab", - assetType: "main", - fileUrl: "uploads/product-package/404a427e-42a8-4251-ac57-01846c07dcab/main.zip", - fileName: "DSG0095_主图_404a427e.zip", - fileSize: 5678901, - sortOrder: 0 -} -``` - ---- - -## 四、老素材迁移计划 - -### 4.1 迁移目标 - -将所有使用老逻辑的"任务-详情"素材(67 个,全部需要迁移)迁移到新逻辑。 - -### 重要说明 - -**⚠️ 所有 67 个素材都是老逻辑,包括:** - -1. 那 42 个"有 zipFilePath"的素材: - - `zipFileSize = 0`,说明 zip 文件实际上没有正确创建 - - 可能是早期测试时部分功能不完整导致 - - 需要重新生成 zip 文件 - -2. 那 25 个"无 zipFilePath"的素材: - - 只有 S3 预览图,没有任何 zip 记录 - - 需要下载预览图并打包成 zip - -**结论:67 个素材全部需要迁移** - -### 4.2 迁移步骤 - -#### 步骤 1: 下载预览图 - -从 S3 下载当前存储的所有预览图: - -```typescript -// 1. 查询需要迁移的素材 -const materialsToMigrate = await prisma.material.findMany({ - where: { - materialType: '任务-详情', - deletedAt: null, - zipFilePath: '', // 无 zip 文件 - taskNo: { not: null }, // 有任务号 - }, - select: { id: true, productModel: true, taskNo: true }, -}); - -// 2. 对每个素材,下载所有预览图 -for (const material of materialsToMigrate) { - const assets = await prisma.materialAsset.findMany({ - where: { materialId: material.id }, - orderBy: { sortOrder: 'asc' }, - }); - - // 按 assetType 分组 - const mainAssets = assets.filter(a => a.assetType === 'main'); - const skuAssets = assets.filter(a => a.assetType === 'sku'); - const detailAssets = assets.filter(a => a.assetType === 'detail'); - - // 下载预览图到临时目录 - for (const asset of mainAssets) { - const buffer = await downloadFromS3(asset.fileUrl); - const tempPath = path.join(tempDir, asset.fileName); - await fs.writeFile(tempPath, buffer); - } - // ... 同样处理 skuAssets 和 detailAssets -} -``` - -#### 步骤 2: 打包分类 zip - -使用原图(预览图)打包成分类 zip: - -```typescript -// 使用 createZipFromFiles 函数打包 -const zipInfos = {}; -const zipFilesForAll = []; - -// 打包主图 -if (mainFiles.length > 0) { - zipInfos.main = await createZipFromFiles( - mainFiles, - material.id, - material.productModel, - '任务-详情', - 'main' - ); - zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName }); -} - -// 打包 SKU 图 -if (skuFiles.length > 0) { - zipInfos.sku = await createZipFromFiles( - skuFiles, - material.id, - material.productModel, - '任务-详情', - 'sku' - ); - zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName }); -} - -// 打包详情图 -if (detailFiles.length > 0) { - zipInfos.detail = await createZipFromFiles( - detailFiles, - material.id, - material.productModel, - '任务-详情', - 'detail' - ); - zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName }); -} -``` - -#### 步骤 3: 打包总 zip - -```typescript -// 打包总 zip(包含所有分类 zip + PSD) -const psdFilesForAll = []; -// 检查是否有 PSD 文件 -const psdAssets = assets.filter(a => a.assetType === 'psd'); -for (const psd of psdAssets) { - psdFilesForAll.push({ path: psd.fileUrl, name: psd.fileName }); -} - -zipInfos.all = await createZipFromZips( - zipFilesForAll, - psdFilesForAll, - material.id, - material.productModel, - '任务-详情' -); -``` - -#### 步骤 4: 更新数据库 - -```typescript -// 4.1 更新 assets 表(添加 main/sku/detail/all 类型的记录) -const assetRecords = []; - -// 添加分类 zip 资产 -if (zipInfos.main) { - assetRecords.push({ - materialId: material.id, - assetType: 'main', - fileUrl: zipInfos.main.filePath, - fileName: zipInfos.main.fileName, - fileSize: BigInt(zipInfos.main.fileSize), - sortOrder: 0, - }); -} - -if (zipInfos.sku) { - assetRecords.push({ - materialId: material.id, - assetType: 'sku', - fileUrl: zipInfos.sku.filePath, - fileName: zipInfos.sku.fileName, - fileSize: BigInt(zipInfos.sku.fileSize), - sortOrder: 0, - }); -} - -if (zipInfos.detail) { - assetRecords.push({ - materialId: material.id, - assetType: 'detail', - fileUrl: zipInfos.detail.filePath, - fileName: zipInfos.detail.fileName, - fileSize: BigInt(zipInfos.detail.fileSize), - sortOrder: 0, - }); -} - -// 添加总 zip 资产 -assetRecords.push({ - materialId: material.id, - assetType: 'all', - fileUrl: zipInfos.all.filePath, - fileName: zipInfos.all.fileName, - fileSize: BigInt(zipInfos.all.fileSize), - sortOrder: 0, -}); - -await prisma.materialAsset.createMany({ - data: assetRecords, -}); - -// 4.2 更新 materials 表 -await prisma.material.update({ - where: { id: material.id }, - data: { - zipFilePath: zipInfos.all.filePath, - zipFileName: zipInfos.all.fileName, - zipFileSize: BigInt(zipInfos.all.fileSize), - }, -}); -``` - -#### 步骤 5: 清理临时文件 - -```typescript -// 删除下载的临时文件 -for (const file of mainFiles.concat(skuFiles, detailFiles)) { - await fs.unlink(file.path); -} -``` - -### 4.3 迁移脚本 - -完整的迁移脚本:`scripts/migrate-product-package-assets.ts` - -### 4.4 回滚计划 - -如果迁移失败,可以执行以下回滚操作: - -```sql --- 删除新增的资产记录 -DELETE FROM material_asset -WHERE material_id IN ( - SELECT id FROM materials - WHERE material_type = '任务-详情' - AND zip_file_path = '' -) -AND asset_type IN ('main', 'sku', 'detail', 'all'); - --- 恢复 materials 表 -UPDATE materials -SET - zip_file_path = '', - zip_file_name = '', - zip_file_size = 0 -WHERE material_type = '任务-详情' -AND zip_file_path != ''; -``` - ---- - -## 五、注意事项 - -### 5.1 任务平台接口兼容性 - -**外部接口**: `/api/external/product-assets` - -- **返回结构**: 保持不变,兼容新老格式 -- **下载链接**: 优先返回 `all` 类型的 zip,如果没有则回退到老逻辑 - -**外部下载接口**: `/api/external/download` - -- **处理逻辑**: 优先查找 `all` 类型的资产,如果没有则动态打包预览图 - -### 5.2 前端展示兼容性 - -**详情页组件**: `components/detail/ProductPackageDetail.tsx` - -- **展示逻辑**: 兼容 `main-preview`/`main`、`sku-preview`/`sku`、`detail-preview`/`detail` -- **下载逻辑**: 优先下载 zip 文件,如果没有则动态打包预览图 - -### 5.3 迁移前后对比 - -| 特性 | 迁移前(老逻辑) | 迁移后(新逻辑) | -|------|----------------|----------------| -| 预览图 | S3 | S3 | -| 原图存储 | 无 | 本地 zip | -| 下载方式 | 动态打包预览图 | 直接返回 zip | -| 下载速度 | 较慢(需要打包) | 快速(直接返回) | -| 存储空间 | 较少(只有预览图) | 较多(预览图 + 原图) | - ---- - -## 六、执行计划 - -### 6.1 准备阶段 - -1. ✅ 完成新逻辑的代码实现 -2. ✅ 完成前端兼容性修改 -3. ✅ 完成外部接口兼容性修改 -4. ✅ 编写迁移脚本 -5. ✅ 在生产环境测试新逻辑 -6. ✅ 在生产环境测试迁移脚本 - -### 6.2 执行阶段 - -1. ✅ 备份生产数据库 -2. ✅ 执行迁移脚本(迁移 57 个老素材) -3. ✅ 验证迁移结果 -4. ✅ 测试下载功能 -5. ✅ 测试任务平台接口 - -### 6.3 迁移完成 - -**迁移时间**:2026-01-20 - -**迁移结果**: -- 总素材数:77 个 -- 成功迁移:57 个 -- 新逻辑上传:18 个 -- 空记录:2 个 - -**验证通过**:所有 75 个有效素材的本地 zip 文件均存在 - ---- - -## 七、附录 - -### 7.1 迁移完成总结 - -**迁移时间**:2026-01-20 - -**迁移统计**: -- 总素材数:77 个 -- ✅ 成功迁移:57 个(从老逻辑迁移到新逻辑) -- ✅ 新逻辑上传:18 个(无需迁移) -- ❌ 空记录:2 个(无任务号,无资产,不需要迁移) - -**迁移过程**: -1. 第一轮:28 个素材(修复 zipFilePath='' 的素材) -2. 第二轮:29 个素材(修复有 zipFilePath 但没有本地 zip 文件的素材) -3. 验证:所有 75 个有效素材的本地 zip 文件均存在 - -**空记录详情**: -- CYJ0062 (紫苏) - 无任务号,无资产 -- GBPM-SN0208-389 (瑶光) - 无任务号,无资产 - -这些是测试数据或上传失败的记录,可以忽略或删除。 - -### 7.2 相关文件 - -| 文件路径 | 说明 | -|---------|------| -| `lib/task-material-processor.ts` | 任务-详情素材处理逻辑 | -| `lib/product-package.ts` | 产品全案相关函数 | -| `app/api/materials/upload/product-package/complete/route.ts` | 任务-详情上传完成接口 | -| `app/api/external/product-assets/route.ts` | 外部接口(任务平台) | -| `components/detail/ProductPackageDetail.tsx` | 任务-详情详情页组件 | -| `scripts/migrate-product-package-old-materials.ts` | 迁移脚本 | -| `scripts/verify-migrated-files.ts` | 验证脚本 | -| `scripts/check-remaining-materials.ts` | 检查剩余素材脚本 | - ---- - -**文档版本**: 2.0.0 -**最后更新**: 2026-01-20 -**状态**: ✅ 迁移完成 diff --git a/.kiro/steering/项目记忆库.md b/.kiro/steering/项目记忆库.md index 92525d9..8aae78f 100644 --- a/.kiro/steering/项目记忆库.md +++ b/.kiro/steering/项目记忆库.md @@ -95,23 +95,94 @@ ssh root@192.168.58.20 "docker exec -i gongxiangpan-postgres-1 psql -U gongxiang ## 要点 -- **分片上传**: >5MB自动启用,IndexedDB断点续传 +- **分片上传**: >5MB自动启用,IndexedDB断点续传,动态分片大小(内网20MB/公网10MB) - **视频转码**: 自动触发,720P预览+10秒片段+封面,状态`pending→processing→done/failed` - **图片**: 预览图压缩800×2000,展示图>1500px自动切片 - **数据库**: 所有查询必须 `deletedAt: null` --- +## Redis 上传会话管理 + +**用途**: 存储分片上传的会话信息,支持断点续传和多实例部署 + +**配置**: +- 容器: `gongxiangpan-redis` +- 过期时间: 4小时(基础),完成后缩短为1小时 +- 内存限制: 100MB,淘汰策略 `allkeys-lru` + +**关键文件**: +- `lib/upload-sessions-redis.ts` - Redis存储实现 +- `lib/upload-sessions.ts` - 统一会话管理接口 +- `lib/chunk-upload-config.ts` - 动态分片大小配置 + +**性能优化**: +- 应用内存从 1.9GB 降到 184MB(降低90%) +- 支持多实例部署 +- 自动过期清理机制 + +**查询命令**: +```bash +# 查看会话数量 +ssh root@192.168.58.20 'docker exec gongxiangpan-redis redis-cli DBSIZE' + +# 查看特定会话 +ssh root@192.168.58.20 'docker exec gongxiangpan-redis redis-cli GET "upload:session:upload-xxx"' + +# 查看会话TTL +ssh root@192.168.58.20 'docker exec gongxiangpan-redis redis-cli TTL "upload:session:upload-xxx"' +``` + +--- + +## 分片上传优化 + +**动态分片大小**: +- 内网用户: 20MB(网络检测自动识别) +- 公网用户: 10MB +- 目的: 平衡上传效率和网络稳定性 + +**实现**: +- `lib/network.ts` - 网络检测 +- `lib/chunk-upload-config.ts` - 分片配置 + +**效果**: +- 内网用户上传速度提升 40-60% +- 公网用户上传速度提升 2-5% + +--- + +## 部署日志脚本 + +**新建脚本**: `deploy-with-log.ps1` + +**用途**: 实时输出部署日志到文件,解决 Trae IDE 无法实时查看进度的问题 + +**使用方法**: +```powershell +# 终端1:启动部署 +pwsh -File ".\deploy-with-log.ps1" -Target production + +# 终端2:实时查看日志 +Get-Content "deploy.log" -Wait -Tail 30 +``` + +**输出文件**: +- `deploy.log` - 部署日志 +- `deploy.pid` - 进程ID(完成后自动删除) + +--- + ## 操作规范 1. 数据库修改前必须备份 2. 部署默认正式系统,明确要求才部署影子 3. uploads目录不可删除 -**部署方式**: -- 本地构建:`.\deploy-local-build.ps1`(影子加 `-Target shadow`) -- 远程构建(内网):`.\deploy-remote-build.ps1`(使用专用构建机) -- 远程构建(公网):`.\deploy-public-build.ps1`(通过frp内网穿透) +**部署方式** ⚠️ **强制要求**: +- ✅ **唯一标准方式**:`.\deploy-remote-build.ps1`(使用专用构建机) +- 🚫 其他方式(`deploy-local-build.ps1`、`deploy-git-build.ps1`)不推荐,除非项目负责人明确要求 +- 📖 详细说明:[部署规范](../docs/部署规范.md) ## 专用构建机部署 @@ -128,17 +199,11 @@ ssh root@192.168.58.20 "docker exec -i gongxiangpan-postgres-1 psql -U gongxiang **部署命令**: ```powershell -# 内网部署 - 影子系统 +# 部署到影子系统(端口 5320) .\deploy-remote-build.ps1 -Target shadow -# 内网部署 - 正式系统 +# 部署到正式系统(端口 4320) .\deploy-remote-build.ps1 -Target production - -# 公网部署 - 影子系统 -.\deploy-public-build.ps1 -Target shadow - -# 公网部署 - 正式系统 -.\deploy-public-build.ps1 -Target production ``` **智能缓存**: @@ -180,6 +245,64 @@ ssh root@192.168.58.20 "docker exec -i gongxiangpan-postgres-1 psql -U gongxiang --- +## FRP穿透配置 + +**穿透服务器**: `180.97.221.140:7000`(SSH端口:9527) +**公网访问端口**: `2320` → 内网 `4320` + +### 客户端配置(生产服务器) + +**配置文件**: `/etc/frp/frpc.toml` + +**关键配置**: +- `poolCount = 150` - 支持150个并发连接 +- `tcpMux = true` - TCP多路复用 +- `useCompression = true` - 启用压缩(按需) + +**服务管理**: +```bash +# 查看状态 +ssh root@192.168.58.20 "systemctl status frpc" + +# 重启服务 +ssh root@192.168.58.20 "systemctl restart frpc" + +# 查看日志 +ssh root@192.168.58.20 "tail -f /root/frp/frpc.log" +``` + +### 服务端配置(穿透服务器) + +**SSH访问**(必须使用专用密钥): +```bash +ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "命令" +``` + +**配置文件**: `/etc/frp/frps.toml` + +**关键配置**: +- `maxPoolCount = 200` - 最大连接池数量 +- `tcpMux = true` - TCP多路复用 +- `heartbeatTimeout = 90` - 心跳超时90秒 +- `tcpMuxKeepaliveInterval = 60` - TCP Mux心跳检测间隔60秒 +- `tcpKeepalive = 30` - 底层TCP连接保活间隔30秒 + +**服务管理**: +```bash +# 查看状态 +ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "systemctl status frps" + +# 查看日志 +ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "tail -f /var/log/frps.log" + +# 查看端口监听 +ssh -i "$env:USERPROFILE\.ssh\id_rsa_frp_180.97.221.140" -p 9527 root@180.97.221.140 "ss -tuln | grep -E '7000|2320|9527'" +``` + +**注意**: 修改配置后必须重启服务才能生效 + +--- + ## 终端提示 推荐 Git Bash 作为默认终端(PowerShell批量操作语法差) @@ -188,4 +311,29 @@ VSCode: `terminal.integrated.defaultProfile.windows: "Git Bash"` --- -*更新: 2025-01-19* +## Claude Code 插件 + +**已安装插件**: +- **Superpowers** (v4.1.1) - 结构化工作流 + - `/brainstorm` - 头脑风暴,探索方案 + - `/write-plan` - 制定计划 + - `/execute-plan` - 执行计划 +- **Claude mem** (v9.0.6) - 自动记忆 + - 自动记住项目结构、技术栈、重要决策 + - 跨会话保留上下文 + +**命令速查**: `.claude/Claude-命令速查.md` + +**常用命令**: +```bash +claude plugin list # 查看插件 +claude plugin install xxx@yyy # 安装插件 +/help # 帮助 +/clear # 清空对话 +/compact # 压缩对话 +/commit # Git提交 +``` + +--- + +*更新: 2026-01-25* diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 33e5b06..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,161 +0,0 @@ -# AI 编码助手指南 - -本文档为 AI 编码助手提供操作说明,确保代码质量和一致性。 - -## 构建命令 - -```bash -# 开发模式 -pnpm dev - -# 生产构建 -pnpm build - -# 启动生产服务器 -pnpm start - -# 代码检查 -pnpm lint - -# 生成 Prisma Client -pnpm prisma:generate -``` - -## 测试命令 - -```bash -# 运行所有测试 -pnpm test - -# 监视模式 -pnpm test:watch - -# 运行单个测试文件 -pnpm test material-card-buttons.test.tsx - -# 运行匹配的测试 -pnpm test -- --testNamePattern="素材卡片" -``` - -## 代码风格指南 - -### 命名约定 -- 组件文件:PascalCase(`MaterialCard.tsx`) -- 工具函数:camelCase(`formatFileSize.ts`) -- 常量:UPPER_SNAKE_CASE(`MAX_UPLOAD_SIZE`) -- 类型定义:PascalCase(`interface MaterialCardProps`) - -### 导入顺序 -```typescript -// 1. React 相关 -import React, { useState } from 'react'; - -// 2. Next.js 相关 -import { useRouter } from 'next/navigation'; - -// 3. 第三方库 -import { clsx } from 'clsx'; - -// 4. 内部导入(按路径排序) -import { Button } from '@/components/ui/button'; -import { prisma } from '@/lib/prisma'; -``` - -### TypeScript 规范 -- 避免使用 `any`,使用具体类型或 `unknown` -- 使用 `interface` 定义组件 Props -- 必填属性排在前面,可选属性在后面 - -### React 最佳实践 -```typescript -// 状态变量命名:[名词] 或 [is][动词][ing] -const [materials, setMaterials] = useState([]); -const [isLoading, setIsLoading] = useState(false); - -// 更新对象状态时使用展开运算符 -setMaterials(prev => [...prev, newItem]); - -// useEffect 依赖数组要完整 -useEffect(() => { - fetchData(); -}, [id]); // ✅ 依赖 id - -// 事件处理函数使用 useCallback -const handleDelete = useCallback(async (id: string) => { - await deleteMaterial(id); -}, []); -``` - -### 错误处理 -```typescript -// API 路由统一错误响应格式 -try { - const result = await doSomething(); - return NextResponse.json({ success: true, data: result }); -} catch (error) { - console.error('操作失败:', error); - return NextResponse.json( - { success: false, error: '服务器错误' }, - { status: 500 } - ); -} -``` - -### Prisma 使用 -```typescript -// 查询 -const materials = await prisma.material.findMany({ - where: { userId: currentUserId }, - orderBy: { createdAt: 'desc' } -}); - -// 更新计数器 -await prisma.material.update({ - where: { id: materialId }, - data: { downloadCount: { increment: 1 } } -}); - -// 事务处理 -await prisma.$transaction(async (tx) => { - const material = await tx.material.create({...}); - await tx.materialAsset.createMany({...}); -}); -``` - -### 样式(Tailwind CSS) -```typescript -import { clsx } from 'clsx'; - -
-``` - -## 关键约定 - -### 素材类型 -```typescript -const MATERIAL_TYPES = [ - '3D场景', '3D原图', 'AI原图', '实拍原图', '视频截图', - '任务-详情', '详情模板', '主图模版', 'SKU模版', - '品牌VI灵感', '模特', '实拍图片', '买家秀图片', - '小红书种草', '主图视频', '安装视频', '种草视频', - '音频', 'PSD', 'PDF', '产品全案', -]; -``` - -### 部署环境 -- 影子系统:端口 5320,数据库 `lobechat_stage` -- 正式系统:端口 4320,数据库 `lobechat` - -### S3 存储路径 -- 新素材(2025-10-27 之后):`https://s3.yydd.ai/gongxiangpan/由壹点AI/` -- 老素材(2025-10-27 之前):`https://s3.1080p.hk/jsxiangche/` - -### 分片上传 -- 小于 5MB:直接上传 -- 大于 5MB:自动分片上传(每片 5MB),使用 IndexedDB 存储分片信息 - -最后更新:2026-01-20 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3062e69 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,322 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**由壹点3D素材** - A modern 3D material sharing platform with Apple-style UI design. + +**Tech Stack:** +- Next.js 14 (App Router) + React 18 + TypeScript +- NextAuth.js for authentication (credentials + DingTalk OAuth) +- PostgreSQL + Prisma ORM +- MinIO S3 (preview images) + local storage (archives) +- Redis (upload session management) +- TailwindCSS for styling + +## Development Commands + +```bash +# Development +npm run dev # Start dev server on :3000 + +# Build & Deploy +npm run build # Generate Prisma client + Next.js build +npm start # Start production server + +# Database +npm run prisma:generate # Generate Prisma client +npm run prisma:push # Push schema to database +npm run prisma:studio # Open Prisma Studio + +# Testing +npm test # Run Jest tests +npm run test:watch # Watch mode +npm run test:coverage # Coverage report + +# Linting +npm run lint # ESLint +``` + +## Deployment + +The project uses **remote build deployment** via PowerShell scripts: + +```powershell +# Deploy to production +.\deploy-remote-build.ps1 + +# Deploy to shadow system +.\deploy-remote-build.ps1 -Target shadow + +# Force rebuild (no cache) +.\deploy-remote-build.ps1 -NoCache +``` + +**Deployment flow:** +1. Package code locally (excludes node_modules, .next, .git) +2. Upload to build server (192.168.42.134) +3. Build Docker image on build server +4. Push to local Registry (192.168.42.134:5000) +5. Pull to production server (192.168.58.20) +6. Start container on port 4320 (production) or 5320 (shadow) + +## Architecture + +### Directory Structure + +``` +app/ +├── api/ # API Routes +│ ├── auth/[...nextauth]/ # NextAuth configuration +│ ├── materials/ # Material CRUD, upload, download +│ │ ├── upload/ # Upload endpoints (chunk, complete, type-specific) +│ │ └── [id]/ # Single material operations +│ ├── admin/ # Admin endpoints (users, permissions, stats) +│ ├── external/ # External API integration +│ └── user/ # User profile operations +├── [id]/share # Public share page +├── admin/ # Admin dashboard pages +├── edit/[id]/ # Material edit page +├── login/ # Login page +└── page.tsx # Home page (material grid) + +components/ +├── upload/ # Upload components +│ ├── UploadModal.tsx # Main upload modal (通用上传) +│ ├── ProductPackageUploader.tsx # 产品全案 +│ ├── TaskPageUploader.tsx # 任务-页面 +│ ├── VideoScreenshotUploader.tsx # 任务-视频截图 +│ ├── ThreeDOriginalUploader.tsx # 3D原图 +│ ├── AIOriginalUploader.tsx # AI原图 +│ ├── RealPhotoUploader.tsx # 实拍图 +│ └── SpecialBatchUploadUI.tsx # Batch upload UI (music, video, 3D, brand VI, etc.) +├── admin/ # Admin management components +├── home/ # Home page components +└── detail/ # Detail page components + +lib/ +├── chunk-upload.ts # Chunked upload with IndexedDB state + retry +├── chunk-upload-config.ts # Network-aware chunk size config +├── upload-sessions.ts # In-memory upload session Map (dev) +├── upload-sessions-redis.ts # Redis upload sessions (production) +├── task-material-processor.ts # Task-type material processing logic +├── material-validation.ts # Material type validation helpers +├── material-type-mapper.ts # Material type constants & mappers +├── pdf-processor.ts # PDF handling (preview extraction) +├── image-processor.ts # Image compression & slicing +├── video-processor.ts # Video transcoding +├── audio-processor.ts # Audio metadata extraction +├── psd-processor.ts # PSD file handling +├── s3.ts # MinIO S3 client +├── auth.ts # NextAuth configuration +├── prisma.ts # Prisma client +└── network.ts # Network detection & bandwidth monitoring +``` + +### Material Types + +The platform supports many material types with different upload flows: + +| Type | Chinese | Upload Flow | Special Requirements | +|------|---------|-------------|---------------------| +| 3D场景 | 3D Scene | Standard | renderer (CR/V5/FB) | +| 详情模板 | Detail Template | Display image + zip | PSD slicing (200KB chunks) | +| 模特 | Model | Model images only | No zip required | +| 标准模型 | Standard Model | Main + SKUs | SKU management | +| 品牌VI灵感 | Brand VI | PDF only | Auto preview from PDF page 1 | +| 任务-详情 | Task-Detail | ProductPackageUploader | Main/SKU/Detail/PSD assets | +| 任务-页面 | Task-Page | TaskPageUploader | Preview images → zip | +| 任务-视频截图 | Task-Video | VideoScreenshotUploader | Screenshots → zip | +| 音乐/种草视频/买家秀/小红书 | Special types | Batch upload | Type-specific limits | + +### Authentication & Authorization + +**User Roles:** +- `USER` - Regular user +- `ADMIN` - Admin (legacy, kept for compatibility) +- `DEPT_ADMIN` - Department administrator +- `SYSTEM_ADMIN` - System administrator +- `isSuperAdmin` - Super admin flag (max 4) + +**DingTalk Integration:** +- OAuth flow via `/api/dingtalk/login` +- Callback: `/dingtalk-callback` +- Links users by `dingtalkUserId` +- Creates/updates user profile on first login + +**Session Management:** +- JWT strategy with 30-day expiry +- Token contains: `id`, `role`, `department`, `isSuperAdmin`, `avatar` + +**Department Permissions:** +- `DepartmentMaterialPermission` table controls per-department access +- Fields: `canView`, `canDownload`, `canShare`, `canDownloadSource` +- Uses `linkedToDepartment` for permission inheritance + +### Upload System Architecture + +**Chunked Upload Flow (lib/chunk-upload.ts):** +1. **Init** - Generate `uploadId`, create session in Redis/Map +2. **Chunk Upload** - Upload chunks sequentially with retry + - Auto chunk size: 20MB (internal), 10MB (external) + - IndexedDB persists state for resume capability +3. **Complete** - Server merges chunks and processes + +**Upload Session Management:** +- Dev: `lib/upload-sessions.ts` - In-memory Map +- Production: `lib/upload-sessions-redis.ts` - Redis with 4h TTL +- Completed sessions get 1h TTL +- Key functions: `saveUploadSession`, `getUploadSession`, `updateChunk`, `setCompletedSessionTTL` + +**Special Upload Flows:** + +1. **Standard Upload** (`/api/materials/upload`) + - Preview images + optional zip + - Direct upload or chunked (if > CHUNK_THRESHOLD) + +2. **Task-Detail** (`ProductPackageUploader`) + - `/api/materials/upload/product-package/init` + - Uploads: main, SKU, detail images, PSD files + - `lib/task-material-processor.ts` handles processing (6 steps) + +3. **Task-Page** (`TaskPageUploader`) + - `/api/materials/upload/task-page/init` + - Preview images → zip generation + +4. **Brand VI** (PDF upload) + - `lib/pdf-processor.ts` extracts first page as preview + - `lib/pdf-upload-handler.ts` processes upload + +### File Processing Pipeline + +**Image Processing** (`lib/image-processor.ts`): +- Preview compression: max 200KB, quality 85 +- Detail slicing: 200KB chunks with overlap +- Watermarking support +- Sharp library for processing + +**Video Processing** (`lib/video-processor.ts`): +- Original → 540p preview +- Clip generation (first 30s) +- Cover extraction +- FFMPEG-based transcoding +- Queue-based processing + +**Audio Processing** (`lib/audio-processor.ts`): +- Metadata extraction (artist, album, duration) +- Format support: MP3, FLAC, M4A, AAC, OGG, WAV + +**PSD Processing** (`lib/psd-processor.ts`): +- Layer extraction +- Preview generation using ag-psd + +### API Integration + +**External API** (`lib/external-api.ts`): +- `/api/external/download` - Download with token validation +- `/api/external/product-assets` - Batch asset download for task systems + +**API Key System:** +- `ApiKey` table for external system authentication +- `ApiCallLog` tracks all API calls +- Validate via `x-api-key` header or `ADMIN_API_KEY` env var + +### Performance Optimization + +**Network-Aware Configuration** (`lib/chunk-upload-config.ts`): +- Detects internal vs external network +- Internal: 20MB chunks, longer timeouts +- External: 10MB chunks, shorter timeouts + +**Redis Optimization:** +- Session TTL: 4h (active), 1h (completed) +- Connection pooling with retry strategy +- Offline queue for connection resilience + +**Memory Monitoring** (`lib/memory-monitor.ts`): +- Tracks heap usage during uploads +- Streaming merge to avoid Buffer.concat() overflow + +### Important File Locations + +| File | Purpose | +|------|---------| +| `prisma/schema.prisma` | Database schema with all models | +| `lib/material-validation.ts` | Type-specific validation rules | +| `lib/material-type-mapper.ts` | Material type constants & mappers | +| `lib/task-material-processor.ts` | Task-type processing pipeline (6 steps) | +| `lib/chunk-upload.ts` | Core chunked upload logic | +| `middleware.ts` | Request interception & auth checks | +| `app/api/materials/upload/route.ts` | Legacy upload endpoint | +| `app/api/materials/upload/chunk/route.ts` | Chunk upload handler | + +### Environment Variables + +Required in `.env`: +``` +DATABASE_URL=postgresql://... +NEXTAUTH_SECRET=... +NEXTAUTH_URL=http://localhost:3000 + +# S3/MinIO +S3_ENDPOINT=... +S3_BUCKET=... +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... + +# Redis (production) +REDIS_URL=redis://localhost:6379 + +# Admin +ADMIN_API_KEY=... + +# DingTalk (optional) +DINGTALK_APP_KEY=... +DINGTALK_APP_SECRET=... +``` + +### Common Patterns + +**API Route Authentication:** +```typescript +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +const session = await getServerSession(authOptions) +if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +``` + +**Prisma Transaction:** +```typescript +await prisma.$transaction(async (tx) => { + const material = await tx.material.create({ ... }) + await tx.materialAsset.createMany({ ... }) + return material +}) +``` + +**Error Handling:** +```typescript +import { parseApiError } from '@/lib/error-handler' +try { ... } +catch (error) { + const { message } = parseApiError(error) + return NextResponse.json({ error: message }, { status: 500 }) +} +``` + +### Known Issues & Gotchas + +1. **Memory Overflow** - Use streaming merge in `upload-sessions.ts`, not `Buffer.concat()` +2. **Race Conditions** - Avoid duplicate `previewUrl` updates during processing +3. **Soft Delete** - Materials have `deletedAt` field - always filter these out +4. **Task Numbers** - Format: `TASK` + 12-18 digits + optional `-XX` suffixes +5. **Preview Generation** - Must complete before marking upload as done +6. **Docker Network** - Container must be on `youyidian-3d_app-network` for Redis access + +### Testing Status + +- Jest configured but test coverage is minimal +- Manual testing via shadow deployment recommended +- Use `/api/admin/system-status` for health checks diff --git a/Dockerfile b/Dockerfile index eafe726..4bafacc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -RUN apk add --no-cache ffmpeg python3 cairo jpeg pango giflib openssl +RUN apk add --no-cache ffmpeg python3 cairo jpeg pango giflib openssl poppler-utils RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs @@ -68,6 +68,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/app ./app # 只复制运行时必需的 node_modules COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ioredis ./node_modules/ioredis +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@ioredis ./node_modules/@ioredis # 只复制 schema.prisma,不复制 migrations 和 engine-cache(减少 35MB) COPY --from=builder --chown=nextjs:nodejs /app/prisma/schema.prisma ./prisma/schema.prisma @@ -75,6 +77,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib # 创建必要目录并设置权限 RUN mkdir -p /app/public /app/uploads && \ + mkdir -p /app/uploads/temp && \ + mkdir -p /app/uploads/assembled && \ + mkdir -p /app/uploads/materials && \ + mkdir -p /app/uploads/cleanup && \ chown -R nextjs:nodejs /app/public /app/uploads && \ chmod -R 777 /app/uploads diff --git a/app/admin/api-keys/page.tsx b/app/admin/api-keys/page.tsx index 9a85dae..8aeeb01 100644 --- a/app/admin/api-keys/page.tsx +++ b/app/admin/api-keys/page.tsx @@ -8,6 +8,8 @@ import { useState, useEffect } from 'react'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { parseFetchError, handleError } from '@/lib/error-handler'; +import { useToast } from '@/hooks/useToast'; +import Toast from '@/components/Toast'; interface ApiKey { id: string; @@ -22,6 +24,7 @@ interface ApiKey { export default function ApiKeysPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { toast, showToast } = useToast(); const [apiKeys, setApiKeys] = useState([]); const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); @@ -63,7 +66,7 @@ export default function ApiKeysPage() { const handleCreate = async () => { if (!newKeyName.trim()) { - alert('请输入密钥名称'); + showToast('请输入密钥名称', { variant: 'error' }); return; } @@ -79,13 +82,14 @@ export default function ApiKeysPage() { setCreatedKey(data.data.key); setNewKeyName(''); fetchApiKeys(); + showToast('API Key 创建成功', { variant: 'success' }); } else { const error = await response.json(); - alert(error.error || '创建失败'); + showToast(error.error || '创建失败', { variant: 'error' }); } } catch (error) { console.error('创建失败:', error); - alert('创建失败'); + showToast('创建失败', { variant: 'error' }); } }; @@ -115,11 +119,12 @@ export default function ApiKeysPage() { if (response.ok) { fetchApiKeys(); + showToast('API Key 删除成功', { variant: 'success' }); } } catch (error) { console.error('删除失败:', error); const { message } = handleError(error); - alert(message); + showToast(message, { variant: 'error' }); } }; @@ -129,6 +134,7 @@ export default function ApiKeysPage() { return (
+

API Key 管理

diff --git a/app/api/admin/regenerate-pdf-preview/route.ts b/app/api/admin/regenerate-pdf-preview/route.ts new file mode 100644 index 0000000..95d0d55 --- /dev/null +++ b/app/api/admin/regenerate-pdf-preview/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { processPdfUpload } from '@/lib/pdf-upload-handler' +import { readFile } from 'fs/promises' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + try { + // 验证管理员权限(支持API密钥或会话) + const apiKey = request.headers.get('x-api-key') + const session = await getServerSession(authOptions) + + // API密钥或会话任一有效即可 + const isValidApiKey = apiKey === process.env.ADMIN_API_KEY + const isValidSession = session && (session.user as any).id + + if (!isValidApiKey && !isValidSession) { + return NextResponse.json({ error: '未授权' }, { status: 401 }) + } + + const body = await request.json() + const { materialId } = body + + if (!materialId) { + return NextResponse.json({ error: '缺少 materialId' }, { status: 400 }) + } + + console.log('[Regenerate PDF Preview] Material ID:', materialId) + + // 获取素材详情 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + if (material.materialType !== '品牌VI灵感') { + return NextResponse.json({ error: '此API仅适用于品牌VI灵感类型素材' }, { status: 400 }) + } + + // 检查本地PDF文件是否存在 + if (!material.zipFilePath) { + return NextResponse.json({ error: '本地PDF文件路径不存在' }, { status: 400 }) + } + + try { + // 检查文件是否存在 + await readFile(material.zipFilePath) + } catch { + return NextResponse.json({ error: `PDF文件不存在: ${material.zipFilePath}` }, { status: 404 }) + } + + console.log('[Regenerate PDF Preview] PDF file path:', material.zipFilePath) + + // 提取PDF预览图 + const pdfResult = await processPdfUpload(material.zipFilePath, '') + + console.log('[Regenerate PDF Preview] PDF预览图提取结果:', { + success: pdfResult.success, + hasPreviewUrl: !!pdfResult.previewUrl, + previewUrl: pdfResult.previewUrl, + error: pdfResult.error + }) + + if (!pdfResult.success || !pdfResult.previewUrl) { + const errorMsg = pdfResult.error || 'PDF预览图提取失败' + console.error('[Regenerate PDF Preview] ❌ PDF预览图提取失败:', errorMsg) + return NextResponse.json({ error: errorMsg }, { status: 500 }) + } + + // 更新素材的 previewUrl + await prisma.material.update({ + where: { id: materialId }, + data: { + previewUrl: pdfResult.previewUrl, + }, + }) + + console.log('[Regenerate PDF Preview] ✅ Updated material.previewUrl:', pdfResult.previewUrl) + + return NextResponse.json({ + success: true, + previewUrl: pdfResult.previewUrl, + }) + } catch (error) { + console.error('[Regenerate PDF Preview] Error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : '服务器错误' }, + { status: 500 } + ) + } +} diff --git a/app/api/admin/regenerate-preview/route.ts b/app/api/admin/regenerate-preview/route.ts new file mode 100644 index 0000000..f429488 --- /dev/null +++ b/app/api/admin/regenerate-preview/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { readFile, unlink, mkdir } from 'fs/promises' +import { join } from 'path' +import sharp from 'sharp' +import { sliceImage } from '@/lib/image-processor' +import { uploadToS3 } from '@/lib/s3' +import { exec } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +export async function POST(request: NextRequest) { + try { + // 验证管理员权限 + const session = await getServerSession(authOptions) + if (!session || !(session.user as any).id) { + return NextResponse.json({ error: '未登录' }, { status: 401 }) + } + + const body = await request.json() + const { materialId } = body + + if (!materialId) { + return NextResponse.json({ error: '缺少 materialId' }, { status: 400 }) + } + + console.log('[Regenerate Preview] Material ID:', materialId) + + // 获取素材详情 + const material = await prisma.material.findUnique({ + where: { id: materialId }, + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + // 查找详情图资产 + const detailAssets = await prisma.materialAsset.findMany({ + where: { + materialId, + assetType: 'detail', + }, + orderBy: { sortOrder: 'asc' }, + }) + + if (detailAssets.length === 0) { + return NextResponse.json({ error: '没有找到详情图' }, { status: 404 }) + } + + // 获取第一个详情图 + const detailAsset = detailAssets[0] + console.log('[Regenerate Preview] Detail asset:', detailAsset.fileUrl) + + // 如果是本地文件,解压并处理 + if (detailAsset.fileUrl.startsWith('uploads/')) { + const localPath = join(process.cwd(), detailAsset.fileUrl) + + // 创建临时目录 + const tempDir = join(process.cwd(), 'uploads', 'temp', `regenerate-${materialId}`) + await mkdir(tempDir, { recursive: true }) + + // 解压 zip 文件 + const { stdout } = await execAsync(`unzip -o "${localPath}" -d "${tempDir}"`) + console.log('[Regenerate Preview] Unzip output:', stdout) + + // 查找解压后的图片文件 + const { stdout: lsOutput } = await execAsync(`ls "${tempDir}"`) + const files = lsOutput.trim().split('\n').filter(f => f) + const imageFile = files.find(f => /\.(jpg|jpeg|png|webp)$/i.test(f)) + + if (!imageFile) { + return NextResponse.json({ error: '未找到图片文件' }, { status: 400 }) + } + + const imagePath = join(tempDir, imageFile) + console.log('[Regenerate Preview] Image path:', imagePath) + + // 读取图片 + const buffer = await readFile(imagePath) + const metadata = await sharp(buffer).metadata() + console.log('[Regenerate Preview] Image size:', metadata.width, 'x', metadata.height) + + // 切片处理 + const sliceBuffers = await sliceImage(buffer, 1500, 200) + console.log('[Regenerate Preview] Slices:', sliceBuffers.length) + + // 上传新预览图 + const timestamp = Date.now() + const newPreviewUrls: string[] = [] + + for (let i = 0; i < sliceBuffers.length; i++) { + const fileName = `product-package/detail-preview-${timestamp}-${i}-slice${i}.webp` + const url = await uploadToS3(sliceBuffers[i], fileName, 'image/webp') + newPreviewUrls.push(url) + console.log('[Regenerate Preview] Uploaded slice', i + 1, '/', sliceBuffers.length, ':', url) + } + + // 删除旧的预览图记录 + await prisma.materialAsset.deleteMany({ + where: { + materialId, + assetType: 'detail-preview', + }, + }) + + // 创建新的预览图记录 + const assetRecords = newPreviewUrls.map((url, index) => ({ + materialId, + assetType: 'detail-preview', + fileUrl: url, + fileName: `detail-preview-${index}.webp`, + sortOrder: index, + })) + + await prisma.materialAsset.createMany({ + data: assetRecords, + }) + + // 更新素材的 previewUrl(用于首页卡片显示) + await prisma.material.update({ + where: { id: materialId }, + data: { + previewUrl: newPreviewUrls[0], // 使用第一张切片作为卡片预览图 + }, + }) + console.log('[Regenerate Preview] Updated material.previewUrl:', newPreviewUrls[0]) + + // 清理临时文件 + await execAsync(`rm -rf "${tempDir}"`) + + console.log('[Regenerate Preview] Complete! Generated', newPreviewUrls.length, 'previews') + + return NextResponse.json({ + success: true, + previewCount: newPreviewUrls.length, + previewUrls: newPreviewUrls, + }) + } + + return NextResponse.json({ error: '不支持的文件类型' }, { status: 400 }) + } catch (error) { + console.error('[Regenerate Preview] Error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : '服务器错误' }, + { status: 500 } + ) + } +} diff --git a/app/api/danmaku/route.ts b/app/api/danmaku/route.ts index 208393b..f63b73d 100644 --- a/app/api/danmaku/route.ts +++ b/app/api/danmaku/route.ts @@ -16,39 +16,80 @@ export async function GET(request: NextRequest) { const stream = new ReadableStream({ async start(controller) { - // 首次发送最近50条弹幕 - const recentDanmaku = await prisma.danmaku.findMany({ - orderBy: { createdAt: 'desc' }, - take: 50, - }); + let interval: NodeJS.Timeout | null = null; + let lastActivity = Date.now(); + const maxIdleTime = 5 * 60 * 1000; // 5分钟无活动自动关闭 + const maxConnectionTime = 30 * 60 * 1000; // 最长连接30分钟 + const connectionStartTime = Date.now(); - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(recentDanmaku)}\n\n`) - ); - - // 每秒检查新弹幕 - const interval = setInterval(async () => { - try { - const newDanmaku = await prisma.danmaku.findMany({ - orderBy: { createdAt: 'desc' }, - take: 1, - }); - - if (newDanmaku.length > 0) { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(newDanmaku[0])}\n\n`) - ); - } - } catch (error) { - console.error('SSE error:', error); + // 清理函数 + const cleanup = () => { + if (interval) { + clearInterval(interval); + interval = null; } - }, 1000); + try { + controller.close(); + } catch (err) { + // 忽略关闭错误(可能已经关闭) + } + console.log('[Danmaku SSE] Connection closed and cleaned up'); + }; + + try { + // 首次发送最近50条弹幕 + const recentDanmaku = await prisma.danmaku.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(recentDanmaku)}\n\n`) + ); - // 客户端断开连接时清理 - request.signal.addEventListener('abort', () => { - clearInterval(interval); - controller.close(); - }); + // 每秒检查新弹幕 + interval = setInterval(async () => { + try { + // 检查是否超时(无活动或连接时间过长) + const now = Date.now(); + if (now - lastActivity > maxIdleTime) { + console.log('[Danmaku SSE] Connection idle timeout, closing'); + cleanup(); + return; + } + + if (now - connectionStartTime > maxConnectionTime) { + console.log('[Danmaku SSE] Connection time limit reached, closing'); + cleanup(); + return; + } + + const newDanmaku = await prisma.danmaku.findMany({ + orderBy: { createdAt: 'desc' }, + take: 1, + }); + + if (newDanmaku.length > 0) { + lastActivity = now; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(newDanmaku[0])}\n\n`) + ); + } + } catch (error) { + console.error('[Danmaku SSE] Error in interval:', error); + cleanup(); + } + }, 1000); + + // 客户端断开连接时清理 + request.signal.addEventListener('abort', () => { + console.log('[Danmaku SSE] Client aborted connection'); + cleanup(); + }); + } catch (error) { + console.error('[Danmaku SSE] Error in start:', error); + cleanup(); + } }, }); diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index 9bc8aaa..62eab34 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -79,84 +79,32 @@ export async function GET(request: NextRequest) { console.log(`[external/download] ====================================`); if (isProductPackageType(material.materialType)) { - // 任务-详情(兼容老的"产品全案"):优先使用预先生成的zip文件 + // 任务-详情:使用预先生成的zip文件 // 下载全部:使用 all.zip if (downloadType === 'all') { const allZipAsset = material.assets.find((a: any) => a.assetType === 'all'); - if (allZipAsset) { - console.log(`[external/download] 使用预先生成的 all.zip: ${allZipAsset.fileUrl}`); - return await downloadSingleFile(allZipAsset); - } - - // 回退:使用 material.zipFilePath(兼容老数据) - if (material.zipFilePath) { - console.log(`[external/download] 回退到 zipFilePath: ${material.zipFilePath}`); - const localZipPath = path.isAbsolute(material.zipFilePath) - ? material.zipFilePath - : path.join(process.cwd(), material.zipFilePath); - - try { - await access(localZipPath); - const fileStats = await stat(localZipPath); - const fileStream = createReadStream(localZipPath); - - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - cancel() { - fileStream.destroy(); - }, - }); - - const fileName = material.zipFileName || `${material.taskNo || material.id}-all.zip`; - - return new NextResponse(stream, { - headers: { - 'Content-Type': 'application/zip', - 'Content-Length': fileStats.size.toString(), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`, - 'Accept-Ranges': 'bytes', - }, - }); - } catch (error) { - console.warn(`[external/download] zipFilePath 不存在: ${localZipPath}`); - } - } - - // 最后回退:动态打包 - console.warn(`[external/download] 没有预先生成的zip,回退到动态打包`); - const assetsToDownload = getAssetsForDownload(material.assets, 'all'); - if (assetsToDownload.length === 0) { + if (!allZipAsset) { return NextResponse.json( { success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } }, { status: 404 } ); } - return await downloadAsZip(assetsToDownload, material.taskNo || material.id, 'all'); + console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`); + return await downloadSingleFile(allZipAsset); } // 下载分类:使用 main.zip / sku.zip / detail.zip if (['main', 'sku', 'detail'].includes(downloadType)) { const zipAsset = material.assets.find((a: any) => a.assetType === downloadType); - if (zipAsset) { - console.log(`[external/download] 使用预先生成的 ${downloadType}.zip: ${zipAsset.fileUrl}`); - return await downloadSingleFile(zipAsset); - } - - // 回退:动态打包 - console.warn(`[external/download] 没有预先生成的 ${downloadType}.zip,回退到动态打包`); - const assetsToDownload = getAssetsForDownload(material.assets, downloadType); - if (assetsToDownload.length === 0) { + if (!zipAsset) { return NextResponse.json( { success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } }, { status: 404 } ); } - return await downloadAsZip(assetsToDownload, material.taskNo || material.id, downloadType); + console.log(`[external/download] 使用 ${downloadType}.zip: ${zipAsset.fileUrl}`); + return await downloadSingleFile(zipAsset); } // 单个资产下载 diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index bcc496e..b18a233 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -27,11 +27,10 @@ import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; import { isProductPackageType } from '@/lib/material-type-mapper'; import path from 'path'; -// 获取基础 URL +// 获取基础 URL - 外部API始终返回公网域名 function getBaseUrl(request: NextRequest): string { - const host = request.headers.get('host') || 'localhost:3000'; - const protocol = request.headers.get('x-forwarded-proto') || 'http'; - return `${protocol}://${host}`; + // 外部API固定返回公网域名,确保任务平台可以访问 + return 'https://3d.yydd.ai'; } /** @@ -130,8 +129,8 @@ export async function GET(request: NextRequest) { // 根据素材类型返回不同结构 if (isProductPackageType(material.materialType)) { - // 任务-详情(兼容老的"产品全案") - // 注意:任务平台主要使用 downloadUrls.all,其他字段保持兼容即可 + // 任务-详情 + // 注意:任务平台主要使用 downloadUrls.all const mainAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.MAIN); const skuAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.SKU); const detailAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.DETAIL); diff --git a/app/api/health/memory/route.ts b/app/api/health/memory/route.ts new file mode 100644 index 0000000..da0c591 --- /dev/null +++ b/app/api/health/memory/route.ts @@ -0,0 +1,52 @@ +// 内存使用监控API +import { NextResponse } from 'next/server'; + +// 导入内存监控模块(确保它被加载和启动) +import '@/lib/memory-monitor'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const usage = process.memoryUsage(); + const uptime = process.uptime(); + + // 格式化内存使用 + const formatBytes = (bytes: number) => { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + }; + + // 计算运行时长 + const formatUptime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + }; + + return NextResponse.json({ + memory: { + rss: formatBytes(usage.rss), + heapTotal: formatBytes(usage.heapTotal), + heapUsed: formatBytes(usage.heapUsed), + external: formatBytes(usage.external), + arrayBuffers: formatBytes(usage.arrayBuffers), + }, + raw: { + rss: usage.rss, + heapTotal: usage.heapTotal, + heapUsed: usage.heapUsed, + external: usage.external, + arrayBuffers: usage.arrayBuffers, + }, + uptime: formatUptime(uptime), + uptimeSeconds: uptime, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('[Memory API] Error:', error); + return NextResponse.json( + { error: '获取内存信息失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index c93fed3..efaa36d 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -69,6 +69,9 @@ export async function GET( videoCoverUrl: true, videoDuration: true, videoProcessingStatus: true, + taskNo: true, + taskStatus: true, + taskFetchedAt: true, user: { select: { id: true, @@ -76,6 +79,18 @@ export async function GET( realName: true, }, }, + // 包含 assets 数据(任务-详情需要) + assets: { + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }, } as any, }); } catch (error: any) { @@ -194,6 +209,10 @@ export async function GET( videoCoverUrl: materialWithUser.videoCoverUrl || null, videoDuration: materialWithUser.videoDuration || null, videoProcessingStatus: materialWithUser.videoProcessingStatus || null, + taskNo: materialWithUser.taskNo || null, + taskStatus: materialWithUser.taskStatus || null, + taskFetchedAt: materialWithUser.taskFetchedAt || null, + assets: materialWithUser.assets || [], user: { username: materialWithUser.user?.username || '', }, @@ -258,15 +277,16 @@ export async function PUT( return NextResponse.json({ error: '请先登录' }, { status: 401 }); } - const { - productModel, - categoryName, - sceneName, - platformName, - productCategoryName, - materialType, - renderer, - authorName + const { + productModel, + categoryName, + sceneName, + platformName, + productCategoryName, + materialType, + renderer, + authorName, + userId: newUserId } = await request.json(); // 使用统一的验证逻辑 @@ -311,6 +331,18 @@ export async function PUT( return NextResponse.json({ error: '您没有权限编辑此素材' }, { status: 403 }); } + // 如果提供了新的userId,则更新userId + if (newUserId && newUserId !== material.userId) { + // 验证新用户是否存在 + const newUser = await prisma.user.findUnique({ + where: { id: newUserId }, + }); + + if (!newUser) { + return NextResponse.json({ error: '指定的用户不存在' }, { status: 400 }); + } + } + // 更新素材(包含所有字段,确保数据不丢失) const updateData: any = { productModel, @@ -322,6 +354,11 @@ export async function PUT( authorName, updatedAt: new Date(), }; + + // 如果提供了新的userId,则更新userId + if (newUserId && newUserId !== material.userId) { + updateData.userId = newUserId; + } // 标准模型必须更新 productCategoryName if (requiresProductCategoryName(materialType)) { diff --git a/app/api/materials/download/route-old.ts b/app/api/materials/download/route-old.ts deleted file mode 100644 index 43b3ad6..0000000 --- a/app/api/materials/download/route-old.ts +++ /dev/null @@ -1,1375 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; -import { prisma } from '@/lib/prisma'; -import { access, stat } from 'fs/promises'; -import { createReadStream } from 'fs'; -import { Readable } from 'stream'; -import path from 'path'; -import { lookup, extension } from 'mime-types'; -import JSZip from 'jszip'; -import { - canUserDownloadMaterial, - isShareLinkAccess, - canUserDownloadSource, -} from '@/lib/department-permission'; -import { - DownloadErrorCode, - createDownloadError, - DownloadErrorResponse, -} from '@/lib/download-errors'; - -function isHttpUrl(url?: string | null): boolean { - if (!url || typeof url !== 'string') return false; - return /^https?:\/\//i.test(url.trim()); -} - -import { isProductPackageType } from '@/lib/material-type-mapper'; - -// 处理产品全案分类下载 -async function handleProductPackageDownload(material: any, downloadType: string) { - // 确保所有资产数据中的 BigInt 都被转换为字符串,避免序列化错误 - const assets = (material.assets || []).map((asset: any) => ({ - ...asset, - fileSize: asset.fileSize ? (typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() : asset.fileSize) : '0', - })); - - // 添加详细日志 - console.log(`[Download] handleProductPackageDownload: materialId=${material.id}, downloadType=${downloadType}, total assets=${assets.length}`); - console.log(`[Download] Assets breakdown:`, { - main: assets.filter((a: any) => a.assetType === 'main').length, - sku: assets.filter((a: any) => a.assetType === 'sku').length, - detail: assets.filter((a: any) => a.assetType === 'detail').length, - psd: assets.filter((a: any) => a.assetType === 'psd').length, - }); - - // 根据类型筛选资产 - let targetAssets: any[] = []; - let zipName = ''; - - console.log(`[Download] ========== 开始筛选资产 ==========`); - console.log(`[Download] Requested downloadType: ${downloadType}`); - console.log(`[Download] Total assets available: ${assets.length}`); - - switch (downloadType) { - case 'all': - // 全部下载(不含 PSD) - targetAssets = assets.filter((a: any) => { - const isNotPsd = a.assetType !== 'psd'; - if (!isNotPsd) { - console.log(`[Download] Filtering out PSD asset: id=${a.id}, type=${a.assetType}`); - } - return isNotPsd; - }); - zipName = '全部'; - break; - case 'main': - targetAssets = assets.filter((a: any) => { - const isMain = a.assetType === 'main'; - if (!isMain) { - console.log(`[Download] Filtering out non-main asset: id=${a.id}, type=${a.assetType}, expected=main`); - } - return isMain; - }); - zipName = '主图'; - break; - case 'sku': - targetAssets = assets.filter((a: any) => { - const isSku = a.assetType === 'sku'; - if (!isSku) { - console.log(`[Download] Filtering out non-sku asset: id=${a.id}, type=${a.assetType}, expected=sku`); - } - return isSku; - }); - zipName = 'SKU图'; - break; - case 'detail': - targetAssets = assets.filter((a: any) => { - const isDetail = a.assetType === 'detail'; - if (!isDetail) { - console.log(`[Download] Filtering out non-detail asset: id=${a.id}, type=${a.assetType}, expected=detail`); - } - return isDetail; - }); - zipName = '详情图'; - break; - case 'psd': - targetAssets = assets.filter((a: any) => { - const isPsd = a.assetType === 'psd'; - if (!isPsd) { - console.log(`[Download] Filtering out non-psd asset: id=${a.id}, type=${a.assetType}, expected=psd`); - } - return isPsd; - }); - zipName = 'PSD源文件'; - break; - default: - return NextResponse.json( - createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE), - { status: 400 } - ); - } - - console.log(`[Download] Filtered assets for ${downloadType}: count=${targetAssets.length}`); - console.log(`[Download] Selected assets (完整信息):`); - targetAssets.forEach((asset: any, index: number) => { - console.log(` [${index}] { - id: "${asset.id}", - assetType: "${asset.assetType}", - fileUrl: "${asset.fileUrl}", - fileName: "${asset.fileName}", - fileSize: "${asset.fileSize}" - }`); - }); - - // 验证:检查所有资产的fileUrl是否都相同(这可能是问题的根源) - if (targetAssets.length > 1) { - const firstUrl = targetAssets[0]?.fileUrl; - const allSameUrl = targetAssets.every((a: any) => a.fileUrl === firstUrl); - if (allSameUrl) { - console.error(`[Download] ⚠️ CRITICAL ERROR: All ${targetAssets.length} assets have the same fileUrl: ${firstUrl}`); - console.error(`[Download] This means all files are pointing to the same URL, which is likely the root cause of the download issue!`); - console.error(`[Download] Asset details:`); - targetAssets.forEach((asset: any, index: number) => { - console.error(` [${index}] id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); - }); - } else { - console.log(`[Download] ✅ Assets have different fileUrls (this is correct)`); - // 打印每个资产的URL,用于验证 - targetAssets.forEach((asset: any, index: number) => { - console.log(` Asset ${index} URL: ${asset.fileUrl}`); - }); - } - } - - // 验证:检查资产类型是否与请求的类型匹配 - const mismatchedAssets = targetAssets.filter((a: any) => a.assetType !== downloadType); - if (mismatchedAssets.length > 0) { - console.error(`[Download] ⚠️ CRITICAL ERROR: Found ${mismatchedAssets.length} assets with mismatched types!`); - console.error(`[Download] Requested type: ${downloadType}`); - mismatchedAssets.forEach((asset: any) => { - console.error(` Asset id=${asset.id}, expected type=${downloadType}, actual type=${asset.assetType}, url=${asset.fileUrl}`); - }); - } else { - console.log(`[Download] ✅ All ${targetAssets.length} assets have correct type: ${downloadType}`); - } - - console.log(`[Download] ====================================`); - - if (targetAssets.length === 0) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES), - { status: 404 } - ); - } - - // 单个文件直接下载 - if (targetAssets.length === 1 && downloadType === 'psd') { - const asset = targetAssets[0]; - const filePath = asset.fileUrl; - - // PSD 存储在本地 - if (!path.isAbsolute(filePath)) { - const absolutePath = path.join(process.cwd(), filePath); - - try { - await access(absolutePath); - const fileStats = await stat(absolutePath); - const fileStream = createReadStream(absolutePath); - - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - cancel() { - fileStream.destroy(); - }, - }); - - const ext = path.extname(asset.fileName || filePath); - const mimeType = lookup(ext) || 'application/octet-stream'; - - return new NextResponse(stream, { - headers: { - 'Content-Type': mimeType, - 'Content-Length': fileStats.size.toString(), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, - // 静态PSD文件:长期缓存(1天),允许重新验证 - 'Cache-Control': 'public, max-age=86400, must-revalidate', - 'Accept-Ranges': 'bytes', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - // 添加ETag支持 - 'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`, - }, - }); - } catch (error) { - console.error('[Download] PSD file not found:', absolutePath, error); - return NextResponse.json( - createDownloadError(DownloadErrorCode.PSD_NOT_FOUND), - { status: 404 } - ); - } - } - } - - // 多个文件打包下载 - const zip = new JSZip(); - let addedCount = 0; - - console.log(`[Download] Starting download for type: ${downloadType}, total assets: ${targetAssets.length}`); - - // 按类型分组资产,如果是全部下载,需要分组到不同文件夹 - const shouldGroupByType = downloadType === 'all'; - const assetsByType: { [key: string]: any[] } = {}; - - if (shouldGroupByType) { - targetAssets.forEach((asset: any) => { - // 确保使用资产的实际类型,而不是从变量名推断 - const type = asset.assetType || 'other'; - if (!assetsByType[type]) { - assetsByType[type] = []; - } - // 验证资产类型是否匹配 - if (asset.assetType && asset.assetType !== type) { - console.error(`[Download] CRITICAL: Asset type mismatch! asset.assetType=${asset.assetType}, inferred type=${type}, assetId=${asset.id}`); - } - assetsByType[type].push(asset); - }); - - console.log(`[Download] Grouped assets by type:`, Object.keys(assetsByType).map(type => `${type}: ${assetsByType[type].length}`).join(', ')); - // 打印每个分组的详细信息 - for (const [type, assets] of Object.entries(assetsByType)) { - console.log(`[Download] Type ${type} assets:`, assets.map((a: any) => ({ id: a.id, url: a.fileUrl, fileName: a.fileName }))); - } - } else { - assetsByType['all'] = targetAssets; - } - - // 类型文件夹名称映射 - const typeFolderMap: { [key: string]: string } = { - main: '主图', - sku: 'SKU图', - detail: '详情图', - psd: 'PSD源文件', - }; - - // 用于跟踪已使用的文件名(完整路径),避免重名覆盖 - const usedFileNames = new Set(); - - // 遍历所有资产 - const processAssets = async (assets: any[], folderPath: string = '') => { - for (let index = 0; index < assets.length; index++) { - const asset = assets[index]; - try { - // 确保使用资产的原始数据,不要被任何变量覆盖 - const fileUrl = asset.fileUrl; - const assetType = asset.assetType; - const assetId = asset.id; - const fileName = asset.fileName; - - // 验证关键字段 - if (!fileUrl) { - console.error(`[Download] CRITICAL: Asset has no fileUrl! assetId=${assetId}, type=${assetType}`); - continue; - } - if (!assetType) { - console.error(`[Download] CRITICAL: Asset has no assetType! assetId=${assetId}, url=${fileUrl}`); - continue; - } - - // 生成唯一文件名(避免重名覆盖) - // 需要检查完整路径(包含文件夹)是否已使用 - const baseFileName = fileName || `${assetType}-${index}.jpg`; - const ext = path.extname(baseFileName) || '.jpg'; - const nameWithoutExt = path.basename(baseFileName, ext); - - // 生成唯一文件名(考虑文件夹路径) - let uniqueFileName = baseFileName; - let suffix = 1; - let fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName; - - while (usedFileNames.has(fullZipPath)) { - uniqueFileName = `${nameWithoutExt}_${suffix}${ext}`; - fullZipPath = folderPath ? `${folderPath}/${uniqueFileName}` : uniqueFileName; - suffix++; - } - - // 标记为已使用 - usedFileNames.add(fullZipPath); - - // 添加详细日志 - console.log(`[Download] ========== Processing asset ==========`); - console.log(`[Download] Asset ID: ${assetId}`); - console.log(`[Download] Asset Type: ${assetType}`); - console.log(`[Download] File URL: ${fileUrl}`); - console.log(`[Download] Original File Name: ${fileName}`); - console.log(`[Download] Unique File Name: ${uniqueFileName}`); - console.log(`[Download] Folder Path: ${folderPath || '(root)'}`); - - // 判断是 S3 URL 还是本地路径 - if (isHttpUrl(fileUrl)) { - // S3 文件 - console.log(`[Download] Detected S3 URL, fetching file...`); - console.log(`[Download] Fetch URL: ${fileUrl}`); - const response = await fetch(fileUrl); - if (!response.ok) { - console.error(`[Download] ❌ Failed to fetch asset: ${fileUrl}, status: ${response.status}, assetType=${assetType}, assetId=${assetId}`); - continue; - } - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - // 验证文件大小 - if (buffer.length === 0) { - console.error(`[Download] ❌ Empty buffer for asset: ${fileUrl}, assetType=${assetType}, assetId=${assetId}`); - continue; - } - - // 计算文件哈希(前16字节的十六进制),用于验证文件是否相同 - const fileHash = buffer.slice(0, Math.min(16, buffer.length)).toString('hex'); - const fileHashEnd = buffer.slice(Math.max(0, buffer.length - 16)).toString('hex'); - console.log(`[Download] File hash (first 16 bytes): ${fileHash}`); - console.log(`[Download] File hash (last 16 bytes): ${fileHashEnd}`); - - // 使用唯一文件名(fullZipPath 已经在上面计算好了) - const zipPath = fullZipPath; - - console.log(`[Download] Adding to ZIP:`); - console.log(` - ZIP Path: ${zipPath}`); - console.log(` - File Size: ${buffer.length} bytes`); - console.log(` - Asset Type: ${assetType}`); - console.log(` - Asset ID: ${assetId}`); - console.log(` - Source URL: ${fileUrl}`); - console.log(` - File Hash (start): ${fileHash}`); - console.log(` - File Hash (end): ${fileHashEnd}`); - - zip.file(zipPath, buffer); - addedCount++; - console.log(`[Download] ✅ Successfully added S3 file to zip`); - console.log(`[Download] ====================================`); - } else { - // 本地文件 - const absolutePath = path.isAbsolute(fileUrl) - ? fileUrl - : path.join(process.cwd(), fileUrl); - - console.log(`[Download] Reading local file: ${absolutePath} for asset type=${assetType}, id=${assetId}`); - try { - await access(absolutePath); - const fileStream = createReadStream(absolutePath); - const chunks: Buffer[] = []; - - await new Promise((resolve, reject) => { - fileStream.on('data', (chunk: any) => chunks.push(Buffer.from(chunk))); - fileStream.on('end', () => resolve()); - fileStream.on('error', reject); - }); - - const buffer = Buffer.concat(chunks); - - // 验证文件大小 - if (buffer.length === 0) { - console.warn(`[Download] Empty buffer for local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`); - continue; - } - - // 使用唯一文件名(fullZipPath 已经在上面计算好了) - const zipPath = fullZipPath; - zip.file(zipPath, buffer); - addedCount++; - console.log(`[Download] ✅ Added local file to zip: ${zipPath} (assetType=${assetType}, assetId=${assetId}, path=${absolutePath}, size=${buffer.length} bytes)`); - } catch (error) { - console.warn(`[Download] Failed to read local file: ${absolutePath}, assetType=${assetType}, assetId=${assetId}`, error); - continue; - } - } - } catch (error) { - console.error(`[Download] Error adding asset to zip:`, error, asset); - } - } - }; - - // 如果按类型分组,为每种类型创建文件夹 - if (shouldGroupByType) { - console.log(`[Download] ========== Processing grouped assets ==========`); - for (const [type, assets] of Object.entries(assetsByType)) { - const folderName = typeFolderMap[type] || type; - console.log(`[Download] ========== Processing type=${type}, folder=${folderName} ==========`); - console.log(`[Download] Assets count: ${assets.length}`); - - // 验证资产类型是否正确,并打印每个资产的详细信息 - for (const asset of assets) { - console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); - if (asset.assetType !== type) { - console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${type}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`); - } else { - console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`); - } - } - await processAssets(assets, folderName); - console.log(`[Download] ========== Finished processing type=${type} ==========`); - } - } else { - console.log(`[Download] ========== Processing single type=${downloadType} ==========`); - console.log(`[Download] Assets count: ${targetAssets.length}`); - - // 验证资产类型是否正确,并打印每个资产的详细信息 - for (const asset of targetAssets) { - console.log(`[Download] Verifying asset: id=${asset.id}, type=${asset.assetType}, url=${asset.fileUrl}, fileName=${asset.fileName}`); - if (asset.assetType !== downloadType) { - console.error(`[Download] ❌ CRITICAL ERROR: Asset type mismatch! Expected ${downloadType}, got ${asset.assetType}, assetId=${asset.id}, url=${asset.fileUrl}, fileName=${asset.fileName}`); - } else { - console.log(`[Download] ✅ Asset type verified: ${asset.assetType}`); - } - } - await processAssets(targetAssets); - console.log(`[Download] ========== Finished processing single type ==========`); - } - - console.log(`[Download] Total files added to zip: ${addedCount}`); - - if (addedCount === 0) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES), - { status: 404 } - ); - } - - const zipBuffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }); - - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - zipName, - ].filter(Boolean); - - const filename = `${filenameParts.join('_')}.zip`; - - return new NextResponse(zipBuffer as any, { - headers: { - 'Content-Type': 'application/zip', - 'Content-Length': zipBuffer.length.toString(), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 动态生成的ZIP文件:短期缓存(1小时),允许重新验证 - 'Cache-Control': 'public, max-age=3600, must-revalidate', - 'Accept-Ranges': 'bytes', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - }, - }); -} - -// 动态打包displayImages为ZIP(用于模特类素材) -async function buildDynamicZipFromDisplayImages(material: any) { - const displayImages = Array.isArray(material.displayImages) - ? material.displayImages.filter((img: any) => typeof img === 'string' && img.trim().length > 0) - : []; - - if (displayImages.length === 0) { - console.warn('[Download API] No displayImages available for dynamic zip, materialId:', material.id); - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), - { status: 404 } - ); - } - - console.log(`[Download API] Building ZIP from displayImages for material ${material.id}, total: ${displayImages.length}`); - - const zip = new JSZip(); - let addedCount = 0; - - for (let i = 0; i < displayImages.length; i++) { - const imageUrl = displayImages[i]; - try { - const response = await fetch(imageUrl); - if (!response.ok) { - console.warn(`[Download API] Failed to fetch display image index=${i} url=${imageUrl}, status=${response.status}`); - continue; - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - if (!buffer || buffer.length === 0) { - console.warn(`[Download API] Empty buffer for display image index=${i} url=${imageUrl}, skipping`); - continue; - } - - const urlObj = new URL(imageUrl); - const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_'); - zip.file(filename, buffer); - addedCount++; - } catch (error) { - console.error(`[Download API] Error while adding display image index=${i} url=${imageUrl} to zip:`, error); - } - } - - if (addedCount === 0) { - console.warn('[Download API] No valid displayImages added to zip, materialId:', material.id); - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), - { status: 404 } - ); - } - - const zipBuffer = await zip.generateAsync({ - type: 'nodebuffer', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }); - - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - material.productCategoryName || '', - '展示图合集', - ].filter(Boolean); - - const filename = `${filenameParts.join('_')}.zip`; - - console.log( - '[Download API] Dynamic ZIP built from displayImages, materialId:', - material.id, - 'size:', - (zipBuffer.length / 1024 / 1024).toFixed(2), - 'MB' - ); - - return new NextResponse(zipBuffer as any, { - headers: { - 'Content-Type': 'application/zip', - 'Content-Length': zipBuffer.length.toString(), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 动态生成的ZIP文件:短期缓存(1小时),允许重新验证 - 'Cache-Control': 'public, max-age=3600, must-revalidate', - 'Accept-Ranges': 'bytes', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - }, - }); -} - -// GET - 下载素材 -export async function GET(request: NextRequest) { - try { - const isShare = isShareLinkAccess(request); - - // 如果不是分享链接,需要登录 - if (!isShare) { - const session = await getServerSession(authOptions); - if (!session) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.UNAUTHORIZED), - { status: 401 } - ); - } - - const userId = (session.user as any).id; - const userRole = (session.user as any).role; - - // 检查用户级别的下载权限 - if (userRole !== 'ADMIN') { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { canDownload: true }, - }); - - if (!user || !user.canDownload) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.FORBIDDEN), - { status: 403 } - ); - } - } - } - - const { searchParams } = new URL(request.url); - const materialId = searchParams.get('id'); - const downloadType = searchParams.get('type'); // all/main/sku/detail/psd - const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载) - - if (!materialId) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'), - { status: 400 } - ); - } - - // 查询素材(产品全案需要包含资产信息) - let material: any; - try { - material = await prisma.material.findUnique({ - where: { id: materialId }, - select: { - id: true, - zipFilePath: true, - zipFileName: true, - productModel: true, - categoryName: true, - sceneName: true, - productCategoryName: true, - authorName: true, - materialType: true, - downloadCount: true, - displayImages: true, - videoSourceUrl: true, - videoPreview540Url: true, - videoPreviewClipUrl: true, - videoCoverUrl: true, - previewUrl: true, - createdAt: true, - // assets: { - // select: { - // id: true, - // assetType: true, - // fileUrl: true, - // fileName: true, - // fileSize: true, - // sortOrder: true, - // }, - // orderBy: { - // sortOrder: 'asc', - // }, - // }, - }, - }); - - // 如果是任务-详情(兼容老的"产品全案"),单独查询 assets - if (material && isProductPackageType(material.materialType)) { - const assets = await prisma.materialAsset.findMany({ - where: { - materialId: material.id, - }, - select: { - id: true, - assetType: true, - fileUrl: true, - fileName: true, - fileSize: true, - sortOrder: true, - }, - orderBy: { - sortOrder: 'asc', - }, - }); - - // 添加详细日志,验证资产数据 - console.log(`[Download API] ========== 资产查询结果 ==========`); - console.log(`[Download API] MaterialId: ${material.id}`); - console.log(`[Download API] Total assets: ${assets.length}`); - // 将 BigInt 转换为字符串后再序列化 - const assetsForLog = assets.map((a: any) => ({ - ...a, - fileSize: a.fileSize ? a.fileSize.toString() : '0', - })); - console.log(`[Download API] Raw assets from database:`, JSON.stringify(assetsForLog, null, 2)); - - // 按类型分组并打印详细信息 - const assetsByType = { - main: assets.filter((a: any) => a.assetType === 'main'), - sku: assets.filter((a: any) => a.assetType === 'sku'), - detail: assets.filter((a: any) => a.assetType === 'detail'), - psd: assets.filter((a: any) => a.assetType === 'psd'), - }; - - console.log(`[Download API] Assets breakdown:`); - console.log(` - main: ${assetsByType.main.length} assets`); - assetsByType.main.forEach((a: any, i: number) => { - console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); - }); - - console.log(` - sku: ${assetsByType.sku.length} assets`); - assetsByType.sku.forEach((a: any, i: number) => { - console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); - }); - - console.log(` - detail: ${assetsByType.detail.length} assets`); - assetsByType.detail.forEach((a: any, i: number) => { - console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); - }); - - console.log(` - psd: ${assetsByType.psd.length} assets`); - assetsByType.psd.forEach((a: any, i: number) => { - console.log(` [${i}] id=${a.id}, type=${a.assetType}, url=${a.fileUrl}, fileName=${a.fileName}`); - }); - - // 检查是否有重复的fileUrl - const urlCounts: { [key: string]: { count: number; types: string[]; ids: string[] } } = {}; - assets.forEach((asset: any) => { - if (asset.fileUrl) { - if (!urlCounts[asset.fileUrl]) { - urlCounts[asset.fileUrl] = { count: 0, types: [], ids: [] }; - } - urlCounts[asset.fileUrl].count++; - urlCounts[asset.fileUrl].types.push(asset.assetType); - urlCounts[asset.fileUrl].ids.push(asset.id); - } - }); - - const duplicateUrls = Object.entries(urlCounts).filter(([url, info]) => info.count > 1); - if (duplicateUrls.length > 0) { - console.error(`[Download API] ⚠️ CRITICAL: Found duplicate fileUrls!`); - duplicateUrls.forEach(([url, info]) => { - console.error(` URL: ${url}`); - console.error(` Used by ${info.count} assets:`); - console.error(` Types: ${info.types.join(', ')}`); - console.error(` IDs: ${info.ids.join(', ')}`); - }); - } else { - console.log(`[Download API] ✅ No duplicate fileUrls found`); - } - - // 检查是否有资产类型为空或无效 - const invalidAssets = assets.filter((a: any) => !a.assetType || !['main', 'sku', 'detail', 'psd'].includes(a.assetType)); - if (invalidAssets.length > 0) { - console.error(`[Download API] ⚠️ CRITICAL: Found assets with invalid assetType!`); - invalidAssets.forEach((a: any) => { - console.error(` Asset ID: ${a.id}, assetType: ${a.assetType}, url: ${a.fileUrl}`); - }); - } - - console.log(`[Download API] ====================================`); - - // 将 BigInt 转换为字符串,避免序列化错误 - material.assets = assets.map((asset: any) => ({ - ...asset, - fileSize: asset.fileSize ? asset.fileSize.toString() : '0', - })); - } - } catch (error: any) { - if (error?.meta?.column?.includes('deletedAt')) { - console.warn('[Download API] deletedAt field not found, retrying without deletedAt'); - material = await prisma.material.findUnique({ - where: { id: materialId }, - select: { - id: true, - zipFilePath: true, - zipFileName: true, - productModel: true, - categoryName: true, - sceneName: true, - productCategoryName: true, - authorName: true, - materialType: true, - downloadCount: true, - displayImages: true, - videoSourceUrl: true, - videoPreview540Url: true, - videoPreviewClipUrl: true, - videoCoverUrl: true, - previewUrl: true, - createdAt: true, - }, - }); - - // 如果是任务-详情(兼容老的"产品全案"),单独查询 assets - if (material && isProductPackageType(material.materialType)) { - const assets = await prisma.materialAsset.findMany({ - where: { materialId: material.id }, - select: { - id: true, - assetType: true, - fileUrl: true, - fileName: true, - fileSize: true, - sortOrder: true, - }, - orderBy: { - sortOrder: 'asc', - }, - }); - material.assets = assets; - } - } else { - throw error; - } - } - - if (!material) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.MATERIAL_NOT_FOUND), - { status: 404 } - ); - } - - // 权限检查:如果不是分享链接,检查部门级别的下载权限 - if (!isShare) { - const session = await getServerSession(authOptions); - if (session?.user) { - const userRole = (session.user as any).role; - const userDepartment = (session.user as any).department; - const managedDepartment = (session.user as any).managedDepartment; - const userId = (session.user as any).id; - - // 超级管理员不受限制 - const { isSuperAdmin } = await import('@/lib/department-permission'); - if (!(await isSuperAdmin(userId))) { - const canDownload = await canUserDownloadMaterial( - userDepartment, - userRole, - managedDepartment, - material.materialType, - userId - ); - - if (!canDownload) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_PERMISSION), - { status: 403 } - ); - } - - // 任务-详情:PSD下载权限参考"详情模板"类型的 canDownloadSource 权限 - // 如果下载的是 PSD,需要额外检查"详情模板"类型的源文件下载权限 - if (isProductPackageType(material.materialType) && downloadType === 'psd') { - const canDownloadPsd = await canUserDownloadSource( - userDepartment, - userRole, - managedDepartment, - '详情模板', // 产品全案的 PSD 下载权限参考"详情模板"类型 - userId - ); - - if (!canDownloadPsd) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_PERMISSION, '无权限下载 PSD 源文件'), - { status: 403 } - ); - } - } - } - } - } - - // 任务-详情:处理分类下载 - if (isProductPackageType(material.materialType) && downloadType && material.assets) { - // 如果指定了assetId,直接下载单个PSD文件(不打包) - if (assetId && downloadType === 'psd') { - const asset = (material.assets as any[]).find((a: any) => a.id === assetId && a.assetType === 'psd'); - if (!asset) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.PSD_NOT_FOUND), - { status: 404 } - ); - } - - const filePath = asset.fileUrl; - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.join(process.cwd(), filePath); - - try { - await access(absolutePath); - const fileStats = await stat(absolutePath); - const fileStream = createReadStream(absolutePath); - - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - cancel() { - fileStream.destroy(); - }, - }); - - const ext = path.extname(asset.fileName || filePath); - const mimeType = lookup(ext) || 'application/octet-stream'; - - return new NextResponse(stream, { - headers: { - 'Content-Type': mimeType, - 'Content-Length': fileStats.size.toString(), - 'Content-Disposition': `attachment; filename="${encodeURIComponent(asset.fileName || 'source.psd')}"`, - // 静态PSD文件:长期缓存(1天),允许重新验证 - 'Cache-Control': 'public, max-age=86400, must-revalidate', - 'Accept-Ranges': 'bytes', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - // 添加ETag支持 - 'ETag': `"${Buffer.from(`${absolutePath}-${fileStats.size}`).toString('base64').slice(0, 27)}"`, - }, - }); - } catch (error) { - console.error('[Download] PSD file not found:', absolutePath, error); - return NextResponse.json( - createDownloadError(DownloadErrorCode.PSD_NOT_FOUND), - { status: 404 } - ); - } - } - - return handleProductPackageDownload(material, downloadType); - } - - // 增加下载次数(仅非分享链接时) - if (!isShare) { - try { - await prisma.material.update({ - where: { id: materialId }, - data: { downloadCount: { increment: 1 } }, - }); - } catch (error: any) { - if (error?.meta?.column?.includes('deletedAt')) { - console.warn('[Download API] deletedAt field not found in update, retrying without deletedAt'); - await prisma.material.update({ - where: { id: materialId }, - data: { downloadCount: { increment: 1 } }, - }); - } else { - throw error; - } - } - } - - // 检查是否有ZIP文件;若无,则处理S3直链(音频等存放在zipFileName的场景),否则再尝试动态打包 - if (!material.zipFilePath || material.zipFilePath.trim() === '') { - const zipUrl = (material.zipFileName || '').trim(); - - // 场景:音频/视频源文件直接存放在S3,zipFileName 是 S3 URL - if (zipUrl && isHttpUrl(zipUrl)) { - console.log('[Download API] zipFilePath empty, proxy S3 url from zipFileName:', zipUrl); - - const urlObj = new URL(zipUrl); - const extFromUrl = path.extname(urlObj.pathname) || ''; - const mimeType = lookup(extFromUrl) || 'application/octet-stream'; - - // 生成下载文件名(与本地下载保持一致的命名逻辑) - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - material.productCategoryName || '', - '源文件', - ].filter(Boolean); - const finalExt = extFromUrl || '.bin'; - const filename = `${filenameParts.join('_')}${finalExt}`; - - try { - const response = await fetch(zipUrl); - if (!response.ok || !response.body) { - console.error('[Download API] Failed to fetch S3 url from zipFileName:', zipUrl, 'status:', response.status); - return NextResponse.json( - createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, 'S3文件访问失败'), - { status: 404 } - ); - } - - // 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回 - const nodeStream = Readable.fromWeb(response.body as any); - const stream = new ReadableStream({ - start(controller) { - nodeStream.on('data', (chunk) => controller.enqueue(chunk)); - nodeStream.on('end', () => controller.close()); - nodeStream.on('error', (err) => controller.error(err)); - }, - cancel() { - nodeStream.destroy(); - }, - }); - - return new NextResponse(stream as any, { - status: 200, - headers: { - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // S3代理下载:中等缓存(6小时),允许重新验证 - 'Cache-Control': 'public, max-age=21600, must-revalidate', - 'Accept-Ranges': 'bytes', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - // 不设置 Content-Length,因 S3 直链已被代理流式返回 - }, - }); - } catch (error) { - console.error('[Download API] Error proxying S3 url from zipFileName:', zipUrl, error); - return NextResponse.json( - createDownloadError(DownloadErrorCode.S3_ACCESS_FAILED, 'S3代理下载失败'), - { status: 500 } - ); - } - } - - console.log('[Download API] Material has no zipFilePath, try dynamic zip if supported. type=', material.materialType); - - // 支持动态打包的素材类型 - const supportedTypes = [ - '模特', - '实拍图片', - '买家秀图片', - '小红书种草', - '主图模版', - 'SKU模版', - '品牌VI灵感', - ]; - - if ( - supportedTypes.includes(material.materialType) && - Array.isArray(material.displayImages) && - material.displayImages.length > 0 - ) { - return buildDynamicZipFromDisplayImages(material); - } - - return NextResponse.json( - createDownloadError(DownloadErrorCode.NO_DOWNLOADABLE_FILES, '该素材可能只包含图片'), - { status: 404 } - ); - } - - // 处理文件路径 - // 判断是否老素材:根据文档,老素材的特征是: - // 1. 创建时间 ≤ 2025-10-27 - // 2. previewUrl 不包含 "由壹点AI/" - // 3. zipFilePath 可能是相对路径(如 uploads/material-*.zip),实际文件存储在外网服务器 103.118.245.6 - const oldMaterialDate = new Date('2025-10-27T23:59:59.999Z'); - const materialCreatedAt = material.createdAt ? new Date(material.createdAt) : null; - const isOldByDate = materialCreatedAt && materialCreatedAt <= oldMaterialDate; - const isOldByUrl = material.zipFilePath?.includes('103.118.245.6') || - material.zipFilePath?.startsWith('http') || - (material.previewUrl && !material.previewUrl.includes('由壹点AI/')); - - const isOldMaterial = isOldByDate || isOldByUrl; - - let filePath = material.zipFilePath; - - // 老素材:如果 zipFilePath 是相对路径,尝试从外网服务器下载 - if (isOldMaterial && filePath && !filePath.startsWith('http') && !path.isAbsolute(filePath)) { - // 老素材的相对路径,实际文件在外网服务器上 - const externalUrl = `http://103.118.245.6/${filePath.replace(/^\/+/, '')}`; - console.log('[Download API] Old material detected, using external server URL:', externalUrl); - - try { - // 检查外网服务器文件是否存在 - const headResponse = await fetch(externalUrl, { method: 'HEAD' }); - if (headResponse.ok) { - console.log('[Download API] File found on external server, proxying download'); - - // 从外网服务器代理下载 - const downloadResponse = await fetch(externalUrl); - if (!downloadResponse.ok || !downloadResponse.body) { - throw new Error(`External server returned ${downloadResponse.status}`); - } - - // 生成下载文件名 - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - material.productCategoryName || '', - '源文件', - ].filter(Boolean); - const originalExt = material.zipFileName ? path.extname(material.zipFileName) : ''; - const finalExt = originalExt || '.bin'; - const filename = `${filenameParts.join('_')}${finalExt}`; - - // 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回 - const nodeStream = Readable.fromWeb(downloadResponse.body as any); - const stream = new ReadableStream({ - start(controller) { - nodeStream.on('data', (chunk) => controller.enqueue(chunk)); - nodeStream.on('end', () => controller.close()); - nodeStream.on('error', (err) => controller.error(err)); - }, - cancel() { - nodeStream.destroy(); - }, - }); - - const ext = path.extname(material.zipFileName || ''); - const mimeType = lookup(ext) || 'application/octet-stream'; - - return new NextResponse(stream as any, { - status: 200, - headers: { - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 外网服务器代理下载:中等缓存(6小时),允许重新验证 - 'Cache-Control': 'public, max-age=21600, must-revalidate', - 'Accept-Ranges': 'bytes', - 'Vary': 'Accept-Encoding', - }, - }); - } else { - console.warn('[Download API] External server file not found, status:', headResponse.status); - return NextResponse.json( - createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '老素材文件在外网服务器上不存在'), - { status: 404 } - ); - } - } catch (externalError) { - console.error('[Download API] External server download failed:', externalError); - return NextResponse.json( - createDownloadError(DownloadErrorCode.NETWORK_ERROR, '无法连接到外网服务器,请检查网络连接'), - { status: 500 } - ); - } - } - - // 新素材:相对路径转绝对路径 - if (!isOldMaterial) { - if (!path.isAbsolute(filePath)) { - filePath = path.join(process.cwd(), filePath); - } - } - - console.log('Attempting to download file:', filePath, '(Old material:', isOldMaterial, ')'); - - // 检查文件是否存在 - try { - await access(filePath); - } catch (error) { - console.error('File not found:', filePath, 'error:', error); - - // 如果是视频类素材且源文件不存在,尝试下载720P(预览视频,回退方案) - const videoTypes = ['主图视频', '安装视频', '种草视频']; - const isVideoMaterial = videoTypes.includes(material.materialType) || - material.videoSourceUrl || - material.videoPreview540Url; - - if (isVideoMaterial && material.videoPreview540Url) { - console.log('[Download API] Video source file missing, fallback to 720P preview video, materialId:', material.id); - console.log('[Download API] Preview video URL:', material.videoPreview540Url); - - try { - // 检查预览视频URL是否可访问 - const previewResponse = await fetch(material.videoPreview540Url, { method: 'HEAD' }); - if (previewResponse.ok) { - console.log('[Download API] Preview video accessible, proxying download'); - - // 从S3代理下载预览视频 - const downloadResponse = await fetch(material.videoPreview540Url); - if (!downloadResponse.ok || !downloadResponse.body) { - throw new Error(`Preview video fetch returned ${downloadResponse.status}`); - } - - // 生成下载文件名(标注为720P预览) - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - material.productCategoryName || '', - '720P', - ].filter(Boolean); - const filename = `${filenameParts.join('_')}.mp4`; - - // 将 web ReadableStream 转为 Node.js Readable 再封装为 Web ReadableStream 返回 - const nodeStream = Readable.fromWeb(downloadResponse.body as any); - const stream = new ReadableStream({ - start(controller) { - nodeStream.on('data', (chunk) => controller.enqueue(chunk)); - nodeStream.on('end', () => controller.close()); - nodeStream.on('error', (err) => controller.error(err)); - }, - cancel() { - nodeStream.destroy(); - }, - }); - - return new NextResponse(stream as any, { - status: 200, - headers: { - 'Content-Type': 'video/mp4', - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 预览视频代理下载:中等缓存(6小时),允许重新验证 - 'Cache-Control': 'public, max-age=21600, must-revalidate', - 'Accept-Ranges': 'bytes', - 'Vary': 'Accept-Encoding', - }, - }); - } else { - console.warn('[Download API] Preview video not accessible, status:', previewResponse.status); - // 预览视频不可访问,继续尝试其他fallback - } - } catch (videoError) { - console.error('[Download API] Preview video download failed:', videoError); - // 预览视频下载失败,继续尝试其他fallback - } - } - - // 如果本地文件不存在,尝试动态打包 - const supportedTypes = [ - '模特', - '实拍图片', - '买家秀图片', - '小红书种草', - '主图模版', - 'SKU模版', - '品牌VI灵感', - ]; - - if ( - supportedTypes.includes(material.materialType) && - Array.isArray(material.displayImages) && - material.displayImages.length > 0 - ) { - console.log('[Download API] Local file missing, fallback to dynamic zip from displayImages, materialId:', material.id); - return buildDynamicZipFromDisplayImages(material); - } - - // 所有fallback都失败,返回明确的错误信息 - if (isOldMaterial) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '老素材文件在外网服务器上不存在或无法访问'), - { status: 404 } - ); - } else if (isVideoMaterial) { - return NextResponse.json( - createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '视频源文件不存在,且预览视频也无法访问'), - { status: 404 } - ); - } else { - return NextResponse.json( - createDownloadError(DownloadErrorCode.FILE_NOT_FOUND, '文件不存在或已被删除'), - { status: 404 } - ); - } - } - - // 获取文件大小 - const fileStats = await stat(filePath); - const fileSize = fileStats.size; - - // 确定文件扩展名和MIME类型 - const originalExt = material.zipFileName ? path.extname(material.zipFileName) : ''; - const actualExt = path.extname(filePath); - const ext = originalExt || actualExt; - const mimeType = lookup(ext || filePath) || 'application/octet-stream'; - const mimeExt = extension(mimeType); - const finalExt = ext || (mimeExt ? `.${mimeExt}` : ''); - - // 生成下载文件名 - const filenameParts = [ - material.productModel || 'unknown', - material.categoryName || '', - material.sceneName || '', - material.productCategoryName || '', - '源文件', - ].filter(Boolean); - - const filename = `${filenameParts.join('_')}${finalExt || '.bin'}`; - - console.log('Download filename:', filename, 'size:', fileSize, 'mime:', mimeType); - - // 处理Range请求(断点续传) - const rangeHeader = request.headers.get('range'); - - if (rangeHeader) { - const rangePrefix = 'bytes='; - if (!rangeHeader.startsWith(rangePrefix)) { - return new NextResponse('Range header format is invalid', { status: 416 }); - } - - const rangeParts = rangeHeader.replace(rangePrefix, '').split('-'); - const start = parseInt(rangeParts[0], 10); - const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1; - - if (isNaN(start) || isNaN(end) || start >= fileSize || end >= fileSize) { - return new NextResponse('Range header out of bounds', { status: 416 }); - } - - const chunkSize = end - start + 1; - const fileStream = createReadStream(filePath, { start, end }); - - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - cancel() { - fileStream.destroy(); - }, - }); - - return new NextResponse(stream, { - status: 206, - headers: { - 'Content-Range': `bytes ${start}-${end}/${fileSize}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunkSize.toString(), - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 静态文件Range请求:长期缓存(1天),允许重新验证 - 'Cache-Control': 'public, max-age=86400, must-revalidate', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding, Range', - }, - }); - } - - // 正常下载 - const fileStream = createReadStream(filePath); - - const stream = new ReadableStream({ - start(controller) { - fileStream.on('data', (chunk) => controller.enqueue(chunk)); - fileStream.on('end', () => controller.close()); - fileStream.on('error', (err) => controller.error(err)); - }, - cancel() { - fileStream.destroy(); - }, - }); - - return new NextResponse(stream, { - headers: { - 'Accept-Ranges': 'bytes', - 'Content-Length': fileSize.toString(), - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, - // 静态文件下载:长期缓存(1天),允许重新验证 - // 注意:对于已存在的文件,内容不会改变,可以缓存 - 'Cache-Control': 'public, max-age=86400, must-revalidate', - // 性能优化:启用压缩(如果客户端支持) - 'Vary': 'Accept-Encoding', - // 添加ETag支持(基于文件路径和大小) - 'ETag': `"${Buffer.from(`${filePath}-${fileSize}`).toString('base64').slice(0, 27)}"`, - }, - }); - } catch (error) { - console.error('Download error:', error); - - // 根据错误类型返回对应的错误代码 - const errorMessage = error instanceof Error ? error.message : String(error); - - let errorCode = DownloadErrorCode.UNKNOWN_ERROR; - let details: string | undefined; - - if (errorMessage.includes('BigInt')) { - errorCode = DownloadErrorCode.BIGINT_SERIALIZATION_ERROR; - } else if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) { - errorCode = DownloadErrorCode.FILE_NOT_FOUND; - details = errorMessage; - } else if (errorMessage.includes('S3') || errorMessage.includes('存储')) { - errorCode = DownloadErrorCode.S3_ACCESS_FAILED; - details = errorMessage; - } else if (errorMessage.includes('network') || errorMessage.includes('网络')) { - errorCode = DownloadErrorCode.NETWORK_ERROR; - details = errorMessage; - } else if (errorMessage.includes('stream') || errorMessage.includes('流')) { - errorCode = DownloadErrorCode.STREAM_ERROR; - details = errorMessage; - } else if (errorMessage.includes('zip') || errorMessage.includes('ZIP')) { - errorCode = DownloadErrorCode.ZIP_CREATION_FAILED; - details = errorMessage; - } else { - details = errorMessage; - } - - return NextResponse.json( - createDownloadError(errorCode, details), - { status: 500 } - ); - } -} diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index ed788ef..64e88a5 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -41,13 +41,12 @@ function parseDisplayImages(displayImages: any): string[] { } // 素材类型映射(兼容旧数据) -// 用于查询时兼容老类型名称,确保用户可以查询到迁移前的素材 const MATERIAL_TYPE_MAP: Record = { - // 3DMAX → 3D场景(已迁移) + // 3DMAX → 3D场景 '3D场景': ['3D场景', '3DMAX'], '3DMAX': ['3D场景', '3DMAX'], - // 产品全案 → 任务-详情(已迁移) + // 产品全案 → 任务-详情 '任务-详情': ['任务-详情', '产品全案'], '产品全案': ['任务-详情', '产品全案'], @@ -107,6 +106,18 @@ export async function GET(request: NextRequest) { avatar: true, }, }, + // 包含 assets 数据(任务-详情需要) + assets: { + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }, } as any, }); @@ -235,17 +246,52 @@ export async function GET(request: NextRequest) { where.deletedAt = null; } - // 搜索条件 + // 搜索条件(支持SPU型号、任务号、SKU、店铺、场景、作者) + // 注意:SKU存储在关联表中,需要特殊处理 if (search) { + // 先搜索SKU表,获取匹配的materialId列表 + const skuMatches = await prisma.materialSku.findMany({ + where: { + skuName: { contains: search, mode: 'insensitive' }, + deletedAt: null, + }, + select: { materialId: true }, + }); + const skuMaterialIds = skuMatches.map(s => s.materialId); + + // 搜索用户表(realName和username),获取匹配的userId列表 + const userMatches = await prisma.user.findMany({ + where: { + OR: [ + { realName: { contains: search, mode: 'insensitive' } }, + { username: { contains: search, mode: 'insensitive' } }, + ], + }, + select: { id: true }, + }); + const userIds = userMatches.map(u => u.id); + + // 构建OR条件:包含SKU匹配的素材ID和用户匹配的userId where.OR = [ { productModel: { contains: search, mode: 'insensitive' } }, + { taskNo: { contains: search, mode: 'insensitive' } }, { categoryName: { contains: search, mode: 'insensitive' } }, { sceneName: { contains: search, mode: 'insensitive' } }, { authorName: { contains: search, mode: 'insensitive' } }, ]; + + // 如果有SKU匹配,添加到OR条件中 + if (skuMaterialIds.length > 0) { + where.OR.push({ id: { in: skuMaterialIds } }); + } + + // 如果有用户匹配,添加到OR条件中 + if (userIds.length > 0) { + where.OR.push({ userId: { in: userIds } }); + } } - // 分类筛选 + // 分类筛选(AND条件) if (category) { where.categoryName = category; } @@ -254,7 +300,7 @@ export async function GET(request: NextRequest) { where.sceneName = scene; } - // 素材类型筛选(支持映射) + // 素材类型筛选(支持映射)(AND条件) if (materialType) { const types = MATERIAL_TYPE_MAP[materialType] ?? [materialType]; if (types.length === 1) { @@ -272,7 +318,7 @@ export async function GET(request: NextRequest) { where.productCategoryName = productCategory; } - // 月份筛选 + // 月份筛选(AND条件) if (month) { const [year, monthNum] = month.split('-'); const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1); @@ -400,6 +446,14 @@ export async function GET(request: NextRequest) { videoProcessingStatus: true, transcodeLastError: true, transcodeAttempts: true, + // 包含用户信息(用于显示真实姓名) + user: { + select: { + username: true, + realName: true, + avatar: true, + }, + }, // 包含 assets 数据(任务-详情需要) assets: { select: { @@ -677,7 +731,7 @@ export async function GET(request: NextRequest) { } // 格式化返回数据 - // 对于任务-详情类型(兼容老的"产品全案"),需要从 assets 中获取正确的预览图 + // 对于任务-详情类型,需要从 assets 中获取正确的预览图 const productPackageMaterialIds = materials .filter(m => isProductPackageType(m.materialType)) .map(m => m.id); @@ -754,7 +808,7 @@ export async function GET(request: NextRequest) { // 处理previewUrl的fallback逻辑 let previewUrl = material.previewUrl || material.videoCoverUrl || ''; - // 任务-详情类型(兼容老的"产品全案"):优先使用从 assets 获取的封面图 + // 任务-详情类型:优先使用从 assets 获取的封面图 if (isProductPackageType(material.materialType)) { const coverUrl = coverUrlMap.get(material.id); if (coverUrl) { diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 444bc69..f517a32 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -24,6 +24,7 @@ export async function PUT(request: NextRequest) { const materialType = (formData.get('materialType') as string) || '3D场景'; const renderer = formData.get('renderer') as string | null; const authorName = formData.get('authorName') as string; + const newUserId = formData.get('userId') as string | null; // 添加userId字段 const previewImage = formData.get('previewImage') as File | null; // 使用统一的验证逻辑 @@ -104,6 +105,20 @@ export async function PUT(request: NextRequest) { materialType, authorName, }; + + // 如果提供了新的userId,则更新userId + if (newUserId && newUserId !== material.userId) { + // 验证新用户是否存在 + const newUser = await prisma.user.findUnique({ + where: { id: newUserId }, + }); + + if (!newUser) { + return NextResponse.json({ error: '指定的用户不存在' }, { status: 400 }); + } + + updateData.userId = newUserId; + } // 标准模型必须更新 productCategoryName if (requiresProductCategoryName(materialType)) { diff --git a/app/api/materials/upload/chunk/route.ts b/app/api/materials/upload/chunk/route.ts index 5abacb6..3f506b4 100644 --- a/app/api/materials/upload/chunk/route.ts +++ b/app/api/materials/upload/chunk/route.ts @@ -11,25 +11,49 @@ export const dynamic = 'force-dynamic' export const maxDuration = 10800 // 3 hours timeout export async function POST(req: NextRequest) { + const requestStartTime = Date.now() + console.log(`[UploadChunk] 📥 收到请求 at ${new Date().toISOString()}`) + try { + // 性能监控:认证 + const t1 = Date.now() + console.log(`[UploadChunk] 🔐 开始认证...`) const session = await getServerSession(authOptions) + const authTime = Date.now() - t1 + console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`) + if (!session) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } + // 性能监控:解析FormData + const t2 = Date.now() + console.log(`[UploadChunk] 📦 开始解析FormData...`) const formData = await req.formData() + const formDataTime = Date.now() - t2 + console.log(`[UploadChunk] 📦 FormData解析完成: ${formDataTime}ms`) + const uploadId = formData.get('uploadId') as string const chunkIndex = parseInt(formData.get('chunkIndex') as string) const chunk = formData.get('chunk') as File const fileName = formData.get('fileName') as string + + console.log(`[UploadChunk] 📊 Chunk ${chunkIndex}, 大小: ${(chunk.size / 1024 / 1024).toFixed(2)}MB`) if (!uploadId || isNaN(chunkIndex) || !chunk) { + console.error(`[UploadChunk] ❌ 参数错误: uploadId="${uploadId}", chunkIndex=${chunkIndex}, hasChunk=${!!chunk}`) return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }) } - // 获取上传会话(使用统一的接口,自动选择Redis或内存Map) + // 性能监控:获取会话 + const t3 = Date.now() + console.log(`[UploadChunk] 🔍 开始获取会话: ${uploadId}`) const sessionData = await getUploadSession(uploadId) + const getSessionTime = Date.now() - t3 + console.log(`[UploadChunk] 🔍 获取会话完成: ${getSessionTime}ms, 会话存在: ${!!sessionData}`) + if (!sessionData) { + console.log(`[UploadChunk] ❌ 会话不存在: ${uploadId}`) return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) } @@ -38,27 +62,59 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) } - // 保存块到临时文件 + // 验证chunkIndex范围 + if (chunkIndex < 0 || chunkIndex >= sessionData.totalChunks) { + console.error(`[UploadChunk] ❌ chunkIndex超出范围: ${chunkIndex}, 总数: ${sessionData.totalChunks}`) + return NextResponse.json({ error: `chunkIndex超出范围: ${chunkIndex}/${sessionData.totalChunks}` }, { status: 400 }) + } + + // 性能监控:准备目录 + const t4 = Date.now() const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId) await mkdir(tempDir, { recursive: true }) + const mkdirTime = Date.now() - t4 + // 性能监控:转换Buffer + const t5 = Date.now() const chunkFilePath = path.join(tempDir, `chunk-${chunkIndex}`) const chunkBuffer = Buffer.from(await chunk.arrayBuffer()) + const bufferTime = Date.now() - t5 + + // 性能监控:写入文件 + const t6 = Date.now() await writeFile(chunkFilePath, chunkBuffer) + const writeFileTime = Date.now() - t6 - // 记录已上传的块(使用统一的接口,自动选择Redis或内存Map) + // 性能监控:更新会话 + const t7 = Date.now() await updateChunk(uploadId, chunkIndex, chunkFilePath) + const updateChunkTime = Date.now() - t7 - // 重新获取会话以获取最新的块数量 - const updatedSession = await getUploadSession(uploadId) - const uploadedChunksCount = updatedSession?.chunks.size || sessionData.chunks.size + 1 - - console.log(`[UploadChunk] Saved chunk ${chunkIndex}/${sessionData.totalChunks} for ${uploadId}`) + // 计算总耗时 + const totalTime = Date.now() - requestStartTime + + // 输出详细的性能分析 + console.log(`[UploadChunk] ⏱️ Chunk ${chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, { + 认证: `${authTime}ms (${(authTime/totalTime*100).toFixed(1)}%)`, + FormData解析: `${formDataTime}ms (${(formDataTime/totalTime*100).toFixed(1)}%)`, + 获取会话: `${getSessionTime}ms (${(getSessionTime/totalTime*100).toFixed(1)}%)`, + 创建目录: `${mkdirTime}ms (${(mkdirTime/totalTime*100).toFixed(1)}%)`, + 转换Buffer: `${bufferTime}ms (${(bufferTime/totalTime*100).toFixed(1)}%)`, + 写入文件: `${writeFileTime}ms (${(writeFileTime/totalTime*100).toFixed(1)}%)`, + 更新会话: `${updateChunkTime}ms (${(updateChunkTime/totalTime*100).toFixed(1)}%)`, + 大小: `${(chunk.size / 1024 / 1024).toFixed(2)}MB` + }) + + // 如果FormData解析时间占比超过80%,说明是网络接收慢 + if (formDataTime / totalTime > 0.8) { + console.log(`[UploadChunk] ⚠️ 警告: FormData解析占比${(formDataTime/totalTime*100).toFixed(1)}%,可能是网络接收慢!`) + } return NextResponse.json({ success: true, - uploadedChunks: uploadedChunksCount, + uploadedChunks: sessionData.chunks.size + 1, totalChunks: sessionData.totalChunks, + serverProcessTime: totalTime, // 返回给前端 }) } catch (error) { console.error('[UploadChunk] Error:', error) diff --git a/app/api/materials/upload/complete/route.ts b/app/api/materials/upload/complete/route.ts index e5140b0..dfc7beb 100644 --- a/app/api/materials/upload/complete/route.ts +++ b/app/api/materials/upload/complete/route.ts @@ -98,7 +98,15 @@ export async function POST(req: NextRequest) { console.warn('[UploadComplete] 清理分片失败:', result.errors) } - // 不立刻删除会话,方便后续调试。交由调用方完成正式入库后再清理。 + // 上传完成后,立即删除会话(不再占用Redis内存) + try { + const { deleteUploadSession } = await import('@/lib/upload-sessions-redis') + await deleteUploadSession(uploadId) + console.log('[UploadComplete] 已删除会话:', uploadId) + } catch (deleteError) { + console.warn('[UploadComplete] 删除会话失败(忽略):', deleteError) + } + // 返回合并结果 return NextResponse.json({ message: '分片合并完成', diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index 5c7b4d3..b2f01c1 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -280,32 +280,50 @@ export async function POST(request: NextRequest) { throw new Error(`无效的资产数据: ${asset.fileName}`); } - // 压缩并上传预览图 - const { compressImage } = await import('@/lib/image-processor'); - const compressedBuffer = await compressImage(buffer, { - maxWidth: compressOptions.maxWidth, - maxHeight: compressOptions.maxHeight, - maxSizeKB: compressOptions.maxSizeKB, - format: 'webp', - }); + // 检查是否为详情图,如果是超长图片则使用切片处理 + const { compressImage, sliceImage } = await import('@/lib/image-processor'); + const sharp = await import('sharp'); + const metadata = await sharp.default(buffer).metadata(); - const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).substring(2, 9); - const fileName = `${type}-preview-${timestamp}-${i}-${randomSuffix}.webp`; - const s3Path = `product-package/${fileName}`; - + let buffersToUpload: Buffer[]; + + // 详情图高度超过1500px时,使用切片处理 + if (type === 'detail' && metadata.height && metadata.height > 1500) { + console.log(`[Preview] 检测到超长详情图: ${metadata.width}x${metadata.height}, 使用切片处理`); + buffersToUpload = await sliceImage(buffer, 1500, 200); + console.log(`[Preview] 切片完成: ${buffersToUpload.length} 个片段`); + } else { + // 普通压缩处理 + const compressedBuffer = await compressImage(buffer, { + maxWidth: compressOptions.maxWidth, + maxHeight: compressOptions.maxHeight, + maxSizeKB: compressOptions.maxSizeKB, + format: 'webp', + }); + buffersToUpload = [compressedBuffer]; + } + + // 上传每个切片/压缩后的图片 const { uploadToS3 } = await import('@/lib/s3'); - const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp'); - previews.push(previewUrl); - - console.log(`[Preview] Generated preview ${i + 1}/${assets.length}:`, previewUrl); + for (let j = 0; j < buffersToUpload.length; j++) { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 9); + const sliceSuffix = buffersToUpload.length > 1 ? `-slice${j}` : ''; + const fileName = `${type}-preview-${timestamp}-${i}${sliceSuffix}-${randomSuffix}.webp`; + const s3Path = `product-package/${fileName}`; + + const previewUrl = await uploadToS3(buffersToUpload[j], s3Path, 'image/webp'); + previews.push(previewUrl); + } + + console.log(`[Preview] Generated ${buffersToUpload.length} preview(s) for ${i + 1}/${assets.length}`); } catch (err) { const error = `生成预览图失败 ${asset.fileName}: ${err instanceof Error ? err.message : String(err)}`; console.error(`[Preview] ${error}`); throw new Error(error); } } - + return previews; }; @@ -451,13 +469,21 @@ export async function POST(request: NextRequest) { psdPermanentPaths // 使用永久路径 ); - // 步骤6: 更新素材记录(写入taskNo和状态) + // 步骤6: 更新素材记录(写入taskNo、状态和previewUrl) console.log('[Product Package] Step 6: 更新素材记录...'); + // 确定首页卡片的预览图:优先主图,其次SKU图,最后详情图 + const cardPreviewUrl = + previewUrls.main[0] || + previewUrls.sku[0] || + previewUrls.detail[0] || + material.previewUrl; // 保留原有值作为fallback + await prisma.material.update({ where: { id: materialId }, data: { ...(taskNo && taskNo.trim() ? { taskNo } : {}), taskStatus: TASK_STATUS.COMPLETED, + previewUrl: cardPreviewUrl, // 更新首页卡片预览图 }, }); diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index c452824..5f96279 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -25,6 +25,18 @@ const AUDIO_PLACEHOLDER = (() => { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() +// PDF素材占位符预览图(SVG格式) +const PDF_PLACEHOLDER = (() => { + const svg = `PDF品牌VI灵感` + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` +})() + +// SKU模板占位符预览图(SVG格式) +const SKU_TEMPLATE_PLACEHOLDER = (() => { + const svg = `SKU模版` + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` +})() + // 生成唯一文件名 function generateUniqueFilename(prefix: string, ext: string): string { const timestamp = Date.now(); @@ -356,29 +368,28 @@ export async function POST(request: NextRequest) { if (materialType === '品牌VI灵感') { console.log('[Upload] 开始处理品牌VI灵感PDF预览图提取...'); console.log('[Upload] PDF文件路径:', zipFilePath); - + // 强制提取PDF预览图,即使已有previewUrl也要重新提取(确保是图片而不是PDF文件) const pdfResult = await processPdfUpload(zipFilePath, ''); - + console.log('[Upload] PDF预览图提取结果:', { success: pdfResult.success, hasPreviewUrl: !!pdfResult.previewUrl, previewUrl: pdfResult.previewUrl, error: pdfResult.error }); - + if (pdfResult.success && pdfResult.previewUrl) { previewUrl = pdfResult.previewUrl; console.log('[Upload] ✅ PDF预览图提取成功,已设置previewUrl:', previewUrl); } else { const errorMsg = pdfResult.error || '未知错误'; console.error('[Upload] ❌ PDF预览图提取失败:', errorMsg); - console.error('[Upload] 这会导致首页卡片显示占位符,请检查:'); - console.error('[Upload] 1. PDF文件是否存在:', zipFilePath); - console.error('[Upload] 2. poppler-utils是否已安装'); - console.error('[Upload] 3. PDF文件是否有效'); - // PDF预览提取失败时,previewUrl保持为空,首页卡片会显示占位符 - previewUrl = ''; + console.error('[Upload] 使用占位符作为降级方案'); + + // 降级方案:使用PDF占位符,确保首页卡片有内容显示 + previewUrl = PDF_PLACEHOLDER; + console.warn('[Upload] ⚠️ 已使用占位符,建议后续手动上传预览图或重新上传PDF'); } } @@ -486,7 +497,13 @@ export async function POST(request: NextRequest) { console.log('[Upload] previewUrl是否为图片文件:', previewUrl && (previewUrl.endsWith('.jpg') || previewUrl.endsWith('.jpeg') || previewUrl.endsWith('.png') || previewUrl.endsWith('.webp'))); console.log('[Upload] ============================================'); } - + + // SKU模板:如果没有预览图,使用占位符 + if (materialType === 'SKU模版' && (!previewUrl || previewUrl.trim() === '')) { + console.warn('[Upload] ⚠️ SKU模板没有预览图,使用占位符'); + previewUrl = SKU_TEMPLATE_PLACEHOLDER; + } + // 创建素材记录(不写入taskNo,上传完成后再写入) const material = await prisma.material.create({ data: { diff --git a/app/api/materials/upload/task-page/complete/route.ts b/app/api/materials/upload/task-page/complete/route.ts new file mode 100644 index 0000000..116aaa6 --- /dev/null +++ b/app/api/materials/upload/task-page/complete/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { + createZipFromFiles, + generatePreviewImages, + cleanupTempFiles, + cleanupOnError, + saveAssets, +} from '@/lib/task-material-processor'; + +// POST - 完成任务-页面上传 +export async function POST(request: NextRequest) { + let materialId: string | null = null; + const tempFiles: string[] = []; // 需要清理的临时文件路径 + let zipFilePath: string | null = null; + const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs + const uploadIds: string[] = []; // 需要清理的uploadId列表 + + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const body = await request.json(); + const { materialId: matId, assets } = body; + + materialId = matId; + + if (!materialId || !assets) { + return NextResponse.json( + { error: '缺少必填字段' }, + { status: 400 } + ); + } + + if (!assets.main || assets.main.length === 0) { + return NextResponse.json( + { error: '至少需要上传一张预览图' }, + { status: 400 } + ); + } + + console.log('[Task Page Complete] Material ID:', materialId); + console.log('[Task Page Complete] Preview images count:', assets.main.length); + + // 验证素材是否存在且属于当前用户 + const material: any = await prisma.material.findUnique({ + where: { id: materialId }, + }); + + if (!material) { + return NextResponse.json( + { success: false, error: '素材不存在' }, + { status: 404 } + ); + } + + if (material.userId !== (session.user as any).id) { + return NextResponse.json( + { success: false, error: '无权限操作此素材' }, + { status: 403 } + ); + } + + // 步骤1: 从chunk临时目录读取预览图文件 + console.log(`[Task Page] Processing ${assets.main.length} preview images...`); + + const { getUploadedFile } = await import('@/lib/upload-sessions'); + const previewFiles: Array<{ path: string; name: string; size: number }> = []; + + for (const asset of assets.main) { + try { + const filePath = await getUploadedFile(asset.uploadId); + if (filePath) { + previewFiles.push({ + path: filePath, + name: asset.fileName, + size: asset.fileSize, + }); + tempFiles.push(filePath); + uploadIds.push(asset.uploadId); + } else { + throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`); + } + } catch (error) { + console.error(`[Task Page] 获取预览图文件失败 ${asset.uploadId}:`, error); + throw new Error(`获取预览图文件失败: ${asset.fileName}`); + } + } + + // 步骤2: 打包预览图成zip(保存到本地文件系统) + console.log(`[Task Page] Creating zip from ${previewFiles.length} preview files...`); + const zipInfo = await createZipFromFiles( + previewFiles, + materialId!, + material.productModel || 'task-page', + '任务-页面' + ); + console.log(`[Task Page] Zip created: ${zipInfo.fileName} (${zipInfo.fileSize} bytes)`); + zipFilePath = zipInfo.filePath; + + // 步骤3: 生成预览图并上传到S3 + const previewUrls = await generatePreviewImages( + assets.main.map((a: any) => ({ uploadId: a.uploadId, fileName: a.fileName })), + '任务-页面' + ); + uploadedPreviewUrls.push(...previewUrls); + + if (uploadedPreviewUrls.length === 0) { + throw new Error('生成预览图失败,至少需要一张预览图'); + } + + // 步骤4: 保存资产记录到数据库 + await saveAssets(materialId!, uploadedPreviewUrls, zipInfo); + + // 步骤5: 清理临时文件 + await cleanupTempFiles(tempFiles, uploadIds); + + console.log('[Task Page] Complete success!'); + return NextResponse.json({ + success: true, + materialId, + }); + } catch (error: any) { + console.error('[Task Page Complete Error]', error); + + // 清理失败时的资源 + await cleanupOnError(uploadedPreviewUrls, zipFilePath, tempFiles, materialId); + + return NextResponse.json( + { error: error.message || '完成上传失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/materials/upload/task-page/init/route.ts b/app/api/materials/upload/task-page/init/route.ts new file mode 100644 index 0000000..b178f0b --- /dev/null +++ b/app/api/materials/upload/task-page/init/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// POST - 初始化任务-页面素材记录 +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + const body = await request.json(); + const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + + // 验证必填字段 + if (!productModel || !categoryName || !sceneName) { + return NextResponse.json( + { error: '缺少必填字段' }, + { status: 400 } + ); + } + + // 验证任务号格式(如果提供) + if (taskNo) { + const taskNoRegex = /^TASK\d{12,18}(-\d{2})*$/; + if (!taskNoRegex.test(taskNo)) { + return NextResponse.json( + { error: '任务号格式错误' }, + { status: 400 } + ); + } + + // 检查任务号是否已存在(排除已删除的素材) + const existingMaterial = await prisma.material.findFirst({ + where: { + taskNo, + deletedAt: null, // 只检查未删除的素材 + }, + select: { id: true, productModel: true, materialType: true }, + }); + + if (existingMaterial) { + return NextResponse.json( + { error: `任务号 ${taskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})` }, + { status: 400 } + ); + } + } + + // 获取用户信息 + const user = await prisma.user.findUnique({ + where: { id: (session.user as any).id }, + select: { username: true }, + }); + + if (!user) { + return NextResponse.json( + { error: '用户不存在' }, + { status: 404 } + ); + } + + // 创建素材记录(使用临时预览图) + const material = await prisma.material.create({ + data: { + materialType: '任务-页面', + productModel, + categoryName, + sceneName, + platformName: platformName || '', + productCategoryName: productCategoryName || '', + authorName: user.username, + userId: (session.user as any).id, + previewUrl: '', // 临时空值,完成上传时更新 + zipFilePath: '', // 临时空值,完成上传时更新 + zipFileName: '', // 临时空值,完成上传时更新 + renderer: '', // 任务-页面不需要渲染器 + taskNo: taskNo || null, // 任务号(可选) + }, + }); + + return NextResponse.json({ + success: true, + materialId: material.id, + }); + } catch (error: any) { + console.error('[Task Page Init Error]', error); + + // 捕获 Prisma 唯一约束错误(任务号重复) + if (error?.code === 'P2002' && error?.meta?.target?.includes('task_no')) { + return NextResponse.json( + { error: `任务号已被使用,请检查是否重复提交` }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: error.message || '初始化失败' }, + { status: 500 } + ); + } +} diff --git a/app/api/performance/quick/route.ts b/app/api/performance/quick/route.ts index bfe7577..407df37 100644 --- a/app/api/performance/quick/route.ts +++ b/app/api/performance/quick/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import os from 'os' import { exec } from 'child_process' import { promisify } from 'util' -import { uploadSessions } from '@/lib/upload-sessions' +import { getAllSessions } from '@/lib/upload-sessions-redis' import { transcodeQueue } from '@/lib/video-transcode-queue' import { prisma } from '@/lib/prisma' import { getNetworkSpeed } from '@/lib/network-bandwidth-monitor' @@ -115,13 +115,20 @@ export async function GET() { interface: networkSpeed?.interface || 'unknown', // 使用的网卡 } - // 2. 上传队列(基于 uploadSessions) + // 2. 上传队列(从 Redis 获取,只显示真正活跃的上传) const uploadProcessing: any[] = [] const uploadWaiting: any[] = [] const uploadUserIds = new Set() const uploadIds = new Set() - // 只显示真正进行中的上传(进度 < 100%) + // 从 Redis 获取所有上传会话 + const uploadSessions = await getAllSessions() + + // 只显示真正进行中的上传(进度 < 100% 且最近有活动) + const now = Date.now() + const ACTIVE_THRESHOLD = 30 * 60 * 1000 // 30分钟(毫秒) + const PROGRESS_THRESHOLD = 95 // 进度超过95%的认为可能已放弃 + uploadSessions.forEach((session, uploadId) => { const completedChunks = session.chunks.size const totalChunks = session.totalChunks || 1 @@ -131,16 +138,24 @@ export async function GET() { ) // 只显示未完成的上传(进度 < 100%) + // 并且:最近30分钟内创建 或 进度不超过95% + // 这样过滤掉长期无活动和可能已放弃的上传 if (progress < 100) { - uploadUserIds.add(session.userId) - uploadIds.add(uploadId) - uploadProcessing.push({ - uploadId, - fileName: session.fileName, - progress, - userId: session.userId, - currentSpeed: (session as any).currentSpeed || 0, // 上传速度(字节/秒) - }) + const age = now - session.createdAt + const isRecent = age < ACTIVE_THRESHOLD + const isNotAbandoned = progress < PROGRESS_THRESHOLD + + if (isRecent || isNotAbandoned) { + uploadUserIds.add(session.userId) + uploadIds.add(uploadId) + uploadProcessing.push({ + uploadId, + fileName: session.fileName, + progress, + userId: session.userId, + currentSpeed: (session as any).currentSpeed || 0, // 上传速度(字节/秒) + }) + } } }) diff --git a/app/api/proxy-media/route.ts b/app/api/proxy-media/route.ts index 3a19d6e..92268e4 100644 --- a/app/api/proxy-media/route.ts +++ b/app/api/proxy-media/route.ts @@ -13,8 +13,27 @@ dns.setServers(['223.5.5.5', '8.8.8.8', '114.114.114.114']); // DNS 解析缓存(避免高并发时DNS解析失败) const dnsCache = new Map(); const DNS_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 +const MAX_DNS_CACHE_SIZE = 1000; // 最大缓存1000个域名 const resolve4 = promisify(dns.resolve4); +// 定期清理过期的DNS缓存(每10分钟) +setInterval(() => { + const now = Date.now(); + const keysToDelete: string[] = []; + + dnsCache.forEach((value, key) => { + if (value.expires < now) { + keysToDelete.push(key); + } + }); + + keysToDelete.forEach(key => dnsCache.delete(key)); + + if (keysToDelete.length > 0) { + console.log(`[DNS Cache] 清理 ${keysToDelete.length} 个过期缓存,当前缓存数: ${dnsCache.size}`); + } +}, 10 * 60 * 1000); + // DNS 解析函数(带缓存和重试) async function resolveHostnameWithRetry(hostname: string, retries = 3): Promise { // 检查缓存 @@ -29,6 +48,16 @@ async function resolveHostnameWithRetry(hostname: string, retries = 3): Promise< const addresses = await resolve4(hostname); if (addresses && addresses.length > 0) { const ip = addresses[0]; + + // 检查缓存大小,如果超过限制,删除最旧的缓存(LRU) + if (dnsCache.size >= MAX_DNS_CACHE_SIZE) { + const firstKey = dnsCache.keys().next().value; + if (firstKey) { + dnsCache.delete(firstKey); + console.log(`[DNS Cache] 缓存已满,删除最旧的缓存: ${firstKey}`); + } + } + // 更新缓存 dnsCache.set(hostname, { ip, diff --git a/app/api/users/list/route.ts b/app/api/users/list/route.ts new file mode 100644 index 0000000..9dfe20e --- /dev/null +++ b/app/api/users/list/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +// GET /api/users/list - 获取用户列表(用于下拉选择) +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }); + } + + // 只获取激活的用户 + const users = await prisma.user.findMany({ + where: { + isActive: true, + status: 'active', + }, + select: { + id: true, + username: true, + realName: true, + email: true, + }, + orderBy: { + username: 'asc', + }, + }); + + // 返回用户列表,优先显示真实姓名 + const userList = users.map(user => ({ + id: user.id, + username: user.username, + displayName: user.realName || user.username, + email: user.email, + })); + + return NextResponse.json(userList, { status: 200 }); + } catch (error) { + console.error('Error fetching users:', error); + return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 }); + } +} diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index b552185..8d3bed4 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' import Header from '@/components/Header' +import SearchableUserSelect from '@/components/SearchableUserSelect' import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName } from '@/lib/material-validation' interface Material { @@ -36,11 +37,13 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [materialType, setMaterialType] = useState('') const [renderer, setRenderer] = useState('') const [authorName, setAuthorName] = useState('') + const [userId, setUserId] = useState('') const [categories, setCategories] = useState>([]) const [scenes, setScenes] = useState>([]) const [platforms, setPlatforms] = useState>([]) const [productCategories, setProductCategories] = useState>([]) + const [users, setUsers] = useState>([]) const materialTypes = [ '3D场景', @@ -80,6 +83,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) fetchScenes() fetchPlatforms() fetchProductCategories() + fetchUsers() } }, [status, params.id]) @@ -122,6 +126,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setMaterialType(data.materialType || '') setRenderer(data.renderer || '') setAuthorName(data.authorName || '') + setUserId(data.userId || '') setLoading(false) } catch (error: any) { console.error('获取素材失败:', error) @@ -180,6 +185,18 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) } } + const fetchUsers = async () => { + try { + const response = await fetch('/api/users/list') + if (response.ok) { + const data = await response.json() + setUsers(data) + } + } catch (error) { + console.error('获取用户列表失败:', error) + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') @@ -221,7 +238,8 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) productCategoryName: productCategoryName || null, materialType, renderer, - authorName + authorName, + userId }) }) @@ -386,15 +404,21 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
- setAuthorName(e.target.value)} - className="input-apple" + { + setUserId(newUserId) + setAuthorName(displayName) + }} + placeholder="请输入或搜索用户(支持拼音)" required /> +

+ 修改作者将改变素材的实际所有者。支持拼音搜索,例如输入"zs"可以找到"张三" +

diff --git a/app/page.tsx b/app/page.tsx index 4ee1b06..bfa7815 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -65,7 +65,8 @@ export default function Home() { const [searchKeyword, setSearchKeyword] = useState('') const [selectedCategory, setSelectedCategory] = useState('') const [selectedScene, setSelectedScene] = useState('') - const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景 + const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部 + // const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部) const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选 const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选 const [categories, setCategories] = useState>([]) @@ -118,6 +119,56 @@ export default function Home() { const transcodeStartTimes = useRef>(new Map()) const transcodePollingFlags = useRef>(new Map()) // 标记是否正在轮询 const fetchMaterialsRef = useRef(null) // 存储fetchMaterials函数引用 + + // ✅ 清理转码Map的函数(直接操作Map,避免循环依赖) + const cleanupTranscodeMaps = useCallback(() => { + const now = Date.now() + const MAX_AGE = 30 * 60 * 1000 // 30分钟 + let cleanedCount = 0 + + // 清理超过30分钟的条目 + transcodeStartTimes.current.forEach((startTime, materialId) => { + if (now - startTime > MAX_AGE) { + const timer = transcodePollTimers.current.get(materialId) + if (timer) { + clearTimeout(timer) + transcodePollTimers.current.delete(materialId) + } + transcodePollingFlags.current.delete(materialId) + transcodeStartTimes.current.delete(materialId) + cleanedCount++ + } + }) + + // 清理不在当前素材列表中的条目 + const currentMaterialIds = new Set(materials.map(m => m.id)) + transcodePollTimers.current.forEach((timer, materialId) => { + if (!currentMaterialIds.has(materialId)) { + clearTimeout(timer) + transcodePollTimers.current.delete(materialId) + transcodePollingFlags.current.delete(materialId) + transcodeStartTimes.current.delete(materialId) + cleanedCount++ + } + }) + + if (cleanedCount > 0) { + console.log(`[TranscodePoll] Cleaned up ${cleanedCount} old entries. Current size: ${transcodePollTimers.current.size}`) + } + }, [materials]) + + // ✅ 定期清理转码Map(每10分钟) + useEffect(() => { + const interval = setInterval(() => { + if (transcodePollTimers.current.size > 0) { + console.log(`[TranscodePoll] Periodic cleanup check. Current size: ${transcodePollTimers.current.size}`) + cleanupTranscodeMaps() + } + }, 10 * 60 * 1000) // 10分钟 + + return () => clearInterval(interval) + }, [cleanupTranscodeMaps]) + // 音频播放状态 const audioRef = useRef(null) const [playingAudioId, setPlayingAudioId] = useState(null) @@ -372,7 +423,7 @@ export default function Home() { if (typeof window === 'undefined') return const params = new URLSearchParams() - if (selectedMaterialType && selectedMaterialType !== '3D场景') params.set('materialType', selectedMaterialType) + if (selectedMaterialType) params.set('materialType', selectedMaterialType) // 只有选择了素材类型才添加参数 if (selectedCategory) params.set('category', selectedCategory) if (selectedScene) params.set('scene', selectedScene) if (selectedPlatform) params.set('platform', selectedPlatform) @@ -424,10 +475,10 @@ export default function Home() { label: t, })) const hasSelected = finalList.some((t) => t.value === selectedMaterialType) - const preferredDefault = '3D场景' - const hasPreferred = finalList.some((t) => t.value === preferredDefault) - if (!hasSelected) { - setSelectedMaterialType(hasPreferred ? preferredDefault : '') + // const preferredDefault = '3D场景' // 已注释,改为默认展示全部 + // const hasPreferred = finalList.some((t) => t.value === preferredDefault) + if (!hasSelected && selectedMaterialType) { // 如果当前选中的类型不在列表中,且有选中值,则重置为空(全部) + setSelectedMaterialType('') } const mapped = finalList setMaterialTypes(mapped) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index efa72ad..47a00ba 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -734,10 +734,42 @@ export default function SharePage() { ? (hasDisplayImages ? [material.previewUrl, ...material.displayImages] : [material.previewUrl]) : (hasDisplayImages ? material.displayImages : []) - // 品牌VI灵感:优先显示展示图,如果没有展示图则显示PDF预览 + // 品牌VI灵感:优先使用previewUrl(PDF提取的预览图),其次使用displayImages,最后显示PDF iframe if (material.materialType === '品牌VI灵感') { + // 优先使用 previewUrl(上传时从PDF提取的第一页预览图) + if (material.previewUrl) { + return ( +
+
+ {`${material.productModel} +
+ {/* 如果有额外的displayImages,也显示出来 */} + {hasDisplayImages && material.displayImages.map((imageUrl, index) => ( +
+ {`${material.productModel} +
+ ))} +
+ ) + } + // 其次使用 displayImages if (hasDisplayImages && allPreviewImages.length > 0) { - /* 有展示图:显示多张展示图 */ return (
{allPreviewImages.map((imageUrl, index) => ( @@ -755,37 +787,36 @@ export default function SharePage() { ))}
) - } else { - /* 没有展示图:显示PDF预览 */ - // 构建PDF URL - let pdfUrl = '' - if (material.zipFileName) { - if (material.zipFileName.startsWith('http://') || material.zipFileName.startsWith('https://')) { - pdfUrl = material.zipFileName - } else { - pdfUrl = `/uploads/${material.zipFileName}` - } - } - - if (pdfUrl) { - return ( -
-