From 8c39d96249385d7b6fca974b172fef66cada1ca5 Mon Sep 17 00:00:00 2001 From: chenpei Date: Thu, 7 May 2026 16:37:22 +0800 Subject: [PATCH] chore: sync latest project source --- .claude/settings.local.json | 54 -- .dockerignore | 4 +- .gitignore | 16 + CLAUDE.md | 135 ++- Dockerfile | 7 +- app/api/admin/regenerate-preview/route.ts | 13 +- app/api/admin/system-status/route.ts | 2 +- app/api/admin/video-transcode-stats/route.ts | 5 +- .../external/dingtalk-material-match/route.ts | 271 ++++++ .../dingtalk-material-options/route.ts | 145 +++ app/api/external/download/route.ts | 2 +- app/api/external/product-assets/route.ts | 12 +- .../route.ts | 536 +++++++++++ app/api/materials/[id]/route.ts | 58 +- app/api/materials/[id]/skus/[skuId]/route.ts | 27 +- app/api/materials/[id]/skus/reorder/route.ts | 9 - app/api/materials/[id]/skus/route.ts | 57 +- app/api/materials/[id]/skus/upload/route.ts | 11 - .../materials/[id]/white-background/route.ts | 91 +- app/api/materials/route.ts | 15 + app/api/materials/share/route.ts | 49 +- app/api/materials/update/route.ts | 3 + .../certificate-report/complete/route.ts | 249 ++++++ .../upload/certificate-report/init/route.ts | 63 ++ .../upload/product-package/init/route.ts | 11 + app/api/materials/upload/route.ts | 92 +- .../taobao-detail-plus/image-proxy/route.ts | 128 +++ app/page.tsx | 253 +----- app/profile/page.tsx | 17 +- app/share/[id]/page.tsx | 197 ++-- components/DocumentAssetViewer.tsx | 160 ++++ components/EditMaterialModal.tsx | 64 +- components/HeaderTaobaoSearch.tsx | 276 +++--- components/NetworkDiagnoseModal.tsx | 28 +- components/SkuEditModal.tsx | 176 +++- components/SkuList.tsx | 21 +- components/TaobaoImageGalleryModal.tsx | 149 ++++ components/UploadModal.task-other.test.tsx | 74 ++ components/UploadModal.tsx | 159 +++- components/admin/MaterialManagement.tsx | 9 +- components/detail/ProductPackageDetail.tsx | 8 + components/detail/ThreeDOriginalDetail.tsx | 25 +- components/home/MaterialCard.test.tsx | 35 +- components/home/MaterialCard.tsx | 57 +- .../upload/CertificateReportUploader.tsx | 371 ++++++++ .../upload/ProductPackageUploader.test.tsx | 11 + components/upload/ProductPackageUploader.tsx | 26 + components/upload/UploadForm.tsx | 3 +- components/upload/file-handlers.ts | 3 +- components/upload/file-input-handlers.ts | 3 +- components/upload/special-batch-uploaders.ts | 7 +- components/upload/upload-constants.ts | 15 +- components/upload/upload-utils.test.ts | 19 + components/upload/upload-utils.ts | 6 +- docs/AI生图平台桶缓存落地对接清单.md | 83 +- docs/AI生图平台第一层桶缓存落地方案.md | 201 ++--- docs/AI生图白底图以及详情页面接口文档.md | 10 +- docs/NETWORK_TOPOLOGY.md | 285 +++--- docs/前端UI改动清单.md | 34 + docs/生产存储桶接入信息.md | 199 +++++ docs/素材库平台预览缓存方案.md | 146 ++- docs/统一流量访问规则.md | 346 ++----- docs/钉钉素材库接口对接文档.md | 254 ++++++ lib/__tests__/chunk-upload.test.ts | 80 ++ .../dingtalk-material-match-route.test.ts | 203 +++++ .../dingtalk-material-options-route.test.ts | 95 ++ lib/__tests__/external-api-key.test.ts | 40 + .../material-cleanup-task-page.test.ts | 14 +- lib/__tests__/material-edit-routes.test.ts | 104 ++- lib/__tests__/material-type-options.test.ts | 15 + lib/__tests__/material-update.test.ts | 195 ++++ .../materials-route-ai-generated.test.ts | 3 + .../materials-upload-task-other-route.test.ts | 239 +++++ lib/__tests__/network-entry-cleanup.test.ts | 21 + lib/__tests__/product-assets-route.test.ts | 70 ++ .../product-package-completion.test.ts | 36 + .../product-package-init-route.test.ts | 121 +++ lib/__tests__/public-display.test.ts | 46 + .../regenerate-preview-route.test.ts | 134 +++ .../taobao-detail-plus-route.test.ts | 112 +++ .../taobao-image-proxy-route.test.ts | 89 ++ lib/__tests__/task-material-processor.test.ts | 83 ++ lib/__tests__/video-material-types.test.ts | 50 ++ .../white-background-preview.test.ts | 12 +- .../white-background-upload-route.test.ts | 81 +- lib/certificate-report.ts | 57 ++ lib/chunk-upload.ts | 76 +- lib/department-permission.ts | 49 +- lib/excel-export.ts | 137 +++ lib/external-api.ts | 26 +- lib/history-material-import.ts | 844 ++++++++++++++++++ lib/history-sku-csv-import.ts | 677 ++++++++++++++ lib/material-cleanup.ts | 13 +- lib/material-type-mapper.ts | 17 +- lib/material-type-options.ts | 14 +- lib/material-update.ts | 35 +- lib/material-validation.ts | 8 +- lib/pdf-processor.ts | 34 +- lib/product-package-completion.ts | 104 ++- lib/public-display.ts | 8 + ...tandard-model-white-background-metadata.ts | 15 + lib/taobao-detail-plus.ts | 305 ++++++- lib/task-detail-source-type.ts | 21 + lib/task-material-processor.ts | 38 +- lib/url-rewrite.ts | 6 +- lib/video-material-types.ts | 73 ++ lib/video-transcode-queue.ts | 4 +- lib/white-background-preview.ts | 10 + middleware.ts | 8 - package-lock.json | 654 +++++++++++++- package.json | 5 + .../migration.sql | 5 + .../migration.sql | 73 ++ .../migration.sql | 17 + .../migration.sql | 8 + .../migration.sql | 9 + .../migration.sql | 4 + .../migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 4 + .../migration.sql | 7 + .../migration.sql | 29 + .../migrations/product_package_migration.sql | 107 +++ prisma/schema.prisma | 33 +- scripts/add_windows_path_to_sku_exports.js | 140 +++ scripts/build-history-import-retry-files.ts | 179 ++++ scripts/export_sku_folder_db.py | 121 +++ scripts/filter_sku_export_zero_rows.js | 123 +++ scripts/import-history-materials.ts | 88 ++ scripts/query_sku_folder_db.py | 58 ++ scripts/remove_sku_rows_by_windows_prefix.js | 92 ++ scripts/run-history-import-daemon.sh | 69 ++ scripts/run-history-import-queue.ts | 189 ++++ scripts/scan-history-from-sku-csv.ts | 109 +++ scripts/scan-history-materials.ts | 79 ++ scripts/scan_sku_folders_to_sqlite.py | 313 +++++++ 136 files changed, 11130 insertions(+), 1607 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 app/api/external/dingtalk-material-match/route.ts create mode 100644 app/api/external/dingtalk-material-options/route.ts create mode 100644 app/api/external/standard-model-sku-white-background/route.ts create mode 100644 app/api/materials/upload/certificate-report/complete/route.ts create mode 100644 app/api/materials/upload/certificate-report/init/route.ts create mode 100644 app/api/taobao-detail-plus/image-proxy/route.ts create mode 100644 components/DocumentAssetViewer.tsx create mode 100644 components/TaobaoImageGalleryModal.tsx create mode 100644 components/UploadModal.task-other.test.tsx create mode 100644 components/upload/CertificateReportUploader.tsx create mode 100644 components/upload/upload-utils.test.ts create mode 100644 docs/前端UI改动清单.md create mode 100644 docs/生产存储桶接入信息.md create mode 100644 docs/钉钉素材库接口对接文档.md create mode 100644 lib/__tests__/dingtalk-material-match-route.test.ts create mode 100644 lib/__tests__/dingtalk-material-options-route.test.ts create mode 100644 lib/__tests__/external-api-key.test.ts create mode 100644 lib/__tests__/material-type-options.test.ts create mode 100644 lib/__tests__/materials-upload-task-other-route.test.ts create mode 100644 lib/__tests__/network-entry-cleanup.test.ts create mode 100644 lib/__tests__/product-package-init-route.test.ts create mode 100644 lib/__tests__/public-display.test.ts create mode 100644 lib/__tests__/regenerate-preview-route.test.ts create mode 100644 lib/__tests__/taobao-image-proxy-route.test.ts create mode 100644 lib/__tests__/task-material-processor.test.ts create mode 100644 lib/__tests__/video-material-types.test.ts create mode 100644 lib/certificate-report.ts create mode 100644 lib/excel-export.ts create mode 100644 lib/history-material-import.ts create mode 100644 lib/history-sku-csv-import.ts create mode 100644 lib/standard-model-white-background-metadata.ts create mode 100644 lib/task-detail-source-type.ts create mode 100644 lib/video-material-types.ts create mode 100644 prisma/migrations/20251230_add_maintenance_notification/migration.sql create mode 100644 prisma/migrations/20251231_151735_add_employment_status_and_material_transfer/migration.sql create mode 100644 prisma/migrations/20251231_160000_add_user_status_fields/migration.sql create mode 100644 prisma/migrations/20260115_add_brand_field_and_3d_original/migration.sql create mode 100644 prisma/migrations/20260115_rename_material_types_and_remove_brand/migration.sql create mode 100644 prisma/migrations/20260318_add_material_is_full_set/migration.sql create mode 100644 prisma/migrations/20260319_add_material_sku_lookup_index/migration.sql create mode 100644 prisma/migrations/20260423_add_task_detail_source_type/migration.sql create mode 100644 prisma/migrations/20260428_add_report_title_for_certificate_report/migration.sql create mode 100644 prisma/migrations/20260430_200000_rename_video_material_types/migration.sql create mode 100644 prisma/migrations/20260430_add_standard_model_white_background_metadata/migration.sql create mode 100644 prisma/migrations/product_package_migration.sql create mode 100644 scripts/add_windows_path_to_sku_exports.js create mode 100644 scripts/build-history-import-retry-files.ts create mode 100644 scripts/export_sku_folder_db.py create mode 100644 scripts/filter_sku_export_zero_rows.js create mode 100644 scripts/import-history-materials.ts create mode 100644 scripts/query_sku_folder_db.py create mode 100644 scripts/remove_sku_rows_by_windows_prefix.js create mode 100644 scripts/run-history-import-daemon.sh create mode 100644 scripts/run-history-import-queue.ts create mode 100644 scripts/scan-history-from-sku-csv.ts create mode 100644 scripts/scan-history-materials.ts create mode 100644 scripts/scan_sku_folders_to_sqlite.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 7363f5f..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "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)", - "Bash(ping:*)", - "Bash(Test-NetConnection:*)", - "Bash(Select-Object ComputerName, RemotePort, TcpTestSucceeded)", - "Bash(nc:*)", - "Bash(ssh prod:*)", - "Bash(npx tsc:*)" - ] - } -} diff --git a/.dockerignore b/.dockerignore index c9003ba..b4edec3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -43,7 +43,9 @@ cache/** .env*.local # 用户上传的文件(关键优化!) -uploads +# 这里只排除项目根目录的真实上传文件,不能误伤 app/api/uploads、app/uploads 这类路由目录 +/uploads +/uploads/** # Prisma 引擎缓存(不需要在构建上下文中) prisma/engine-cache diff --git a/.gitignore b/.gitignore index dce783b..55284c9 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,22 @@ missing-video-files*.json /output/ /截图/ +# Local agent/browser artifacts +/.claude/settings.local.json +/.claude/worktrees/ +/.playwright-mcp/ + +# Temporary investigation/probe artifacts +/tmp/ +/tmp_epplus_probe/ +/tmp_excel_cell_image_probe/ + +# Root-level generated export/conversation files +/*.csv +/*.xlsx +/*.ison +/历史详情导入失败汇总.md + # 垃圾文件(用 Git 管理,不需要备份) *.bak diff --git a/CLAUDE.md b/CLAUDE.md index b764126..bb4af47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,14 @@ npm run lint # ESLint **物理服务器:** - 生产服务器:`192.168.58.20` - 构建服务器:`192.168.42.134` +- 香港公网服务器:`103.118.245.6`(FRP 入口 + Nginx 反向代理) + +**公网访问架构:** +- 域名:`sc.jiansun.vip`(素材平台)、`s3.jiansun.vip`(MinIO) +- 公网入口:香港服务器 103.118.245.6 +- FRP 服务:`frps-jiansun-entry`(端口 7002) +- 生产服务器通过 FRPC 连接到香港服务器 +- Nginx 反向代理:sc.jiansun.vip → 127.0.0.1:2321,s3.jiansun.vip → 127.0.0.1:2323 **Docker 网络:** - 网络名称:`youyidian-3d_app-network` @@ -122,7 +130,8 @@ docker restart youyidian-3d-material - 文件路径:`由壹点AI/` **公网访问域名:** -- URL:`https://s3.yydd.ai/gongxiangpan` +- URL:`https://s3.jiansun.vip/gongxiangpan` +- 展示缓存前缀:`/gongxiangpan/public-display/` - 预览链接过期时间:7200 秒(2 小时) - 路径样式:已启用 @@ -154,10 +163,13 @@ docker restart youyidian-3d-material |--------|--------|---------|---------|------| | 生产服务器 | 192.168.58.20 | `prod` | `youyidian_prod_ed25519` | 应用容器、数据库、Redis | | 构建服务器 | 192.168.42.134 | `build` | `id_rsa_build_192.168.42.134` | Docker镜像构建 | -| FRP服务器 | 180.97.221.140 | `frp` | `id_rsa_frp_180.97.221.140` | 内网穿透 | +| 香港公网服务器 | 103.118.245.6 | - | - | FRP 入口、Nginx 反向代理 | | License服务器 | 122.51.149.232 | `license-server` | `id_ed25519_license_122.51.149.232` | License服务 | | 腾讯云服务器 | 45.249.208.118 | `tencent` | `tencent_server.pem` | 腾讯云服务器 | +**已废弃服务器:** +- 旧镇江服务器 180.97.221.140(已封禁,不再使用) + **未识别的密钥**: - `id_rsa` / `id_rsa.pub` - 默认密钥(保留不动) - `flnxilicenseserver.pem` - 可能是 license 服务器的备用密钥(PEM格式) @@ -213,6 +225,7 @@ ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env 当密码变更时,在此处记录: - **2026-01-27**:初始记录,建立密码管理机制 +- **2026-05-05**:公网接入迁移到香港服务器 103.118.245.6 --- @@ -229,6 +242,9 @@ ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env # 强制重新构建(无缓存) .\deploy-remote-build.ps1 -NoCache + +# 深度清理构建缓存(默认保留缓存以加快构建) +.\deploy-remote-build.ps1 -AggressiveBuildCacheCleanup ``` **部署流程:** @@ -239,6 +255,12 @@ ssh root@192.168.58.20 "cat /root/youyidian-3d/.env 2>/dev/null || echo 'No .env 5. 拉取到生产服务器 (192.168.58.20) 6. 在端口 4320(生产)或 5320(影子)启动容器 +**部署脚本优化(2026-05-05):** +- 默认保留构建缓存,加快小改动的构建速度 +- 打包完整性校验:SHA256 哈希验证 +- Alpine 镜像源:使用清华源 `https://mirrors.tuna.tsinghua.edu.cn/alpine` +- 临时文件处理:避免 `tar: .: file changed as we read it` 错误 + ## 架构 ### 目录结构 @@ -301,12 +323,23 @@ lib/ | 3D场景 | 3D Scene | 标准流程 | 渲染器 (CR/V5/FB) | | 详情模板 | Detail Template | 展示图 + zip | PSD 切片(200KB 分块) | | 模特 | Model | 仅模特图片 | 无需 zip | -| 标准模型 | Standard Model | 主图 + SKU | SKU 管理 | +| 标准模型 | Standard Model | 主图 + SKU | SKU 管理 + 白底图元数据 | | 品牌VI灵感 | Brand VI | 仅 PDF | 自动从 PDF 第1页提取预览 | +| 证书-检测报告 | Certificate Report | 仅 PDF | 支持中文文件名 | | 任务-详情 | Task-Detail | ProductPackageUploader | 主图/SKU/详情图/PSD | | 任务-页面 | Task-Page | TaskPageUploader | 预览图 → zip | | 任务-视频截图 | Task-Video | VideoScreenshotUploader | 截图 → zip | -| 音乐/种草视频/买家秀/小红书 | 特殊类型 | 批量上传 | 特定类型限制 | +| 淘系-主图视频 | Video (Main) | 批量上传 | 原名:主图视频 | +| 淘系-逛逛视频 | Video (Browse) | 批量上传 | 原名:种草视频 | +| 抖音-千川素材 | Douyin Qianchuan | 批量上传 | 新增类型 | +| 抖音-账号维护 | Douyin Account | 批量上传 | 新增类型 | +| 音乐/买家秀/小红书 | 特殊类型 | 批量上传 | 特定类型限制 | + +**视频类型改名说明(2026-04-30):** +- `主图视频` → `淘系-主图视频` +- `种草视频` → `淘系-逛逛视频` +- 权限系统支持新旧名称兼容(`lib/video-material-types.ts`、`lib/department-permission.ts`) +- 数据库迁移:`prisma/migrations/20260430_200000_rename_video_material_types/migration.sql` ### 身份认证与授权 @@ -331,6 +364,13 @@ lib/ - `DepartmentMaterialPermission` 表控制部门访问权限 - 字段:`canView`、`canDownload`、`canShare`、`canDownloadSource` - 通过 `linkedToDepartment` 实现权限继承 +- 视频类型权限支持新旧名称兼容(`lib/department-permission.ts`) + +**标准模型 SKU 权限(2026-05-05):** +- SKU 操作权限改为登录即可(不再限制作者/管理员) +- 白底图上传记录:出图方式(实拍/渲染/AI)、上传人、上传时间 +- 白底图元数据字段:`whiteBackgroundSourceType`、`whiteBackgroundUploadedBy`、`whiteBackgroundUploadedAt` +- 历史数据回填:已有白底图默认标记为"渲染" ### 上传系统架构 @@ -366,6 +406,12 @@ lib/ - `lib/pdf-processor.ts` 提取第1页作为预览 - `lib/pdf-upload-handler.ts` 处理上传 +5. **证书-检测报告**(PDF 上传) + - `/api/materials/upload/certificate-report/init` + - `/api/materials/upload/certificate-report/complete` + - 支持中文文件名(使用 execFile 调用 pdftoppm) + - 核心提交成功后,收尾步骤失败不影响整体成功 + ### 文件处理流程 **图片处理**(`lib/image-processor.ts`): @@ -394,11 +440,22 @@ lib/ **外部 API**(`lib/external-api.ts`): - `/api/external/download` - 通过 Token 验证下载 - `/api/external/product-assets` - 任务系统批量资产下载 +- `/api/external/dingtalk-material-match` - 钉钉素材匹配接口 +- `/api/external/dingtalk-material-options` - 钉钉下拉选项接口 +- `/api/external/standard-model-sku-white-background` - 标准模型 SKU 白底图元数据查询 + +**钉钉接口集成(2026-05-05):** +- 支持两种鉴权方式:URL 参数 `apiKey` 或请求头 `X-API-Key` +- 钉钉低代码流程推荐使用 URL 参数方式 +- 程序系统对接推荐使用请求头方式 +- 字段映射:需求类型→素材类型、品牌→店铺、场景类别、产品类别、SPU +- 文档:`docs/钉钉素材库接口对接文档.md` **API Key 系统:** - `ApiKey` 表用于外部系统身份认证 - `ApiCallLog` 跟踪所有 API 调用 - 通过 `x-api-key` 请求头或 `ADMIN_API_KEY` 环境变量验证 +- 钉钉接口额外支持 URL 参数 `apiKey`(`lib/external-api.ts` 中 `allowQuery: true`) ### 性能优化 @@ -424,11 +481,21 @@ lib/ | `prisma/schema.prisma` | 数据库结构(所有模型) | | `lib/material-validation.ts` | 类型特定验证规则 | | `lib/material-type-mapper.ts` | 素材类型常量和映射器 | +| `lib/video-material-types.ts` | 视频类型定义和新旧兼容 | +| `lib/department-permission.ts` | 部门权限判断(支持视频类型兼容) | | `lib/task-material-processor.ts` | 任务类型处理流程(6个步骤) | | `lib/chunk-upload.ts` | 核心分片上传逻辑 | +| `lib/pdf-processor.ts` | PDF 处理(支持中文文件名) | +| `lib/external-api.ts` | 外部 API 鉴权(支持 URL 参数) | +| `lib/standard-model-white-background-metadata.ts` | 标准模型白底图元数据 | | `middleware.ts` | 请求拦截和认证检查 | | `app/api/materials/upload/route.ts` | 旧版上传端点 | | `app/api/materials/upload/chunk/route.ts` | 分片上传处理 | +| `app/api/materials/upload/certificate-report/complete/route.ts` | 证书报告上传完成 | +| `components/HeaderTaobaoSearch.tsx` | 标题栏淘宝 ID 搜索框 | +| `components/SkuEditModal.tsx` | SKU 编辑弹窗(白底图上传) | +| `components/SkuList.tsx` | SKU 列表(白底图元数据显示) | +| `docs/钉钉素材库接口对接文档.md` | 钉钉接口对接文档 | ### 环境变量 @@ -493,13 +560,45 @@ catch (error) { 4. **任务号格式** - 格式:`TASK` + 12-18位数字 + 可选的 `-XX` 后缀 5. **预览生成** - 标记上传完成前必须完成预览生成 6. **Docker 网络** - 容器必须在 `youyidian-3d_app-network` 上才能访问 Redis +7. **视频类型权限** - 视频类型改名后需同步更新部门权限表,或依赖代码兼容逻辑 +8. **PDF 中文文件名** - 必须使用 `execFile` 而非 `exec` 调用 pdftoppm,避免路径注入 +9. **部署版本管理** - 不同对话中的改动需合并到同一工作区再部署,避免"回退"现象 +10. **构建缓存** - 默认保留构建缓存加快构建,只在必要时使用 `-AggressiveBuildCacheCleanup` +11. **Alpine 镜像源** - 使用清华源,避免官方源在国内网络环境下的连接问题 +12. **音频/文件访问路由** - `.dockerignore` 和部署脚本中排除 `uploads` 时要精确,避免误伤 `app/api/uploads` 路由 + +### 最近修复的问题(2026-04-30 至 2026-05-05) + +1. **证书-检测报告中文 PDF 上传失败** - 修复 `lib/pdf-processor.ts` 路径校验,支持中文文件名 +2. **DNZ032912 视频素材"看不见"** - 视频类型改名后权限表未同步,已补齐权限并增加代码兼容 +3. **标准模型 SKU 白底图缺少时间显示** - 新增元数据字段并回填历史数据(2730 条) +4. **钉钉接口需要手动输入请求头** - 增加 URL 参数 `apiKey` 支持,适配钉钉低代码流程 +5. **音频素材播放 404** - 修复 `.dockerignore` 和部署脚本,避免误排除 `app/api/uploads` 路由 +6. **部署后小改动重装基础包** - 优化构建缓存策略,默认保留缓存 +7. **Alpine 镜像源连接失败** - 从官方源改为清华源 ### 测试状态 -- Jest 已配置,但测试覆盖率较低 +- Jest 已配置,测试覆盖关键业务逻辑 +- 重要测试文件: + - `lib/__tests__/video-material-types.test.ts` - 视频类型兼容测试 + - `lib/__tests__/external-api-key.test.ts` - 外部 API 鉴权测试 + - `lib/__tests__/dingtalk-material-match-route.test.ts` - 钉钉匹配接口测试 + - `lib/__tests__/dingtalk-material-options-route.test.ts` - 钉钉选项接口测试 - 推荐通过影子环境部署进行手动测试 - 使用 `/api/admin/system-status` 进行健康检查 +### 数据库维护记录 + +**2026-04-30:视频类型改名** +- 执行迁移:`prisma/migrations/20260430_200000_rename_video_material_types/migration.sql` +- 手动补齐部门权限:复制 6 条"主图视频"权限到"淘系-主图视频",复制 6 条"种草视频"权限到"淘系-逛逛视频" + +**2026-05-05:标准模型白底图元数据** +- 执行迁移:`prisma/migrations/20260430_add_standard_model_white_background_metadata/migration.sql` +- 历史数据回填:2730 条 SKU 的 `white_background_uploaded_at` 字段(使用资产最后创建时间) +- 历史数据默认值:`white_background_source_type` 统一回填为"渲染" + --- ## 👤 开发者偏好 @@ -573,3 +672,29 @@ catch (error) { **文件操作:** - 修改前先读取文件内容 - 创建新文件前检查是否已存在 + +### 🚨 重要提醒 + +**工作区状态:** +- 工作区长期存在大量未提交改动,这是正常状态 +- 不同对话中的改动需要合并到同一工作区再部署 +- 部署脚本只打包当前磁盘工作区,不会自动合并其它对话的改动 +- 高敏感文件(如 `components/HeaderTaobaoSearch.tsx`)曾多次被"回退",根因是未合并工作区 + +**部署前检查清单:** +1. 确认所有相关改动都在当前工作区 +2. 检查 `.dockerignore` 和部署脚本的排除规则 +3. 验证 Alpine 镜像源配置(清华源) +4. 确认构建缓存策略(默认保留) +5. 检查 Docker 网络配置(`youyidian-3d_app-network`) + +**数据库操作原则:** +- 生产数据库名称是 `lobechat`,不是 `gongxiangpan` +- 涉及权限、素材类型、用户数据的变更需谨慎 +- 大批量数据回填前先在影子环境验证 +- 保持迁移文件和手动操作的记录 + +**外部接口注意事项:** +- 钉钉接口使用 URL 参数 `apiKey`,不要做成用户填写字段 +- API Key 不要在日志中明文输出 +- 外部接口调用需记录到 `ApiCallLog` 表 diff --git a/Dockerfile b/Dockerfile index 7aa70f1..bd102c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # 基础镜像(使用 Alpine,手动处理 OpenSSL 兼容性) ARG BASE_IMAGE=node:18-alpine -ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine +ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine FROM ${BASE_IMAGE} AS base # 安装依赖阶段 @@ -34,6 +34,11 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, +# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 +RUN test -f "/app/app/api/uploads/[...path]/route.ts" && \ + test -f "/app/app/uploads/[...path]/route.ts" + # 设置环境变量 ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/app/api/admin/regenerate-preview/route.ts b/app/api/admin/regenerate-preview/route.ts index ede3e1c..ca24d59 100644 --- a/app/api/admin/regenerate-preview/route.ts +++ b/app/api/admin/regenerate-preview/route.ts @@ -6,7 +6,10 @@ import { readFile, unlink, mkdir, rm, readdir } from 'fs/promises' import { join } from 'path' import sharp from 'sharp' import { sliceImage } from '@/lib/image-processor' -import { uploadToS3 } from '@/lib/s3' +import { + buildTaskDetailPublicDisplayPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display' import AdmZip from 'adm-zip' export async function POST(request: NextRequest) { @@ -95,8 +98,12 @@ export async function POST(request: NextRequest) { 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') + const fileName = `detail-preview-${timestamp}-${i}-slice${i}.webp` + const url = await uploadPublicDisplayToS3( + sliceBuffers[i], + buildTaskDetailPublicDisplayPath(materialId, 'detail', fileName), + 'image/webp', + ) newPreviewUrls.push(url) console.log('[Regenerate Preview] Uploaded slice', i + 1, '/', sliceBuffers.length, ':', url) } diff --git a/app/api/admin/system-status/route.ts b/app/api/admin/system-status/route.ts index 096c6a6..cddb6e4 100644 --- a/app/api/admin/system-status/route.ts +++ b/app/api/admin/system-status/route.ts @@ -238,4 +238,4 @@ export async function GET(req: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/api/admin/video-transcode-stats/route.ts b/app/api/admin/video-transcode-stats/route.ts index e1bf136..255c161 100644 --- a/app/api/admin/video-transcode-stats/route.ts +++ b/app/api/admin/video-transcode-stats/route.ts @@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES } from '@/lib/video-material-types' export async function GET(req: NextRequest) { try { @@ -25,7 +26,7 @@ export async function GET(req: NextRequest) { } // 识别视频素材类型 - const videoMaterialTypes = ['主图视频', '安装视频', '种草视频', '视频'] + const videoMaterialTypes = [...LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES] // 获取视频素材统计(兼容旧数据库) let videoStats: any = {} @@ -185,4 +186,4 @@ export async function GET(req: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/api/external/dingtalk-material-match/route.ts b/app/api/external/dingtalk-material-match/route.ts new file mode 100644 index 0000000..f012320 --- /dev/null +++ b/app/api/external/dingtalk-material-match/route.ts @@ -0,0 +1,271 @@ +import { randomUUID } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' +import { + getApiKeyFromRequest, + getClientIp, + logApiCall, + successResponse, + validateApiKey, +} from '@/lib/external-api' +import { prisma } from '@/lib/prisma' +import { getPublicAppOrigin } from '@/lib/public-app-url' +import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' + +type TraceContext = { + traceId: string + startTime: number + normalizedSpu: string + ipAddress?: string + apiKeyName?: string +} + +type LogBase = { + apiKeyId: string + endpoint: string + method: string + ipAddress?: string + userAgent?: string +} + +function normalizeValue(value: string | null): string { + return String(value || '').trim() +} + +function normalizeSpu(value: string | null): string { + return normalizeValue(value).replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase() +} + +function getElapsedMs(startTime: number) { + return Math.max(0, Date.now() - startTime) +} + +function buildErrorResponse(status: number, code: string, message: string) { + return NextResponse.json( + { + success: false, + error: { + code, + message, + }, + }, + { status }, + ) +} + +function finalizeResponse( + response: NextResponse, + trace: TraceContext, + statusCode: number, + outcome: string, +) { + const duration = getElapsedMs(trace.startTime) + response.headers.set('X-Trace-Id', trace.traceId) + response.headers.set('X-Server-Duration-Ms', String(duration)) + response.headers.set('Server-Timing', `app;dur=${duration}`) + + if (trace.normalizedSpu) { + response.headers.set('X-Request-Spu', trace.normalizedSpu) + } + + console.info( + '[external/dingtalk-material-match]', + JSON.stringify({ + traceId: trace.traceId, + outcome, + statusCode, + duration, + spu: trace.normalizedSpu || null, + ipAddress: trace.ipAddress || null, + apiKeyName: trace.apiKeyName || null, + }), + ) + + return response +} + +function buildAbsoluteUrl(url?: string | null): string | null { + const rewritten = rewriteStorageUrlForClient(url) + if (!rewritten) { + return null + } + + if (/^https?:\/\//i.test(rewritten)) { + return rewritten + } + + const baseUrl = getPublicAppOrigin().replace(/\/+$/, '') + const normalizedPath = rewritten.replace(/\\/g, '/').replace(/^\.?\//, '/') + return `${baseUrl}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}` +} + +function pickAnyAddress(material: { + previewUrl: string + videoPreview540Url: string | null + videoCoverUrl: string | null + zipFilePath: string +}) { + return ( + buildAbsoluteUrl(material.previewUrl) || + buildAbsoluteUrl(material.videoPreview540Url) || + buildAbsoluteUrl(material.videoCoverUrl) || + buildAbsoluteUrl(material.zipFilePath) || + null + ) +} + +export async function GET(request: NextRequest) { + const trace: TraceContext = { + traceId: randomUUID(), + startTime: Date.now(), + normalizedSpu: normalizeSpu( + request.nextUrl.searchParams.get('spu') || + request.nextUrl.searchParams.get('SPU'), + ), + ipAddress: getClientIp(request), + } + const apiKey = getApiKeyFromRequest(request, { allowQuery: true }) + const keyRecord = await validateApiKey(apiKey) + + if (!keyRecord) { + return finalizeResponse( + buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'), + trace, + 401, + 'INVALID_API_KEY', + ) + } + + trace.apiKeyName = keyRecord.name + + const logBase: LogBase = { + apiKeyId: keyRecord.id, + endpoint: '/api/external/dingtalk-material-match', + method: 'GET', + ipAddress: trace.ipAddress, + userAgent: request.headers.get('user-agent') || undefined, + } + + if (!trace.normalizedSpu) { + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: '', + statusCode: 400, + duration, + }) + return finalizeResponse( + buildErrorResponse(400, 'MISSING_SPU', 'SPU 为必填项'), + trace, + 400, + 'MISSING_SPU', + ) + } + + const materialType = + normalizeValue(request.nextUrl.searchParams.get('requirementType')) || + normalizeValue(request.nextUrl.searchParams.get('materialType')) || + normalizeValue(request.nextUrl.searchParams.get('需求类型')) + const brand = + normalizeValue(request.nextUrl.searchParams.get('brand')) || + normalizeValue(request.nextUrl.searchParams.get('品牌')) + const sceneName = + normalizeValue(request.nextUrl.searchParams.get('sceneCategory')) || + normalizeValue(request.nextUrl.searchParams.get('sceneName')) || + normalizeValue(request.nextUrl.searchParams.get('场景类别')) + const productCategoryName = + normalizeValue(request.nextUrl.searchParams.get('productCategory')) || + normalizeValue(request.nextUrl.searchParams.get('productCategoryName')) || + normalizeValue(request.nextUrl.searchParams.get('产品类别')) + + try { + const matchedMaterial = await prisma.material.findFirst({ + where: { + deletedAt: null, + productModel: trace.normalizedSpu, + ...(materialType ? { materialType } : {}), + ...(brand ? { categoryName: brand } : {}), + ...(sceneName ? { sceneName } : {}), + ...(productCategoryName ? { productCategoryName } : {}), + }, + select: { + id: true, + productModel: true, + materialType: true, + categoryName: true, + sceneName: true, + productCategoryName: true, + previewUrl: true, + videoPreview540Url: true, + videoCoverUrl: true, + zipFilePath: true, + updatedAt: true, + }, + orderBy: [ + { updatedAt: 'desc' }, + { createdAt: 'desc' }, + ], + }) + + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: trace.normalizedSpu, + statusCode: 200, + duration, + }) + + if (!matchedMaterial) { + return finalizeResponse( + successResponse({ + matched: false, + message: '该SPU下暂无素材', + spu: trace.normalizedSpu, + requirementType: materialType || null, + brand: brand || null, + sceneCategory: sceneName || null, + productCategory: productCategoryName || null, + address: null, + }), + trace, + 200, + 'NO_MATCH', + ) + } + + return finalizeResponse( + successResponse({ + matched: true, + message: '查询成功', + spu: matchedMaterial.productModel, + materialId: matchedMaterial.id, + requirementType: matchedMaterial.materialType, + brand: matchedMaterial.categoryName, + sceneCategory: matchedMaterial.sceneName, + productCategory: matchedMaterial.productCategoryName || '', + address: pickAnyAddress(matchedMaterial), + updatedAt: matchedMaterial.updatedAt.toISOString(), + }), + trace, + 200, + 'MATCHED', + ) + } catch (error) { + console.error('[external/dingtalk-material-match] error:', error) + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: trace.normalizedSpu, + statusCode: 500, + duration, + }) + return finalizeResponse( + buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), + trace, + 500, + 'SERVER_ERROR', + ) + } +} diff --git a/app/api/external/dingtalk-material-options/route.ts b/app/api/external/dingtalk-material-options/route.ts new file mode 100644 index 0000000..16c7bf7 --- /dev/null +++ b/app/api/external/dingtalk-material-options/route.ts @@ -0,0 +1,145 @@ +import { randomUUID } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' +import { + getApiKeyFromRequest, + getClientIp, + logApiCall, + successResponse, + validateApiKey, +} from '@/lib/external-api' +import { prisma } from '@/lib/prisma' +import { MATERIAL_TYPE_OPTIONS } from '@/lib/material-type-options' + +type TraceContext = { + traceId: string + startTime: number + ipAddress?: string + apiKeyName?: string +} + +function getElapsedMs(startTime: number) { + return Math.max(0, Date.now() - startTime) +} + +function buildErrorResponse(status: number, code: string, message: string) { + return NextResponse.json( + { + success: false, + error: { + code, + message, + }, + }, + { status }, + ) +} + +function finalizeResponse( + response: NextResponse, + trace: TraceContext, + statusCode: number, + outcome: string, +) { + const duration = getElapsedMs(trace.startTime) + response.headers.set('X-Trace-Id', trace.traceId) + response.headers.set('X-Server-Duration-Ms', String(duration)) + response.headers.set('Server-Timing', `app;dur=${duration}`) + + console.info( + '[external/dingtalk-material-options]', + JSON.stringify({ + traceId: trace.traceId, + outcome, + statusCode, + duration, + ipAddress: trace.ipAddress || null, + apiKeyName: trace.apiKeyName || null, + }), + ) + + return response +} + +export async function GET(request: NextRequest) { + const trace: TraceContext = { + traceId: randomUUID(), + startTime: Date.now(), + ipAddress: getClientIp(request), + } + + const apiKey = getApiKeyFromRequest(request, { allowQuery: true }) + const keyRecord = await validateApiKey(apiKey) + + if (!keyRecord) { + return finalizeResponse( + buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'), + trace, + 401, + 'INVALID_API_KEY', + ) + } + + trace.apiKeyName = keyRecord.name + + try { + const [brands, sceneCategories, productCategories] = await Promise.all([ + prisma.category.findMany({ + orderBy: { name: 'asc' }, + select: { name: true }, + }), + prisma.scene.findMany({ + orderBy: { name: 'asc' }, + select: { name: true }, + }), + prisma.productCategory.findMany({ + orderBy: { name: 'asc' }, + select: { name: true }, + }), + ]) + + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/dingtalk-material-options', + method: 'GET', + queryType: 'options', + queryValue: 'all', + statusCode: 200, + duration, + ipAddress: trace.ipAddress, + userAgent: request.headers.get('user-agent') || undefined, + }) + + return finalizeResponse( + successResponse({ + requirementTypes: [...MATERIAL_TYPE_OPTIONS], + brands: brands.map((item) => item.name), + sceneCategories: sceneCategories.map((item) => item.name), + productCategories: productCategories.map((item) => item.name), + }), + trace, + 200, + 'OPTIONS_OK', + ) + } catch (error) { + console.error('[external/dingtalk-material-options] error:', error) + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/dingtalk-material-options', + method: 'GET', + queryType: 'options', + queryValue: 'all', + statusCode: 500, + duration, + ipAddress: trace.ipAddress, + userAgent: request.headers.get('user-agent') || undefined, + }) + return finalizeResponse( + buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), + trace, + 500, + 'SERVER_ERROR', + ) + } +} diff --git a/app/api/external/download/route.ts b/app/api/external/download/route.ts index c1cdcc9..6ece8ca 100644 --- a/app/api/external/download/route.ts +++ b/app/api/external/download/route.ts @@ -73,7 +73,7 @@ export async function GET(request: NextRequest) { // 参考:/api/materials/download 中的权限检查逻辑 // 任务类型素材:使用assets逻辑 - const isTaskMaterial = ['任务-详情', '任务-页面', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(material.materialType); + const isTaskMaterial = ['任务-详情', '任务-页面', '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(material.materialType); if (isTaskMaterial) { console.log(`[external/download] ========== 任务类型素材下载 ==========`); diff --git a/app/api/external/product-assets/route.ts b/app/api/external/product-assets/route.ts index 8405cbe..7b8b228 100644 --- a/app/api/external/product-assets/route.ts +++ b/app/api/external/product-assets/route.ts @@ -28,6 +28,7 @@ import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'; import { isProductPackageType } from '@/lib/material-type-mapper'; import { getPublicAppOrigin } from '@/lib/public-app-url'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; +import { isVideoMaterialType } from '@/lib/video-material-types'; import path from 'path'; function getBaseUrl(): string { @@ -224,7 +225,7 @@ export async function GET(request: NextRequest) { downloadType: 'all', }); - const responseData = { + const responseData: Record = { materialId: material.id, taskNo: material.taskNo, productModel: material.productModel, @@ -273,7 +274,7 @@ export async function GET(request: NextRequest) { if (material.productModel) { // 根据素材类型推断扩展名 const ext = material.materialType === '音乐' ? '.mp3' : - ['主图视频', '安装视频', '种草视频'].includes(material.materialType) ? '.mp4' : + isVideoMaterialType(material.materialType) ? '.mp4' : material.materialType === '品牌VI灵感' ? '.pdf' : '.zip'; fileName = `${material.productModel}${ext}`; @@ -282,7 +283,7 @@ export async function GET(request: NextRequest) { } } - const responseData = { + const responseData: Record = { materialId: material.id, taskNo: material.taskNo, productModel: material.productModel, @@ -308,6 +309,11 @@ export async function GET(request: NextRequest) { }, }; + if (material.materialType === '任务-其他') { + responseData.materialType = material.materialType; + responseData.status = TASK_STATUS.FETCHED; + } + await logApiCall({ apiKeyId: keyRecord.id, endpoint: '/api/external/product-assets', diff --git a/app/api/external/standard-model-sku-white-background/route.ts b/app/api/external/standard-model-sku-white-background/route.ts new file mode 100644 index 0000000..fdd2513 --- /dev/null +++ b/app/api/external/standard-model-sku-white-background/route.ts @@ -0,0 +1,536 @@ +import path from 'path' +import { randomUUID } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' +import { + getApiKeyFromRequest, + getClientIp, + logApiCall, + successResponse, + validateApiKey, +} from '@/lib/external-api' +import { generateDownloadUrl } from '@/lib/download-token' +import { prisma } from '@/lib/prisma' +import { + countWhiteBackgroundAssets, + countWhiteBackgroundFixedViews, + countWhiteBackgroundOtherImages, + getMissingWhiteBackgroundViewsFromAssetGroup, + getWhiteBackgroundAssetWhereForSku, + getWhiteBackgroundZipFileName, + groupWhiteBackgroundAssetsBySku, + hasAnyWhiteBackgroundAssetGroup, + hasCompleteWhiteBackgroundAssetGroup, + STANDARD_MODEL_WHITE_BG_VIEWS, + type GroupedWhiteBackgroundAssets, + type WhiteBackgroundAssetLike, +} from '@/lib/standard-model-white-background' +import { getPublicAppOrigin } from '@/lib/public-app-url' +import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' +import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview' + +type WhiteBackgroundAssetRecord = WhiteBackgroundAssetLike & { + id: string + fileUrl: string + fileName: string | null + fileSize: bigint | number | string | null + sortOrder: number +} + +type TraceContext = { + traceId: string + startTime: number + normalizedSkuName: string + normalizedSpu: string + ipAddress?: string + apiKeyName?: string +} + +type LogBase = { + apiKeyId: string + endpoint: string + method: string + ipAddress?: string + userAgent?: string +} + +function normalizeSkuName(value: string): string { + return String(value || '').replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase().trim() +} + +function buildErrorResponse(status: number, code: string, message: string) { + return NextResponse.json( + { + success: false, + error: { + code, + message, + }, + }, + { status }, + ) +} + +function getElapsedMs(startTime: number) { + return Math.max(0, Date.now() - startTime) +} + +function finalizeResponse( + response: NextResponse, + trace: TraceContext, + statusCode: number, + outcome: string, +) { + const duration = getElapsedMs(trace.startTime) + response.headers.set('X-Trace-Id', trace.traceId) + response.headers.set('X-Server-Duration-Ms', String(duration)) + response.headers.set('Server-Timing', `app;dur=${duration}`) + + if (trace.normalizedSkuName) { + response.headers.set('X-Request-Sku', trace.normalizedSkuName) + } + + if (trace.normalizedSpu) { + response.headers.set('X-Request-Spu', trace.normalizedSpu) + } + + console.info( + '[external/standard-model-sku-white-background]', + JSON.stringify({ + traceId: trace.traceId, + outcome, + statusCode, + duration, + sku: trace.normalizedSkuName || null, + spu: trace.normalizedSpu || null, + ipAddress: trace.ipAddress || null, + apiKeyName: trace.apiKeyName || null, + }), + ) + + return response +} + +function getBaseUrl() { + return getPublicAppOrigin() +} + +function formatFileSize(value: bigint | number | string | null | undefined): number { + if (typeof value === 'bigint') { + return Number(value) + } + const parsed = Number(value || 0) + return Number.isFinite(parsed) ? parsed : 0 +} + +function buildPreviewUrl(url?: string | null): string | null { + const previewFileUrl = resolveWhiteBackgroundPreviewFileUrl(url) + const rewrittenUrl = rewriteStorageUrlForClient(previewFileUrl) + if (!rewrittenUrl) { + return null + } + + if (/^https?:\/\//i.test(rewrittenUrl)) { + return rewrittenUrl + } + + const normalizedPath = rewrittenUrl + .trim() + .replace(/\\/g, '/') + .replace(/^\.?\//, '/') + + if (!normalizedPath) { + return null + } + + try { + return new URL(normalizedPath, getBaseUrl()).toString() + } catch { + return `${getBaseUrl().replace(/\/+$/, '')}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}` + } +} + +function buildUploaderMap(users: Array<{ id: string; username: string; realName: string | null }>) { + return new Map(users.map((user) => [user.id, user])) +} + +function pickRepresentativePreview( + group?: GroupedWhiteBackgroundAssets | null, +) { + return Object.values(group?.fixed || {}).find(Boolean) || group?.other?.[0] || null +} + +function buildSkuSummary(params: { + materialId: string + spu: string + sku: { + id: string + skuName: string + whiteBackgroundSourceType: string | null + whiteBackgroundUploadedBy: string | null + whiteBackgroundUploadedAt: Date | null + } + group?: GroupedWhiteBackgroundAssets | null + uploaderMap: Map +}) { + const { materialId, spu, sku, group, uploaderMap } = params + const hasWhiteBackground = hasAnyWhiteBackgroundAssetGroup(group) + const fixedCount = countWhiteBackgroundFixedViews(group) + const otherCount = countWhiteBackgroundOtherImages(group) + const whiteBackgroundCount = countWhiteBackgroundAssets(group) + const previewAsset = pickRepresentativePreview(group) + const uploader = sku.whiteBackgroundUploadedBy + ? uploaderMap.get(sku.whiteBackgroundUploadedBy) || null + : null + const baseUrl = getBaseUrl() + + return { + spu, + sku: sku.skuName, + hasWhiteBackground, + isComplete: hasCompleteWhiteBackgroundAssetGroup(group), + whiteBackgroundCount, + fixedCount, + otherCount, + sourceType: sku.whiteBackgroundSourceType, + uploader: uploader + ? { + id: uploader.id, + username: uploader.username, + realName: uploader.realName, + } + : null, + uploadedAt: sku.whiteBackgroundUploadedAt ? sku.whiteBackgroundUploadedAt.toISOString() : null, + download: hasWhiteBackground + ? { + zipFileName: getWhiteBackgroundZipFileName(sku.skuName), + downloadUrl: generateDownloadUrl(baseUrl, { + materialId, + downloadType: 'white-background-zip', + skuId: sku.id, + }), + } + : null, + preview: previewAsset + ? { + fileName: + previewAsset.fileName || + path.basename(previewAsset.fileUrl || `${sku.skuName}-white-background.webp`), + previewUrl: buildPreviewUrl(previewAsset.fileUrl), + } + : null, + missingViews: hasWhiteBackground + ? getMissingWhiteBackgroundViewsFromAssetGroup(group).map((view) => ({ + key: view.key, + label: view.label, + })) + : STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => ({ + key: view.key, + label: view.label, + })), + } +} + +export async function GET(request: NextRequest) { + const trace: TraceContext = { + traceId: randomUUID(), + startTime: Date.now(), + normalizedSkuName: normalizeSkuName(request.nextUrl.searchParams.get('sku') || ''), + normalizedSpu: normalizeSkuName(request.nextUrl.searchParams.get('spu') || ''), + ipAddress: getClientIp(request), + } + const apiKey = getApiKeyFromRequest(request) + const keyRecord = await validateApiKey(apiKey) + + if (!keyRecord) { + return finalizeResponse( + buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'), + trace, + 401, + 'INVALID_API_KEY', + ) + } + + trace.apiKeyName = keyRecord.name + + const logBase: LogBase = { + apiKeyId: keyRecord.id, + endpoint: '/api/external/standard-model-sku-white-background', + method: 'GET', + ipAddress: trace.ipAddress, + userAgent: request.headers.get('user-agent') || undefined, + } + + if (!trace.normalizedSkuName && !trace.normalizedSpu) { + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'empty', + queryValue: '', + statusCode: 400, + duration, + }) + return finalizeResponse( + buildErrorResponse(400, 'MISSING_PARAM', '请传入 sku 或 spu 参数'), + trace, + 400, + 'MISSING_PARAM', + ) + } + + if (trace.normalizedSkuName) { + return handleSkuQuery(trace.normalizedSkuName, logBase, trace) + } + + return handleSpuQuery(trace.normalizedSpu, logBase, trace) +} + +async function handleSkuQuery(normalizedSkuName: string, logBase: LogBase, trace: TraceContext) { + try { + const matchedSkus = await prisma.materialSku.findMany({ + where: { + deletedAt: null, + skuName: normalizedSkuName, + material: { + is: { + materialType: '标准模型', + deletedAt: null, + }, + }, + }, + select: { + id: true, + skuName: true, + materialId: true, + whiteBackgroundSourceType: true, + whiteBackgroundUploadedBy: true, + whiteBackgroundUploadedAt: true, + material: { + select: { + productModel: true, + }, + }, + }, + take: 2, + }) + + if (matchedSkus.length === 0) { + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'sku', + queryValue: normalizedSkuName, + statusCode: 404, + duration, + }) + return finalizeResponse( + buildErrorResponse(404, 'SKU_NOT_FOUND', '未找到对应的标准模型 SKU'), + trace, + 404, + 'SKU_NOT_FOUND', + ) + } + + if (matchedSkus.length > 1) { + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'sku', + queryValue: normalizedSkuName, + statusCode: 409, + duration, + }) + return finalizeResponse( + buildErrorResponse(409, 'SKU_NOT_UNIQUE', '该 SKU 存在重复数据,暂无法对外提供稳定结果'), + trace, + 409, + 'SKU_NOT_UNIQUE', + ) + } + + const matchedSku = matchedSkus[0] + trace.normalizedSpu = matchedSku.material.productModel + + const [whiteBackgroundAssets, uploaders] = await Promise.all([ + prisma.materialAsset.findMany({ + where: { + materialId: matchedSku.materialId, + ...getWhiteBackgroundAssetWhereForSku(matchedSku.id), + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }), + matchedSku.whiteBackgroundUploadedBy + ? prisma.user.findMany({ + where: { id: { in: [matchedSku.whiteBackgroundUploadedBy] } }, + select: { id: true, username: true, realName: true }, + }) + : Promise.resolve([]), + ]) + + const group = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets).get(matchedSku.id) + const data = buildSkuSummary({ + materialId: matchedSku.materialId, + spu: matchedSku.material.productModel, + sku: matchedSku, + group, + uploaderMap: buildUploaderMap(uploaders), + }) + + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'sku', + queryValue: normalizedSkuName, + statusCode: 200, + duration, + }) + + return finalizeResponse(successResponse(data), trace, 200, 'SKU_OK') + } catch (error) { + console.error('[external/standard-model-sku-white-background] sku query error:', error) + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'sku', + queryValue: normalizedSkuName, + statusCode: 500, + duration, + }) + return finalizeResponse( + buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), + trace, + 500, + 'SERVER_ERROR', + ) + } +} + +async function handleSpuQuery(normalizedSpu: string, logBase: LogBase, trace: TraceContext) { + try { + const material = await prisma.material.findFirst({ + where: { + productModel: normalizedSpu, + materialType: '标准模型', + deletedAt: null, + }, + select: { + id: true, + productModel: true, + skus: { + where: { deletedAt: null }, + select: { + id: true, + skuName: true, + whiteBackgroundSourceType: true, + whiteBackgroundUploadedBy: true, + whiteBackgroundUploadedAt: true, + }, + orderBy: { sortOrder: 'asc' }, + }, + }, + }) + + if (!material) { + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: normalizedSpu, + statusCode: 404, + duration, + }) + return finalizeResponse( + buildErrorResponse(404, 'SPU_NOT_FOUND', '未找到对应的标准模型 SPU'), + trace, + 404, + 'SPU_NOT_FOUND', + ) + } + + const skuIds = material.skus.map((sku) => sku.id) + const uploaderIds = Array.from( + new Set(material.skus.map((sku) => sku.whiteBackgroundUploadedBy).filter((value): value is string => Boolean(value))), + ) + + const [whiteBackgroundAssets, uploaders] = await Promise.all([ + skuIds.length > 0 + ? prisma.materialAsset.findMany({ + where: { + materialId: material.id, + OR: skuIds.flatMap((skuId) => getWhiteBackgroundAssetWhereForSku(skuId).OR), + }, + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + }, + orderBy: { sortOrder: 'asc' }, + }) + : Promise.resolve([]), + uploaderIds.length > 0 + ? prisma.user.findMany({ + where: { id: { in: uploaderIds } }, + select: { id: true, username: true, realName: true }, + }) + : Promise.resolve([]), + ]) + + const grouped = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets) + const uploaderMap = buildUploaderMap(uploaders) + const items = material.skus.map((sku) => + buildSkuSummary({ + materialId: material.id, + spu: material.productModel, + sku, + group: grouped.get(sku.id), + uploaderMap, + }), + ) + + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: normalizedSpu, + statusCode: 200, + duration, + }) + + return finalizeResponse( + successResponse({ + spu: material.productModel, + skuCount: items.length, + items, + }), + trace, + 200, + 'SPU_OK', + ) + } catch (error) { + console.error('[external/standard-model-sku-white-background] spu query error:', error) + const duration = getElapsedMs(trace.startTime) + await logApiCall({ + ...logBase, + queryType: 'spu', + queryValue: normalizedSpu, + statusCode: 500, + duration, + }) + return finalizeResponse( + buildErrorResponse(500, 'SERVER_ERROR', '服务器内部错误'), + trace, + 500, + 'SERVER_ERROR', + ) + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 9d9215b..cdd4a7e 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -9,6 +9,7 @@ import { import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; import { isWebFileLike } from '@/lib/web-file'; +import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -35,17 +36,20 @@ function parseDisplayImages(displayImages: any): string[] { function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl?: (url: string) => string) { const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl + const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl) if (asset.assetType !== 'sku-preview') { return { ...asset, fileUrl: nextFileUrl, + fileKind, } } return { ...asset, fileUrl: nextFileUrl, + fileKind, skuCode: asset.skuCode || null, barcodes: Array.isArray(asset.barcodes) ? asset.barcodes @@ -58,6 +62,41 @@ function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl? } } +function buildSourceFileUrl(material: any, isIntranet: boolean): string | null { + const zipFilePath = String(material?.zipFilePath || '').trim() + const zipFileName = String(material?.zipFileName || '').trim() + const rawSource = zipFilePath || zipFileName + if (!rawSource) { + return null + } + + if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) { + return rawSource + } + + const uploadDir = process.env.UPLOAD_DIR || './uploads' + const normalizedUploadDir = uploadDir.replace(/\\/g, '/').replace(/\/+$/, '') + let sourceRelativePath = rawSource.replace(/\\/g, '/').replace(/^\/+/, '') + const prefixCandidates = Array.from( + new Set([ + normalizedUploadDir, + normalizedUploadDir.replace(/^\.\//, ''), + normalizedUploadDir.replace(/^\//, ''), + 'uploads', + ].filter(Boolean)), + ) + + for (const prefix of prefixCandidates) { + const normalizedPrefix = prefix.replace(/\/+$/, '') + if (sourceRelativePath.startsWith(`${normalizedPrefix}/`)) { + sourceRelativePath = sourceRelativePath.slice(normalizedPrefix.length + 1) + break + } + } + + return isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}` +} + // GET /api/materials/[id] - 获取单个素材详情 export async function GET( request: NextRequest, @@ -76,16 +115,19 @@ export async function GET( id: true, userId: true, productModel: true, + reportTitle: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, previewUrl: true, zipFileName: true, + zipFilePath: true, zipFileSize: true, downloadCount: true, likeCount: true, @@ -142,16 +184,19 @@ export async function GET( id: true, userId: true, productModel: true, + reportTitle: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, previewUrl: true, zipFileName: true, + zipFilePath: true, zipFileSize: true, downloadCount: true, likeCount: true, @@ -255,16 +300,19 @@ export async function GET( id: materialWithUser.id, userId: materialWithUser.userId, productModel: materialWithUser.productModel, + reportTitle: materialWithUser.reportTitle || null, categoryName: materialWithUser.categoryName, sceneName: materialWithUser.sceneName, platformName: materialWithUser.platformName || '', productCategoryName: materialWithUser.productCategoryName || '', materialType: materialWithUser.materialType, isAiGenerated: Boolean(materialWithUser.isAiGenerated), + taskDetailSourceType: materialWithUser.taskDetailSourceType || null, renderer: materialWithUser.renderer, authorName: displayAuthorName, // 使用处理后的作者名 previewUrl: materialWithUser.previewUrl, zipFileName: materialWithUser.zipFileName, + zipFilePath: materialWithUser.zipFilePath, zipFileSize: materialWithUser.zipFileSize.toString(), downloadCount: materialWithUser.downloadCount, likeCount: materialWithUser.likeCount, @@ -317,6 +365,7 @@ export async function GET( const rewrittenResponse = { ...response, + sourceFileUrl: buildSourceFileUrl(response, isIntranet), // 图片:使用统一规则(小文件直接访问,大文件走代理) previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'), videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'), @@ -331,7 +380,11 @@ export async function GET( zipFileName: processedZipFileName, assets: formattedAssets.map((asset: any) => ({ ...asset, - fileUrl: rewriteMediaUrlForClient(asset.fileUrl, request, 'image'), + fileUrl: rewriteMediaUrlForClient( + asset.fileUrl, + request, + asset.fileKind === 'image' ? 'image' : 'source', + ), fileSize: typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() @@ -367,6 +420,7 @@ export async function PUT( const formData = await request.formData(); input = { productModel: String(formData.get('productModel') || ''), + reportTitle: formData.get('reportTitle') as string | null, categoryName: formData.get('categoryName') as string | null, sceneName: formData.get('sceneName') as string | null, platformName: formData.get('platformName') as string | null, @@ -376,6 +430,7 @@ export async function PUT( authorName: String(formData.get('authorName') || ''), userId: formData.get('userId') as string | null, taskNo: formData.get('taskNo') as string | null, + taskDetailSourceType: formData.get('taskDetailSourceType') as string | null, isFullSet: formData.get('isFullSet') as string | null, }; const formPreview = formData.get('previewImage'); @@ -412,6 +467,7 @@ export async function PUT( renderer: result.material.renderer, authorName: result.material.authorName, taskNo: result.material.taskNo, + taskDetailSourceType: (result.material as any).taskDetailSourceType || null, taskStatus: result.material.taskStatus, taskFetchedAt: result.material.taskFetchedAt, isFullSet: Boolean((result.material as any).isFullSet), diff --git a/app/api/materials/[id]/skus/[skuId]/route.ts b/app/api/materials/[id]/skus/[skuId]/route.ts index 6a0340a..d517a36 100644 --- a/app/api/materials/[id]/skus/[skuId]/route.ts +++ b/app/api/materials/[id]/skus/[skuId]/route.ts @@ -53,6 +53,13 @@ export async function GET( return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 }) } + const uploader = sku.whiteBackgroundUploadedBy + ? await prisma.user.findUnique({ + where: { id: sku.whiteBackgroundUploadedBy }, + select: { id: true, username: true, realName: true }, + }) + : null + return NextResponse.json({ id: sku.id, materialId: sku.materialId, @@ -62,6 +69,10 @@ export async function GET( zipFilePath: sku.filePath, zipFileSize: sku.fileSize.toString(), sortOrder: sku.sortOrder, + whiteBackgroundSourceType: sku.whiteBackgroundSourceType, + whiteBackgroundUploadedBy: sku.whiteBackgroundUploadedBy, + whiteBackgroundUploadedAt: sku.whiteBackgroundUploadedAt, + whiteBackgroundUploader: uploader, createdAt: sku.createdAt, updatedAt: sku.updatedAt, }) @@ -99,14 +110,6 @@ export async function PUT( } const userId = (session.user as any).id - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { role: true }, - }) - - if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') { - return NextResponse.json({ error: '没有权限编辑此 SKU' }, { status: 403 }) - } const body = await request.json() const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize, sortOrder } = body @@ -240,14 +243,6 @@ export async function DELETE( } const userId = (session.user as any).id - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { role: true }, - }) - - if (existingSku.material.userId !== userId && user?.role !== 'ADMIN') { - return NextResponse.json({ error: '没有权限删除此 SKU' }, { status: 403 }) - } const whiteBgAssets = await prisma.materialAsset.findMany({ where: { diff --git a/app/api/materials/[id]/skus/reorder/route.ts b/app/api/materials/[id]/skus/reorder/route.ts index 845add4..1764a7a 100644 --- a/app/api/materials/[id]/skus/reorder/route.ts +++ b/app/api/materials/[id]/skus/reorder/route.ts @@ -29,16 +29,7 @@ export async function PUT( return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } - // 检查权限:素材上传者或管理员可以调整排序 const userId = (session.user as any).id; - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { role: true } - }) - - if (material.userId !== userId && user?.role !== 'ADMIN') { - return NextResponse.json({ error: '没有权限调整排序' }, { status: 403 }) - } const body = await request.json() const { skuIds } = body // 按新顺序排列的 SKU ID 数组 diff --git a/app/api/materials/[id]/skus/route.ts b/app/api/materials/[id]/skus/route.ts index 2cffa3a..89750c8 100644 --- a/app/api/materials/[id]/skus/route.ts +++ b/app/api/materials/[id]/skus/route.ts @@ -56,7 +56,7 @@ export async function GET( const material = await prisma.material.findUnique({ where: { id: materialId }, - select: { id: true, materialType: true }, + select: { id: true, materialType: true, userId: true }, }) if (!material) { @@ -106,6 +106,16 @@ export async function GET( : [] const whiteBackgroundMap = groupWhiteBackgroundAssetsBySku(whiteBackgroundAssets) + const uploaderIds = Array.from( + new Set(skus.map((sku) => sku.whiteBackgroundUploadedBy).filter((value): value is string => Boolean(value))), + ) + const uploaders = uploaderIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: uploaderIds } }, + select: { id: true, username: true, realName: true }, + }) + : [] + const uploaderMap = new Map(uploaders.map((user) => [user.id, user])) return NextResponse.json({ skus: skus.map((sku) => { @@ -120,6 +130,9 @@ export async function GET( whiteBackgroundGroup?.other?.[0] || whiteBackgroundGroup?.zipAssets?.[0] const previewAssetUrl = resolveWhiteBackgroundPreviewFileUrl(previewAsset?.fileUrl || '') + const uploader = sku.whiteBackgroundUploadedBy + ? uploaderMap.get(sku.whiteBackgroundUploadedBy) || null + : null return { id: sku.id, @@ -137,6 +150,10 @@ export async function GET( whiteBackgroundFixedCount, whiteBackgroundOtherCount, whiteBackgroundCount, + whiteBackgroundSourceType: sku.whiteBackgroundSourceType, + whiteBackgroundUploadedBy: sku.whiteBackgroundUploadedBy, + whiteBackgroundUploadedAt: sku.whiteBackgroundUploadedAt, + whiteBackgroundUploader: uploader, whiteBackgroundFileName: whiteBackgroundUploaded ? `${sku.skuName}_白底图.zip` : '', whiteBackgroundFileUrl: rewriteMediaUrlForClient(previewAssetUrl || '', request, 'image') || '', } @@ -153,13 +170,16 @@ export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { + let body: any = null + let normalizedSkuName = '' + let userId = '' try { const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } - const userId = (session.user as any).id + userId = (session.user as any).id const materialId = params.id const material = await prisma.material.findUnique({ @@ -175,9 +195,20 @@ export async function POST( return NextResponse.json({ error: '只有标准模型类型才能添加 SKU' }, { status: 400 }) } - const body = await request.json() + body = await request.json() const { skuName, previewUrl, zipFileName, zipFilePath, zipFileSize } = body - const normalizedSkuName = normalizeSkuName(skuName) + normalizedSkuName = normalizeSkuName(skuName) + + console.info('[SKU Create] start', JSON.stringify({ + materialId, + userId, + rawSkuName: skuName, + normalizedSkuName, + hasPreviewUrl: Boolean(previewUrl), + hasZipFilePath: Boolean(zipFilePath), + zipFileName: zipFileName || null, + zipFileSize: Number(zipFileSize || 0), + })) if (!normalizedSkuName || !previewUrl) { return NextResponse.json({ error: '缺少必填字段:skuName 和 previewUrl' }, { status: 400 }) @@ -235,6 +266,14 @@ export async function POST( }, }) + console.info('[SKU Create] success', JSON.stringify({ + materialId, + userId, + skuId: sku.id, + normalizedSkuName: sku.skuName, + sortOrder: sku.sortOrder, + })) + return NextResponse.json({ id: sku.id, materialId: sku.materialId, @@ -248,7 +287,15 @@ export async function POST( updatedAt: sku.updatedAt, }, { status: 201 }) } catch (error) { - console.error('添加 SKU 失败:', error) + console.error('[SKU Create] failed:', { + materialId: params.id, + userId, + rawSkuName: body?.skuName || null, + normalizedSkuName: normalizedSkuName || null, + hasPreviewUrl: Boolean(body?.previewUrl), + hasZipFilePath: Boolean(body?.zipFilePath), + error, + }) const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json({ error: '添加 SKU 失败', diff --git a/app/api/materials/[id]/skus/upload/route.ts b/app/api/materials/[id]/skus/upload/route.ts index f40e1ff..d3f79ce 100644 --- a/app/api/materials/[id]/skus/upload/route.ts +++ b/app/api/materials/[id]/skus/upload/route.ts @@ -37,17 +37,6 @@ export async function POST( return NextResponse.json({ error: '只有标准模型类型才能上传 SKU' }, { status: 400 }) } - // 检查权限 - const userId = (session.user as any).id; - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { role: true } - }) - - if (material.userId !== userId && user?.role !== 'ADMIN') { - return NextResponse.json({ error: '没有权限上传 SKU' }, { status: 403 }) - } - const formData = await request.formData() const skuName = formData.get('skuName') as string const file = formData.get('file') as File diff --git a/app/api/materials/[id]/white-background/route.ts b/app/api/materials/[id]/white-background/route.ts index 6d46d28..42da3c6 100644 --- a/app/api/materials/[id]/white-background/route.ts +++ b/app/api/materials/[id]/white-background/route.ts @@ -6,6 +6,13 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { sanitizeFileName } from '@/lib/image-processor' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' +import { + buildPublicDisplayUrl, + buildWhiteBackgroundPublicDisplayPath, + buildWhiteBackgroundPublicDisplayPathFromAssetPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display' +import { deleteFromS3 } from '@/lib/s3' import { buildWhiteBackgroundFixedFileName, countWhiteBackgroundFixedViews, @@ -13,8 +20,6 @@ import { getNextWhiteBackgroundOtherIndex, getWhiteBackgroundAssetWhereForSku, getWhiteBackgroundOtherAssetType, - getWhiteBackgroundOtherPreviewRelativeFilePath, - getWhiteBackgroundPreviewRelativeFilePath, getWhiteBackgroundPreviewRelativePathFromAssetPath, getWhiteBackgroundRelativeDirectory, getWhiteBackgroundRelativeFilePath, @@ -23,11 +28,11 @@ import { groupWhiteBackgroundAssetsBySku, hasCompleteWhiteBackgroundAssetGroup, normalizeWhiteBackgroundSkuName, - parseStandardModelWhiteBgAssetType, STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' +import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata' import { createMaterialLog } from '@/lib/material-log' import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview' @@ -48,6 +53,7 @@ export async function POST( { params }: { params: { id: string } }, ) { const writtenFilePaths: string[] = [] + const uploadedPreviewUrls: string[] = [] let assetsPersisted = false try { @@ -78,23 +84,21 @@ export async function POST( return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 }) } - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { role: true }, - }) - - if (material.userId !== userId && user?.role !== 'ADMIN') { - return NextResponse.json({ error: '没有权限上传白底图' }, { status: 403 }) - } - const formData = await request.formData() const skuId = ((formData.get('skuId') as string) || '').trim() const skuName = ((formData.get('skuName') as string) || '').trim() + const sourceType = normalizeStandardModelWhiteBackgroundSourceType( + (formData.get('sourceType') as string) || '', + ) if (!skuId) { return NextResponse.json({ error: '缺少SKU ID' }, { status: 400 }) } + if (!sourceType) { + return NextResponse.json({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' }, { status: 400 }) + } + const sku = await prisma.materialSku.findFirst({ where: { id: skuId, @@ -159,7 +163,6 @@ export async function POST( ) await fs.mkdir(path.join(uploadDir, 'other'), { recursive: true }) - await fs.mkdir(path.join(uploadDir, 'preview', 'other'), { recursive: true }) const assetsToCreate: Array<{ materialId: string @@ -185,19 +188,20 @@ export async function POST( ) const diskFileName = displayFileName const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) - const previewRelativePath = getWhiteBackgroundPreviewRelativeFilePath(materialId, skuId, diskFileName) const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) - const previewAbsolutePath = path.join(process.cwd(), ...previewRelativePath.split('/')) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) assetTypesToReplace.add(getWhiteBackgroundViewAssetType(skuId, view.key)) await fs.mkdir(path.dirname(absolutePath), { recursive: true }) - await fs.mkdir(path.dirname(previewAbsolutePath), { recursive: true }) await fs.writeFile(absolutePath, original) - await fs.writeFile(previewAbsolutePath, preview) writtenFilePaths.push(relativePath) - writtenFilePaths.push(previewRelativePath) + const previewUrl = await uploadPublicDisplayToS3( + preview, + buildWhiteBackgroundPublicDisplayPath(materialId, skuId, diskFileName), + 'image/webp', + ) + uploadedPreviewUrls.push(previewUrl) assetsToCreate.push({ materialId, @@ -216,18 +220,19 @@ export async function POST( const displayFileName = `${String(otherIndex).padStart(2, '0')}-${safeBaseName}.webp` const diskFileName = `other/${displayFileName}` const relativePath = getWhiteBackgroundRelativeFilePath(materialId, skuId, diskFileName) - const previewRelativePath = getWhiteBackgroundOtherPreviewRelativeFilePath(materialId, skuId, displayFileName) const absolutePath = path.join(process.cwd(), ...relativePath.split('/')) - const previewAbsolutePath = path.join(process.cwd(), ...previewRelativePath.split('/')) const sourceBuffer = Buffer.from(await imageFile.arrayBuffer()) const { original, preview } = await buildWhiteBackgroundWebpVariants(sourceBuffer) await fs.mkdir(path.dirname(absolutePath), { recursive: true }) - await fs.mkdir(path.dirname(previewAbsolutePath), { recursive: true }) await fs.writeFile(absolutePath, original) - await fs.writeFile(previewAbsolutePath, preview) writtenFilePaths.push(relativePath) - writtenFilePaths.push(previewRelativePath) + const previewUrl = await uploadPublicDisplayToS3( + preview, + buildWhiteBackgroundPublicDisplayPath(materialId, skuId, diskFileName), + 'image/webp', + ) + uploadedPreviewUrls.push(previewUrl) assetsToCreate.push({ materialId, @@ -256,6 +261,8 @@ export async function POST( const isComplete = hasCompleteWhiteBackgroundAssetGroup({ fixed: nextFixed }) && nextFixedCount === STANDARD_MODEL_WHITE_BG_VIEWS.length + const uploadedAt = new Date() + await prisma.$transaction(async (tx) => { if (assetTypeListToReplace.length > 0) { await tx.materialAsset.deleteMany({ @@ -273,6 +280,15 @@ export async function POST( data: assetsToCreate, }) } + + await tx.materialSku.update({ + where: { id: skuId }, + data: { + whiteBackgroundSourceType: sourceType, + whiteBackgroundUploadedBy: userId, + whiteBackgroundUploadedAt: uploadedAt, + }, + }) }) assetsPersisted = true @@ -282,12 +298,15 @@ export async function POST( } await deleteFileIfUnreferenced(asset.fileUrl) - const parsedAssetType = parseStandardModelWhiteBgAssetType(asset.assetType) - if (parsedAssetType?.kind === 'view' || parsedAssetType?.kind === 'other') { - const previewRelativePath = getWhiteBackgroundPreviewRelativePathFromAssetPath(asset.fileUrl) - if (previewRelativePath) { - await deleteFileIfUnreferenced(previewRelativePath) - } + const previewRelativePath = getWhiteBackgroundPreviewRelativePathFromAssetPath(asset.fileUrl) + if (previewRelativePath) { + await deleteFileIfUnreferenced(previewRelativePath) + } + + const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl) + const publicDisplayUrl = publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : null + if (publicDisplayUrl) { + await deleteFileIfUnreferenced(publicDisplayUrl) } } @@ -309,6 +328,9 @@ export async function POST( otherCount: nextOtherCount, skuId, skuName: normalizedSkuName, + sourceType, + whiteBackgroundUploadedBy: userId, + whiteBackgroundUploadedAt: uploadedAt.toISOString(), zipFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }, }, @@ -326,6 +348,9 @@ export async function POST( otherCount: nextOtherCount, whiteBackgroundCount, isComplete, + sourceType, + whiteBackgroundUploadedBy: userId, + whiteBackgroundUploadedAt: uploadedAt.toISOString(), downloadFileName: getWhiteBackgroundZipFileName(normalizedSkuName), }) } catch (error) { @@ -338,6 +363,14 @@ export async function POST( // ignore cleanup failure } } + + for (const previewUrl of uploadedPreviewUrls) { + try { + await deleteFromS3(previewUrl) + } catch { + // ignore cleanup failure + } + } } console.error('上传白底图失败:', error) diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 28cfce9..3c10885 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -17,6 +17,7 @@ import { } from '@/lib/department-permission'; import { getMaterialCoverUrl } from '@/lib/product-package'; import { isProductPackageType } from '@/lib/material-type-mapper'; +import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; // 🔒 R45修复:数据库查询限制常量(防止OOM) const MAX_PAGE_SIZE = 100; // 单页最大100条记录 @@ -52,9 +53,11 @@ function formatTaskDetailAssetSummary( rewriteFileUrl?: (url: string) => string | null, ) { const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl; + const fileKind = getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl) const baseAsset = { ...asset, fileUrl: nextFileUrl || asset.fileUrl || '', + fileKind, fileSize: typeof asset.fileSize === 'bigint' ? asset.fileSize.toString() @@ -100,6 +103,10 @@ const MATERIAL_TYPE_MAP: Record = { 'AI': ['矢量文件', 'AI'], 'C4D模型': ['C4D模型', 'C4D'], 'C4D': ['C4D模型', 'C4D'], + '淘系-主图视频': ['淘系-主图视频', '主图视频'], + '主图视频': ['淘系-主图视频', '主图视频'], + '淘系-逛逛视频': ['淘系-逛逛视频', '种草视频'], + '种草视频': ['淘系-逛逛视频', '种草视频'], }; // GET - 获取素材列表 @@ -121,12 +128,14 @@ export async function GET(request: NextRequest) { userId: true, previewUrl: true, productModel: true, + reportTitle: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, zipFileName: true, @@ -344,6 +353,7 @@ export async function GET(request: NextRequest) { // 构建OR条件:包含SKU匹配的素材ID和用户匹配的userId where.OR = [ { productModel: { contains: search, mode: 'insensitive' } }, + { reportTitle: { contains: search, mode: 'insensitive' } }, { taskNo: { contains: search, mode: 'insensitive' } }, { categoryName: { contains: search, mode: 'insensitive' } }, { sceneName: { contains: search, mode: 'insensitive' } }, @@ -501,12 +511,14 @@ export async function GET(request: NextRequest) { userId: true, previewUrl: true, productModel: true, + reportTitle: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, zipFileName: true, @@ -612,6 +624,7 @@ export async function GET(request: NextRequest) { productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, zipFileName: true, @@ -673,6 +686,7 @@ export async function GET(request: NextRequest) { productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, zipFileName: true, @@ -740,6 +754,7 @@ export async function GET(request: NextRequest) { productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, renderer: true, authorName: true, zipFileName: true, diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 0c6525a..2344199 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -7,6 +7,7 @@ import { detectIsIntranet } from '@/lib/network-detection'; import { canUserDownloadSource, isSuperAdmin } from '@/lib/department-permission'; import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper'; import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'; +import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report'; // 解析 displayImages 字段 function parseDisplayImages(displayImages: any): string[] { @@ -31,6 +32,41 @@ function parseDisplayImages(displayImages: any): string[] { return []; } +function buildSourceFileUrl(material: any, isIntranet: boolean): string | null { + const zipFilePath = String(material?.zipFilePath || '').trim() + const zipFileName = String(material?.zipFileName || '').trim() + const rawSource = zipFilePath || zipFileName + if (!rawSource) { + return null + } + + if (rawSource.startsWith('http://') || rawSource.startsWith('https://')) { + return rawSource + } + + const uploadDir = process.env.UPLOAD_DIR || './uploads' + const normalizedUploadDir = uploadDir.replace(/\\/g, '/').replace(/\/+$/, '') + let sourceRelativePath = rawSource.replace(/\\/g, '/').replace(/^\/+/, '') + const prefixCandidates = Array.from( + new Set([ + normalizedUploadDir, + normalizedUploadDir.replace(/^\.\//, ''), + normalizedUploadDir.replace(/^\//, ''), + 'uploads', + ].filter(Boolean)), + ) + + for (const prefix of prefixCandidates) { + const normalizedPrefix = prefix.replace(/\/+$/, '') + if (sourceRelativePath.startsWith(`${normalizedPrefix}/`)) { + sourceRelativePath = sourceRelativePath.slice(normalizedPrefix.length + 1) + break + } + } + + return isIntranet ? `/uploads/${sourceRelativePath}` : `/api/uploads/${sourceRelativePath}` +} + // GET /api/materials/share - 获取分享素材信息(无需登录,但登录用户会返回点赞和收藏状态) export async function GET(request: NextRequest) { try { @@ -51,14 +87,17 @@ export async function GET(request: NextRequest) { id: true, previewUrl: true, productModel: true, + reportTitle: true, categoryName: true, sceneName: true, platformName: true, productCategoryName: true, materialType: true, isAiGenerated: true, + taskDetailSourceType: true, authorName: true, zipFileName: true, + zipFilePath: true, zipFileSize: true, downloadCount: true, likeCount: true, @@ -136,6 +175,7 @@ export async function GET(request: NextRequest) { // 将 BigInt 转换为字符串 assets = rawAssets.map(asset => ({ ...asset, + fileKind: getCertificateReportFileKindByAssetType(asset.assetType) || detectFileKindFromName(asset.fileName || asset.fileUrl), fileSize: asset.fileSize.toString(), })); } @@ -191,7 +231,9 @@ export async function GET(request: NextRequest) { const response: any = { ...material, + reportTitle: (material as any).reportTitle || null, isAiGenerated: Boolean((material as any).isAiGenerated), + taskDetailSourceType: (material as any).taskDetailSourceType || null, zipFileSize: material.zipFileSize.toString(), displayImages: parseDisplayImages(material.displayImages || null), isLiked, @@ -229,6 +271,7 @@ export async function GET(request: NextRequest) { const rewrittenResponse = { ...response, + sourceFileUrl: buildSourceFileUrl(response, isIntranet), // 图片:使用统一规则(小文件直接访问,大文件走代理) previewUrl: rewriteMediaUrlForClient(response.previewUrl, request, 'image'), videoCoverUrl: rewriteMediaUrlForClient(response.videoCoverUrl, request, 'image'), @@ -244,7 +287,11 @@ export async function GET(request: NextRequest) { // 任务-详情和任务-3D原图等:重写 assets 中的 fileUrl assets: response.assets?.map((asset: any) => ({ ...asset, - fileUrl: rewriteMediaUrlForClient(asset.fileUrl, request, 'image'), + fileUrl: rewriteMediaUrlForClient( + asset.fileUrl, + request, + asset.fileKind === 'image' ? 'image' : 'source', + ), })), }; diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 790da96..9bef1ec 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -22,6 +22,7 @@ export async function PUT(request: NextRequest) { id = String(formData.get('id') || ''); input = { productModel: String(formData.get('productModel') || ''), + reportTitle: formData.get('reportTitle') as string | null, categoryName: formData.get('categoryName') as string | null, sceneName: formData.get('sceneName') as string | null, platformName: formData.get('platformName') as string | null, @@ -31,6 +32,7 @@ export async function PUT(request: NextRequest) { authorName: String(formData.get('authorName') || ''), userId: formData.get('userId') as string | null, taskNo: formData.get('taskNo') as string | null, + taskDetailSourceType: formData.get('taskDetailSourceType') as string | null, isFullSet: formData.get('isFullSet') as string | null, }; const formPreview = formData.get('previewImage'); @@ -62,6 +64,7 @@ export async function PUT(request: NextRequest) { ...result.material, zipFileSize: result.material.zipFileSize.toString(), isFullSet: Boolean((result.material as any).isFullSet), + taskDetailSourceType: (result.material as any).taskDetailSourceType || null, }; return NextResponse.json( diff --git a/app/api/materials/upload/certificate-report/complete/route.ts b/app/api/materials/upload/certificate-report/complete/route.ts new file mode 100644 index 0000000..4ae9c50 --- /dev/null +++ b/app/api/materials/upload/certificate-report/complete/route.ts @@ -0,0 +1,249 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { lookup as mimeTypeLookup } from 'mime-types' +import path from 'path' +import { readFile, unlink } from 'fs/promises' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { + CERTIFICATE_REPORT_ASSET_TYPE, + CERTIFICATE_REPORT_MATERIAL_TYPE, + detectFileKindFromName, +} from '@/lib/certificate-report' +import { + buildCertificateReportPublicDisplayPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display' +import { cleanupTempFiles } from '@/lib/task-material-processor' +import { createMaterialLog } from '@/lib/material-log' +import { deleteFromS3 } from '@/lib/s3' +import { generatePdfPreview } from '@/lib/pdf-processor' + +interface UploadedReportFile { + uploadId: string + fileName: string + fileSize: number + sortOrder: number + fileKind: 'pdf' | 'image' +} + +function sanitizeFileNameForStorage(fileName: string): string { + return String(fileName || '') + .trim() + .replace(/[\/\\:*?"<>|]/g, '_') +} + +export async function POST(request: NextRequest) { + const tempFiles: string[] = [] + const uploadIds: string[] = [] + const uploadedAssetUrls: string[] = [] + let previewUrlToCleanup = '' + let materialCommitted = false + let committedMaterialId = '' + + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const body = await request.json() + const materialId = String(body.materialId || '').trim() + committedMaterialId = materialId + const rawFiles = Array.isArray(body.files) ? body.files : [] + const files = rawFiles + .map((item: any) => ({ + uploadId: String(item.uploadId || '').trim(), + fileName: String(item.fileName || '').trim(), + fileSize: Number(item.fileSize || 0), + sortOrder: Number.isFinite(Number(item.sortOrder)) ? Number(item.sortOrder) : 0, + fileKind: item.fileKind === 'pdf' ? 'pdf' : 'image', + })) + .filter((item: UploadedReportFile) => item.uploadId && item.fileName) + .sort((left: UploadedReportFile, right: UploadedReportFile) => left.sortOrder - right.sortOrder) + + if (!materialId || files.length === 0) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }) + } + + const material = await prisma.material.findUnique({ + where: { id: materialId }, + select: { + id: true, + userId: true, + materialType: true, + productModel: true, + reportTitle: true, + } as any, + }) + + if (!material) { + return NextResponse.json({ error: '素材不存在' }, { status: 404 }) + } + + const materialRecord = material as any + + if (materialRecord.userId !== (session.user as any).id) { + return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 }) + } + + if (materialRecord.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) { + return NextResponse.json({ error: '素材类型不匹配' }, { status: 400 }) + } + + const { getUploadedFile } = await import('@/lib/upload-sessions') + const assetRecords: Array<{ + materialId: string + assetType: string + fileUrl: string + fileName: string + fileSize: bigint + sortOrder: number + }> = [] + + let nextPreviewUrl = '' + let totalFileSize = BigInt(0) + let firstFileName = '' + + for (let index = 0; index < files.length; index += 1) { + const item = files[index] + const filePath = await getUploadedFile(item.uploadId) + + if (!filePath) { + throw new Error(`找不到上传文件:${item.fileName}`) + } + + tempFiles.push(filePath) + uploadIds.push(item.uploadId) + totalFileSize += BigInt(item.fileSize || 0) + + if (!firstFileName) { + firstFileName = item.fileName + } + + const buffer = await readFile(filePath) + const safeFileName = sanitizeFileNameForStorage(item.fileName) + const fileKind = item.fileKind || detectFileKindFromName(item.fileName) + const contentType = + mimeTypeLookup(path.extname(item.fileName))?.toString() || + (fileKind === 'pdf' ? 'application/pdf' : 'image/jpeg') + const targetRelativePath = buildCertificateReportPublicDisplayPath( + materialId, + fileKind === 'pdf' ? 'pdf' : 'image', + `${String(item.sortOrder).padStart(3, '0')}-${safeFileName}`, + ) + + const uploadedUrl = await uploadPublicDisplayToS3(buffer, targetRelativePath, contentType) + uploadedAssetUrls.push(uploadedUrl) + + assetRecords.push({ + materialId, + assetType: fileKind === 'pdf' ? CERTIFICATE_REPORT_ASSET_TYPE.PDF : CERTIFICATE_REPORT_ASSET_TYPE.IMAGE, + fileUrl: uploadedUrl, + fileName: item.fileName, + fileSize: BigInt(item.fileSize || 0), + sortOrder: item.sortOrder, + }) + + if (!nextPreviewUrl) { + if (fileKind === 'image') { + nextPreviewUrl = uploadedUrl + } else { + const previewResult = await generatePdfPreview(filePath) + if (previewResult.success && previewResult.previewPath) { + const previewBuffer = await readFile(previewResult.previewPath) + const previewPath = buildCertificateReportPublicDisplayPath( + materialId, + 'preview', + `report-preview-${Date.now()}-${safeFileName.replace(/\.pdf$/i, '.jpg')}`, + ) + nextPreviewUrl = await uploadPublicDisplayToS3(previewBuffer, previewPath, 'image/jpeg') + previewUrlToCleanup = nextPreviewUrl + await unlink(previewResult.previewPath).catch(() => {}) + } + } + } + } + + if (!nextPreviewUrl) { + const firstImageAsset = assetRecords.find((item) => item.assetType === CERTIFICATE_REPORT_ASSET_TYPE.IMAGE) + if (firstImageAsset) { + nextPreviewUrl = firstImageAsset.fileUrl + } + } + + await prisma.$transaction([ + prisma.materialAsset.createMany({ + data: assetRecords, + }), + prisma.material.update({ + where: { id: materialId }, + data: { + previewUrl: nextPreviewUrl, + zipFileName: firstFileName, + zipFileSize: totalFileSize, + } as any, + }), + ]) + materialCommitted = true + + try { + await cleanupTempFiles(tempFiles, uploadIds) + } catch (cleanupError) { + console.warn('[Certificate Report Complete] 清理临时文件失败,但素材已保存:', cleanupError) + } + + try { + await createMaterialLog({ + materialId, + userId: (session.user as any).id, + action: 'UPLOAD', + changes: { + uploadType: { + oldValue: null, + newValue: CERTIFICATE_REPORT_MATERIAL_TYPE, + }, + assetCount: { + oldValue: 0, + newValue: assetRecords.length, + }, + }, + }) + } catch (logError) { + console.warn('[Certificate Report Complete] 上传日志记录失败,但素材已保存:', logError) + } + + return NextResponse.json({ + success: true, + materialId, + }) + } catch (error: any) { + console.error('[Certificate Report Complete Error]', error) + + if (materialCommitted) { + return NextResponse.json({ + success: true, + materialId: committedMaterialId || undefined, + warning: '素材已保存,但收尾步骤存在异常', + }) + } + + const cleanupTargets = previewUrlToCleanup + ? Array.from(new Set([...uploadedAssetUrls, previewUrlToCleanup])) + : uploadedAssetUrls + + for (const fileUrl of cleanupTargets) { + try { + await deleteFromS3(fileUrl) + } catch (cleanupError) { + console.error('[Certificate Report Cleanup] 删除S3文件失败:', cleanupError) + } + } + + await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => { + console.error('[Certificate Report Cleanup] 清理临时文件失败:', cleanupError) + }) + + return NextResponse.json({ error: error.message || '完成上传失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/upload/certificate-report/init/route.ts b/app/api/materials/upload/certificate-report/init/route.ts new file mode 100644 index 0000000..51c1dcd --- /dev/null +++ b/app/api/materials/upload/certificate-report/init/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { CERTIFICATE_REPORT_MATERIAL_TYPE, normalizeReportTitle } from '@/lib/certificate-report' + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + const body = await request.json() + const productModel = String(body.productModel || '').trim() + const reportTitle = normalizeReportTitle(body.reportTitle) + const productCategoryName = String(body.productCategoryName || '').trim() + const platformName = String(body.platformName || '').trim() + + if (!productModel && !reportTitle) { + return NextResponse.json({ error: 'SPU 和 报告名称/检测对象 至少填写一个' }, { status: 400 }) + } + + if (!productCategoryName) { + return NextResponse.json({ error: '品类不能为空' }, { 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: CERTIFICATE_REPORT_MATERIAL_TYPE, + productModel, + reportTitle, + categoryName: '通用', + sceneName: '通用', + platformName, + productCategoryName, + renderer: '', + authorName: user.username, + userId: (session.user as any).id, + previewUrl: '', + zipFilePath: '', + zipFileName: '', + } as any, + }) + + return NextResponse.json({ + success: true, + materialId: material.id, + }) + } catch (error: any) { + console.error('[Certificate Report Init Error]', error) + return NextResponse.json({ error: error.message || '初始化失败' }, { status: 500 }) + } +} diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index d0edd12..0bff593 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -8,6 +8,7 @@ import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } from '@/lib/product-package'; +import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'; export async function POST(request: NextRequest) { try { @@ -22,6 +23,15 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { taskNo, productModel, categoryName, sceneName, platformName, productCategoryName } = body; + const rawTaskDetailSourceType = body?.taskDetailSourceType; + const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType); + + if (rawTaskDetailSourceType != null && String(rawTaskDetailSourceType).trim() && !taskDetailSourceType) { + return NextResponse.json( + { success: false, error: '出图方式只能是 实拍、渲染、AI 或留空' }, + { status: 400 } + ); + } // 验证必填字段(任务号改为可选) if (!productModel || !categoryName) { @@ -79,6 +89,7 @@ export async function POST(request: NextRequest) { userId: (session.user as any).id, renderer: '', // 任务-详情不需要渲染器 isFullSet: false, + taskDetailSourceType, } as any, }); diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index 7dbef7f..8700b84 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -15,6 +15,14 @@ import { processPdfUpload } from '@/lib/pdf-upload-handler'; import { deleteFiles } from '@/lib/file-cleanup'; import { deleteMaterialRelatedRecords } from '@/lib/material-cleanup'; import { createMaterialLog } from '@/lib/material-log'; +import { + buildMaterialPublicDisplayPath, + uploadPublicDisplayToS3, +} from '@/lib/public-display'; +import { + LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES, + normalizeVideoMaterialType, +} from '@/lib/video-material-types'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -38,7 +46,7 @@ const SKU_TEMPLATE_PLACEHOLDER = (() => { return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() -const VIDEO_TYPES = new Set(['视频', 'VIDEO', '主图视频', '安装视频', '种草视频']) +const VIDEO_TYPES = new Set(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES) const AUDIO_TYPES = new Set(['音乐']) const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp']) const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac']) @@ -163,7 +171,7 @@ function normalizeMaterialType(materialTypeRaw?: string | null): string { return 'VIDEO' } - return trimmed + return normalizeVideoMaterialType(trimmed) } // 生成唯一文件名 @@ -203,19 +211,26 @@ export async function POST(request: NextRequest) { // 支持分片上传后复用服务器已合并的文件 const existingFilePath = (formData.get('existingFilePath') as string) || ''; const originalFileName = (formData.get('originalFileName') as string) || ''; - const productModel = formData.get('productModel') as string; + const productModelInput = (formData.get('productModel') as string) || ''; const categoryName = formData.get('categoryName') as string; const sceneName = formData.get('sceneName') as string; const platformName = (formData.get('platformName') as string) || ''; const productCategoryName = (formData.get('productCategoryName') as string) || ''; const materialTypeRaw = formData.get('materialType') as string | null; const materialType = normalizeMaterialType(materialTypeRaw); + const isTaskOtherMaterial = materialType === '任务-其他'; const rendererParam = formData.get('renderer') as string | null; // 统一处理renderer:3D场景必须有renderer,其他类型可选 const renderer = requiresRenderer(materialType) ? (rendererParam || getDefaultRenderer(materialType)) : (rendererParam || ''); - const taskNo = (formData.get('taskNo') as string) || ''; + const rawTaskNo = (formData.get('taskNo') as string) || ''; + const taskNo = isTaskOtherMaterial ? rawTaskNo.trim().toUpperCase() : rawTaskNo; + const productModel = productModelInput || (isTaskOtherMaterial ? taskNo : ''); + const shouldAutoFillGeneralCategoryScene = + AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型'; + const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName; + const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName; console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, @@ -225,8 +240,8 @@ export async function POST(request: NextRequest) { hasZipFile: !!zipFile, hasExistingFilePath: !!existingFilePath, productModel, - categoryName, - sceneName, + categoryName: resolvedCategoryName, + sceneName: resolvedSceneName, materialType, materialTypeRaw, }); @@ -234,11 +249,18 @@ export async function POST(request: NextRequest) { // 使用统一的验证逻辑验证必填字段 if (!productModel) { return NextResponse.json( - { error: '产品型号是必填的' }, + { error: isTaskOtherMaterial ? '任务-其他必须填写任务号' : '产品型号是必填的' }, { status: 400 } ); } - + + if (isTaskOtherMaterial && !taskNo) { + return NextResponse.json( + { error: '任务-其他必须填写任务号' }, + { status: 400 } + ); + } + // 任务号验证(可选,如果提供则验证格式和唯一性) if (taskNo && taskNo.trim()) { const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = await import('@/lib/product-package'); @@ -276,6 +298,13 @@ export async function POST(request: NextRequest) { ); } + if (isTaskOtherMaterial && !previewImage && previewImages.length === 0) { + return NextResponse.json( + { error: '任务-其他必须上传至少一张预览图' }, + { status: 400 } + ); + } + // 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件) const requiresMainFile = !['模特', '标准模型', '买家秀图片', '小红书种草'].includes(materialType); const hasMainFile = !!zipFile || !!existingFilePath; @@ -327,7 +356,11 @@ export async function POST(request: NextRequest) { const fileName = sanitizeFileName(previewImage.name); const filename = generateUniqueFilename('preview', '.webp'); console.log('[Upload] Uploading compressed preview image to S3...'); - previewUrl = await uploadToS3(compressedBuffer, filename, 'image/webp'); + previewUrl = await uploadPublicDisplayToS3( + compressedBuffer, + buildMaterialPublicDisplayPath(filename), + 'image/webp', + ); uploadedFileUrls.push(previewUrl); // 记录已上传的文件,用于失败时清理 console.log('[Upload] Preview image uploaded:', previewUrl); } @@ -363,7 +396,11 @@ export async function POST(request: NextRequest) { const filename = generateUniqueFilename(`preview-${index + 2}`, '.webp'); console.log(`[Upload] Uploading compressed preview image ${index + 2} to S3...`); - const url = await uploadToS3(compressedBuffer, filename, 'image/webp'); + const url = await uploadPublicDisplayToS3( + compressedBuffer, + buildMaterialPublicDisplayPath(filename), + 'image/webp', + ); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 console.log(`[Upload] Preview image ${index + 2} uploaded:`, url); return url; @@ -388,7 +425,11 @@ export async function POST(request: NextRequest) { for (let i = 0; i < slices.length; i++) { const sliceBuffer = slices[i]; const filename = generateUniqueFilename(`display-${i + 1}`, '.webp'); - const url = await uploadToS3(sliceBuffer, filename, 'image/webp'); + const url = await uploadPublicDisplayToS3( + sliceBuffer, + buildMaterialPublicDisplayPath(filename), + 'image/webp', + ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 @@ -412,7 +453,11 @@ export async function POST(request: NextRequest) { } const buffer = Buffer.from(await image.arrayBuffer()); const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name)); - const url = await uploadToS3(buffer, filename, image.type); + const url = await uploadPublicDisplayToS3( + buffer, + buildMaterialPublicDisplayPath(filename), + image.type, + ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 @@ -438,7 +483,11 @@ export async function POST(request: NextRequest) { } const buffer = Buffer.from(await image.arrayBuffer()); const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name)); - const url = await uploadToS3(buffer, filename, image.type); + const url = await uploadPublicDisplayToS3( + buffer, + buildMaterialPublicDisplayPath(filename), + image.type, + ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 if (!previewUrl) { @@ -526,6 +575,13 @@ export async function POST(request: NextRequest) { throw new Error('未找到可写入的素材文件数据'); } + if (isTaskOtherMaterial) { + const sourceName = sanitizeFileName(originalFileName || zipFile?.name || zipFileName); + if (sourceName) { + zipFileName = sourceName; + } + } + // PDF:从PDF文件提取第一页作为预览图(在文件保存后) if (materialType === '品牌VI灵感') { console.log('[Upload] 开始处理品牌VI灵感PDF预览图提取...'); @@ -660,8 +716,8 @@ export async function POST(request: NextRequest) { data: { previewUrl, productModel, - categoryName, - sceneName, + categoryName: resolvedCategoryName, + sceneName: resolvedSceneName, platformName, productCategoryName, materialType, @@ -688,7 +744,7 @@ export async function POST(request: NextRequest) { materialId = material.id; // 保存materialId,用于失败时清理 // 为任务类型素材创建 material_assets 记录 - const taskMaterialTypes = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图']; + const taskMaterialTypes = ['任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图']; if (taskMaterialTypes.includes(materialType)) { try { console.log('[Upload] Creating material_assets records for task material...'); @@ -781,11 +837,11 @@ export async function POST(request: NextRequest) { }, categoryName: { oldValue: null, - newValue: categoryName, + newValue: resolvedCategoryName, }, sceneName: { oldValue: null, - newValue: sceneName, + newValue: resolvedSceneName, }, taskNo: { oldValue: null, diff --git a/app/api/taobao-detail-plus/image-proxy/route.ts b/app/api/taobao-detail-plus/image-proxy/route.ts new file mode 100644 index 0000000..f01c79b --- /dev/null +++ b/app/api/taobao-detail-plus/image-proxy/route.ts @@ -0,0 +1,128 @@ +import sharp from 'sharp' +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +const ALLOWED_IMAGE_HOST_SUFFIXES = [ + 'alicdn.com', + 'taobaocdn.com', + 'tbcdn.cn', +] + +function isAllowedImageHost(hostname: string): boolean { + return ALLOWED_IMAGE_HOST_SUFFIXES.some( + (suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`), + ) +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session) { + return NextResponse.json( + { + success: false, + error: { + code: 'UNAUTHORIZED', + message: '请先登录后再导出图片', + }, + }, + { status: 401 }, + ) + } + + const rawUrl = request.nextUrl.searchParams.get('url') + if (!rawUrl) { + return NextResponse.json( + { + success: false, + error: { + code: 'MISSING_URL', + message: '缺少图片地址', + }, + }, + { status: 400 }, + ) + } + + let targetUrl: URL + try { + targetUrl = new URL(rawUrl) + } catch { + return NextResponse.json( + { + success: false, + error: { + code: 'INVALID_URL', + message: '图片地址不正确', + }, + }, + { status: 400 }, + ) + } + + if (!['http:', 'https:'].includes(targetUrl.protocol) || !isAllowedImageHost(targetUrl.hostname)) { + return NextResponse.json( + { + success: false, + error: { + code: 'IMAGE_HOST_NOT_ALLOWED', + message: '图片来源不允许导出', + }, + }, + { status: 400 }, + ) + } + + const upstream = await fetch(targetUrl.toString(), { + method: 'GET', + cache: 'no-store', + headers: { + 'User-Agent': 'Mozilla/5.0 Codex Taobao Image Proxy', + Referer: 'https://item.taobao.com/', + }, + }) + + if (!upstream.ok) { + return NextResponse.json( + { + success: false, + error: { + code: 'UPSTREAM_IMAGE_FETCH_FAILED', + message: `图片下载失败,上游返回 ${upstream.status}`, + }, + }, + { status: 502 }, + ) + } + + const buffer = Buffer.from(await upstream.arrayBuffer()) + const pngBuffer = await sharp(buffer, { failOn: 'none' }) + .flatten({ background: '#ffffff' }) + .png() + .toBuffer() + const metadata = await sharp(pngBuffer).metadata() + + return new NextResponse(Uint8Array.from(pngBuffer), { + status: 200, + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'private, max-age=300', + 'X-Image-Width': String(metadata.width || 0), + 'X-Image-Height': String(metadata.height || 0), + }, + }) + } catch (error) { + console.error('[api/taobao-detail-plus/image-proxy] GET error:', error) + return NextResponse.json( + { + success: false, + error: { + code: 'IMAGE_PROXY_ERROR', + message: '图片处理失败,请稍后再试', + }, + }, + { status: 500 }, + ) + } +} diff --git a/app/page.tsx b/app/page.tsx index 9ef5e19..f437e98 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,27 +13,12 @@ import { useToast } from '@/hooks/useToast' import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' -import { getAllowedPublicAppOrigins, getPublicAppOrigin } from '@/lib/public-app-url' import { MATERIAL_TYPE_OPTIONS } from '@/lib/material-type-options' +import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' -const PUBLIC_APP_ORIGIN = getPublicAppOrigin() const DEFAULT_MATERIAL_TYPES = [...MATERIAL_TYPE_OPTIONS] -const PUBLIC_DOMAIN = (() => { - try { - return new URL(PUBLIC_APP_ORIGIN).hostname - } catch { - return 'sc.jiansun.vip' - } -})() -const KNOWN_PUBLIC_HOSTS = getAllowedPublicAppOrigins().map((origin) => { - try { - return new URL(origin).hostname - } catch { - return origin - } -}) interface Material { id: string @@ -46,6 +31,7 @@ interface Material { productCategoryName?: string // 品类分类 materialType: string isAiGenerated?: boolean + taskDetailSourceType?: string | null renderer: string authorName: string zipFileName: string @@ -184,214 +170,6 @@ export default function Home() { // 实时搜索:使用 useDeferredValue 实现非阻塞搜索 const deferredSearchKeyword = useDeferredValue(searchKeyword) const isSearchPending = searchKeyword !== deferredSearchKeyword - const [hasCheckedIntranet, setHasCheckedIntranet] = useState(false) // 是否已检查过内网状态 - - // 检测内网用户访问公网域名,自动跳转到内网地址 - // 同时检测:如果在内网地址但无法访问,自动回退到公网域名 - useEffect(() => { - if (hasCheckedIntranet || typeof window === 'undefined') return - - const checkIntranet = async () => { - try { - // 判断当前是否在内网地址上 - const isOnIntranetAddress = window.location.hostname === '192.168.58.20' || - window.location.hostname.match(/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./) - - // 如果当前在内网地址上,检查是否能正常访问 - if (isOnIntranetAddress) { - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 3000) // 3秒超时 - - // 尝试访问当前服务器的 API - const testResponse = await fetch('/api/network-status', { - method: 'GET', - cache: 'no-store', - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (!testResponse.ok) { - throw new Error('API 返回非 200 状态') - } - - // 能正常访问,继续正常流程 - console.debug('[网络检测] 当前在内网地址,且能正常访问') - } catch (error) { - // 无法访问内网地址,说明用户可能在公网环境但使用了内网地址 - console.warn('[网络检测] 当前在内网地址但无法访问,可能用户已切换到公网环境,自动回退到公网域名') - - // 重定向到公网域名 - const currentPath = window.location.pathname + window.location.search - window.location.replace(`https://${PUBLIC_DOMAIN}${currentPath}`) - return - } - - // 如果在内网地址且能正常访问,直接返回,不需要进一步检测 - setHasCheckedIntranet(true) - return - } - - // 判断是否为公网域名 - const isPublicDomain = window.location.hostname !== '192.168.58.20' && - window.location.hostname !== 'localhost' && - !window.location.hostname.match(/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./) - - // 如果访问的是公网域名,尝试检测用户是否在内网 - if (isPublicDomain) { - // 检查是否已经跳过(避免无限重定向) - // 注意:每次访问公网域名时都重新检测,不依赖 sessionStorage - // 这样可以确保内网用户在清除缓存后仍能正常跳转 - const hasRedirected = sessionStorage.getItem('intranet-auto-redirect') - if (hasRedirected && KNOWN_PUBLIC_HOSTS.includes(window.location.hostname)) { - // 如果已经跳过,且当前访问的是公网域名,清除标记并重新检测 - console.debug('[内网切换] 检测到已跳过标记,但访问的是公网域名,重新检测...') - sessionStorage.removeItem('intranet-auto-redirect') - } else if (hasRedirected) { - // 如果已经跳过,且当前访问的不是公网域名,跳过检测 - setHasCheckedIntranet(true) - return - } - - // 检查当前协议 - const isHttps = window.location.protocol === 'https:' - - // 公网 HTTPS 页面不能主动探测 http:// 内网地址,否则会触发 mixed content - // 因此仅在 HTTP 场景下尝试直连内网地址;HTTPS 场景统一走后端 /api/network-status - let canAccessIntranet = false - - try { - if (!isHttps) { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败 - - const intranetUrl = `http://192.168.58.20:4320/api/network-status` - const intranetResponse = await fetch(intranetUrl, { - method: 'GET', - cache: 'no-store', - mode: 'cors', - credentials: 'omit', - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (intranetResponse.ok) { - const intranetData = await intranetResponse.json() - const hostname = intranetData.hostname || '' - const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true - - if (isTargetServer) { - canAccessIntranet = true - console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户') - } - } - } - } catch (error) { - // 访问失败(可能是混合内容阻止或网络问题),尝试通过后端API检测 - console.debug('[内网切换] 直接访问内网地址失败(可能是混合内容或网络问题),尝试通过后端API检测:', error instanceof Error ? error.message : error) - - // 如果直接访问失败,通过后端API检测(备用方案) - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 2000) - - const networkStatusUrl = `/api/network-status` - const networkResponse = await fetch(networkStatusUrl, { - method: 'GET', - cache: 'no-store', - signal: controller.signal - }) - - clearTimeout(timeoutId) - - if (networkResponse.ok) { - const networkData = await networkResponse.json() - const clientIp = networkData.clientIp || 'unknown' - const hostname = networkData.hostname || '' - const isIntranet = networkData.isIntranet || false - - console.debug('[内网切换] 后端API检测结果:', { - clientIp, - hostname, - isIntranet, - networkData - }) - - // 注意:如果clientIp是公网IP(如58.216.201.86),可能是代理IP,不准确 - // 但如果后端返回 isIntranet: true,说明后端通过其他方式(如hostname)判断为内网 - if (isIntranet) { - canAccessIntranet = true - console.debug('[内网切换] 后端API检测:判定为内网用户(isIntranet:', isIntranet, ')') - } else if (!isHttps) { - // 仅 HTTP 场景下继续尝试 iframe 探测,避免 HTTPS 公网页面触发 mixed content - console.debug('[内网切换] 后端API检测:clientIp为公网IP(', clientIp, '),可能是代理IP,尝试其他检测方式') - - const testIframe = document.createElement('iframe') - testIframe.style.display = 'none' - testIframe.src = `http://192.168.58.20:4320/api/network-status` - - const iframeTimeout = setTimeout(() => { - try { - document.body.removeChild(testIframe) - } catch {} - }, 3000) - - testIframe.onload = () => { - clearTimeout(iframeTimeout) - try { - document.body.removeChild(testIframe) - canAccessIntranet = true - console.debug('[内网切换] iframe检测:能访问内网地址,判定为内网用户') - } catch {} - } - - testIframe.onerror = () => { - clearTimeout(iframeTimeout) - try { - document.body.removeChild(testIframe) - } catch {} - console.debug('[内网切换] iframe检测:无法访问内网地址') - } - - document.body.appendChild(testIframe) - - // 等待iframe加载(最多等待2秒) - await new Promise(resolve => setTimeout(resolve, 2000)) - } - } - } catch (apiError) { - console.debug('[内网切换] 后端API检测失败:', apiError) - } - } - - // 如果检测到在内网,执行跳转 - if (canAccessIntranet) { - console.debug('[内网切换] 检测到内网访问,自动跳转到内网地址') - sessionStorage.setItem('intranet-auto-redirect', 'true') - const currentPath = window.location.pathname + window.location.search - // 跳转到HTTP内网地址(内网环境可以使用HTTP) - window.location.replace(`http://192.168.58.20:4320${currentPath}`) - return - } else { - console.debug('[内网切换] 未检测到内网访问,保持当前页面') - } - } else { - // 如果访问的是内网地址,不需要切换 - console.debug('[内网切换] 访问的是内网地址,不需要切换') - } - } catch (error) { - console.warn('网络检测失败:', error) - } finally { - setHasCheckedIntranet(true) - } - } - - // 延迟检测,避免影响页面加载 - const timer = setTimeout(checkIntranet, 1000) - return () => clearTimeout(timer) - }, [hasCheckedIntranet]) // 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制 const handleSearchChange = useCallback((value: string) => { @@ -420,7 +198,7 @@ export default function Home() { const sort = params.get('sortBy') const sortOrderParam = params.get('sortOrder') - if (materialType) setSelectedMaterialType(materialType) + if (materialType) setSelectedMaterialType(normalizeVideoMaterialType(materialType)) if (category) setSelectedCategory(category) if (scene) setSelectedScene(scene) if (platform) setSelectedPlatform(platform) @@ -467,7 +245,7 @@ export default function Home() { if (t === '3DMAX') return '3D场景' if (t === '产品全案') return '任务-详情' if (t === '详情页') return '详情模板' - return t + return normalizeVideoMaterialType(t) } const res = await fetch('/api/materials/viewable-types') if (res.ok) { @@ -532,13 +310,14 @@ export default function Home() { const cache = JSON.parse(savedCache) const cacheAge = Date.now() - (cache.timestamp || 0) const CACHE_EXPIRY = 5 * 60 * 1000 // 5分钟 + const cachedMaterials = Array.isArray(cache.materials) ? cache.materials : [] // 如果缓存未过期,恢复状态 - if (cacheAge < CACHE_EXPIRY) { + if (cacheAge < CACHE_EXPIRY && cachedMaterials.length > 0) { // 恢复筛选条件 if (cache.selectedCategory) setSelectedCategory(cache.selectedCategory) if (cache.selectedScene) setSelectedScene(cache.selectedScene) - if (cache.selectedMaterialType) setSelectedMaterialType(cache.selectedMaterialType) + if (cache.selectedMaterialType) setSelectedMaterialType(normalizeVideoMaterialType(cache.selectedMaterialType)) if (cache.selectedPlatform) setSelectedPlatform(cache.selectedPlatform) if (cache.selectedProductCategory) setSelectedProductCategory(cache.selectedProductCategory) if (cache.selectedMonth) setSelectedMonth(cache.selectedMonth) @@ -547,9 +326,9 @@ export default function Home() { if (cache.searchKeyword) setSearchKeyword(cache.searchKeyword) // 恢复素材数据和显示数量 - if (cache.materials && Array.isArray(cache.materials)) { - setMaterials(cache.materials) - setDisplayedCount(cache.displayedCount || cache.materials.length) + if (cachedMaterials.length > 0) { + setMaterials(cachedMaterials) + setDisplayedCount(cache.displayedCount || cachedMaterials.length) setCurrentPage(cache.currentPage || 1) } @@ -832,7 +611,7 @@ export default function Home() { // 对需要转码的视频素材启动轮询 appendedItems.forEach((material) => { - if (isVideoMaterialType(material.materialType) && material.videoSourceUrl) { + if (isVideoMaterial(material.materialType) && material.videoSourceUrl) { startTranscodePoll(material, 0) } }) @@ -862,9 +641,9 @@ export default function Home() { }, [fetchMaterials]) // 判断是否为视频类型素材 - const isVideoMaterialType = (materialType: string): boolean => { - return materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频' - } + const isVideoMaterial = useCallback((materialType: string): boolean => { + return isVideoMaterialTypeValue(materialType) + }, []) // 停止转码轮询 const stopTranscodePoll = useCallback((materialId: string) => { @@ -879,7 +658,7 @@ export default function Home() { // 启动转码轮询 const startTranscodePoll = useCallback((material: Material, delay: number = 5000) => { - if (!isVideoMaterialType(material.materialType)) return + if (!isVideoMaterial(material.materialType)) return if (!material.id || typeof material.id !== 'string') { console.error('[TranscodePoll] Invalid material ID (empty or not string):', material.id) return @@ -1256,7 +1035,7 @@ export default function Home() { // 根据素材类型获取角标背景颜色 const getMaterialTypeBadgeColor = (materialType: string): string => { // 判断是否为视频类型 - const isVideoType = materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频' + const isVideoType = isVideoMaterial(materialType) if (materialType === '3D场景' || materialType === '3DMAX') { return 'bg-red-500' diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 0abd909..e0d30af 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -15,6 +15,14 @@ import Header from '@/components/Header' import VideoPlayerModal from '@/components/VideoPlayerModal' import { useToast } from '@/hooks/useToast' import ToastBubble from '@/components/ToastBubble' +import { + DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE, + DOUYIN_QIANCHUAN_VIDEO_TYPE, + INSTALL_VIDEO_TYPE, + TAOXI_GUANGGUANG_VIDEO_TYPE, + TAOXI_MAIN_VIDEO_TYPE, + isVideoMaterialType, +} from '@/lib/video-material-types' interface UserProfile { id: string @@ -121,7 +129,7 @@ export default function ProfilePage() { // 判断是否为视频素材 const isVideoMaterial = (materialType?: string) => { - return materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频' + return isVideoMaterialType(materialType) } // 格式化时长 @@ -140,9 +148,13 @@ export default function ProfilePage() { '矢量文件': 'bg-green-500', 'C4D模型': 'bg-orange-500', '视频': 'bg-red-500', + [TAOXI_MAIN_VIDEO_TYPE]: 'bg-red-500', '主图视频': 'bg-red-500', - '安装视频': 'bg-red-500', + [INSTALL_VIDEO_TYPE]: 'bg-red-500', + [TAOXI_GUANGGUANG_VIDEO_TYPE]: 'bg-red-500', '种草视频': 'bg-red-500', + [DOUYIN_QIANCHUAN_VIDEO_TYPE]: 'bg-red-500', + [DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE]: 'bg-red-500', '模特': 'bg-pink-500', '主图模版': 'bg-indigo-500', 'SKU模版': 'bg-teal-500', @@ -556,4 +568,3 @@ export default function ProfilePage() { ) } - diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 19a94ee..572ed67 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -15,20 +15,25 @@ import AudioVisualizer from '@/components/AudioVisualizer' import ProductPackageDetail from '@/components/detail/ProductPackageDetail' import TaskPageDetail from '@/components/detail/TaskPageDetail' import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail' +import DocumentAssetViewer, { type ViewerAssetItem } from '@/components/DocumentAssetViewer' import { parseDownloadError } from '@/lib/download-errors' import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, parseFetchError } from '@/lib/error-handler' +import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report' +import { isVideoMaterialType } from '@/lib/video-material-types' interface Material { id: string userId: string previewUrl: string productModel: string + reportTitle?: string | null categoryName: string sceneName: string platformName?: string // 平台分类 productCategoryName?: string // 品类分类 materialType: string + taskDetailSourceType?: string | null authorName: string zipFileName: string zipFileSize: string | number @@ -49,6 +54,7 @@ interface Material { taskFetchedAt?: string // 产品全案被获取时间 isFullSet?: boolean canDownloadSource?: boolean // 产品全案 PSD 下载权限(参考"详情模板"类型) + sourceFileUrl?: string | null assets?: Array<{ id: string assetType: string @@ -56,6 +62,7 @@ interface Material { fileName: string fileSize: number sortOrder: number + fileKind?: 'pdf' | 'image' }> // 产品全案资产列表 skus?: Array<{ id: string @@ -95,6 +102,47 @@ export default function SharePage() { const [downloadingBatchType, setDownloadingBatchType] = useState<'model' | 'white-background' | null>(null) const [skuListRefreshKey, setSkuListRefreshKey] = useState(0) + const materialDisplayTitle = material + ? getCertificateReportDisplayTitle({ + productModel: material.productModel, + reportTitle: material.reportTitle, + }) + : '' + + const canManageStandardModelSku = Boolean( + session?.user && material, + ) + + const reportViewerItems: ViewerAssetItem[] = material && isCertificateReportMaterialType(material.materialType) + ? (material.assets || []) + .filter((asset) => asset.assetType === 'report-pdf' || asset.assetType === 'report-image') + .map((asset) => ({ + id: asset.id, + fileName: asset.fileName || `文件-${asset.sortOrder + 1}`, + fileUrl: asset.fileUrl, + fileKind: asset.fileKind === 'pdf' ? 'pdf' : 'image', + })) + : [] + + const brandViViewerItems: ViewerAssetItem[] = material && material.materialType === '品牌VI灵感' + ? [ + ...(material.sourceFileUrl + ? [{ + id: 'brand-vi-source-pdf', + fileName: material.zipFileName || `${materialDisplayTitle || '品牌VI灵感'}.pdf`, + fileUrl: material.sourceFileUrl, + fileKind: 'pdf' as const, + }] + : []), + ...((material.displayImages || []).map((imageUrl, index) => ({ + id: `brand-vi-image-${index}`, + fileName: `附图-${index + 1}.jpg`, + fileUrl: imageUrl, + fileKind: 'image' as const, + }))), + ] + : [] + useEffect(() => { // 分享页面允许未登录访问,立即开始加载,不等待session状态 // 使用setTimeout确保在页面渲染后立即开始加载 @@ -135,7 +183,7 @@ export default function SharePage() { // 如果是视频类型,立即开始预加载视频文件(不等待渲染) // 优化:优先使用更小的预览视频(videoPreviewClipUrl < videoPreview540Url < videoSourceUrl) - if (data.materialType === '主图视频' || data.materialType === '安装视频' || data.materialType === '种草视频') { + if (isVideoMaterialType(data.materialType)) { // 优先使用最小的预览视频(10秒片段 < 720p预览 < 源视频) const videoUrl = data.videoPreviewClipUrl || data.videoPreview540Url || data.videoSourceUrl if (videoUrl) { @@ -551,7 +599,7 @@ export default function SharePage() { const isTaskPageType = mappedMaterialType === '任务-页面' // 检查是否为3D原图/AI原图/实拍原图/视频截图类型(带"任务-"前缀) - const is3DOriginalType = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'].includes(mappedMaterialType) + const is3DOriginalType = ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '任务-其他'].includes(mappedMaterialType) return (
@@ -567,6 +615,7 @@ export default function SharePage() { productModel: material.productModel, categoryName: material.categoryName, sceneName: material.sceneName, + taskDetailSourceType: material.taskDetailSourceType || null, isFullSet: material.isFullSet, taskStatus: material.taskStatus || 'pending', taskFetchedAt: material.taskFetchedAt, @@ -633,6 +682,7 @@ export default function SharePage() { productModel: material.productModel, categoryName: material.categoryName, sceneName: material.sceneName, + materialType: mappedMaterialType, taskStatus: material.taskStatus || 'pending', taskFetchedAt: material.taskFetchedAt, assets: material.assets || [], @@ -666,7 +716,7 @@ export default function SharePage() {
{(() => { // 判断是否为视频类型 - const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频' + const isVideoType = isVideoMaterialType(material.materialType) const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url || material.videoSourceUrl const posterUrl = material.videoCoverUrl || material.previewUrl @@ -678,17 +728,34 @@ export default function SharePage() { const allPreviewImages = material.previewUrl ? (hasDisplayImages ? [material.previewUrl, ...material.displayImages] : [material.previewUrl]) : (hasDisplayImages ? material.displayImages : []) + + if (isCertificateReportMaterialType(material.materialType) && reportViewerItems.length > 0) { + return ( + + ) + } - // 品牌VI灵感:优先使用previewUrl(PDF提取的预览图),其次使用displayImages,最后显示PDF iframe + // 品牌VI灵感:优先使用原始PDF多页预览,其次显示附图,最后才回退到旧的首图预览 if (material.materialType === '品牌VI灵感') { - // 优先使用 previewUrl(上传时从PDF提取的第一页预览图) + if (brandViViewerItems.length > 0) { + return ( + + ) + } + 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) => ( -
- {`${material.productModel} -
- ))} -
- ) - } - // 最后:显示PDF iframe(仅当previewUrl和displayImages都没有时) - let pdfUrl = '' - if (material.zipFileName) { - if (material.zipFileName.startsWith('http://') || material.zipFileName.startsWith('https://')) { - pdfUrl = material.zipFileName - } else { - // 本地文件需要通过API路由访问 - pdfUrl = `/api/uploads/${material.zipFileName}` - } - } - if (pdfUrl) { - return ( -
-