chore: sync latest project source
This commit is contained in:
parent
f675abe5ee
commit
8c39d96249
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,9 @@ cache/**
|
||||
.env*.local
|
||||
|
||||
# 用户上传的文件(关键优化!)
|
||||
uploads
|
||||
# 这里只排除项目根目录的真实上传文件,不能误伤 app/api/uploads、app/uploads 这类路由目录
|
||||
/uploads
|
||||
/uploads/**
|
||||
|
||||
# Prisma 引擎缓存(不需要在构建上下文中)
|
||||
prisma/engine-cache
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -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
|
||||
|
||||
135
CLAUDE.md
135
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` 表
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -238,4 +238,4 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
271
app/api/external/dingtalk-material-match/route.ts
vendored
Normal file
271
app/api/external/dingtalk-material-match/route.ts
vendored
Normal file
@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
145
app/api/external/dingtalk-material-options/route.ts
vendored
Normal file
145
app/api/external/dingtalk-material-options/route.ts
vendored
Normal file
@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
2
app/api/external/download/route.ts
vendored
2
app/api/external/download/route.ts
vendored
@ -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] ========== 任务类型素材下载 ==========`);
|
||||
|
||||
12
app/api/external/product-assets/route.ts
vendored
12
app/api/external/product-assets/route.ts
vendored
@ -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<string, any> = {
|
||||
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<string, any> = {
|
||||
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',
|
||||
|
||||
536
app/api/external/standard-model-sku-white-background/route.ts
vendored
Normal file
536
app/api/external/standard-model-sku-white-background/route.ts
vendored
Normal file
@ -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<WhiteBackgroundAssetRecord> | 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<WhiteBackgroundAssetRecord> | null
|
||||
uploaderMap: Map<string, { id: string; username: string; realName: string | null }>
|
||||
}) {
|
||||
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',
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 数组
|
||||
|
||||
@ -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 失败',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<string, string[]> = {
|
||||
'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,
|
||||
|
||||
@ -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',
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
249
app/api/materials/upload/certificate-report/complete/route.ts
Normal file
249
app/api/materials/upload/certificate-report/complete/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
||||
63
app/api/materials/upload/certificate-report/init/route.ts
Normal file
63
app/api/materials/upload/certificate-report/init/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
128
app/api/taobao-detail-plus/image-proxy/route.ts
Normal file
128
app/api/taobao-detail-plus/image-proxy/route.ts
Normal file
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
253
app/page.tsx
253
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'
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
@ -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() {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden">
|
||||
{(() => {
|
||||
// 判断是否为视频类型
|
||||
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 (
|
||||
<DocumentAssetViewer
|
||||
title={materialDisplayTitle}
|
||||
items={reportViewerItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 品牌VI灵感:优先使用previewUrl(PDF提取的预览图),其次使用displayImages,最后显示PDF iframe
|
||||
// 品牌VI灵感:优先使用原始PDF多页预览,其次显示附图,最后才回退到旧的首图预览
|
||||
if (material.materialType === '品牌VI灵感') {
|
||||
// 优先使用 previewUrl(上传时从PDF提取的第一页预览图)
|
||||
if (brandViViewerItems.length > 0) {
|
||||
return (
|
||||
<DocumentAssetViewer
|
||||
title={materialDisplayTitle}
|
||||
items={brandViViewerItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (material.previewUrl) {
|
||||
return (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
|
||||
<div className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||||
<Image
|
||||
src={material.previewUrl}
|
||||
alt={`${material.productModel} - PDF预览`}
|
||||
alt={`${materialDisplayTitle} - 预览图`}
|
||||
width={790}
|
||||
height={1500}
|
||||
unoptimized
|
||||
@ -696,72 +763,15 @@ export default function SharePage() {
|
||||
style={{ display: 'block', maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
{/* 如果有额外的displayImages,也显示出来 */}
|
||||
{hasDisplayImages && material.displayImages.map((imageUrl, index) => (
|
||||
<div key={`display-${index}`} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`${material.productModel} - 展示图 ${index + 1}`}
|
||||
width={790}
|
||||
height={1500}
|
||||
unoptimized
|
||||
className="w-full h-auto"
|
||||
style={{ display: 'block', maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 其次使用 displayImages
|
||||
if (hasDisplayImages && allPreviewImages.length > 0) {
|
||||
return (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex flex-col items-center">
|
||||
{allPreviewImages.map((imageUrl, index) => (
|
||||
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`${material.productModel} - 展示图 ${index + 1}`}
|
||||
width={790}
|
||||
height={1500}
|
||||
unoptimized
|
||||
className="w-full h-auto"
|
||||
style={{ display: 'block', maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 最后:显示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 (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700" style={{ minHeight: '800px' }}>
|
||||
<iframe
|
||||
src={`${pdfUrl}#toolbar=0&navpanes=0&scrollbar=1`}
|
||||
className="w-full"
|
||||
style={{ height: '800px', border: 'none' }}
|
||||
title={`${material.productModel} - PDF预览`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||||
<p className="text-gray-500 dark:text-gray-400">PDF文件不存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center" style={{ minHeight: '400px' }}>
|
||||
<p className="text-gray-500 dark:text-gray-400">PDF文件不存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果有 displayImages 或有多张预览图,显示多张预览图(适用于所有类型,除了视频和音乐)
|
||||
@ -773,7 +783,7 @@ export default function SharePage() {
|
||||
<div key={index} className="relative" style={{ maxWidth: '790px', width: '100%' }}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={`${material.productModel} - 预览图 ${index + 1}`}
|
||||
alt={`${materialDisplayTitle} - 预览图 ${index + 1}`}
|
||||
width={790}
|
||||
height={1500}
|
||||
unoptimized
|
||||
@ -884,7 +894,7 @@ export default function SharePage() {
|
||||
<div className="mb-6">
|
||||
<Image
|
||||
src={material.previewUrl}
|
||||
alt={material.productModel}
|
||||
alt={materialDisplayTitle}
|
||||
width={400}
|
||||
height={400}
|
||||
unoptimized
|
||||
@ -913,7 +923,7 @@ export default function SharePage() {
|
||||
)}
|
||||
</button>
|
||||
{/* 音频名称 */}
|
||||
<p className="text-white text-xl font-medium text-center px-4 mt-6 drop-shadow-lg">{material.productModel}</p>
|
||||
<p className="text-white text-xl font-medium text-center px-4 mt-6 drop-shadow-lg">{materialDisplayTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -932,7 +942,7 @@ export default function SharePage() {
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Image
|
||||
src={material.previewUrl}
|
||||
alt={material.productModel}
|
||||
alt={materialDisplayTitle}
|
||||
width={790}
|
||||
height={1000}
|
||||
unoptimized
|
||||
@ -952,7 +962,7 @@ export default function SharePage() {
|
||||
{/* 标题和标签 - 统一间距 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{material.productModel}
|
||||
{materialDisplayTitle}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full text-sm font-medium">
|
||||
@ -973,7 +983,7 @@ export default function SharePage() {
|
||||
{material.materialType === '标准模型' ? (
|
||||
<SkuList
|
||||
materialId={materialId}
|
||||
canEdit={!!(session?.user && ((session.user as any)?.id === material.userId || (session.user as any)?.role === 'ADMIN'))}
|
||||
canEdit={canManageStandardModelSku}
|
||||
onEditClick={() => setShowSkuEditModal(true)}
|
||||
onSelectionChange={setSelectedSkuIds}
|
||||
refreshKey={skuListRefreshKey}
|
||||
@ -985,10 +995,27 @@ export default function SharePage() {
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">作者</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.authorName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||||
<p className="text-base font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
{isCertificateReportMaterialType(material.materialType) ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">报告名称 / 检测对象</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.reportTitle || '未填写'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件数量</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{reportViewerItems.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">总大小</p>
|
||||
<p className="text-base font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件大小</p>
|
||||
<p className="text-base font-medium text-blue-600 dark:text-blue-400">{formatFileSize(material.zipFileSize)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">下载次数</p>
|
||||
@ -999,10 +1026,12 @@ export default function SharePage() {
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.likeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||||
</div>
|
||||
{!isCertificateReportMaterialType(material.materialType) && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">文件名</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 break-all">{material.zipFileName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">上传时间</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
@ -1071,6 +1100,10 @@ export default function SharePage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : isCertificateReportMaterialType(material.materialType) ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-700/40 dark:text-gray-300">
|
||||
报告文件请在左侧文件列表中打开或下载
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDownloadByDirectLink}
|
||||
|
||||
160
components/DocumentAssetViewer.tsx
Normal file
160
components/DocumentAssetViewer.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
|
||||
export interface ViewerAssetItem {
|
||||
id: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileKind: 'pdf' | 'image'
|
||||
}
|
||||
|
||||
interface DocumentAssetViewerProps {
|
||||
title: string
|
||||
items: ViewerAssetItem[]
|
||||
}
|
||||
|
||||
function formatLabel(fileName: string, index: number): string {
|
||||
const normalized = String(fileName || '').trim()
|
||||
return normalized || `文件 ${index + 1}`
|
||||
}
|
||||
|
||||
export default function DocumentAssetViewer({ title, items }: DocumentAssetViewerProps) {
|
||||
const normalizedItems = useMemo(
|
||||
() =>
|
||||
items
|
||||
.filter((item) => item.fileUrl)
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
fileName: formatLabel(item.fileName, index),
|
||||
})),
|
||||
[items],
|
||||
)
|
||||
|
||||
const [selectedId, setSelectedId] = useState(normalizedItems[0]?.id || '')
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId(normalizedItems[0]?.id || '')
|
||||
}, [normalizedItems])
|
||||
|
||||
const selectedItem = normalizedItems.find((item) => item.id === selectedId) || normalizedItems[0]
|
||||
|
||||
const handleOpenFile = () => {
|
||||
if (!selectedItem?.fileUrl) {
|
||||
return
|
||||
}
|
||||
window.open(selectedItem.fileUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleDownloadFile = async () => {
|
||||
if (!selectedItem?.fileUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(selectedItem.fileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error('文件下载失败')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = objectUrl
|
||||
anchor.download = selectedItem.fileName
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
} catch (error) {
|
||||
console.error('[DocumentAssetViewer] 下载失败,改为直接打开原文件:', error)
|
||||
window.open(selectedItem.fileUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedItem) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center bg-gray-100 text-sm text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
||||
暂无可预览文件
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white dark:bg-gray-800">
|
||||
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div className="mb-3 text-sm font-medium text-gray-700 dark:text-gray-200">{title}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{normalizedItems.map((item) => {
|
||||
const isActive = item.id === selectedItem.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs transition-colors ${
|
||||
isActive
|
||||
? 'bg-gray-900 text-white dark:bg-blue-600'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{item.fileKind === 'pdf' ? 'PDF' : '图片'} · {item.fileName}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-700">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{selectedItem.fileName}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{selectedItem.fileKind === 'pdf' ? 'PDF 文档预览' : '图片原图预览'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenFile}
|
||||
className="rounded-lg px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
打开原文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleDownloadFile()
|
||||
}}
|
||||
className="rounded-lg bg-gray-900 px-3 py-2 text-sm text-white transition-colors hover:bg-gray-800 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
下载原文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 dark:bg-gray-700">
|
||||
{selectedItem.fileKind === 'pdf' ? (
|
||||
<iframe
|
||||
src={`${selectedItem.fileUrl}#toolbar=0&navpanes=0&scrollbar=1`}
|
||||
className="w-full"
|
||||
style={{ height: '980px', border: 'none' }}
|
||||
title={selectedItem.fileName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-6">
|
||||
<Image
|
||||
src={selectedItem.fileUrl}
|
||||
alt={selectedItem.fileName}
|
||||
width={790}
|
||||
height={1200}
|
||||
unoptimized
|
||||
className="h-auto w-full object-contain"
|
||||
style={{ maxWidth: '790px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,6 +8,8 @@ import {
|
||||
supportsIsFullSet,
|
||||
supportsTaskNo,
|
||||
} from '@/lib/material-type-options'
|
||||
import { isCertificateReportMaterialType } from '@/lib/certificate-report'
|
||||
import { TASK_DETAIL_SOURCE_TYPE_OPTIONS } from '@/lib/task-detail-source-type'
|
||||
import {
|
||||
requiresCategoryAndScene,
|
||||
requiresProductCategoryName,
|
||||
@ -19,6 +21,7 @@ interface EditMaterialModalProps {
|
||||
id: string
|
||||
previewUrl: string
|
||||
productModel: string
|
||||
reportTitle?: string | null
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
platformName?: string | null
|
||||
@ -28,6 +31,7 @@ interface EditMaterialModalProps {
|
||||
authorName: string
|
||||
userId: string
|
||||
taskNo?: string | null
|
||||
taskDetailSourceType?: string | null
|
||||
taskStatus?: string | null
|
||||
taskFetchedAt?: string | null
|
||||
isFullSet?: boolean
|
||||
@ -40,6 +44,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
const [previewImage, setPreviewImage] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string>(material.previewUrl || '')
|
||||
const [productModel, setProductModel] = useState(material.productModel)
|
||||
const [reportTitle, setReportTitle] = useState(material.reportTitle || '')
|
||||
const [categoryName, setCategoryName] = useState(material.categoryName)
|
||||
const [sceneName, setSceneName] = useState(material.sceneName)
|
||||
const [platformName, setPlatformName] = useState(material.platformName || '')
|
||||
@ -49,6 +54,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
const [authorName, setAuthorName] = useState(material.authorName)
|
||||
const [userId, setUserId] = useState(material.userId)
|
||||
const [taskNo, setTaskNo] = useState(material.taskNo || '')
|
||||
const [taskDetailSourceType, setTaskDetailSourceType] = useState(material.taskDetailSourceType || '')
|
||||
const [taskStatus] = useState(material.taskStatus || '')
|
||||
const [taskFetchedAt] = useState(material.taskFetchedAt || '')
|
||||
const [isFullSet] = useState(Boolean(material.isFullSet))
|
||||
@ -65,6 +71,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
const showTaskNo = supportsTaskNo(materialType)
|
||||
const showIsFullSet = supportsIsFullSet(materialType)
|
||||
const allowPreviewReplacement = canManuallyReplacePreview(materialType)
|
||||
const isCertificateReport = isCertificateReportMaterialType(materialType)
|
||||
const isFetchedTask = taskStatus === 'fetched'
|
||||
const materialTypeOptions = getSelectableMaterialTypeOptions(materialType)
|
||||
|
||||
@ -122,7 +129,17 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!productModel || !materialType || !authorName) {
|
||||
if (!materialType || !authorName) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
if (isCertificateReport) {
|
||||
if (!productModel.trim() && !reportTitle.trim()) {
|
||||
setError('SPU 和 报告名称/检测对象 至少填写一个')
|
||||
return
|
||||
}
|
||||
} else if (!productModel) {
|
||||
setError('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
@ -155,6 +172,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
const formData = new FormData()
|
||||
formData.append('id', material.id)
|
||||
formData.append('productModel', productModel)
|
||||
formData.append('reportTitle', reportTitle)
|
||||
formData.append('categoryName', categoryName)
|
||||
formData.append('sceneName', sceneName)
|
||||
formData.append('platformName', platformName)
|
||||
@ -164,6 +182,7 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
formData.append('authorName', authorName)
|
||||
formData.append('userId', userId)
|
||||
formData.append('taskNo', showTaskNo ? taskNo : '')
|
||||
formData.append('taskDetailSourceType', materialType === '任务-详情' ? taskDetailSourceType : '')
|
||||
|
||||
if (previewImage && allowPreviewReplacement) {
|
||||
formData.append('previewImage', previewImage)
|
||||
@ -240,10 +259,31 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">产品型号 *</label>
|
||||
<input type="text" value={productModel} onChange={(event) => setProductModel(event.target.value)} className="input-apple" required />
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
{isCertificateReport ? 'SPU' : '产品型号 *'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(event) => setProductModel(event.target.value)}
|
||||
className="input-apple"
|
||||
required={!isCertificateReport}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCertificateReport && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">报告名称 / 检测对象</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reportTitle}
|
||||
onChange={(event) => setReportTitle(event.target.value)}
|
||||
className="input-apple"
|
||||
placeholder="SPU 和这个字段至少填写一个"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">素材类型 *</label>
|
||||
<select value={materialType} onChange={(event) => setMaterialType(event.target.value)} className="input-apple" required>
|
||||
@ -334,6 +374,24 @@ export default function EditMaterialModal({ material, onClose, onSuccess }: Edit
|
||||
</div>
|
||||
)}
|
||||
|
||||
{materialType === '任务-详情' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">出图方式</label>
|
||||
<select
|
||||
value={taskDetailSourceType}
|
||||
onChange={(event) => setTaskDetailSourceType(event.target.value)}
|
||||
className="input-apple"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showIsFullSet && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">整套判定</label>
|
||||
|
||||
@ -1,23 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface TaobaoDetailSummary {
|
||||
itemId: string
|
||||
title: string
|
||||
shopName: string
|
||||
price: string
|
||||
location: string
|
||||
sellerType: string
|
||||
detailUrl: string | null
|
||||
videoUrl: string | null
|
||||
mainImageUrl: string | null
|
||||
itemImages: string[]
|
||||
itemImageCount: number
|
||||
skuCount: number
|
||||
attributeCount: number
|
||||
totalCount: string | null
|
||||
}
|
||||
import TaobaoImageGalleryModal from './TaobaoImageGalleryModal'
|
||||
import { TaobaoDetailSummary } from '@/lib/taobao-detail-plus'
|
||||
|
||||
function extractItemId(value: string): string | null {
|
||||
const match = value.trim().match(/\d{8,20}/)
|
||||
@ -29,36 +14,46 @@ export default function HeaderTaobaoSearch() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<TaobaoDetailSummary | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const normalizedItemId = useMemo(() => extractItemId(keyword), [keyword])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
// 如果没有正在加载,也没有结果展示,或者用户点击了很远的地方,则收起
|
||||
if (!isLoading && !result && !error) {
|
||||
setIsExpanded(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isLoading, result, error])
|
||||
|
||||
const handleSearch = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
// 当展开时,自动聚焦输入框
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [isExpanded])
|
||||
|
||||
const handleSearch = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault()
|
||||
|
||||
if (!normalizedItemId) {
|
||||
setError('请输入正确的商品ID')
|
||||
setResult(null)
|
||||
setIsOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setIsOpen(true)
|
||||
|
||||
const response = await fetch(`/api/taobao-detail-plus?itemId=${encodeURIComponent(normalizedItemId)}`, {
|
||||
method: 'GET',
|
||||
@ -83,145 +78,142 @@ export default function HeaderTaobaoSearch() {
|
||||
setKeyword('')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setIsOpen(false)
|
||||
// 如果清空后失焦,考虑是否收起
|
||||
}
|
||||
|
||||
const showDropdown = isExpanded && (isLoading || error || result)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-40 sm:w-56 lg:w-72">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="relative flex items-center justify-end">
|
||||
{/* 搜索容器:控制宽度动画 */}
|
||||
<div
|
||||
className={`group relative flex items-center overflow-hidden rounded-full transition-all duration-300 ease-in-out ${
|
||||
isExpanded
|
||||
? 'w-64 bg-gray-100 ring-2 ring-blue-500/20 dark:bg-gray-800'
|
||||
: 'w-10 bg-white/10 hover:bg-white/20 dark:bg-gray-800/50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{/* 点击触发图标 (在左侧保持固定) */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center transition-colors ${
|
||||
isExpanded ? 'text-blue-500' : 'text-gray-300 dark:text-gray-400'
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 实际输入框:宽度随容器展开 */}
|
||||
<form onSubmit={handleSearch} className="flex-1 pr-10">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onFocus={() => {
|
||||
if (result || error) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder="搜商品ID"
|
||||
className="w-full rounded-full border border-white/12 bg-white/8 py-2 pl-9 pr-16 text-sm text-white placeholder:text-gray-300 outline-none transition focus:border-blue-400/70 focus:bg-white/12 focus:ring-2 focus:ring-blue-400/20"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
placeholder="粘贴链接或商品ID"
|
||||
className="w-full border-none bg-transparent py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0 dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35m1.85-5.15a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-12 top-1/2 -translate-y-1/2 text-gray-300 transition hover:text-white"
|
||||
aria-label="清空商品搜索"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 清空按钮 */}
|
||||
{isExpanded && keyword && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-full bg-blue-500 px-3 py-1 text-xs font-medium text-white transition hover:bg-blue-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
{isLoading ? '查询中' : '查询'}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (isLoading || error || result) && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-[22rem] max-w-[calc(100vw-2rem)] rounded-2xl border border-gray-200 bg-white p-4 shadow-2xl">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-200 border-t-blue-500" />
|
||||
<span>正在查询商品详情...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div className="rounded-xl border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{result.mainImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={result.mainImageUrl}
|
||||
alt={result.title}
|
||||
className="h-20 w-20 rounded-xl border border-gray-200 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-xl border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
无主图
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm font-semibold text-gray-900">{result.title}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{result.shopName || '未知店铺'}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-orange-50 px-2 py-1 text-xs font-medium text-orange-600">
|
||||
{result.price ? `¥${result.price}` : '暂无价格'}
|
||||
</span>
|
||||
{result.sellerType && (
|
||||
<span className="rounded-full bg-blue-50 px-2 py-1 text-xs text-blue-600">
|
||||
{result.sellerType}
|
||||
</span>
|
||||
)}
|
||||
{result.location && (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-600">
|
||||
{result.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 搜索结果下拉面板 (仅在展开且有状态时显示) */}
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 top-full z-50 mt-3 w-80 origin-top-right overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl animate-in fade-in zoom-in duration-200 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="p-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500/20 border-t-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-gray-600">
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">商品ID {result.itemId}</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">主图 {result.itemImageCount} 张</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">SKU {result.skuCount} 个</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">属性 {result.attributeCount} 项</span>
|
||||
{error && !isLoading && (
|
||||
<div className="rounded-xl bg-red-50 p-3 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="truncate text-xs text-gray-400">
|
||||
{result.totalCount ? `店铺商品量 ${result.totalCount}` : '已获取商品摘要'}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{result.detailUrl && (
|
||||
<a
|
||||
href={result.detailUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-gray-900 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-gray-700"
|
||||
>
|
||||
打开详情
|
||||
</a>
|
||||
{result && !isLoading && (
|
||||
<div className="animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex gap-3">
|
||||
{result.mainImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={result.mainImageUrl}
|
||||
alt=""
|
||||
className="h-16 w-16 rounded-lg border border-gray-100 object-cover dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||
<svg className="h-6 w-6 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm font-bold text-gray-900 dark:text-gray-100">{result.title}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-orange-500">¥{result.price}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{result.shopName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-gray-50 p-2 text-center dark:bg-gray-700/50">
|
||||
<div className="text-xs text-gray-500">SKU 图片</div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-gray-100">{result.skuImages?.length || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-2 text-center dark:bg-gray-700/50">
|
||||
<div className="text-xs text-gray-500">详情图片</div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-gray-100">{result.descImages?.length || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsModalOpen(true)
|
||||
// 不关闭展开状态,但可以暂时关闭结果面板(如果需要)
|
||||
}}
|
||||
className="mt-4 w-full rounded-xl bg-gray-900 py-2.5 text-sm font-bold text-white transition-all hover:bg-gray-800 active:scale-[0.98] dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
查看/下载全部图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-4 py-2 text-[10px] text-gray-400 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
支持自动从商品链接中提取 ID
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全量图片预览弹窗 */}
|
||||
{result && (
|
||||
<TaobaoImageGalleryModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
result={result}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -59,6 +59,12 @@ interface TestResult {
|
||||
}
|
||||
}
|
||||
|
||||
const PUBLIC_S3_DOMAINS = ['s3.jiansun.vip', 's3.1080p.hk']
|
||||
|
||||
function isPublicS3Url(url: string): boolean {
|
||||
return PUBLIC_S3_DOMAINS.some((domain) => url.includes(domain))
|
||||
}
|
||||
|
||||
export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnoseModalProps) {
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [results, setResults] = useState<TestResult[]>([])
|
||||
@ -462,7 +468,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
// 7. 预览视频加载测试(增强版)
|
||||
const testPreviewVideoLoad = async (): Promise<TestResult> => {
|
||||
try {
|
||||
const response = await fetch('/api/materials?pageSize=1&materialType=主图视频')
|
||||
const response = await fetch('/api/materials?pageSize=1&materialType=淘系-主图视频')
|
||||
if (!response.ok) throw new Error('获取视频素材失败')
|
||||
|
||||
const data = await response.json()
|
||||
@ -669,7 +675,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
let proxyUrl = testUrl
|
||||
if (!testUrl.includes('/api/proxy-media')) {
|
||||
// 确保URL是公网S3地址,才构造代理URL
|
||||
const isPublicS3 = testUrl.includes('s3.yydd.ai') || testUrl.includes('s3.jiansun.vip') || testUrl.includes('s3.1080p.hk')
|
||||
const isPublicS3 = isPublicS3Url(testUrl)
|
||||
if (isPublicS3) {
|
||||
proxyUrl = `/api/proxy-media?url=${encodeURIComponent(testUrl)}`
|
||||
} else {
|
||||
@ -729,7 +735,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
// 10. 视频Range请求测试
|
||||
const testVideoRangeRequest = async (): Promise<TestResult> => {
|
||||
try {
|
||||
const response = await fetch('/api/materials?pageSize=1&materialType=主图视频')
|
||||
const response = await fetch('/api/materials?pageSize=1&materialType=淘系-主图视频')
|
||||
if (!response.ok) throw new Error('获取视频素材失败')
|
||||
|
||||
const data = await response.json()
|
||||
@ -903,7 +909,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
const testUrl = materialWithPreview.previewUrl
|
||||
|
||||
// 检查URL是否为公网S3地址
|
||||
const isPublicS3 = testUrl.includes('s3.yydd.ai') || testUrl.includes('s3.jiansun.vip') || testUrl.includes('s3.1080p.hk')
|
||||
const isPublicS3 = isPublicS3Url(testUrl)
|
||||
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
|
||||
|
||||
// 如果是内网MinIO地址,说明是内网用户,这是正常的(内网用户应该使用内网地址)
|
||||
@ -1197,7 +1203,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
|
||||
// 测试1: 检测是否使用frp(仅对公网域名,跳过内网IP以避免CORS错误)
|
||||
let isFrp = false
|
||||
const isPublicDomain = s3Domain.includes('s3.yydd.ai') || s3Domain.includes('s3.jiansun.vip') || s3Domain.includes('s3.1080p.hk')
|
||||
const isPublicDomain = isPublicS3Url(s3Domain)
|
||||
const isInternalIP = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(s3Domain)
|
||||
// 仅对公网域名进行FRP检测,跳过内网IP和localhost(避免CORS错误)
|
||||
if (isPublicDomain && !isInternalIP && !['localhost', '127.0.0.1'].includes(s3Domain)) {
|
||||
@ -1252,10 +1258,10 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
const suggestions: string[] = []
|
||||
|
||||
if (isFrp) {
|
||||
issues.push('🔴 使用frp内网穿透,带宽受限')
|
||||
suggestions.push('• 推荐方案1: 使用CDN加速(成本低,见效快)')
|
||||
suggestions.push('• 推荐方案2: 迁移到公网S3存储(彻底解决)')
|
||||
suggestions.push('• 临时方案: 内网用户直接访问内网S3')
|
||||
issues.push('⚠ 当前资源仍需经入口层回源,峰值时吞吐会波动')
|
||||
suggestions.push('• 优先确认展示图是否统一落到 s3.jiansun.vip 的 public-display 前缀')
|
||||
suggestions.push('• 大源文件、压缩包、视频源文件不要纳入展示缓存')
|
||||
suggestions.push('• 如问题集中在公网入口,优先排查 180.97.215.57 缓存层与 192.168.58.20:9000 回源链路')
|
||||
}
|
||||
|
||||
if (ttfb > 1000) {
|
||||
@ -2330,7 +2336,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
// 检查URL是否被正确重写为内网MinIO地址
|
||||
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
|
||||
const isProxyUrl = testUrl.includes('/api/proxy-media')
|
||||
const isPublicS3 = testUrl.includes('s3.yydd.ai') || testUrl.includes('s3.jiansun.vip') || testUrl.includes('s3.1080p.hk')
|
||||
const isPublicS3 = isPublicS3Url(testUrl)
|
||||
|
||||
let status: 'success' | 'warning' | 'error' = 'success'
|
||||
let message = ''
|
||||
@ -2403,7 +2409,7 @@ export default function NetworkDiagnoseModal({ isOpen, onClose }: NetworkDiagnos
|
||||
// 检查URL是否正确(外网用户应使用公网S3或代理)
|
||||
const isIntranetMinIO = testUrl.includes('192.168.') || testUrl.includes('localhost') || testUrl.includes('127.0.0.1')
|
||||
const isProxyUrl = testUrl.includes('/api/proxy-media')
|
||||
const isPublicS3 = testUrl.includes('s3.yydd.ai') || testUrl.includes('s3.jiansun.vip') || testUrl.includes('s3.1080p.hk')
|
||||
const isPublicS3 = isPublicS3Url(testUrl)
|
||||
|
||||
let status: 'success' | 'warning' | 'error' = 'success'
|
||||
let message = ''
|
||||
|
||||
@ -25,13 +25,17 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { parseApiError } from '@/lib/error-handler'
|
||||
import { parseApiError, parseFetchError } from '@/lib/error-handler'
|
||||
import { formatSpeed } from '@/components/upload/upload-utils'
|
||||
import {
|
||||
STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME,
|
||||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||||
type StandardModelWhiteBgKey,
|
||||
} from '@/lib/standard-model-white-background'
|
||||
import {
|
||||
STANDARD_MODEL_WHITE_BACKGROUND_SOURCE_TYPE_OPTIONS,
|
||||
type StandardModelWhiteBackgroundSourceType,
|
||||
} from '@/lib/standard-model-white-background-metadata'
|
||||
|
||||
// SKU 数据类型
|
||||
interface Sku {
|
||||
@ -49,6 +53,14 @@ interface Sku {
|
||||
whiteBackgroundFixedCount?: number
|
||||
whiteBackgroundOtherCount?: number
|
||||
whiteBackgroundCount?: number
|
||||
whiteBackgroundSourceType?: StandardModelWhiteBackgroundSourceType | null
|
||||
whiteBackgroundUploadedBy?: string | null
|
||||
whiteBackgroundUploadedAt?: string | Date | null
|
||||
whiteBackgroundUploader?: {
|
||||
id: string
|
||||
username: string
|
||||
realName?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface WhiteBgOtherImage {
|
||||
@ -97,6 +109,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
const [whiteBgUploadProgress, setWhiteBgUploadProgress] = useState(0)
|
||||
const [whiteBgUploadSpeed, setWhiteBgUploadSpeed] = useState(0)
|
||||
const [whiteBgError, setWhiteBgError] = useState('')
|
||||
const [whiteBgSourceType, setWhiteBgSourceType] = useState<StandardModelWhiteBackgroundSourceType | ''>('')
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const previewInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -125,6 +138,22 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
}
|
||||
}
|
||||
|
||||
const loadSkusSnapshot = async (): Promise<Sku[] | null> => {
|
||||
try {
|
||||
const response = await fetch(`/api/materials/${materialId}/skus`)
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const data = await response.json().catch(() => null)
|
||||
const nextSkus = Array.isArray(data?.skus) ? data.skus : []
|
||||
setSkus(nextSkus)
|
||||
return nextSkus
|
||||
} catch (error) {
|
||||
console.error('刷新 SKU 快照失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resetWhiteBackgroundForm = () => {
|
||||
Object.values(whiteBgPreviewUrlsRef.current).forEach((url) => {
|
||||
if (url && url.startsWith('blob:')) {
|
||||
@ -145,6 +174,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
setWhiteBgUploading(false)
|
||||
setWhiteBgUploadProgress(0)
|
||||
setWhiteBgUploadSpeed(0)
|
||||
setWhiteBgSourceType('')
|
||||
}
|
||||
|
||||
const handleOpenWhiteBackground = (sku: Sku) => {
|
||||
@ -153,6 +183,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
}
|
||||
handleCloseModelUpload()
|
||||
resetWhiteBackgroundForm()
|
||||
setWhiteBgSourceType(sku.whiteBackgroundSourceType || '')
|
||||
setActiveWhiteBgSku(sku)
|
||||
}
|
||||
|
||||
@ -327,9 +358,17 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
return
|
||||
}
|
||||
|
||||
if (!whiteBgSourceType) {
|
||||
const errorMessage = '请选择白底图出图方式'
|
||||
setWhiteBgError(errorMessage)
|
||||
showToast(errorMessage, { variant: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', activeWhiteBgSku.id)
|
||||
formData.append('skuName', activeWhiteBgSku.skuName)
|
||||
formData.append('sourceType', whiteBgSourceType)
|
||||
|
||||
STANDARD_MODEL_WHITE_BG_VIEWS.forEach((view) => {
|
||||
const file = whiteBgFiles[view.key]
|
||||
@ -408,10 +447,14 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
whiteBackgroundFixedCount: fixedCount,
|
||||
whiteBackgroundOtherCount: otherCount,
|
||||
whiteBackgroundCount,
|
||||
whiteBackgroundSourceType: data.sourceType || whiteBgSourceType,
|
||||
whiteBackgroundUploadedBy: data.whiteBackgroundUploadedBy || sku.whiteBackgroundUploadedBy || null,
|
||||
whiteBackgroundUploadedAt: data.whiteBackgroundUploadedAt || new Date().toISOString(),
|
||||
}
|
||||
: sku,
|
||||
),
|
||||
)
|
||||
await fetchSkus()
|
||||
showToast(
|
||||
isComplete
|
||||
? '白底图上传成功'
|
||||
@ -660,6 +703,51 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
}
|
||||
}
|
||||
|
||||
const createSkuRecord = async (payload: {
|
||||
skuName: string
|
||||
previewUrl: string
|
||||
zipFileName: string
|
||||
zipFilePath: string
|
||||
zipFileSize: number
|
||||
}) => {
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`/api/materials/${materialId}/skus`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const parsed = await parseFetchError(response)
|
||||
throw new Error(parsed.message || `创建 SKU 失败 (${response.status})`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
return await response.json().catch(() => ({}))
|
||||
}
|
||||
|
||||
return {}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const isTransientNetworkError =
|
||||
error instanceof TypeError ||
|
||||
lastError.message.includes('network') ||
|
||||
lastError.message.includes('Network') ||
|
||||
lastError.message.includes('Failed to fetch')
|
||||
|
||||
if (!isTransientNetworkError || attempt === 1) {
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('创建 SKU 失败')
|
||||
}
|
||||
|
||||
// 添加或更新 SKU(此时才开始上传文件)
|
||||
const handleAddSku = async () => {
|
||||
const normalizedSkuName = await validateSkuNameBeforeUpload(newSkuName)
|
||||
@ -694,6 +782,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
return
|
||||
}
|
||||
previewUrl = uploadedPreviewUrl
|
||||
setNewSkuPreviewUrl(uploadedPreviewUrl)
|
||||
}
|
||||
|
||||
// 如果有新压缩包,上传它
|
||||
@ -754,19 +843,14 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/materials/${materialId}/skus`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
skuName: normalizedSkuName,
|
||||
previewUrl: previewUrl,
|
||||
zipFileName: zipResult?.fileName || '',
|
||||
zipFilePath: zipResult?.filePath || '',
|
||||
zipFileSize: zipResult?.fileSize || 0
|
||||
})
|
||||
const createdSku = await createSkuRecord({
|
||||
skuName: normalizedSkuName,
|
||||
previewUrl: previewUrl,
|
||||
zipFileName: zipResult?.fileName || '',
|
||||
zipFilePath: zipResult?.filePath || '',
|
||||
zipFileSize: zipResult?.fileSize || 0,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// 清理本地预览URL
|
||||
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(newSkuPreviewUrl)
|
||||
@ -774,7 +858,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
|
||||
// 刷新列表
|
||||
await fetchSkus()
|
||||
showToast('添加成功', { variant: 'success' })
|
||||
showToast(createdSku?.id ? '添加成功' : 'SKU 已创建成功', { variant: 'success' })
|
||||
|
||||
// 重置表单字段(但不关闭表单,可以继续添加)
|
||||
setNewSkuName('')
|
||||
@ -782,13 +866,23 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
setNewSkuPreviewUrl('')
|
||||
setNewSkuZipFile(null)
|
||||
setUploadProgress({ zip: 0, preview: 0 })
|
||||
} else {
|
||||
const error = await response.json()
|
||||
showToast(error.error || '添加失败', { variant: 'error' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加 SKU 失败:', error)
|
||||
showToast('添加失败,请稍后重试', { variant: 'error' })
|
||||
const recoveredSkus = await loadSkusSnapshot()
|
||||
const recoveredSku = recoveredSkus?.find((sku) => sku.skuName === normalizedSkuName)
|
||||
|
||||
if (recoveredSku) {
|
||||
showToast('SKU 已创建成功,页面已自动恢复', { variant: 'success' })
|
||||
setNewSkuName('')
|
||||
setNewSkuPreviewFile(null)
|
||||
setNewSkuPreviewUrl('')
|
||||
setNewSkuZipFile(null)
|
||||
setUploadProgress({ zip: 0, preview: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
const { message } = parseApiError(error)
|
||||
showToast(message || '添加失败,请稍后重试', { variant: 'error' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setUploading(false)
|
||||
@ -961,7 +1055,19 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
{hasModelZip ? `${sku.zipFileName} · ${formatFileSize(sku.zipFileSize)}` : '当前还没有模型压缩包'}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{hasModelZip ? '当前可直接重传模型压缩包' : '当前可补传模型压缩包'}
|
||||
{sku.whiteBackgroundUploaded
|
||||
? [
|
||||
sku.whiteBackgroundSourceType ? `出图方式:${sku.whiteBackgroundSourceType}` : null,
|
||||
sku.whiteBackgroundUploadedAt
|
||||
? `最后更新:${new Date(sku.whiteBackgroundUploadedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||||
: null,
|
||||
sku.whiteBackgroundUploader
|
||||
? `上传人:${sku.whiteBackgroundUploader.realName || sku.whiteBackgroundUploader.username}`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ') || '当前可继续补传白底图'
|
||||
: hasModelZip
|
||||
? '当前可直接重传模型压缩包'
|
||||
: '当前可补传模型压缩包'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1266,6 +1372,36 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/60">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
出图方式 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{STANDARD_MODEL_WHITE_BACKGROUND_SOURCE_TYPE_OPTIONS.map((option) => {
|
||||
const checked = whiteBgSourceType === option
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWhiteBgSourceType(option)
|
||||
setWhiteBgError('')
|
||||
}}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
checked
|
||||
? 'border-gray-700 bg-gray-700 text-white dark:border-gray-200 dark:bg-gray-200 dark:text-gray-900'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
白底图按 SKU 维度必填,后续补传会覆盖成最后一次保存的出图方式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StandardModelWhiteBackgroundUpload
|
||||
imageUrls={whiteBgPreviewUrls}
|
||||
@ -1382,6 +1518,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
{showAddForm ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={uploading}
|
||||
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@ -1389,6 +1526,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSku}
|
||||
disabled={!canSubmitSkuForm}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
|
||||
@ -6,6 +6,7 @@ import { useToast } from '@/hooks/useToast'
|
||||
import ToastBubble from './ToastBubble'
|
||||
import { parseDownloadError } from '@/lib/download-errors'
|
||||
import { validateAndTriggerDownload } from '@/lib/client-download'
|
||||
import type { StandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
|
||||
|
||||
interface Sku {
|
||||
id: string
|
||||
@ -27,6 +28,14 @@ interface Sku {
|
||||
whiteBackgroundFixedCount?: number
|
||||
whiteBackgroundOtherCount?: number
|
||||
whiteBackgroundCount?: number
|
||||
whiteBackgroundSourceType?: StandardModelWhiteBackgroundSourceType | null
|
||||
whiteBackgroundUploadedBy?: string | null
|
||||
whiteBackgroundUploadedAt?: string | Date | null
|
||||
whiteBackgroundUploader?: {
|
||||
id: string
|
||||
username: string
|
||||
realName?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
interface SkuListProps {
|
||||
@ -244,9 +253,19 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
模型:{sku.zipFilePath && sku.zipFilePath.trim() ? '已上传' : '未上传'}
|
||||
<span className="mx-1.5 text-gray-300 dark:text-gray-600">·</span>
|
||||
白底图:{sku.whiteBackgroundUploaded ? `已上传 ${sku.whiteBackgroundCount || 0} 张` : '未上传'}
|
||||
白底图:{sku.whiteBackgroundUploaded ? `${sku.whiteBackgroundCount || 0}张` : '未上传'}
|
||||
{sku.whiteBackgroundUploaded && sku.whiteBackgroundComplete === false ? '(未补齐)' : ''}
|
||||
</p>
|
||||
{sku.whiteBackgroundUploaded && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{[
|
||||
sku.whiteBackgroundSourceType ? `出图:${sku.whiteBackgroundSourceType}` : null,
|
||||
sku.whiteBackgroundUploadedAt
|
||||
? `时间:${new Date(sku.whiteBackgroundUploadedAt).toLocaleDateString('zh-CN')}`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
|
||||
149
components/TaobaoImageGalleryModal.tsx
Normal file
149
components/TaobaoImageGalleryModal.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { TaobaoDetailSummary } from '@/lib/taobao-detail-plus'
|
||||
import { exportTaobaoImagesToExcel } from '@/lib/excel-export'
|
||||
|
||||
interface TaobaoImageGalleryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
result: TaobaoDetailSummary
|
||||
}
|
||||
|
||||
export default function TaobaoImageGalleryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
result
|
||||
}: TaobaoImageGalleryModalProps) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true)
|
||||
const groups = [
|
||||
{ name: '主图', urls: result.allItemImages },
|
||||
{ name: 'SKU图', urls: result.skuImages },
|
||||
{ name: '详情图', urls: result.descImages }
|
||||
]
|
||||
await exportTaobaoImagesToExcel(result.title, result.itemId, groups)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert('导出失败,请重试')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ImageSection = ({ title, images }: { title: string; images: string[] }) => {
|
||||
if (!images || images.length === 0) return null
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
<span className="h-4 w-1 rounded-full bg-blue-500"></span>
|
||||
{title}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">{images.length} 张</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{images.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group relative aspect-square overflow-hidden rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={`${title}-${index}`}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/20">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-white/90 p-2 opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-900/90"
|
||||
>
|
||||
<svg className="h-5 w-5 text-gray-700 dark:text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="relative flex h-full max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-2xl transition-all dark:border-gray-800 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white/80 px-6 py-4 backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{result.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-gray-500">商品ID: {result.itemId} · 获取全量图片预览</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="flex items-center gap-2 rounded-full bg-blue-600 px-5 py-2 text-sm font-semibold text-white transition-all hover:bg-blue-700 hover:shadow-lg hover:shadow-blue-500/30 disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
<span>正在导出...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>导出为 Excel</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full bg-gray-100 p-2 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-800">
|
||||
<ImageSection title="主图" images={result.allItemImages} />
|
||||
<ImageSection title="SKU图" images={result.skuImages} />
|
||||
<ImageSection title="详情图" images={result.descImages} />
|
||||
|
||||
{(!result.allItemImages?.length && !result.skuImages?.length && !result.descImages?.length) && (
|
||||
<div className="flex h-64 flex-col items-center justify-center text-gray-400">
|
||||
<svg className="mb-4 h-16 w-16 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>未发现可展示的图片</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
components/UploadModal.task-other.test.tsx
Normal file
74
components/UploadModal.task-other.test.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import UploadModal from '@/components/UploadModal'
|
||||
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => {
|
||||
const { priority, fill, unoptimized, sizes, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useCategoryData', () => ({
|
||||
useCategoryData: () => ({
|
||||
data: {
|
||||
categories: [],
|
||||
scenes: [],
|
||||
platforms: [],
|
||||
productCategories: [],
|
||||
},
|
||||
refreshCategories: jest.fn(),
|
||||
refreshScenes: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/components/upload/ProductPackageUploader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="product-package-uploader" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/upload/VideoScreenshotUploader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="video-screenshot-uploader" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/upload/TaskPageUploader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="task-page-uploader" />,
|
||||
}))
|
||||
|
||||
describe('UploadModal - 任务-其他', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
materialTypes: ['3D场景', '任务-其他'],
|
||||
}),
|
||||
}) as any
|
||||
})
|
||||
|
||||
test('任务-其他应使用公共上传面板形式,只保留任务号 + 预览图片 + 素材文件', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
|
||||
|
||||
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
|
||||
await user.selectOptions(materialTypeSelect, '任务-其他')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('任务号 *')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('预览图片 *')).toBeInTheDocument()
|
||||
expect(screen.getByText('素材文件 *')).toBeInTheDocument()
|
||||
expect(screen.getByText('支持格式:压缩包(.zip / .rar / .7z)')).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByLabelText('SPU型号 *')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('压缩包源文件 *')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('预览图 *')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -41,6 +41,11 @@ import {
|
||||
import { buildFormData, uploadSingleFile, submitFormData } from '@/components/upload/file-upload-handlers'
|
||||
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
|
||||
import { MATERIAL_TYPE_OPTIONS, type MaterialTypeOption } from '@/lib/material-type-options'
|
||||
import {
|
||||
isBatchVideoMaterialType,
|
||||
isVideoMaterialType,
|
||||
normalizeVideoMaterialType,
|
||||
} from '@/lib/video-material-types'
|
||||
import { useCategoryData } from '@/hooks/useCategoryData'
|
||||
import { parseApiError } from '@/lib/error-handler'
|
||||
import { parseSPU } from '@/lib/spu-parser'
|
||||
@ -56,6 +61,7 @@ import {
|
||||
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
|
||||
import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader'
|
||||
import TaskPageUploader from '@/components/upload/TaskPageUploader'
|
||||
import CertificateReportUploader from '@/components/upload/CertificateReportUploader'
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void
|
||||
@ -63,6 +69,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const SPECIAL_UPLOAD_TYPES = ['任务-详情', '任务-视频截图', '任务-页面', '证书-检测报告'] as const
|
||||
const [previewImages, setPreviewImages] = useState<File[]>([]) // 预览图片数组(支持多图)
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 预览图片预览URLs
|
||||
const [displayImage, setDisplayImage] = useState<File | null>(null) // 详情模板展示图片(原图,后端自动切片)
|
||||
@ -78,7 +85,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
// 保留旧的批量上传数据结构(用于兼容现有代码和特殊类型)
|
||||
// 音乐:最多100个
|
||||
const [batchMusicFiles, setBatchMusicFiles] = useState<Array<{ file: File; productModel: string }>>([])
|
||||
// 种草视频:最多8个
|
||||
// 淘系-逛逛视频:最多8个
|
||||
const [batchVideoFiles, setBatchVideoFiles] = useState<Array<{ file: File; productModel: string; productCategoryName?: string }>>([])
|
||||
// 3D场景:最多5个,每个需要预览图
|
||||
const [batch3DFiles, setBatch3DFiles] = useState<Array<{ file: File; productModel: string; previewImage?: File }>>([])
|
||||
@ -141,6 +148,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const [batchPreviewDragging, setBatchPreviewDragging] = useState<Record<string, boolean>>({}) // 批量模式下每个文件的预览图拖拽状态
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
const isTaskOtherMaterial = materialType === '任务-其他'
|
||||
const usesDedicatedUploader = SPECIAL_UPLOAD_TYPES.includes(materialType as typeof SPECIAL_UPLOAD_TYPES[number])
|
||||
|
||||
const previewInputRef = useRef<HTMLInputElement>(null)
|
||||
const zipInputRef = useRef<HTMLInputElement>(null)
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
@ -159,9 +169,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const res = await fetch('/api/materials/viewable-types')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const types = ((data.materialTypes || []) as string[]).filter((type) =>
|
||||
MATERIAL_TYPE_OPTIONS.includes(type as MaterialTypeOption),
|
||||
)
|
||||
const types = ((data.materialTypes || []) as string[])
|
||||
.map((type) => normalizeVideoMaterialType(type))
|
||||
.filter((type) => MATERIAL_TYPE_OPTIONS.includes(type as MaterialTypeOption))
|
||||
setAvailableMaterialTypes(types)
|
||||
|
||||
// 如果当前选中的类型不在可见列表中,重置为第一个可见类型
|
||||
@ -209,11 +219,24 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
const handlePreviewChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (isTaskOtherMaterial && files.length > 1) {
|
||||
setError('任务-其他一次只能上传一张预览图')
|
||||
if (e.target) {
|
||||
e.target.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
await handlePreviewImageInput(
|
||||
files,
|
||||
(compressedFiles, urls) => {
|
||||
setPreviewImages((prev) => [...prev, ...compressedFiles])
|
||||
setPreviewUrls((prev) => [...prev, ...urls])
|
||||
if (isTaskOtherMaterial) {
|
||||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||
setPreviewImages(compressedFiles.slice(0, 1))
|
||||
setPreviewUrls(urls.slice(0, 1))
|
||||
} else {
|
||||
setPreviewImages((prev) => [...prev, ...compressedFiles])
|
||||
setPreviewUrls((prev) => [...prev, ...urls])
|
||||
}
|
||||
setError('')
|
||||
// 清空 input,允许重复选择同一文件
|
||||
if (e.target) {
|
||||
@ -282,7 +305,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
const message =
|
||||
materialType === '音乐'
|
||||
? audioTip
|
||||
: ['主图视频', '安装视频', '种草视频'].includes(materialType)
|
||||
: isVideoMaterialType(materialType)
|
||||
? videoTip
|
||||
: materialType === '品牌VI灵感'
|
||||
? brandTip
|
||||
@ -297,6 +320,25 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTaskOtherMaterial && validFiles.length > 1) {
|
||||
setError('任务-其他一次只能上传一个源文件')
|
||||
if (e.target) {
|
||||
e.target.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isTaskOtherMaterial) {
|
||||
setZipFile(validFiles[0])
|
||||
setBatchFiles([])
|
||||
setIsBatchMode(false)
|
||||
setError('')
|
||||
if (e.target) {
|
||||
e.target.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经有文件(单文件或批量模式),追加到批量模式
|
||||
if (zipFile || batchFiles.length > 0) {
|
||||
// 如果当前是单文件模式,先将 zipFile 转换为批量模式的第一项
|
||||
@ -388,6 +430,18 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
// 图片文件应该拖拽到对应的预览图或展示图区域
|
||||
const materialFiles = files.filter((f) => isValidMainFileType(f, materialType))
|
||||
if (materialFiles.length > 0) {
|
||||
if (isTaskOtherMaterial) {
|
||||
if (materialFiles.length > 1) {
|
||||
setError('任务-其他一次只能上传一个源文件')
|
||||
return
|
||||
}
|
||||
setZipFile(materialFiles[0])
|
||||
setBatchFiles([])
|
||||
setIsBatchMode(false)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经有文件(单文件或批量模式),追加到批量模式
|
||||
if (zipFile || batchFiles.length > 0) {
|
||||
// 如果当前是单文件模式,先将 zipFile 转换为批量模式的第一项
|
||||
@ -502,6 +556,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'))
|
||||
if (imageFiles.length > 0) {
|
||||
if (isTaskOtherMaterial && imageFiles.length > 1) {
|
||||
setError('任务-其他一次只能上传一张预览图')
|
||||
return
|
||||
}
|
||||
// 支持多图拖拽
|
||||
const dt = { target: { files: imageFiles } } as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
void handlePreviewChange(dt)
|
||||
@ -1089,7 +1147,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量上传视频文件(种草视频)
|
||||
// 批量上传视频文件(淘系-逛逛视频)
|
||||
const handleBatchVideoUpload = async () => {
|
||||
if (batchVideoFiles.length === 0) return
|
||||
|
||||
@ -1304,7 +1362,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
await handleBatchMusicUpload()
|
||||
return
|
||||
}
|
||||
if (materialType === '种草视频' && batchVideoFiles.length > 0) {
|
||||
if (isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) {
|
||||
const invalidFiles = batchVideoFiles.filter(item => !item.productModel.trim())
|
||||
if (invalidFiles.length > 0) {
|
||||
setError('请为所有视频文件填写SPU型号')
|
||||
@ -1372,13 +1430,24 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
}
|
||||
|
||||
// Validation - 基础信息验证(单文件上传)
|
||||
if (!productModel) {
|
||||
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
|
||||
const normalizedTaskNo = taskNo.trim().toUpperCase()
|
||||
const resolvedTaskNo = isTaskOtherMaterial ? normalizedTaskNo : taskNo
|
||||
const resolvedProductModel = isTaskOtherMaterial ? normalizedTaskNo : productModel
|
||||
const resolvedRenderer = isTaskOtherMaterial ? '' : renderer
|
||||
|
||||
if (!resolvedProductModel) {
|
||||
setError(
|
||||
isTaskOtherMaterial
|
||||
? '请填写任务号'
|
||||
: materialType === '材质'
|
||||
? '请填写材质名称'
|
||||
: '请填写SPU型号'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 任务号验证(可选,仅验证格式,不强制要求)
|
||||
if (taskNo.trim() && !validateTaskNo(taskNo)) {
|
||||
if (resolvedTaskNo.trim() && !validateTaskNo(resolvedTaskNo)) {
|
||||
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)')
|
||||
return
|
||||
}
|
||||
@ -1442,13 +1511,13 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
previewImages,
|
||||
displayImage,
|
||||
modelImages,
|
||||
productModel,
|
||||
productModel: resolvedProductModel,
|
||||
materialType,
|
||||
categoryName,
|
||||
sceneName,
|
||||
platformName,
|
||||
productCategoryName,
|
||||
renderer,
|
||||
renderer: resolvedRenderer,
|
||||
onProgress: (progress, speed) => {
|
||||
setUploadProgress(progress)
|
||||
if (speed) setUploadSpeed(speed)
|
||||
@ -1483,14 +1552,14 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
displayImage,
|
||||
modelImages,
|
||||
zipFile: chunkResult ? null : zipFile, // 如果已分片上传,则不传文件
|
||||
productModel,
|
||||
productModel: resolvedProductModel,
|
||||
materialType,
|
||||
categoryName,
|
||||
sceneName,
|
||||
platformName,
|
||||
productCategoryName,
|
||||
renderer,
|
||||
taskNo,
|
||||
renderer: resolvedRenderer,
|
||||
taskNo: resolvedTaskNo,
|
||||
})
|
||||
|
||||
// 分片后主文件已在服务器,添加路径信息
|
||||
@ -1663,7 +1732,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
) : (
|
||||
// 加载中或失败时的后备选项(排除有专用上传组件的类型)
|
||||
<>
|
||||
{MATERIAL_TYPE_OPTIONS.filter((type) => !['任务-详情', '任务-视频截图', '任务-页面'].includes(type)).map((type) => (
|
||||
{MATERIAL_TYPE_OPTIONS.filter((type) => !SPECIAL_UPLOAD_TYPES.includes(type as typeof SPECIAL_UPLOAD_TYPES[number])).map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</>
|
||||
@ -1671,11 +1740,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 任务号输入框(任务-详情、任务-视频截图和任务-页面不显示,因为它们有自己的任务号输入) */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && materialType !== '任务-页面' && (
|
||||
{/* 任务号输入框(有专用上传器的任务类型不显示,因为它们有自己的任务号输入) */}
|
||||
{!usesDedicatedUploader && (
|
||||
<div>
|
||||
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
|
||||
{isTaskOtherMaterial ? '任务号 *' : <>任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span></>}
|
||||
</label>
|
||||
<input
|
||||
id="taskNo"
|
||||
@ -1686,7 +1755,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
disabled={uploading}
|
||||
/>
|
||||
{taskNo && !validateTaskNo(taskNo) && (
|
||||
{taskNo && !validateTaskNo(taskNo.trim().toUpperCase()) && (
|
||||
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
|
||||
)}
|
||||
</div>
|
||||
@ -1749,8 +1818,22 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 证书-检测报告上传组件 */}
|
||||
{materialType === '证书-检测报告' && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<CertificateReportUploader
|
||||
onSuccess={() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}}
|
||||
onCancel={onClose}
|
||||
productCategories={productCategories}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 批量上传模式:可折叠文件列表 */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && isBatchMode && batchFiles.length > 0 && (
|
||||
{!usesDedicatedUploader && !isTaskOtherMaterial && isBatchMode && batchFiles.length > 0 && (
|
||||
<BatchFileList
|
||||
batchFiles={batchFiles}
|
||||
materialType={materialType}
|
||||
@ -1877,8 +1960,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 预览图片(任务-详情和任务-视频截图不显示,批量模式下隐藏) */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && !isBatchMode && (
|
||||
{/* 预览图片(有专用上传器的任务类型不显示,批量模式下隐藏) */}
|
||||
{!usesDedicatedUploader && !isBatchMode && (
|
||||
<PreviewImageUpload
|
||||
materialType={materialType}
|
||||
previewImages={previewImages}
|
||||
@ -1928,8 +2011,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 表单字段 - 使用 UploadForm 组件(任务-详情和任务-视频截图不显示) */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && (
|
||||
{/* 表单字段 - 使用 UploadForm 组件(有专用上传器的任务类型不显示) */}
|
||||
{!usesDedicatedUploader && !isTaskOtherMaterial && (
|
||||
<UploadForm
|
||||
materialType={materialType}
|
||||
productModel={productModel}
|
||||
@ -1976,8 +2059,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Material File(任务-详情和任务-视频截图不显示) */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && (
|
||||
{/* Material File(有专用上传器的任务类型不显示) */}
|
||||
{!usesDedicatedUploader && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
素材文件 {(materialType === '模特' || materialType === '标准模型') ? '(可选)' : '*'}
|
||||
@ -1987,7 +2070,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
(支持批量上传,最多100个)
|
||||
</span>
|
||||
)}
|
||||
{materialType === '种草视频' && (
|
||||
{isBatchVideoMaterialType(materialType) && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
(支持批量上传,最多8个)
|
||||
</span>
|
||||
@ -2029,7 +2112,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(materialType === '种草视频' && batchVideoFiles.length > 0) && (
|
||||
{(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) && (
|
||||
<BatchVideoList
|
||||
files={batchVideoFiles}
|
||||
productCategories={productCategories}
|
||||
@ -2147,7 +2230,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
{/* 单文件上传区域(非批量类型或批量列表为空时显示) */}
|
||||
{!(
|
||||
(materialType === '音乐' && batchMusicFiles.length > 0) ||
|
||||
(materialType === '种草视频' && batchVideoFiles.length > 0) ||
|
||||
(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) ||
|
||||
(materialType === '3D场景' && batch3DFiles.length > 0) ||
|
||||
(materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0) ||
|
||||
(materialType === '买家秀图片' && batchBuyerShowFiles.length > 0) ||
|
||||
@ -2194,7 +2277,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
className="hidden"
|
||||
/>
|
||||
{/* 批量上传文件选择 */}
|
||||
{(materialType === '音乐' || materialType === '种草视频' || materialType === '3D场景' || materialType === '品牌VI灵感') && (
|
||||
{!isTaskOtherMaterial && (materialType === '音乐' || isBatchVideoMaterialType(materialType) || materialType === '3D场景' || materialType === '品牌VI灵感') && (
|
||||
<>
|
||||
<input
|
||||
ref={batchFileInputRef}
|
||||
@ -2210,7 +2293,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
className="btn-secondary mr-2"
|
||||
>
|
||||
{materialType === '音乐' && '选择音乐文件(可多选)'}
|
||||
{materialType === '种草视频' && '选择视频文件(可多选,最多8个)'}
|
||||
{isBatchVideoMaterialType(materialType) && '选择视频文件(可多选,最多8个)'}
|
||||
{materialType === '3D场景' && '选择3D文件或压缩包(可多选,最多5个)'}
|
||||
{materialType === '品牌VI灵感' && '选择PDF文件(可多选,最多50个)'}
|
||||
</button>
|
||||
@ -2226,12 +2309,12 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
支持格式:
|
||||
{materialType === '音乐' && '音频文件(.mp3 / .wav / .flac / .m4a / .aac / .ogg)'}
|
||||
{['主图视频', '安装视频', '种草视频'].includes(materialType) &&
|
||||
{isVideoMaterialType(materialType) &&
|
||||
'视频文件(.mp4 / .mov / .avi / .mkv / .mts / .m4v)'}
|
||||
{materialType === '品牌VI灵感' && 'PDF 文件(.pdf)'}
|
||||
{materialType === '详情模板' &&
|
||||
'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)'}
|
||||
{!['音乐', '主图视频', '安装视频', '种草视频', '品牌VI灵感', '详情模板'].includes(materialType) &&
|
||||
{!['音乐', '品牌VI灵感', '详情模板'].includes(materialType) && !isVideoMaterialType(materialType) &&
|
||||
'压缩包(.zip / .rar / .7z)'}
|
||||
</p>
|
||||
</div>
|
||||
@ -2264,8 +2347,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons(任务-详情、任务-视频截图和任务-页面不显示,因为有自己的按钮) */}
|
||||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && materialType !== '任务-页面' && (
|
||||
{/* Buttons(有专用上传器的任务类型不显示,因为它们有自己的按钮) */}
|
||||
{!usesDedicatedUploader && (
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -14,6 +14,7 @@ import { format } from 'date-fns'
|
||||
import type { Material } from '@/hooks/useAdminState'
|
||||
import { parseFetchError, handleError } from '@/lib/error-handler'
|
||||
import SearchBar from '@/components/home/SearchBar'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
// 搜索分页配置
|
||||
const SEARCH_PAGE_SIZE = 50
|
||||
@ -356,9 +357,13 @@ export default function MaterialManagement({
|
||||
case 'C4D':
|
||||
case 'C4D模型':
|
||||
return 'bg-purple-600/80'
|
||||
case '淘系-主图视频':
|
||||
case '主图视频':
|
||||
case '安装视频':
|
||||
case '淘系-逛逛视频':
|
||||
case '种草视频':
|
||||
case '抖音-千川素材':
|
||||
case '抖音-账号维护':
|
||||
return 'bg-red-600/80'
|
||||
default:
|
||||
return 'bg-black/60'
|
||||
@ -543,9 +548,7 @@ export default function MaterialManagement({
|
||||
{/* 卡片网格布局 */}
|
||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 sm:gap-6">
|
||||
{materials.slice(0, displayedMaterialsCount).map((material) => {
|
||||
const isVideo = material.materialType === '主图视频' ||
|
||||
material.materialType === '安装视频' ||
|
||||
material.materialType === '种草视频'
|
||||
const isVideo = isVideoMaterialType(material.materialType)
|
||||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||||
const videoUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||||
const isSelected = selectedMaterials.has(material.id)
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { parseDownloadError } from '@/lib/download-errors';
|
||||
import { getTaskDetailSourceTypeLabel } from '@/lib/task-detail-source-type';
|
||||
|
||||
interface Asset {
|
||||
id: string;
|
||||
@ -23,6 +24,7 @@ interface ProductPackageDetailProps {
|
||||
productModel: string;
|
||||
categoryName: string;
|
||||
sceneName?: string;
|
||||
taskDetailSourceType?: string | null;
|
||||
isFullSet?: boolean;
|
||||
taskStatus: string;
|
||||
taskFetchedAt?: string;
|
||||
@ -248,6 +250,12 @@ export default function ProductPackageDetail({
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.sceneName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">出图方式</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">是否整套</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@ -23,6 +23,7 @@ interface ThreeDOriginalDetailProps {
|
||||
productModel: string;
|
||||
categoryName: string;
|
||||
sceneName: string;
|
||||
materialType?: string;
|
||||
taskStatus?: string;
|
||||
taskFetchedAt?: string;
|
||||
assets: Asset[];
|
||||
@ -43,6 +44,7 @@ export default function ThreeDOriginalDetail({
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadType, setDownloadType] = useState<string>('');
|
||||
const isTaskOther = material.materialType === '任务-其他';
|
||||
|
||||
// 轮播相关状态
|
||||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(true);
|
||||
@ -113,7 +115,7 @@ export default function ThreeDOriginalDetail({
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium">
|
||||
原图 ({currentImageIndex + 1}/{mainImages.length})
|
||||
{isTaskOther ? '预览图' : '原图'} ({currentImageIndex + 1}/{mainImages.length})
|
||||
</h4>
|
||||
{mainImages.length > 1 && (
|
||||
<button
|
||||
@ -139,6 +141,9 @@ export default function ThreeDOriginalDetail({
|
||||
{mainImages.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h4 className="text-sm font-medium mb-3 text-gray-900 dark:text-white">所有原图 ({mainImages.length})</h4>
|
||||
{isTaskOther && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">任务-其他只展示上传时提交的预览图</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mainImages.map((img, index) => (
|
||||
<button
|
||||
@ -196,15 +201,19 @@ export default function ThreeDOriginalDetail({
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">任务号</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.taskNo}</p>
|
||||
</div>
|
||||
{!isTaskOther && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.productModel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">店铺</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.categoryName}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">SPU型号</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.productModel}</p>
|
||||
</div>
|
||||
{material.sceneName && (
|
||||
)}
|
||||
{!isTaskOther && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">店铺</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.categoryName}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isTaskOther && material.sceneName && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">场景</p>
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">{material.sceneName}</p>
|
||||
|
||||
@ -24,7 +24,7 @@ describe('MaterialCard', () => {
|
||||
sceneName: '客厅',
|
||||
platformName: '天猫',
|
||||
productCategoryName: '沙发',
|
||||
materialType: '任务-详情',
|
||||
materialType: '任务-AI原图',
|
||||
renderer: '',
|
||||
authorName: '张三',
|
||||
previewUrl: 'https://example.com/preview.jpg',
|
||||
@ -86,4 +86,37 @@ describe('MaterialCard', () => {
|
||||
|
||||
expect(screen.queryByText('AI生成')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('任务-详情应优先显示出图方式标签', () => {
|
||||
render(
|
||||
<MaterialCard
|
||||
{...defaultProps}
|
||||
material={{
|
||||
...baseMaterial,
|
||||
materialType: '任务-详情',
|
||||
taskDetailSourceType: '实拍',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('实拍')).toBeInTheDocument()
|
||||
expect(screen.queryByText('AI生成')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('任务-详情未设置出图方式时应显示未设置', () => {
|
||||
render(
|
||||
<MaterialCard
|
||||
{...defaultProps}
|
||||
material={{
|
||||
...baseMaterial,
|
||||
materialType: '任务-详情',
|
||||
isAiGenerated: false,
|
||||
taskDetailSourceType: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('未设置')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -4,15 +4,22 @@ import React, { useRef, useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { format } from 'date-fns'
|
||||
import AudioVisualizer from '../AudioVisualizer'
|
||||
import { getTaskDetailSourceTypeLabel, normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
|
||||
import { getCertificateReportDisplayTitle } from '@/lib/certificate-report'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
// 素材类型定义
|
||||
export interface Material {
|
||||
id: string
|
||||
productModel: string
|
||||
reportTitle?: string | null
|
||||
categoryName: string
|
||||
sceneName: string
|
||||
platformName?: string
|
||||
productCategoryName?: string
|
||||
materialType: string
|
||||
isAiGenerated?: boolean
|
||||
taskDetailSourceType?: string | null
|
||||
renderer?: string
|
||||
authorName: string
|
||||
previewUrl: string
|
||||
@ -79,7 +86,7 @@ interface MaterialCardProps {
|
||||
|
||||
// 根据素材类型获取角标背景颜色
|
||||
const getMaterialTypeBadgeColor = (materialType: string): string => {
|
||||
const isVideoType = materialType === '主图视频' || materialType === '安装视频' || materialType === '种草视频'
|
||||
const isVideoType = isVideoMaterialType(materialType)
|
||||
|
||||
if (materialType === '3D场景' || materialType === '3DMAX') {
|
||||
return 'bg-red-500'
|
||||
@ -105,6 +112,23 @@ const formatFileSize = (bytes: string | number): string => {
|
||||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
const getTaskDetailSourceBadgeClassName = (value?: string | null): string => {
|
||||
const normalized = normalizeTaskDetailSourceType(value)
|
||||
if (normalized === '实拍') {
|
||||
return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
if (normalized === '渲染') {
|
||||
return 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300'
|
||||
}
|
||||
|
||||
if (normalized === 'AI') {
|
||||
return 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300'
|
||||
}
|
||||
|
||||
return 'bg-slate-100 dark:bg-slate-700/60 text-slate-700 dark:text-slate-300'
|
||||
}
|
||||
|
||||
export default function MaterialCard({
|
||||
material,
|
||||
session,
|
||||
@ -132,12 +156,16 @@ export default function MaterialCard({
|
||||
const [posterLoadError, setPosterLoadError] = useState(false)
|
||||
|
||||
// 判断素材类型
|
||||
const isVideoType = material.materialType === '主图视频' || material.materialType === '安装视频' || material.materialType === '种草视频'
|
||||
const isVideoType = isVideoMaterialType(material.materialType)
|
||||
const isAudioMaterial = material.materialType === '音乐'
|
||||
|
||||
// 视频预览URL
|
||||
const videoPreviewUrl = material.videoPreviewClipUrl || material.videoPreview540Url
|
||||
const posterUrl = material.videoCoverUrl || material.previewUrl
|
||||
const materialDisplayTitle = getCertificateReportDisplayTitle({
|
||||
productModel: material.productModel,
|
||||
reportTitle: material.reportTitle,
|
||||
})
|
||||
|
||||
// 检测封面图是否可加载
|
||||
useEffect(() => {
|
||||
@ -150,7 +178,7 @@ export default function MaterialCard({
|
||||
img.onerror = () => {
|
||||
console.error('[MaterialCard] 视频封面图预加载失败:', {
|
||||
materialId: material.id,
|
||||
productModel: material.productModel,
|
||||
productModel: materialDisplayTitle,
|
||||
videoCoverUrl: material.videoCoverUrl,
|
||||
previewUrl: material.previewUrl,
|
||||
posterUrl: posterUrl,
|
||||
@ -162,8 +190,8 @@ export default function MaterialCard({
|
||||
} else {
|
||||
setPosterLoadError(false)
|
||||
}
|
||||
}, [isVideoType, posterUrl, material.id, material.productModel, material.videoCoverUrl, material.previewUrl])
|
||||
|
||||
}, [isVideoType, posterUrl, material.id, materialDisplayTitle, material.videoCoverUrl, material.previewUrl])
|
||||
|
||||
// 音频URL(API已经处理过,直接使用)
|
||||
let audioUrl = ''
|
||||
if (isAudioMaterial && material.zipFileName) {
|
||||
@ -173,6 +201,8 @@ export default function MaterialCard({
|
||||
audioUrl = material.zipFileName
|
||||
}
|
||||
|
||||
const isTaskDetailMaterial = material.materialType === '任务-详情'
|
||||
|
||||
// 视频鼠标悬停播放控制
|
||||
const handleVideoMouseEnter = () => {
|
||||
if (videoRef.current) {
|
||||
@ -323,7 +353,7 @@ export default function MaterialCard({
|
||||
// 视频加载失败时的错误处理
|
||||
console.error('[MaterialCard] 视频加载失败:', {
|
||||
materialId: material.id,
|
||||
productModel: material.productModel,
|
||||
productModel: materialDisplayTitle,
|
||||
videoPreviewUrl: videoPreviewUrl,
|
||||
posterUrl: posterUrl,
|
||||
timestamp: new Date().toISOString()
|
||||
@ -418,7 +448,7 @@ export default function MaterialCard({
|
||||
{material.previewUrl && playingAudioId !== material.id && (
|
||||
<Image
|
||||
src={material.previewUrl}
|
||||
alt={material.productModel}
|
||||
alt={materialDisplayTitle}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover opacity-50"
|
||||
@ -452,7 +482,7 @@ export default function MaterialCard({
|
||||
)}
|
||||
</button>
|
||||
{playingAudioId !== material.id && (
|
||||
<p className="text-white text-sm font-medium text-center px-4 line-clamp-1 drop-shadow-lg mt-3">{material.productModel}</p>
|
||||
<p className="text-white text-sm font-medium text-center px-4 line-clamp-1 drop-shadow-lg mt-3">{materialDisplayTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -471,7 +501,7 @@ export default function MaterialCard({
|
||||
const failedUrl = target.src
|
||||
console.error('[MaterialCard] 预览图加载失败:', {
|
||||
materialId: material.id,
|
||||
productModel: material.productModel,
|
||||
productModel: materialDisplayTitle,
|
||||
previewUrl: material.previewUrl,
|
||||
failedUrl: failedUrl,
|
||||
timestamp: new Date().toISOString()
|
||||
@ -498,7 +528,7 @@ export default function MaterialCard({
|
||||
|
||||
{/* SPU标签 - 左上角 */}
|
||||
<div className="absolute top-2 left-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium z-10">
|
||||
{material.productModel}
|
||||
{materialDisplayTitle}
|
||||
</div>
|
||||
{/* 素材类型角标 - 右上角 */}
|
||||
<div className={`absolute top-2 right-2 text-white px-2 py-1 rounded text-xs font-medium z-10 ${getMaterialTypeBadgeColor(material.materialType)}`}>
|
||||
@ -543,7 +573,12 @@ export default function MaterialCard({
|
||||
{material.materialType}
|
||||
</span>
|
||||
)}
|
||||
{material.isAiGenerated && (
|
||||
{isTaskDetailMaterial && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${getTaskDetailSourceBadgeClassName(material.taskDetailSourceType)}`}>
|
||||
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}
|
||||
</span>
|
||||
)}
|
||||
{!isTaskDetailMaterial && material.isAiGenerated && (
|
||||
<span className="inline-flex items-center px-2 py-1 bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300 rounded text-xs font-medium">
|
||||
AI生成
|
||||
</span>
|
||||
|
||||
371
components/upload/CertificateReportUploader.tsx
Normal file
371
components/upload/CertificateReportUploader.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { CERTIFICATE_REPORT_MATERIAL_TYPE, detectFileKindFromName } from '@/lib/certificate-report'
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface CertificateReportUploaderProps {
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
productCategories: CategoryItem[]
|
||||
}
|
||||
|
||||
interface ReportFileItem {
|
||||
file: File
|
||||
previewUrl?: string
|
||||
sortOrder: number
|
||||
fileKind: 'pdf' | 'image'
|
||||
}
|
||||
|
||||
export default function CertificateReportUploader({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
productCategories,
|
||||
}: CertificateReportUploaderProps) {
|
||||
const [productModel, setProductModel] = useState('')
|
||||
const [reportTitle, setReportTitle] = useState('')
|
||||
const [productCategoryName, setProductCategoryName] = useState('')
|
||||
const [platformName, setPlatformName] = useState('')
|
||||
const [files, setFiles] = useState<ReportFileItem[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [processingStep, setProcessingStep] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const totalSizeText = useMemo(() => {
|
||||
const totalBytes = files.reduce((sum, item) => sum + item.file.size, 0)
|
||||
if (totalBytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const unitIndex = Math.floor(Math.log(totalBytes) / Math.log(1024))
|
||||
return `${(totalBytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`
|
||||
}, [files])
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentLength = files.length
|
||||
const nextFiles: ReportFileItem[] = []
|
||||
|
||||
for (let index = 0; index < selectedFiles.length; index += 1) {
|
||||
const file = selectedFiles[index]
|
||||
const fileKind = detectFileKindFromName(file.name)
|
||||
const isPdf = fileKind === 'pdf'
|
||||
const isImage = file.type.startsWith('image/')
|
||||
|
||||
if (!isPdf && !isImage) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextFiles.push({
|
||||
file,
|
||||
previewUrl: isImage ? URL.createObjectURL(file) : undefined,
|
||||
sortOrder: currentLength + nextFiles.length,
|
||||
fileKind,
|
||||
})
|
||||
}
|
||||
|
||||
if (nextFiles.length === 0) {
|
||||
setError('请选择 PDF 或图片文件')
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setFiles((prev) => [...prev, ...nextFiles])
|
||||
setError('')
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => {
|
||||
const target = prev[index]
|
||||
if (target?.previewUrl) {
|
||||
URL.revokeObjectURL(target.previewUrl)
|
||||
}
|
||||
|
||||
return prev
|
||||
.filter((_, currentIndex) => currentIndex !== index)
|
||||
.map((item, currentIndex) => ({
|
||||
...item,
|
||||
sortOrder: currentIndex,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!productModel.trim() && !reportTitle.trim()) {
|
||||
setError('SPU 和 报告名称/检测对象 至少填写一个')
|
||||
return
|
||||
}
|
||||
|
||||
if (!productCategoryName.trim()) {
|
||||
setError('请选择品类')
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
setError('请至少上传一个 PDF 或图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
setProcessingStep('初始化上传...')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const initResponse = await fetch('/api/materials/upload/certificate-report/init', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productModel: productModel.trim(),
|
||||
reportTitle: reportTitle.trim(),
|
||||
productCategoryName: productCategoryName.trim(),
|
||||
platformName: platformName.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!initResponse.ok) {
|
||||
const errorData = await initResponse.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || '初始化失败')
|
||||
}
|
||||
|
||||
const { materialId } = await initResponse.json()
|
||||
const { chunkUpload } = await import('@/lib/chunk-upload')
|
||||
const uploadedFiles: Array<{
|
||||
uploadId: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sortOrder: number
|
||||
fileKind: 'pdf' | 'image'
|
||||
}> = []
|
||||
|
||||
setProcessingStep('上传文件中...')
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const current = files[index]
|
||||
const result = await chunkUpload(
|
||||
current.file,
|
||||
{
|
||||
materialType: CERTIFICATE_REPORT_MATERIAL_TYPE,
|
||||
fileName: current.file.name,
|
||||
},
|
||||
{
|
||||
skipComplete: true,
|
||||
onProgress: (progress) => {
|
||||
const fileProgress = (index + progress / 100) / files.length
|
||||
const totalProgress = 10 + fileProgress * 70
|
||||
setUploadProgress(Math.round(totalProgress))
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
uploadedFiles.push({
|
||||
uploadId: result.uploadId,
|
||||
fileName: current.file.name,
|
||||
fileSize: current.file.size,
|
||||
sortOrder: current.sortOrder,
|
||||
fileKind: current.fileKind,
|
||||
})
|
||||
}
|
||||
|
||||
setUploadProgress(82)
|
||||
setProcessingStep('生成分享内容...')
|
||||
|
||||
const completeResponse = await fetch('/api/materials/upload/certificate-report/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
materialId,
|
||||
files: uploadedFiles,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
const errorData = await completeResponse.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || '完成上传失败')
|
||||
}
|
||||
|
||||
setUploadProgress(100)
|
||||
setProcessingStep('完成')
|
||||
|
||||
files.forEach((item) => {
|
||||
if (item.previewUrl) {
|
||||
URL.revokeObjectURL(item.previewUrl)
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess()
|
||||
}, 300)
|
||||
} catch (submitError) {
|
||||
console.error('[Certificate Report Upload]', submitError)
|
||||
setError(submitError instanceof Error ? submitError.message : '上传失败,请稍后再试')
|
||||
setProcessingStep('')
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
表格里只需要放分享链接。对方点击后,会在分享页里看到这组检测报告的 PDF 和图片内容。
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">SPU</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productModel}
|
||||
onChange={(event) => setProductModel(event.target.value)}
|
||||
placeholder="没有 SPU 可以留空"
|
||||
className="input-apple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
报告名称 / 检测对象
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reportTitle}
|
||||
onChange={(event) => setReportTitle(event.target.value)}
|
||||
placeholder="例如:E0级颗粒板检测报告"
|
||||
className="input-apple"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">SPU 和这个字段至少填写一个</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">品类 *</label>
|
||||
<select
|
||||
value={productCategoryName}
|
||||
onChange={(event) => setProductCategoryName(event.target.value)}
|
||||
className="input-apple"
|
||||
>
|
||||
<option value="">请选择品类</option>
|
||||
{productCategories.map((category) => (
|
||||
<option key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">平台分类</label>
|
||||
<input
|
||||
type="text"
|
||||
value={platformName}
|
||||
onChange={(event) => setPlatformName(event.target.value)}
|
||||
placeholder="可选"
|
||||
className="input-apple"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
报告文件 *
|
||||
</label>
|
||||
<div className="rounded-apple border-2 border-dashed border-gray-300 p-6 text-center dark:border-gray-600">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,image/*"
|
||||
onChange={(event) => handleFileSelect(event.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-secondary">
|
||||
选择 PDF / 图片
|
||||
</button>
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
可以混合上传多份 PDF 和多张图片,分享时会按这里的顺序展示
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded-apple border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">文件列表</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{files.length} 个文件 · {totalSizeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{files.map((item, index) => (
|
||||
<div key={`${item.file.name}-${index}`} className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||
{item.fileKind === 'image' && item.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.previewUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-red-500">PDF</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{item.file.name}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.fileKind === 'pdf' ? 'PDF 报告' : '图片附件'} · {(item.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-gray-700 dark:text-gray-200">{processingStep || '上传中...'}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className="h-full bg-blue-600 transition-all" style={{ width: `${uploadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button type="button" onClick={onCancel} className="btn-secondary" disabled={uploading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={handleSubmit} className="btn-primary" disabled={uploading}>
|
||||
{uploading ? '上传中...' : '创建分享素材'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -78,4 +78,15 @@ describe('ProductPackageUploader', () => {
|
||||
expect(screen.getByText('以下文件为空,无法上传:empty.psd')).toBeInTheDocument()
|
||||
expect(screen.queryByText('empty.psd')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('应提供任务详情出图方式选择项,且默认未设置', () => {
|
||||
render(<ProductPackageUploader {...defaultProps} />)
|
||||
|
||||
const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement
|
||||
expect(select.value).toBe('')
|
||||
expect(screen.getByRole('option', { name: '未设置' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,10 @@ import { useState, useRef } from 'react';
|
||||
import DetailImageSorter from './DetailImageSorter';
|
||||
import UploadForm from './UploadForm';
|
||||
import { addCategory, addScene } from './category-handlers';
|
||||
import {
|
||||
TASK_DETAIL_SOURCE_TYPE_OPTIONS,
|
||||
type TaskDetailSourceType,
|
||||
} from '@/lib/task-detail-source-type';
|
||||
|
||||
const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const;
|
||||
const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z';
|
||||
@ -95,6 +99,7 @@ export default function ProductPackageUploader({
|
||||
const [sceneName, setSceneName] = useState('');
|
||||
const [platformName, setPlatformName] = useState('');
|
||||
const [productCategoryName, setProductCategoryName] = useState('');
|
||||
const [taskDetailSourceType, setTaskDetailSourceType] = useState<TaskDetailSourceType | ''>('');
|
||||
|
||||
// 资产文件
|
||||
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
|
||||
@ -452,6 +457,7 @@ export default function ProductPackageUploader({
|
||||
sceneName,
|
||||
platformName: platformName || undefined,
|
||||
productCategoryName: productCategoryName || undefined,
|
||||
taskDetailSourceType: taskDetailSourceType || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -672,6 +678,26 @@ export default function ProductPackageUploader({
|
||||
onAddScene={handleAddNewScene}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="task-detail-source-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
出图方式 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
|
||||
</label>
|
||||
<select
|
||||
id="task-detail-source-type"
|
||||
value={taskDetailSourceType}
|
||||
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
|
||||
disabled={uploading}
|
||||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||||
>
|
||||
<option value="">未设置</option>
|
||||
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-apple border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
系统会按素材实际内容自动判定是否整套:同时拥有主图、SKU 图、详情图时,自动标记为整套,无需手工选择。
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
|
||||
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
/**
|
||||
* 分类数据项
|
||||
@ -206,7 +207,7 @@ export default function UploadForm({
|
||||
</div>
|
||||
|
||||
{/* SKU字段(视频类素材使用) */}
|
||||
{['主图视频', '安装视频', '种草视频'].includes(materialType) && (
|
||||
{isVideoMaterialType(materialType) && (
|
||||
<div>
|
||||
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SKU <span className="text-gray-500 dark:text-gray-400 text-xs">(可选,多个SKU请用逗号分隔)</span>
|
||||
|
||||
@ -3,13 +3,14 @@ import type { BatchFileItem } from './upload-types'
|
||||
import { BATCH_LIMITS } from './upload-constants'
|
||||
import { isValidMainFileType, extractSpuFromFileName } from './upload-utils'
|
||||
import { requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
|
||||
import { isBatchVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
/**
|
||||
* 获取批量上传限制
|
||||
*/
|
||||
export function getBatchLimit(materialType: string): number {
|
||||
if (materialType === '音乐') return BATCH_LIMITS.music
|
||||
if (materialType === '种草视频') return BATCH_LIMITS.video
|
||||
if (isBatchVideoMaterialType(materialType)) return BATCH_LIMITS.video
|
||||
if (materialType === '3D场景') return BATCH_LIMITS['3d']
|
||||
if (materialType === '品牌VI灵感') return BATCH_LIMITS.brandVI
|
||||
return Infinity
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { isValidMainFileType, detectMaterialType, extractSpuFromFileName } from './upload-utils'
|
||||
import type { BatchFileItem } from './upload-types'
|
||||
import { createBatchItemFromSingleFile } from './file-handlers'
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
/**
|
||||
* 处理文件选择(单文件或批量)
|
||||
@ -34,7 +35,7 @@ export function handleFileSelection(
|
||||
const message =
|
||||
materialType === '音乐'
|
||||
? audioTip
|
||||
: ['主图视频', '安装视频', '种草视频'].includes(materialType)
|
||||
: isVideoMaterialType(materialType)
|
||||
? videoTip
|
||||
: materialType === '品牌VI灵感'
|
||||
? brandTip
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 特殊类型的批量上传处理函数
|
||||
* 包括:音乐、种草视频、3D场景、品牌VI、买家秀、小红书
|
||||
* 包括:音乐、淘系-逛逛视频、3D场景、品牌VI、买家秀、小红书
|
||||
*/
|
||||
|
||||
import { chunkUpload } from '@/lib/chunk-upload'
|
||||
import { shouldPassRenderer } from '@/lib/material-validation'
|
||||
import { TAOXI_GUANGGUANG_VIDEO_TYPE } from '@/lib/video-material-types'
|
||||
import { compressPreviewImage } from './image-processor'
|
||||
|
||||
/**
|
||||
@ -253,7 +254,7 @@ export async function uploadBatchMusic(
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传种草视频
|
||||
* 批量上传淘系-逛逛视频
|
||||
*/
|
||||
export async function uploadBatchVideo(
|
||||
files: Array<{ file: File; productModel: string; productCategoryName?: string }>,
|
||||
@ -263,7 +264,7 @@ export async function uploadBatchVideo(
|
||||
onProgress: (progress: number) => void
|
||||
) {
|
||||
const baseMetadata = {
|
||||
materialType: '种草视频',
|
||||
materialType: TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
platformName: platformName || '',
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
// 上传相关的常量定义
|
||||
import {
|
||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
||||
INSTALL_VIDEO_TYPE,
|
||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
TAOXI_MAIN_VIDEO_TYPE,
|
||||
} from '@/lib/video-material-types'
|
||||
|
||||
// 预览图可选的类型列表
|
||||
export const PREVIEW_OPTIONAL_TYPES: string[] = [
|
||||
@ -10,9 +17,11 @@ export const PREVIEW_OPTIONAL_TYPES: string[] = [
|
||||
'主图模版',
|
||||
// 'SKU模版', // 已移除:SKU模版现在必须上传预览图
|
||||
'品牌VI灵感',
|
||||
'主图视频',
|
||||
'安装视频',
|
||||
'种草视频',
|
||||
TAOXI_MAIN_VIDEO_TYPE,
|
||||
INSTALL_VIDEO_TYPE,
|
||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
||||
'音乐',
|
||||
]
|
||||
|
||||
|
||||
19
components/upload/upload-utils.test.ts
Normal file
19
components/upload/upload-utils.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { getMainFileAccept, isValidMainFileType } from '@/components/upload/upload-utils'
|
||||
|
||||
describe('upload-utils video types', () => {
|
||||
test('抖音新视频类型只接受视频文件', () => {
|
||||
const validVideo = new File(['video'], 'sample.mp4', { type: 'video/mp4' })
|
||||
const invalidZip = new File(['zip'], 'sample.zip', { type: 'application/zip' })
|
||||
|
||||
expect(isValidMainFileType(validVideo, '抖音-千川素材')).toBe(true)
|
||||
expect(isValidMainFileType(validVideo, '抖音-账号维护')).toBe(true)
|
||||
expect(isValidMainFileType(invalidZip, '抖音-千川素材')).toBe(false)
|
||||
expect(isValidMainFileType(invalidZip, '抖音-账号维护')).toBe(false)
|
||||
})
|
||||
|
||||
test('新视频类型的 accept 与原主图视频保持一致', () => {
|
||||
expect(getMainFileAccept('淘系-主图视频')).toBe('.mp4,.mov,.avi,.mkv,.mts,.m4v')
|
||||
expect(getMainFileAccept('抖音-千川素材')).toBe('.mp4,.mov,.avi,.mkv,.mts,.m4v')
|
||||
expect(getMainFileAccept('抖音-账号维护')).toBe('.mp4,.mov,.avi,.mkv,.mts,.m4v')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,5 @@
|
||||
// 上传相关的工具函数
|
||||
import { isVideoMaterialType } from '@/lib/video-material-types'
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
@ -59,7 +60,7 @@ export function isValidMainFileType(file: File, materialType: string): boolean {
|
||||
if (materialType === '音乐') return audioExts.includes(ext)
|
||||
|
||||
// 视频类:只能是视频文件
|
||||
if (['主图视频', '安装视频', '种草视频'].includes(materialType)) return videoExts.includes(ext)
|
||||
if (isVideoMaterialType(materialType)) return videoExts.includes(ext)
|
||||
|
||||
// 品牌VI灵感:只能是 PDF,展示图在单独的展示图字段里上传
|
||||
if (materialType === '品牌VI灵感') return ext === '.pdf'
|
||||
@ -78,7 +79,7 @@ export function isValidMainFileType(file: File, materialType: string): boolean {
|
||||
*/
|
||||
export function getMainFileAccept(materialType: string): string {
|
||||
if (materialType === '音乐') return '.mp3,.wav,.flac,.m4a,.aac,.ogg'
|
||||
if (['主图视频', '安装视频', '种草视频'].includes(materialType))
|
||||
if (isVideoMaterialType(materialType))
|
||||
return '.mp4,.mov,.avi,.mkv,.mts,.m4v'
|
||||
if (materialType === '品牌VI灵感') return '.pdf'
|
||||
// 详情模板:支持直接上传 PSD/TIF/PSB 文件或压缩包
|
||||
@ -123,4 +124,3 @@ export function detectMaterialType(filename: string): string | null {
|
||||
// 用户需要手动选择素材类型
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -3,18 +3,19 @@
|
||||
> 交付对象:AI 生图平台负责人
|
||||
> 当前主接口域名:`https://sc.jiansun.vip`
|
||||
> 当前资源主域名:`https://s3.jiansun.vip`
|
||||
> 备用资源域名:`https://s3.yydd.ai`
|
||||
> 文档时间:`2026-03-18`
|
||||
> 历史旧资源域名:`https://s3.yydd.ai`(不再作为默认入口)
|
||||
> 文档时间:`2026-04-29`
|
||||
> 当前状态:`180.97.215.57` 读缓存入口已停用,本文保留对接口径和历史方案说明。
|
||||
|
||||
## 1. 这次要做的事情
|
||||
|
||||
计划把新入口服务器 `180.97.215.57` 上独立数据盘中的 `180G` 空间,用作 AI 生图平台相关资源的**读取缓存**。
|
||||
原计划把新入口服务器 `180.97.215.57` 上独立数据盘中的 `180G` 空间,用作 AI 生图平台相关资源的**读取缓存**。该入口现在已经停用,不再作为正式资源入口或联调目标。
|
||||
|
||||
这件事的目标是:
|
||||
当前目标调整为:
|
||||
|
||||
- 提升 AI 生图平台读取图片资源时的稳定性
|
||||
- 降低反复回源到生产机的压力
|
||||
- 让热点资源第二次及之后访问更快
|
||||
- 对外资源域名继续统一使用 `https://s3.jiansun.vip`
|
||||
- 展示型图片继续优先写入 `/gongxiangpan/public-display/`
|
||||
- 排障和联调优先检查 `180.97.221.140 -> FRP -> 192.168.58.20:9000` 链路
|
||||
|
||||
## 2. 这次不会变的东西
|
||||
|
||||
@ -23,12 +24,12 @@
|
||||
- 桶本体位置不变,真实存储仍在生产机 `192.168.58.20:9000`
|
||||
- AI 生图平台的接口入口仍然是 `https://sc.jiansun.vip`
|
||||
- 对外资源主域名仍然是 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 先保留为备用域名,不作为默认对接地址
|
||||
- 这次做的是“读缓存”,不是改上传链路,也不是迁桶
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为默认对接地址
|
||||
- 不迁桶,上传链路不因 `180.97.215.57` 停用而变化
|
||||
|
||||
一句话说清楚:
|
||||
|
||||
**缓存只是在入口服务器上多放一份“最近常用内容的副本”,不是把桶搬走。**
|
||||
**`180.97.215.57` 读缓存入口已经停用,但桶没有搬走,正式资源域名仍然是 `s3.jiansun.vip`。**
|
||||
|
||||
## 3. 当前已经确认的事实
|
||||
|
||||
@ -36,18 +37,19 @@
|
||||
|
||||
```text
|
||||
s3.jiansun.vip
|
||||
-> 180.97.215.57
|
||||
-> nginx 磁盘缓存
|
||||
-> 127.0.0.1:29000
|
||||
-> 180.97.221.140
|
||||
-> nginx
|
||||
-> 127.0.0.1:2323
|
||||
-> FRP
|
||||
-> 192.168.58.20:9000
|
||||
```
|
||||
|
||||
当前缓存盘配置已经到位:
|
||||
历史缓存盘配置曾经是:
|
||||
|
||||
- 缓存目录:`/data/nginx-cache/s3`
|
||||
- 缓存上限:`180g`
|
||||
- 当前入口机资源盘:`/data`,总容量约 `200G`
|
||||
- 旧入口机资源盘:`/data`,总容量约 `200G`
|
||||
- 当前不再把这层缓存作为正式链路门禁
|
||||
|
||||
`2026-03-18` 实测真实桶链路时,使用了一个约 `145MB` 的真实桶对象做下载测试:
|
||||
|
||||
@ -55,20 +57,20 @@ s3.jiansun.vip
|
||||
- 走 `https://s3.jiansun.vip` 第一次请求:`X-Cache-Status: MISS`,约 `22.7 MB/s`
|
||||
- 同一个 URL 第二次请求:`X-Cache-Status: HIT`,约 `63.2 MB/s`
|
||||
|
||||
这说明:
|
||||
这些历史结果只说明旧缓存方案曾经可用:
|
||||
|
||||
- 首次访问主要受回源链路影响
|
||||
- 命中缓存后,速度会明显提升
|
||||
|
||||
基于当前已验证结果,可以先明确 3 个结论:
|
||||
基于当前入口调整,需要明确 3 个结论:
|
||||
|
||||
- 这套 `180G` 读缓存方案本身是可行的
|
||||
- 它适合承接 AI 生图平台里“会被重复访问”的热点资源
|
||||
- 在不改业务入口、不迁桶的前提下,就可以先做灰度验证
|
||||
- `180.97.215.57` 读缓存方案已经停用
|
||||
- `s3.jiansun.vip` 仍是唯一正式资源域名
|
||||
- 后续验证重点是 `180.97.221.140` 入口、FRP 和 MinIO 回源,而不是旧缓存命中率
|
||||
|
||||
## 4. 我还需要 AI 生图平台这边提供什么
|
||||
|
||||
为了把这 `180G` 真正用于 AI 生图平台的热点流量,而不是“缓存了一堆不重要的文件”,还需要下面这些信息。
|
||||
为了让 AI 生图平台稳定使用正式资源链路,还需要下面这些信息。
|
||||
|
||||
### 4.1 需要确认的资源范围
|
||||
|
||||
@ -96,14 +98,14 @@ s3.jiansun.vip
|
||||
请确认:
|
||||
|
||||
- AI 生图平台后续是否统一只读 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 是否只作为备用 / 回切,不参与日常默认调用
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 是否已从日常默认调用中完全移除
|
||||
|
||||
推荐口径:
|
||||
|
||||
- 主用:`https://s3.jiansun.vip`
|
||||
- 备用:`https://s3.yydd.ai`
|
||||
- 历史旧域名:`https://s3.yydd.ai`(仅用于识别历史链接)
|
||||
|
||||
如果两个域名同时长期对外使用,那么**同一份内容就可能同时出现两个公网 URL**。
|
||||
如果旧域名和新域名长期同时对外使用,那么**同一份内容就可能同时出现两个公网 URL**。
|
||||
|
||||
### 4.3 需要确认文件是否“同 key 覆盖”
|
||||
|
||||
@ -187,17 +189,17 @@ s3.jiansun.vip
|
||||
- 对外主资源域名统一使用 `https://s3.jiansun.vip`
|
||||
- 资源尽量使用“新内容新 key”的方式,不要反复覆盖同一个 URL
|
||||
|
||||
只要做到这两点,缓存的效果就是:
|
||||
只要做到这两点,链路效果就是:
|
||||
|
||||
- 第一次访问走回源
|
||||
- 后续重复访问直接命中入口缓存
|
||||
- 统一从 `s3.jiansun.vip` 进入
|
||||
- 通过当前正式入口回源到生产 MinIO
|
||||
- 用户看到的还是同一个公网 URL
|
||||
|
||||
## 6. 会不会出现“同一张图两个 URL”
|
||||
|
||||
### 情况 1:缓存本身
|
||||
### 情况 1:旧缓存本身
|
||||
|
||||
**不会。**
|
||||
**不会,但该方案已经停用。**
|
||||
|
||||
缓存不会凭空再生成一个给用户使用的新 URL。
|
||||
|
||||
@ -207,13 +209,13 @@ s3.jiansun.vip
|
||||
https://s3.jiansun.vip/xxx.png
|
||||
```
|
||||
|
||||
缓存只是入口服务器内部把这个 URL 对应的内容存到磁盘里。
|
||||
旧缓存只是入口服务器内部把这个 URL 对应的内容存到磁盘里。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 对用户看到的 URL 不变
|
||||
- 对程序调用的 URL 不变
|
||||
- 只是入口机内部多了一份缓存文件
|
||||
- 只是旧入口机内部多了一份缓存文件
|
||||
|
||||
### 情况 2:两个域名同时都在用
|
||||
|
||||
@ -222,14 +224,14 @@ https://s3.jiansun.vip/xxx.png
|
||||
如果下面两个地址同时长期对外使用:
|
||||
|
||||
- `https://s3.jiansun.vip/xxx.png`
|
||||
- `https://s3.yydd.ai/xxx.png`
|
||||
- 历史旧链接:`https://s3.yydd.ai/xxx.png`
|
||||
|
||||
那么从“公网 URL”角度看,就是两条 URL 指向了同一份内容。
|
||||
|
||||
所以当前建议非常明确:
|
||||
|
||||
- `s3.jiansun.vip` 主用
|
||||
- `s3.yydd.ai` 备用
|
||||
- `s3.yydd.ai` 不再作为默认入口
|
||||
|
||||
不要两个一起作为默认入口使用。
|
||||
|
||||
@ -266,18 +268,19 @@ https://s3.jiansun.vip/xxx.png
|
||||
|
||||
### 域名规则
|
||||
|
||||
- 主用资源域名:`https://s3.jiansun.vip`
|
||||
- 备用资源域名:`https://s3.yydd.ai`
|
||||
- 正式资源域名:`https://s3.jiansun.vip`
|
||||
- 历史旧域名:`https://s3.yydd.ai`(仅识别,不再默认使用)
|
||||
|
||||
### URL 规则
|
||||
|
||||
- 对外只认主域名,不混用两个资源域名
|
||||
- 新内容尽量使用新 key,不覆盖老 key
|
||||
|
||||
### 缓存规则
|
||||
### 链路规则
|
||||
|
||||
- 把 AI 生图平台的热点读流量优先收敛到 `s3.jiansun.vip`
|
||||
- 入口机 `180G` 只做读缓存,不改桶本体位置
|
||||
- 当前正式入口为 `180.97.221.140`
|
||||
- `180.97.215.57` 不再作为正式资源入口或缓存入口
|
||||
|
||||
## 9. 现在就可以推进的下一步
|
||||
|
||||
@ -303,8 +306,8 @@ https://s3.jiansun.vip/xxx.png
|
||||
1. 主用资源域名:
|
||||
例如:统一使用 https://s3.jiansun.vip
|
||||
|
||||
2. 是否保留备用域名:
|
||||
例如:保留 https://s3.yydd.ai,仅用于备用,不作为默认入口
|
||||
2. 历史旧域名是否已停止默认调用:
|
||||
例如:是,`https://s3.yydd.ai` 不再作为默认入口
|
||||
|
||||
3. 主要读取的资源类型:
|
||||
例如:生图结果图、参考图、放大图、变体图、压缩包
|
||||
@ -329,4 +332,4 @@ https://s3.jiansun.vip/xxx.png
|
||||
|
||||
如果只要一句最短版本,可以直接转给对方:
|
||||
|
||||
**这次不是迁桶,只是在 `180.97.215.57` 上给 `s3.jiansun.vip` 做 180G 读缓存。要继续推进,需要 AI 生图平台确认主用域名、真实热点路径、是否会覆盖同一 URL、以及给一组联调样本。只要统一用一个主域名,并尽量做到新内容新 key,缓存对用户基本无感,也不会凭空多出第二个 URL。**
|
||||
**这次不是迁桶;`180.97.215.57` 读缓存入口已停用,正式资源域名仍然是 `s3.jiansun.vip`,当前应通过 `180.97.221.140` 入口回源到 `192.168.58.20:9000`。要继续推进,需要 AI 生图平台确认主用域名、真实热点路径、是否会覆盖同一 URL、以及给一组联调样本。**
|
||||
|
||||
@ -1,176 +1,123 @@
|
||||
# AI 生图平台第一层桶缓存落地方案
|
||||
|
||||
> 文档时间:`2026-03-19`
|
||||
> 当前资源主域名:`https://s3.jiansun.vip`
|
||||
> 当前资源备用域名:`https://s3.yydd.ai`
|
||||
> 当前入口服务器:`180.97.215.57`
|
||||
> 当前真实桶位置:`192.168.58.20:9000`
|
||||
> 更新时间:2026-04-29
|
||||
> 当前正式资源域名:`https://s3.jiansun.vip`
|
||||
> 当前正式入口机:`180.97.221.140`
|
||||
> 已停用缓存入口机:`180.97.215.57`
|
||||
> 当前桶本体:`192.168.58.20:9000`
|
||||
|
||||
## 1. 这次落地的结论
|
||||
## 1. 结论
|
||||
|
||||
本期只做**第一层桶读缓存**。
|
||||
AI 生图平台不再沿用“整个 `/uploads/` 都进长缓存”的口径,也不再使用 `180.97.215.57` 这台缓存入口机。当前保留更稳定的新前缀分流:
|
||||
|
||||
这次已经落实的口径是:
|
||||
- 正式展示图前缀:`/gongxiangpan/public-display/`
|
||||
- 首期只保证“新生成展示图”走新前缀
|
||||
- 历史 `/uploads/` 对象继续可访问,但不再作为默认展示缓存路径
|
||||
|
||||
- 主资源域名统一使用 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 保留为备用 / 回切域名,不再作为默认资源入口
|
||||
- 桶本体不动,真实对象仍在 `192.168.58.20:9000`
|
||||
- 不改 AI 生图平台业务逻辑代码
|
||||
- 不处理 `image-proxy` 第二层结果缓存
|
||||
|
||||
一句话说清楚:
|
||||
|
||||
**这次做的是“入口层缓存原图读取”,不是改业务,不是迁桶,也不是优化 `image-proxy` 自己的图片处理。**
|
||||
|
||||
## 2. 当前链路
|
||||
## 2. 当前正式链路
|
||||
|
||||
### 2.1 没命中缓存时
|
||||
|
||||
```text
|
||||
用户打开网页
|
||||
-> AI 生图平台 / image-proxy
|
||||
-> 请求 https://s3.jiansun.vip/gongxiangpan/uploads/...
|
||||
-> 180.97.215.57
|
||||
-> 127.0.0.1:29000
|
||||
AI 生图平台
|
||||
-> https://s3.jiansun.vip/gongxiangpan/public-display/...
|
||||
-> 180.97.221.140
|
||||
-> nginx
|
||||
-> 127.0.0.1:2323
|
||||
-> FRP
|
||||
-> 192.168.58.20:9000
|
||||
-> 返回原图
|
||||
-> image-proxy 再返回给用户
|
||||
-> 返回图片
|
||||
```
|
||||
|
||||
### 2.2 命中第一层缓存时
|
||||
### 2.2 关于旧缓存入口
|
||||
|
||||
```text
|
||||
用户打开网页
|
||||
-> AI 生图平台 / image-proxy
|
||||
-> 请求 https://s3.jiansun.vip/gongxiangpan/uploads/...
|
||||
-> 180.97.215.57
|
||||
-> 命中 nginx 磁盘缓存
|
||||
-> 直接返回原图
|
||||
-> image-proxy 再返回给用户
|
||||
```
|
||||
- `180.97.215.57` 已停用,不再作为 `s3.jiansun.vip` 的正式链路。
|
||||
- 旧的 `180G` nginx 磁盘缓存方案只保留历史记录,不再作为联调或排障门禁。
|
||||
|
||||
这两条链路的本质区别是:
|
||||
## 3. 为什么改成新前缀
|
||||
|
||||
- 没命中时,要走 `180.97.215.57 <-> FRP <-> 192.168.58.20:9000`
|
||||
- 命中时,省掉的是这一整段回源开销
|
||||
旧方案的问题是:
|
||||
|
||||
## 3. 本期到底做了什么
|
||||
- `/uploads/` 里既有图片,也有混合对象
|
||||
- 规则边界不清,容易把缓存口径和历史兼容混在一起
|
||||
- 很难长期保证“只缓存该缓存的东西”
|
||||
|
||||
当前 `180.97.215.57` 已确认:
|
||||
新前缀方案的好处是:
|
||||
|
||||
- nginx 已运行
|
||||
- s3 专用入口配置已独立
|
||||
- FRP s3 专用入口已运行,监听 `127.0.0.1:29000`
|
||||
- 磁盘缓存目录为 `/data/nginx-cache/s3`
|
||||
- 缓存上限为 `180g`
|
||||
- 展示图和混合区彻底分开
|
||||
- 展示资源规则稳定,先按前缀收口,减少历史混合路径干扰
|
||||
- 对外链路、代码写入和入口层回源能保持一致
|
||||
|
||||
本期正式收口后的缓存范围是:
|
||||
## 4. 首期范围
|
||||
|
||||
- 域名:`s3.jiansun.vip`
|
||||
- 路径:`/gongxiangpan/uploads/`
|
||||
首期纳入 `public-display` 的对象:
|
||||
|
||||
也就是说:
|
||||
- 白底图预览
|
||||
- 任务-详情主图 / SKU 图 / 详情图预览
|
||||
- AI 生图平台最终展示图
|
||||
- 其它明确属于页面展示的成品图
|
||||
|
||||
- `/gongxiangpan/uploads/` 进入正式长缓存
|
||||
- 其它路径继续回源,但不纳入本期长缓存策略
|
||||
首期不做:
|
||||
|
||||
## 4. 固定缓存策略
|
||||
- 历史 `/uploads/` 批量迁移
|
||||
- 大源文件缓存
|
||||
- 视频源文件缓存
|
||||
- 压缩包缓存
|
||||
|
||||
当前固定策略如下:
|
||||
## 5. 当前展示资源策略
|
||||
|
||||
- 仅缓存 `GET` / `HEAD`
|
||||
- `200/206/301/302` 缓存 `30d`
|
||||
- `404` 缓存 `1m`
|
||||
- 保留 `X-Cache-Status`
|
||||
- 启用防击穿与回源保护:
|
||||
- `proxy_cache_lock on`
|
||||
- `proxy_cache_background_update on`
|
||||
- `proxy_cache_revalidate on`
|
||||
- `proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504`
|
||||
- 展示图对象优先写入 `/gongxiangpan/public-display/`
|
||||
- `uploads` 保留为历史混合区,不作为默认展示资源路径
|
||||
- 当前不再以 `X-Cache-Status` 作为正式验证标准
|
||||
- 当前优先验证 `s3.jiansun.vip -> 180.97.221.140 -> 192.168.58.20:9000` 是否可达
|
||||
|
||||
本期不做:
|
||||
## 6. 历史验证结果
|
||||
|
||||
- purge 主动清缓存机制
|
||||
- `image-proxy` 结果缓存
|
||||
- 历史 `s3.yydd.ai` URL 收敛
|
||||
### 6.1 `public-display` 命中缓存
|
||||
|
||||
## 5. 对用户体验的实际影响
|
||||
以下结果属于 `180.97.215.57` 缓存方案历史记录,当前不再作为正式门禁。测试对象:
|
||||
|
||||
这次优先改善的是:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/20260325-cache-test.png`
|
||||
|
||||
- 用户打开网页时图片加载速度
|
||||
- 热门图片二次及之后访问的稳定性
|
||||
- 高重复读取场景下的回源压力
|
||||
实测结果:
|
||||
|
||||
这次不会直接解决的是:
|
||||
- 第一次:`X-Cache-Status: MISS`
|
||||
- 第二次:`X-Cache-Status: HIT`
|
||||
|
||||
- `image-proxy` 自己的缩放、压缩、转格式耗时
|
||||
- 历史 `s3.yydd.ai` URL 带来的分散命中问题
|
||||
边界验证:
|
||||
|
||||
所以当前的准确口径应该是:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/limit-exact-10m-1774428109043.png`
|
||||
- `Content-Length: 10485760`
|
||||
- 第一次:`MISS`
|
||||
- 第二次:`HIT`
|
||||
|
||||
**第一层缓存优先解决“取原图慢”,不是解决 `image-proxy` 自己处理图片慢。**
|
||||
### 6.2 大文件不进入这层缓存
|
||||
|
||||
## 6. 为什么这次不需要改 AI 生图平台代码
|
||||
测试对象:
|
||||
|
||||
本期默认不改 AI 生图平台业务逻辑代码。
|
||||
- `https://s3.jiansun.vip/gongxiangpan/由壹点AI/video-preview-720p-9b1c86b6-0249-44ae-98fe-e480f5f5ac2c-1767153103562.mp4`
|
||||
|
||||
前提是:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/limit-over-10m-1774428109043.png`
|
||||
|
||||
- AI 平台默认资源域名已经统一到 `s3.jiansun.vip`
|
||||
- `image-proxy` 里传入的原图 URL 也是 `https://s3.jiansun.vip/gongxiangpan/uploads/...`
|
||||
实测结果:
|
||||
|
||||
在这个前提下,本期主要是服务器侧改造:
|
||||
- `X-Cache-Status: BYPASS`
|
||||
- `limit-over-10m-1774428109043.png`:第一次 `BYPASS`,第二次 `BYPASS`
|
||||
|
||||
- 在 `180.97.215.57` 上增加并收口第一层缓存
|
||||
- 保持回源到原桶
|
||||
- 不动 AI 平台业务流程
|
||||
## 7. 对接口径
|
||||
|
||||
## 7. 已完成的现场验证
|
||||
AI 生图平台后续如果要对接正式展示图,统一按下面这个口径:
|
||||
|
||||
### 7.1 入口服务器状态
|
||||
- 域名:`https://s3.jiansun.vip`
|
||||
- 路径:`/gongxiangpan/public-display/...`
|
||||
|
||||
已确认:
|
||||
不要再把下面这些当默认入口:
|
||||
|
||||
- `s3.jiansun.vip -> 180.97.215.57`
|
||||
- `/data` 盘总容量约 `196G`
|
||||
- 当前可用空间约 `186G`
|
||||
- nginx 运行正常
|
||||
- s3 专用 `frps` 运行正常
|
||||
- `https://s3.yydd.ai`
|
||||
- `https://s3.jiansun.vip/gongxiangpan/uploads/...`
|
||||
|
||||
### 7.2 路径级缓存验证
|
||||
## 8. 一句话版本
|
||||
|
||||
已确认:
|
||||
AI 生图平台现在的第一层桶缓存口径是:
|
||||
|
||||
- `/gongxiangpan/uploads/1773846444732-r8bzni.jpg`
|
||||
- 返回 `HTTP 200`
|
||||
- 返回 `X-Cache-Status: HIT`
|
||||
- `/`
|
||||
- 返回 `HTTP 400`
|
||||
- 返回 `X-Cache-Status: BYPASS`
|
||||
|
||||
这说明:
|
||||
|
||||
- uploads 路径已经命中第一层缓存
|
||||
- 非 uploads 路径已经不再纳入本期长缓存
|
||||
|
||||
## 8. 当前风险与后续二期
|
||||
|
||||
当前仍然存在但**不阻塞本期**的问题:
|
||||
|
||||
- 历史 `s3.yydd.ai` URL 残留
|
||||
- `image-proxy` 自身的处理耗时
|
||||
|
||||
后续二期再处理:
|
||||
|
||||
1. `image-proxy` 结果缓存
|
||||
2. 历史 `s3.yydd.ai` URL 收敛
|
||||
3. 是否要把更多路径纳入缓存
|
||||
4. 是否要补主动 purge 机制
|
||||
|
||||
## 9. 当前推荐口径
|
||||
|
||||
如果后续要对外统一说明,可以直接用下面这段:
|
||||
|
||||
**AI 生图平台当前已经先落地第一层桶缓存:`s3.jiansun.vip` 的 `/gongxiangpan/uploads/` 由 `180.97.215.57` 提供 `180G` nginx 磁盘缓存,命中后可直接在入口机返回原图,不再每次经 `FRP` 回源到 `192.168.58.20:9000`。本期不改业务逻辑代码,不迁桶,也不处理 `image-proxy` 第二层缓存;后续如果还要继续优化页面图片加载体验,再单独处理 `image-proxy`。**
|
||||
**新生成的展示型图片统一写到 `s3.jiansun.vip` 的 `/gongxiangpan/public-display/`,当前正式入口收敛到 `180.97.221.140`;`180.97.215.57` 缓存入口已停用,历史 `/uploads/` 继续可访问,但不再作为默认展示路径。**
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
> 用途:AI 生图平台统一通过同一条接口,查询 `标准模型白底图` 或 `任务-详情整套预览下载链接`
|
||||
> 当前主域名:`https://sc.jiansun.vip`
|
||||
> 当前资源主域名:`https://s3.jiansun.vip`
|
||||
> 备用资源域名:`https://s3.yydd.ai`(备用 / 回切用,非默认对接地址)
|
||||
> 历史旧资源域名:`https://s3.yydd.ai`(仅用于识别历史链接,不再作为对接口径)
|
||||
> 旧应用域名:`https://3d.yydd.ai`(仅保留兼容跳转)
|
||||
|
||||
## 1. 基础信息
|
||||
@ -20,7 +20,7 @@
|
||||
- 接口查询、鉴权和下载 token 入口统一使用 `https://sc.jiansun.vip`
|
||||
- `white-background` 分支里返回的 `previewUrl`,当前默认也是 `https://sc.jiansun.vip/uploads/...`
|
||||
- `task-detail` 分支返回的预览图直链,当前默认资源域名使用 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 当前只保留为备用资源域名;除非单独通知切换,否则调用方继续以 `https://s3.jiansun.vip` 为准
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为默认对接口径
|
||||
|
||||
返回统一格式:
|
||||
|
||||
@ -305,7 +305,7 @@ X-API-Key: your-api-key
|
||||
- 返回的是**预览图直链数组**,不是 zip,也不是 token 下载链接
|
||||
- 返回顺序按 `uploadedAt` 倒序,也就是最新上传的排前面
|
||||
- 当前默认返回的资源直链域名是 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 保留备用说明,但不是当前默认示例地址
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为默认示例地址
|
||||
|
||||
---
|
||||
|
||||
@ -500,7 +500,7 @@ GET https://sc.jiansun.vip/api/external/download?token=xxx
|
||||
补充说明:
|
||||
|
||||
- 当前默认回包中的预览图资源域名为 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 仅保留为备用资源域名说明,不作为当前默认对接地址
|
||||
- 历史旧资源域名 `https://s3.yydd.ai` 不再作为当前默认对接地址
|
||||
|
||||
## 6. 错误码
|
||||
|
||||
@ -588,5 +588,5 @@ curl -X GET \
|
||||
- 白底图已采用 `previewUrl` / `downloadUrl` 双轨语义
|
||||
- 后续新上传白底图将统一转为 WebP 存储,并额外生成预览级 WebP
|
||||
- `任务-详情` 整套字段 `isFullSet` 已改为按主图 / SKU 图 / 详情图自动判定
|
||||
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`,同时保留 `https://s3.yydd.ai` 作为备用说明
|
||||
- 对外文档默认资源域名已统一到 `https://s3.jiansun.vip`
|
||||
- 相关测试已通过:`standard-model-white-background`、`white-background-preview`、`standard-model-white-background-route`、`white-background-upload-route`
|
||||
|
||||
@ -1,215 +1,164 @@
|
||||
# 网络拓扑文档
|
||||
|
||||
> 现网快照(2026-03-19):
|
||||
> - `sc.jiansun.vip -> 180.97.221.140 -> nginx -> 127.0.0.1:2321(主) / 127.0.0.1:2320(备)`
|
||||
> - `sc` 专用穿透已独立:`192.168.58.20 frpc-sc-entry(0.65.0) -> 180.97.221.140:7002 frps-sc-entry(0.65.0) -> 127.0.0.1:2321/2322`
|
||||
> - `3d.yydd.ai` 目前仍直接走南京机旧链路 `127.0.0.1:2320`,所以旧 `2320` 不能立刻下线
|
||||
> - `s3.jiansun.vip -> 180.97.215.57 -> nginx cache(/data/nginx-cache/s3, 180g) -> 127.0.0.1:29000 -> FRP -> 192.168.58.20:9000`
|
||||
> - `s3.jiansun.vip` 当前已经收口为“预览图优先缓存”:
|
||||
> - `AI 生图平台`:`/gongxiangpan/uploads/` 下的图片对象进入长缓存
|
||||
> - `素材库`:`preview-*`、`display-*`、`model-*`、`video-cover-*`、`audio-cover-*`、`pdf-preview-*`、`product-package/*-preview` 这类预览图片对象进入长缓存
|
||||
> - `video-preview-*.mp4`、`video-clip-*.mp4`、压缩包、源文件下载等大对象继续 `BYPASS`
|
||||
> - `s3.yydd.ai -> 180.97.221.140`,当前只保留为备用 / 回切域名,不再作为默认资源入口维护
|
||||
> - 两端 `WireGuard` 当前都是停用状态,仅保留安装与配置,现网主链路已经回到 `FRP`
|
||||
>
|
||||
> 最后更新:2026-03-19
|
||||
> 本文档优先记录“当前现网已验证的对外行为”和“项目内实际配置”。南京入口机的内层转发细节,如需继续下钻,请以服务器现场配置为准。
|
||||
> 更新时间:2026-05-07
|
||||
> 本文只记录当前正式口径,不再把历史旁路和备用域名混进默认方案。
|
||||
|
||||
## 当前结论
|
||||
## 1. 正式入口
|
||||
|
||||
- 主站主域名:`sc.jiansun.vip -> 180.97.221.140`
|
||||
- 当前主站入口已经切到南京服务器
|
||||
- 旧主站域名:`3d.yydd.ai -> 103.146.230.120`
|
||||
- 现网行为是 `301` 跳转到 `https://sc.jiansun.vip/`
|
||||
- 资源主域名:`s3.jiansun.vip -> 180.97.215.57`
|
||||
- 当前对外资源默认统一使用这个域名
|
||||
- 当前已启用第一层 nginx 磁盘缓存
|
||||
- 资源备用域名:`s3.yydd.ai -> 180.97.221.140`
|
||||
- 当前只作为备用 / 回切域名保留,不再作为默认资源入口
|
||||
- 项目运行配置已经切到新主域名:
|
||||
- `APP_PUBLIC_URL=https://sc.jiansun.vip`
|
||||
- `NEXTAUTH_URL=https://sc.jiansun.vip`
|
||||
- 当前第一层桶缓存实施不依赖 AI 生图平台业务代码改造
|
||||
- 主要改动发生在 `180.97.215.57` 的入口层
|
||||
- 主站:`sc.jiansun.vip -> 103.118.245.6`
|
||||
- 资源 / S3:`s3.jiansun.vip -> 103.118.245.6`
|
||||
- 内网生产机:`192.168.58.20`
|
||||
- 当前公网入口机:香港服务器 `103.118.245.6`
|
||||
- 已停用入口:`180.97.215.57` 不再作为正式资源入口或缓存入口使用。
|
||||
- 历史入口:`180.97.221.140`、`156.239.227.105` 不再作为当前默认排障入口。
|
||||
|
||||
## 当前对外链路
|
||||
## 2. 当前正式链路
|
||||
|
||||
### 1. 主站访问
|
||||
### 2.1 主站
|
||||
|
||||
```text
|
||||
公网用户
|
||||
-> DNS
|
||||
-> sc.jiansun.vip
|
||||
-> 180.97.221.140(南京入口机)
|
||||
-> 当前站点入口
|
||||
-> 登录页 / 首页 / API
|
||||
-> https://sc.jiansun.vip
|
||||
-> 103.118.245.6
|
||||
-> nginx
|
||||
-> 127.0.0.1:2321
|
||||
-> FRP 专用入口
|
||||
-> 192.168.58.20:4320
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 备案完成后,主站对外入口已经从旧方案切到南京服务器
|
||||
- 仓库内没有保存南京入口机完整 nginx / FRP 配置,因此这里只记录已验证的入口行为,不强行推断内层链路
|
||||
- 主站公网入口只认 `sc.jiansun.vip`。
|
||||
- 首页自动切到 `192.168.58.20:4320` 的逻辑已经下线。
|
||||
- `180.97.221.140:2320` 不再作为正式入口链路的一部分。
|
||||
- `sc.jiansun.vip` 的 nginx 必须放开大文件上传:`client_max_body_size 2048m`,并设置较长 `proxy_read_timeout` / `proxy_send_timeout`。2026-05-07 曾因该配置缺失导致 `/api/materials/upload/chunk` 50MB 分片被 nginx 直接返回 `413`,表现为素材上传一直卡住。
|
||||
|
||||
### 2. 旧主站域名兼容
|
||||
### 2.2 资源 / S3
|
||||
|
||||
```text
|
||||
公网用户
|
||||
-> 3d.yydd.ai
|
||||
-> 103.146.230.120
|
||||
-> nginx 301
|
||||
-> https://sc.jiansun.vip/
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `3d.yydd.ai` 现在主要承担旧域名兼容和跳转入口,不再作为默认主站域名使用
|
||||
|
||||
### 3. 资源访问
|
||||
|
||||
```text
|
||||
公网用户
|
||||
-> s3.jiansun.vip
|
||||
-> 180.97.215.57
|
||||
-> nginx cache(仅预览图长缓存,大对象回源)
|
||||
-> 127.0.0.1:29000
|
||||
-> https://s3.jiansun.vip
|
||||
-> 103.118.245.6
|
||||
-> nginx
|
||||
-> 127.0.0.1:2323
|
||||
-> FRP
|
||||
-> 192.168.58.20:9000
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 当前默认资源入口已经切到 `s3.jiansun.vip`
|
||||
- 第一层缓存命中后,省掉的是 `180.97.215.57 <-> FRP <-> 192.168.58.20:9000` 这一整段回源开销
|
||||
- 当前入口层缓存重点服务“打开网页加载图片”这一环节,不接管源文件下载链路
|
||||
- `s3.yydd.ai` 仍可保留,但不再作为默认新流量入口
|
||||
- `180.97.215.57` 已停用,不再作为 `s3.jiansun.vip` 的正式链路。
|
||||
- 当前资源域名和主站域名都收敛到 `103.118.245.6` 入口机。
|
||||
- `s3.jiansun.vip` 是否命中缓存不再作为当前正式验证标准,先以域名可达、FRP 可达、MinIO 回源可达为准。
|
||||
- `s3.yydd.ai` 不再作为正式资源入口。
|
||||
|
||||
## 已验证证据
|
||||
## 3. 展示资源口径
|
||||
|
||||
### 1. 公网 DNS(2026-03-19)
|
||||
### 3.1 正式展示前缀
|
||||
|
||||
- `sc.jiansun.vip -> 180.97.221.140`
|
||||
- `3d.yydd.ai -> 103.146.230.120`
|
||||
- `s3.jiansun.vip -> 180.97.215.57`
|
||||
- `s3.yydd.ai -> 180.97.221.140`
|
||||
- 正式展示图统一落到:`/gongxiangpan/public-display/`
|
||||
|
||||
以上结论已用公网 DoH 实测确认:
|
||||
当前应走这个前缀的对象包括:
|
||||
|
||||
- `https://dns.google/resolve?name=sc.jiansun.vip&type=A`
|
||||
- `https://dns.google/resolve?name=3d.yydd.ai&type=A`
|
||||
- `https://dns.google/resolve?name=s3.jiansun.vip&type=A`
|
||||
- `https://dns.google/resolve?name=s3.yydd.ai&type=A`
|
||||
- 白底图预览
|
||||
- 任务-详情主图 / SKU 图 / 详情图预览
|
||||
- 素材库通用预览图、展示图、模特图等展示型图片
|
||||
- AI 生图平台最终对外展示图
|
||||
|
||||
### 2. HTTP 行为(2026-03-19)
|
||||
### 3.2 不再默认承担展示资源的路径
|
||||
|
||||
- `curl -I https://sc.jiansun.vip`
|
||||
- 返回 `307` 到 `/login`,继续访问可得到 `200 OK`
|
||||
- 响应头包含 `X-Powered-By: Next.js`
|
||||
- `curl -I https://3d.yydd.ai`
|
||||
- 返回 `301 Moved Permanently`
|
||||
- `Location: https://sc.jiansun.vip/`
|
||||
- `curl -I https://s3.jiansun.vip`
|
||||
- 当前返回对象存储风格的 `400 Bad Request`
|
||||
- 命中上传对象路径时可返回 `X-Cache-Status: HIT`
|
||||
- `curl -I https://s3.yydd.ai`
|
||||
- 当前 HTTPS 行为不稳定,实测会出现 `connection reset`
|
||||
- 不应再作为默认资源入口口径
|
||||
|
||||
## 项目内对应配置
|
||||
|
||||
### 1. 主域名配置
|
||||
|
||||
- `docker-compose.yml`
|
||||
- `APP_PUBLIC_URL=https://sc.jiansun.vip`
|
||||
- `NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=https://sc.jiansun.vip`
|
||||
- `NEXTAUTH_URL=https://sc.jiansun.vip`
|
||||
- `lib/public-app-url.ts`
|
||||
- 默认对外主域名是 `https://sc.jiansun.vip`
|
||||
- 旧域名 `https://3d.yydd.ai` 保留在兼容列表中
|
||||
|
||||
### 2. 对外下载链接生成
|
||||
|
||||
- `app/api/external/product-assets/route.ts`
|
||||
- `app/api/external/standard-model-white-background/route.ts`
|
||||
|
||||
这两个外部接口都会通过 `getPublicAppOrigin()` 生成对外下载链接,因此默认会落到 `sc.jiansun.vip`。
|
||||
|
||||
### 3. 资源域名配置
|
||||
|
||||
- `docker-compose.yml`
|
||||
- `S3_PUBLIC_DOMAIN=https://s3.yydd.ai/gongxiangpan`
|
||||
- `lib/url-rewrite.ts`
|
||||
- 仍把 `s3.yydd.ai` 视为当前公网资源域名
|
||||
- `/gongxiangpan/uploads/`
|
||||
|
||||
说明:
|
||||
|
||||
- 以上是当前仓库里的历史代码口径,不代表 AI 生图平台本期第一层缓存实施口径
|
||||
- 本期实际实施口径已经收敛为:`s3.jiansun.vip` 主用,`s3.yydd.ai` 备用
|
||||
- `uploads` 保留为混合区和历史兼容区。
|
||||
- 首期不做历史对象全量迁移。
|
||||
- 新生成展示图直接写 `public-display`,不再指望 `uploads` 承担正式展示路径。
|
||||
|
||||
### 4. 历史南京直连口痕迹
|
||||
### 3.3 明确不作为展示路径的大对象
|
||||
|
||||
- `middleware.ts` 里仍保留 `http://180.97.221.140:2320`
|
||||
- 视频源文件
|
||||
- 视频预览大文件
|
||||
- 压缩包
|
||||
- 其它下载型源文件
|
||||
|
||||
这说明:
|
||||
## 4. 已完成的入口收口
|
||||
|
||||
- 项目内还保留着南京旧 TCP 直连口的兼容痕迹
|
||||
- 但当前对外主站口径应以 `https://sc.jiansun.vip` 为准
|
||||
### 4.1 `103.118.245.6`
|
||||
|
||||
## 口径变更说明
|
||||
- 主站 `nginx upstream` 使用 `127.0.0.1:2321`
|
||||
- 资源 / S3 `nginx upstream` 使用 `127.0.0.1:2323`
|
||||
- FRPS 服务:`frps-jiansun-entry`
|
||||
- FRPS 端口:`7002`
|
||||
- 入口配置:`/etc/nginx/conf.d/cutover-legacy.conf`
|
||||
- FRPS 配置:`/opt/frp/frps-jiansun-entry.toml`
|
||||
- FRPS 日志:`/var/log/frps-jiansun-entry.log`
|
||||
|
||||
- `2026-03-09` 那版文档里把 `103.146.230.120` 写成“当前公网主入口”,这个口径现在已经失效
|
||||
- 当前正确口径应当是:
|
||||
- `sc.jiansun.vip`:主站主域名,当前解析到南京 `180.97.221.140`
|
||||
- `3d.yydd.ai`:旧主站域名,当前保留跳转
|
||||
- `s3.jiansun.vip`:当前资源主域名,解析到 `180.97.215.57`
|
||||
- `s3.yydd.ai`:当前资源备用域名,解析到 `180.97.221.140`
|
||||
- 也就是说,当前已经不是“资源域名暂未切走”,而是“资源默认入口已切到 `s3.jiansun.vip`,`s3.yydd.ai` 只保留备用”
|
||||
### 4.2 `180.97.221.140`
|
||||
|
||||
## 当前风险与待确认项
|
||||
- 旧 `127.0.0.1:2320 backup` 已移除
|
||||
- `frps` 已不再开放 `2320` / `2330`
|
||||
- 防火墙已移除 `2320/tcp` 和 `2330/tcp`
|
||||
- 不再作为当前默认公网入口。
|
||||
|
||||
- 主站入口和资源入口分属不同公网 IP,网络问题不能混看
|
||||
- 仓库内未保存南京入口机完整的现场配置
|
||||
- 如果后续要继续追 `sc.jiansun.vip` 的内层代理路径,需要直接登录 `180.97.221.140` 核对
|
||||
- `s3.yydd.ai` 历史引用仍存在
|
||||
- 这不会阻塞第一层桶缓存实施
|
||||
- 但后续仍需要单独治理历史 URL 残留
|
||||
- 当前第一层缓存已经扩展为“预览图优先缓存”
|
||||
- 已纳入:`/gongxiangpan/uploads/` 图片、素材库预览图、`product-package/*-preview`
|
||||
- 仍未纳入:`video-preview-*.mp4`、`video-clip-*.mp4`、压缩包、源文件下载
|
||||
- 如果后续还要优化 `image-proxy` 或 `proxy-media` 自身处理耗时,需要单独做二期
|
||||
### 4.3 `192.168.58.20`
|
||||
|
||||
## 推荐验证命令
|
||||
- `/etc/frp/frpc.toml` 中旧 `2320 -> 4320`、`2330 -> 9000` 映射已移除
|
||||
- `frpc-sc-entry` 仍保留正式主站 `2321 -> 4320`
|
||||
- `frpc-s3-jiansun-entry` 保留正式资源入口 `2323 -> 9000`
|
||||
- `frpc-s3-entry` 面向 `180.97.215.57` 的旧资源入口不再作为正式链路使用
|
||||
- `frpc-sc-health-check` 必须检查 `103.118.245.6:7002`
|
||||
- 2026-05-07 曾因 `/root/frp/frpc-sc-health-check.sh` 仍检查旧入口 `156.239.227.105:7002`,误判正式 `frpc-sc-entry` 异常并约每 70 秒重启一次,导致公网上传从 `413` 修复后继续出现 `502`、`499`、`408` 和卡住。
|
||||
- 已在线修正为 `FRP_SERVER_HOST="103.118.245.6"`,备份:`/root/frp/frpc-sc-health-check.sh.bak-20260507133353`。
|
||||
|
||||
### 公网 DNS 验证
|
||||
|
||||
```bash
|
||||
curl -sS "https://dns.google/resolve?name=sc.jiansun.vip&type=A"
|
||||
curl -sS "https://dns.google/resolve?name=3d.yydd.ai&type=A"
|
||||
curl -sS "https://dns.google/resolve?name=s3.jiansun.vip&type=A"
|
||||
curl -sS "https://dns.google/resolve?name=s3.yydd.ai&type=A"
|
||||
```
|
||||
|
||||
### HTTP 行为验证
|
||||
### 4.4 `180.97.215.57`
|
||||
|
||||
- 已停用,不再作为 `s3.jiansun.vip` 的正式入口。
|
||||
- 相关 `180G` nginx 磁盘缓存方案只作为历史记录保留,不再作为当前上线或排障门禁。
|
||||
|
||||
## 5. 当前验证标准
|
||||
|
||||
### 5.1 主站
|
||||
|
||||
- `https://sc.jiansun.vip` 应能通过 `103.118.245.6 -> nginx -> 127.0.0.1:2321 -> FRP -> 192.168.58.20:4320` 返回页面。
|
||||
- 如果公网直接超时,优先检查 `103.118.245.6` 的公网可达性、`nginx`、`frps-jiansun-entry` 和生产机 `frpc-sc-entry`。
|
||||
- 上传排障要同时看 `103.118.245.6:/var/log/nginx/error.log`,如果出现 `client intended to send too large body`,说明入口机 `sc.jiansun.vip` 的上传 body 限制配置丢失或未生效。
|
||||
- 如果不再出现 `413`,但 `/api/materials/upload/chunk` 仍出现 `502`、`499`、`408`,继续检查生产机 `frpc-sc-health-check.service` 是否又用旧入口误判并重启 `frpc-sc-entry.service`。
|
||||
|
||||
### 5.2 资源 / S3
|
||||
|
||||
- `https://s3.jiansun.vip` 应能通过 `103.118.245.6 -> nginx -> 127.0.0.1:2323 -> FRP -> 192.168.58.20:9000` 回源。
|
||||
- 不再使用 `180.97.215.57` 或 `127.0.0.1:29000` 作为正式资源验证链路。
|
||||
|
||||
### 5.3 历史缓存验证
|
||||
|
||||
- 过去关于 `X-Cache-Status: MISS/HIT/BYPASS` 的验证结果属于 `180.97.215.57` 缓存方案历史记录。
|
||||
- 当前排障不要再把 `180.97.215.57` 缓存层作为默认检查项。
|
||||
|
||||
## 6. 当前对外说明口径
|
||||
|
||||
- 主站统一写:`https://sc.jiansun.vip`
|
||||
- 资源统一写:`https://s3.jiansun.vip`
|
||||
- 展示资源统一写:`https://s3.jiansun.vip/gongxiangpan/public-display/...`
|
||||
|
||||
不要再把下面这些写成正式入口:
|
||||
|
||||
- `s3.yydd.ai`
|
||||
- `http://180.97.221.140:2320`
|
||||
- `http://180.97.221.140:2330`
|
||||
- `http://156.239.227.105:7002`
|
||||
- `http://192.168.58.20:4320`
|
||||
- `http://180.97.215.57`
|
||||
|
||||
## 7. 建议验证命令
|
||||
|
||||
```bash
|
||||
# 主站
|
||||
curl -I https://sc.jiansun.vip
|
||||
curl -I https://3d.yydd.ai
|
||||
curl -I https://s3.jiansun.vip
|
||||
curl -I https://s3.yydd.ai
|
||||
|
||||
# 展示资源
|
||||
curl -I https://s3.jiansun.vip/gongxiangpan/public-display/network-check/20260325-cache-test.png
|
||||
|
||||
# 大文件访问
|
||||
curl -I https://s3.jiansun.vip/gongxiangpan/由壹点AI/video-preview-720p-9b1c86b6-0249-44ae-98fe-e480f5f5ac2c-1767153103562.mp4
|
||||
```
|
||||
|
||||
### 跳转结果验证
|
||||
|
||||
```bash
|
||||
curl -I -L https://3d.yydd.ai
|
||||
curl -I -L https://sc.jiansun.vip
|
||||
```
|
||||
|
||||
## 当前建议
|
||||
|
||||
- 对外沟通时,主站统一写 `https://sc.jiansun.vip`
|
||||
- 旧域名 `https://3d.yydd.ai` 只作为兼容跳转说明,不再作为默认示例地址
|
||||
- 资源直链默认统一写 `https://s3.jiansun.vip`
|
||||
- `https://s3.yydd.ai` 只作为备用 / 回切说明,不再作为默认示例地址
|
||||
- 当前建议继续维持:
|
||||
- `180.97.215.57`:只做 `s3.jiansun.vip` 的预览图入口缓存
|
||||
- `221.140` 剩余 `90G`:暂不重复缓存同一层原图,后续如要继续优化,再考虑给 `image-proxy / proxy-media` 结果层使用
|
||||
|
||||
34
docs/前端UI改动清单.md
Normal file
34
docs/前端UI改动清单.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 前端 UI 改动清单
|
||||
|
||||
## 当前进行中
|
||||
- [x] **标题栏商品搜索重构 (HeaderTaobaoSearch)**
|
||||
- [x] 交互升级:从常驻输入框改为图标按钮触发的**拉出式 (Pull-out) 输入框 + 下拉面板**模式。
|
||||
- [x] 主题适配:完美支持深浅色模式切换。
|
||||
- [x] 功能增强:支持全量查看主图、SKU图、详情图。
|
||||
- [x] 导出功能:支持带图片嵌入的 Excel 导出。
|
||||
- [x] 头部挂载完成:入口已放在标题栏 `网络状态` 前。
|
||||
- [x] 后端对接完成:前端统一走 `/api/taobao-detail-plus?itemId=...`,不直连第三方,不暴露 `API Key`。
|
||||
- [x] 查询链路完成:服务端代理、超时控制、错误处理、商品 ID 提取已收口。
|
||||
- [x] 影子环境联调完成:`http://192.168.58.20:5320`
|
||||
- [x] 联调测试商品:`867081550619`
|
||||
|
||||
## 当前对接口径
|
||||
- 这份文档就是 Gemini 改完后的前端交接清单,后续以它作为标题栏商品搜索的唯一对接入口。
|
||||
- 当前代码文件:
|
||||
- `components/Header.tsx`
|
||||
- `components/HeaderTaobaoSearch.tsx`
|
||||
- `components/TaobaoImageGalleryModal.tsx`
|
||||
- `app/api/taobao-detail-plus/route.ts`
|
||||
- `lib/taobao-detail-plus.ts`
|
||||
- 当前已确认边界:
|
||||
- 前端只调站内接口 `/api/taobao-detail-plus`
|
||||
- 不把第三方 `API Key` 放前端
|
||||
- 不把这块能力迁到 `/api/external/*`
|
||||
- 当前阶段先保持“商品搜索 + 结果查看 + 全量图片查看/导出”,不顺手扩成上传、落库或批量导图
|
||||
|
||||
## 待处理
|
||||
- (暂无)
|
||||
|
||||
## 已完成 (最近)
|
||||
- 标题栏商品搜索功能重构 (2026-04-22)
|
||||
- 标题栏商品搜索后端对接、影子环境部署与联调 (2026-04-22)
|
||||
199
docs/生产存储桶接入信息.md
Normal file
199
docs/生产存储桶接入信息.md
Normal file
@ -0,0 +1,199 @@
|
||||
# 生产存储桶接入信息
|
||||
|
||||
适用范围:本文记录当前线上 `MinIO(对象存储)` / `S3(对象存储兼容接口)` 的已确认接入信息,供其它项目接入同一个生产存储桶时参考。
|
||||
|
||||
最后核对时间:`2026-04-14`
|
||||
核对方式:生产服务器只读核对 + 生产应用容器环境变量核对 + 宿主机挂载路径核对
|
||||
|
||||
## 1. 已确认的生产接入信息
|
||||
|
||||
### 1.1 存储服务
|
||||
|
||||
- 存储服务:`MinIO(对象存储服务)`
|
||||
- 生产服务器:`192.168.58.20`
|
||||
- API 端口:`9000`
|
||||
- 控制台端口:`9001`
|
||||
- 线上应用容器:`youyidian-3d-material`
|
||||
- 存储服务容器:`minio`
|
||||
|
||||
### 1.2 S3 兼容接入参数
|
||||
|
||||
- `S3_ENDPOINT(对象存储接口地址)`:`http://192.168.58.20:9000`
|
||||
- `S3_BUCKET(桶名)`:`gongxiangpan`
|
||||
- `S3_REGION(区域)`:`us-east-1`
|
||||
- `S3_ENABLE_PATH_STYLE(路径风格访问)`:`1`
|
||||
- `S3_SET_ACL(上传后写 public-read)`:`1`
|
||||
- `S3_PREVIEW_URL_EXPIRE_IN(预览链接过期秒数)`:`7200`
|
||||
|
||||
### 1.3 对外访问域名
|
||||
|
||||
- `S3_PUBLIC_DOMAIN(公开访问域名)`:`https://s3.jiansun.vip/gongxiangpan`
|
||||
|
||||
当前项目最终对象 URL(访问地址)规则:
|
||||
|
||||
```text
|
||||
https://s3.jiansun.vip/gongxiangpan/<前缀><文件名>
|
||||
```
|
||||
|
||||
例如当前项目使用的前缀是:
|
||||
|
||||
- `S3_FILE_PATH(对象前缀)`:`由壹点AI/`
|
||||
|
||||
所以当前项目上传出来的对象通常长这样:
|
||||
|
||||
```text
|
||||
https://s3.jiansun.vip/gongxiangpan/由壹点AI/<文件名>
|
||||
```
|
||||
|
||||
## 2. 宿主机真实挂载路径
|
||||
|
||||
### 2.1 MinIO 数据目录
|
||||
|
||||
- 宿主机数据路径:`/mnt/tank/apps/minio/data`
|
||||
- 容器内路径:`/data`
|
||||
|
||||
### 2.2 MinIO 配置目录
|
||||
|
||||
- 宿主机配置路径:`/mnt/tank/apps/minio/config`
|
||||
- 容器内路径:`/root/.minio`
|
||||
|
||||
### 2.3 当前项目本地上传目录
|
||||
|
||||
这个不是存储桶,但很多视频源文件会先落这里:
|
||||
|
||||
- 宿主机路径:`/mnt/PoolOne/apps/youyidian-3d/uploads`
|
||||
- 容器内路径:`/app/uploads`
|
||||
|
||||
## 3. 新项目接入时建议怎么用
|
||||
|
||||
### 3.1 可以共用什么
|
||||
|
||||
新项目如果要共用这一个生产桶,可以共用这些固定信息:
|
||||
|
||||
- `S3_ENDPOINT`
|
||||
- `S3_BUCKET`
|
||||
- `S3_REGION`
|
||||
- `S3_PUBLIC_DOMAIN`
|
||||
- `path-style(路径风格)`
|
||||
|
||||
### 3.2 不建议直接共用什么
|
||||
|
||||
下面两项不建议直接原样复用:
|
||||
|
||||
- 当前项目的 `S3_FILE_PATH(对象前缀)`
|
||||
- 当前项目正在使用的长期访问密钥
|
||||
|
||||
原因很直接:
|
||||
|
||||
1. 如果多个项目共用同一个前缀,文件名一旦撞上,后面排查和清理会很乱。
|
||||
2. 如果多个项目长期共用同一套生产密钥,任何一个项目泄露,整个生产桶都会一起暴露。
|
||||
|
||||
### 3.3 推荐接法
|
||||
|
||||
新项目建议这样配:
|
||||
|
||||
```env
|
||||
S3_ENDPOINT=http://192.168.58.20:9000
|
||||
S3_BUCKET=gongxiangpan
|
||||
S3_REGION=us-east-1
|
||||
S3_ENABLE_PATH_STYLE=1
|
||||
S3_PUBLIC_DOMAIN=https://s3.jiansun.vip/gongxiangpan
|
||||
S3_FILE_PATH=<新项目自己的前缀>/
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```env
|
||||
S3_FILE_PATH=新项目名/
|
||||
```
|
||||
|
||||
或者:
|
||||
|
||||
```env
|
||||
S3_FILE_PATH=业务线A/新项目名/
|
||||
```
|
||||
|
||||
## 4. 密钥处理建议
|
||||
|
||||
风险等级:`高`
|
||||
|
||||
当前生产环境里确实存在可用的 `Access Key(访问密钥)` / `Secret Key(私钥)`,但本文不直接记录明文。
|
||||
|
||||
不建议:
|
||||
|
||||
- 直接把 `MinIO Root(管理员根账号)` 给业务项目使用
|
||||
- 让多个项目长期共用同一套生产访问密钥
|
||||
|
||||
建议:
|
||||
|
||||
1. 给每个新项目单独发一套对象存储访问密钥
|
||||
2. 至少给每个项目分独立前缀
|
||||
3. 如果条件允许,进一步按项目拆权限策略
|
||||
|
||||
## 5. 如果必须核对线上实际配置
|
||||
|
||||
### 5.1 看当前应用实际在用的 S3 配置
|
||||
|
||||
```powershell
|
||||
ssh root@192.168.58.20 "docker exec youyidian-3d-material sh -lc 'env'"
|
||||
```
|
||||
|
||||
然后重点看:
|
||||
|
||||
- `S3_ENDPOINT`
|
||||
- `S3_BUCKET`
|
||||
- `S3_REGION`
|
||||
- `S3_PUBLIC_DOMAIN`
|
||||
- `S3_FILE_PATH`
|
||||
- `S3_ACCESS_KEY_ID`
|
||||
- `S3_SECRET_ACCESS_KEY`
|
||||
|
||||
### 5.2 看 MinIO 挂载
|
||||
|
||||
```powershell
|
||||
ssh root@192.168.58.20 "docker inspect minio --format '{{json .Mounts}}'"
|
||||
```
|
||||
|
||||
### 5.3 看 MinIO 本机配置文件
|
||||
|
||||
```powershell
|
||||
ssh root@192.168.58.20 "sed -n '1,200p' /mnt/tank/apps/minio/config/minio.env"
|
||||
```
|
||||
|
||||
## 6. 当前结论
|
||||
|
||||
- 生产对象存储实例可以复用
|
||||
- 生产桶名已经确认是 `gongxiangpan`
|
||||
- 公开访问域名已经确认是 `https://s3.jiansun.vip/gongxiangpan`
|
||||
- 当前项目对象前缀是 `由壹点AI/`
|
||||
- 其它项目如果接入,建议“共用桶,不共用前缀,不共用长期密钥”
|
||||
|
||||
## 7. 另一个项目临时接入说明
|
||||
|
||||
已确认另一个项目使用下面这套固定配置:
|
||||
|
||||
- `S3_ENDPOINT=http://192.168.58.20:9000`
|
||||
- `S3_BUCKET=gongxiangpan`
|
||||
- `S3_REGION=us-east-1`
|
||||
- `S3_ENABLE_PATH_STYLE=1`
|
||||
- `S3_PUBLIC_DOMAIN=https://s3.jiansun.vip/gongxiangpan`
|
||||
- `S3_FILE_PATH=3danzhuang/furniture-3d-system/`
|
||||
|
||||
风险等级:`高`
|
||||
|
||||
说明:
|
||||
|
||||
- 另一个项目当前要求的是“先能接上生产桶”
|
||||
- 目前本项目已经掌握现有生产项目使用中的对象存储访问密钥
|
||||
- 但把明文密钥直接写进仓库文档不安全,后续极易被提交扩散
|
||||
|
||||
因此实际可用的临时接入环境变量,已单独写入本地忽略文件:
|
||||
|
||||
- [`.env.furniture-3d-system.local`](/D:/AI%20Code/gongxiangp2/.env.furniture-3d-system.local)
|
||||
|
||||
这份文件可直接给另一个项目参考或落到新服务器。
|
||||
|
||||
不建议长期这样复用当前生产项目密钥。更稳的最终方案仍然是:
|
||||
|
||||
1. 给另一个项目单独创建一套 `Access Key / Secret Key(访问密钥 / 密钥)`
|
||||
2. 继续保留它自己的对象前缀 `3danzhuang/furniture-3d-system/`
|
||||
@ -1,115 +1,85 @@
|
||||
# 素材库平台预览缓存方案
|
||||
|
||||
> 生效时间:2026-03-19
|
||||
> 当前入口:`s3.jiansun.vip -> 180.97.215.57`
|
||||
> 当前目标:只优化“打开网页加载预览图”的体验,不让大源文件占满 `215.57` 的 `180G` 磁盘
|
||||
> 更新时间:2026-04-29
|
||||
> 状态:`180.97.215.57` 缓存入口已停用,本文保留历史方案和当前展示资源口径。
|
||||
|
||||
## 1. 当前实施口径
|
||||
## 1. 当前口径
|
||||
|
||||
- 桶本体不动,仍在内网 `192.168.58.20:9000`
|
||||
- `180.97.215.57` 只做入口层 `nginx` 磁盘缓存
|
||||
- 缓存目录:`/data/nginx-cache/s3`
|
||||
- 缓存上限:`180g`
|
||||
- 回源链路保持不变:
|
||||
- 资源入口:`s3.jiansun.vip -> 180.97.221.140`
|
||||
- 桶本体:`192.168.58.20:9000`
|
||||
- 已停用缓存入口:`180.97.215.57`
|
||||
- 旧缓存目录:`/data/nginx-cache/s3`,不再作为当前正式链路门禁
|
||||
- 旧缓存上限:`180g`,只作为历史记录保留
|
||||
|
||||
```text
|
||||
公网用户
|
||||
-> s3.jiansun.vip
|
||||
-> 180.97.215.57 nginx cache
|
||||
-> 127.0.0.1:29000
|
||||
-> FRP
|
||||
-> 192.168.58.20:9000
|
||||
```
|
||||
## 2. 正式展示缓存路径
|
||||
|
||||
## 2. 现在会进缓存的对象
|
||||
### 2.1 默认展示路径
|
||||
|
||||
### AI 生图平台
|
||||
- `/gongxiangpan/public-display/`
|
||||
|
||||
- `/gongxiangpan/uploads/*.png`
|
||||
- `/gongxiangpan/uploads/*.jpg`
|
||||
- `/gongxiangpan/uploads/*.jpeg`
|
||||
- `/gongxiangpan/uploads/*.webp`
|
||||
这里存放:
|
||||
|
||||
### 素材库平台预览图
|
||||
- 素材库卡片预览图
|
||||
- 展示图
|
||||
- 白底图预览
|
||||
- 任务-详情主图 / SKU 图 / 详情图预览
|
||||
|
||||
- `/gongxiangpan/{前缀}/preview-*`
|
||||
- `/gongxiangpan/{前缀}/display-*`
|
||||
- `/gongxiangpan/{前缀}/model-*`
|
||||
- `/gongxiangpan/{前缀}/video-cover-*`
|
||||
- `/gongxiangpan/{前缀}/audio-cover-*`
|
||||
- `/gongxiangpan/{前缀}/pdf-preview-*`
|
||||
- `/gongxiangpan/{前缀}/product-package/main-preview-*`
|
||||
- `/gongxiangpan/{前缀}/product-package/sku-preview-*`
|
||||
- `/gongxiangpan/{前缀}/product-package/detail-preview-*`
|
||||
### 2.2 继续保留的历史预览规则
|
||||
|
||||
- `preview-*`
|
||||
- `display-*`
|
||||
- `model-*`
|
||||
- `video-cover-*`
|
||||
- `audio-cover-*`
|
||||
- `pdf-preview-*`
|
||||
- `product-package/main-preview-*`
|
||||
- `product-package/sku-preview-*`
|
||||
- `product-package/detail-preview-*`
|
||||
|
||||
说明:
|
||||
|
||||
- `{前缀}` 现在实际常见值是 `由壹点AI`
|
||||
- 这些对象对应的就是素材库首页卡片、详情页预览、任务详情主图/SKU 图/详情图、视频封面等“看图”链路
|
||||
- 这些规则先保留,是为了兼容历史对象。
|
||||
- 新写入展示图应优先走 `public-display`。
|
||||
|
||||
## 3. 现在明确不进缓存的对象
|
||||
## 3. 明确不走展示缓存的对象
|
||||
|
||||
- `video-preview-*.mp4`
|
||||
- `video-clip-*.mp4`
|
||||
- 各类压缩包
|
||||
- `/gongxiangpan/uploads/` 下的一般混合对象
|
||||
- 视频大文件
|
||||
- 压缩包
|
||||
- 源文件下载
|
||||
- 其它未命中预览图规则的大对象
|
||||
|
||||
这样做的原因很简单:
|
||||
## 4. 历史缓存策略
|
||||
|
||||
- 用户打开网页时最影响体验的是“预览图能不能秒开”
|
||||
- 大视频、大压缩包、源文件下载虽然也能缓存,但会很快吃掉 `215.57` 的磁盘空间
|
||||
- 当前更稳的做法是先把图片预览层吃满,再把大对象和下载链路留在原有模式
|
||||
- 下面规则属于 `180.97.215.57` 缓存方案的历史记录。
|
||||
- 当前正式链路不再用 `X-Cache-Status` 作为排障门禁。
|
||||
- 当前优先验证 `s3.jiansun.vip -> 180.97.221.140 -> 192.168.58.20:9000` 是否可达。
|
||||
|
||||
## 4. 当前缓存策略
|
||||
## 5. 历史验证结果
|
||||
|
||||
- 只缓存 `GET` / `HEAD`
|
||||
- `200/206/301/302`:`30d`
|
||||
- `404`:`1m`
|
||||
- 开启:
|
||||
- `proxy_cache_lock on`
|
||||
- `proxy_cache_background_update on`
|
||||
- `proxy_cache_revalidate on`
|
||||
- `proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504`
|
||||
- 响应头保留:`X-Cache-Status`
|
||||
### 5.1 展示缓存命中
|
||||
|
||||
## 5. 已验结果
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/20260325-cache-test.png`
|
||||
- 第一次:`MISS`
|
||||
- 第二次:`HIT`
|
||||
|
||||
### 预览图命中验证
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/limit-exact-10m-1774428109043.png`
|
||||
- `Content-Length: 10485760`
|
||||
- 第一次:`MISS`
|
||||
- 第二次:`HIT`
|
||||
|
||||
- `uploads` 图片样本:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/uploads/1773890958454-wttd7k.png`
|
||||
- 首次请求:`MISS`
|
||||
- 再次请求:`HIT`
|
||||
### 5.2 大文件绕过
|
||||
|
||||
- 素材库 `product-package` 预览图样本:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/product-package/main-preview-1772766064017-0-wy0tr3j.webp`
|
||||
- 首次请求:`MISS`
|
||||
- 再次请求:`HIT`
|
||||
- `https://s3.jiansun.vip/gongxiangpan/由壹点AI/video-preview-720p-9b1c86b6-0249-44ae-98fe-e480f5f5ac2c-1767153103562.mp4`
|
||||
- 结果:`BYPASS`
|
||||
|
||||
### 大对象绕过验证
|
||||
- `https://s3.jiansun.vip/gongxiangpan/public-display/network-check/limit-over-10m-1774428109043.png`
|
||||
- `Content-Length: 10485761`
|
||||
- 第一次:`BYPASS`
|
||||
- 第二次:`BYPASS`
|
||||
|
||||
- 视频预览样本:
|
||||
- `https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/video-preview-720p-9b1c86b6-0249-44ae-98fe-e480f5f5ac2c-1767153103562.mp4`
|
||||
- 连续两次请求:`BYPASS`
|
||||
## 6. 使用建议
|
||||
|
||||
## 6. 对用户体验的实际影响
|
||||
|
||||
- 命中缓存后,改善的是“打开网页加载图片”这一步
|
||||
- 首页卡片、详情页看图、任务详情里的主图/SKU 图/详情图,会优先受益
|
||||
- 下载源文件、下载压缩包,不会因为这次缓存而改链路
|
||||
- 不会额外制造两套 URL
|
||||
- 对用户暴露的仍然是原来的 `s3.jiansun.vip`
|
||||
- 命中缓存只是 `215.57` 在入口层直接回图,不再每次都回源
|
||||
|
||||
## 7. 221.140 的 90G 暂定口径
|
||||
|
||||
- 暂时不要在 `221.140` 上重复做同一层桶原图缓存
|
||||
- 否则两台机器会缓存同一批对象,空间利用率不高
|
||||
- 后续如果还要继续优化,再优先考虑把这 `90G` 留给:
|
||||
- `image-proxy` 结果缓存
|
||||
- `proxy-media` 结果缓存
|
||||
|
||||
结论:
|
||||
|
||||
`215.57` 现在已经适合承担“统一资源入口 + 预览图缓存层”这个角色;素材库平台和 AI 生图平台都会受益,但大源文件不会继续挤占这 180G。
|
||||
- 以后只要是“页面上要展示给人看”的成品图,就优先写 `public-display`
|
||||
- 只要是“下载型、源文件型、大对象”,就不要硬塞进展示资源口径
|
||||
- 对外说明时,统一写 `s3.jiansun.vip`,不要再把 `s3.yydd.ai` 当正式入口
|
||||
- 排障时不要再默认检查 `180.97.215.57`,先检查 `180.97.221.140` 入口和 FRP 回源链路
|
||||
|
||||
346
docs/统一流量访问规则.md
346
docs/统一流量访问规则.md
@ -1,311 +1,103 @@
|
||||
# 统一流量访问规则
|
||||
|
||||
> 现网快照(2026-03-18):
|
||||
> - 当前公网资源主域名优先使用 `https://s3.jiansun.vip/gongxiangpan`
|
||||
> - 生产机业务访问 MinIO 继续走内网 `http://192.168.58.20:9000`
|
||||
> - `s3.jiansun.vip` 当前实际入口是 `180.97.215.57`,并且已经启用 `nginx` 磁盘缓存
|
||||
> - `s3.yydd.ai` 现在在南京机上反代到 `s3.jiansun.vip`,不再作为独立桶入口维护
|
||||
>
|
||||
## 📋 规则总览
|
||||
> 更新时间:2026-04-29
|
||||
> 目标:统一正式入口、展示资源口径和内外网分工,减少历史残留带来的分流与误判。
|
||||
|
||||
**核心原则**:
|
||||
- ✅ **内网用户**:全部直接访问内网MinIO或本地文件(不走代理,最快)
|
||||
- ✅ **外网用户**:根据文件类型和大小智能选择访问方式
|
||||
- ✅ **服务器操作**:全部使用内网地址访问MinIO(不走FRP)
|
||||
## 1. 统一原则
|
||||
|
||||
---
|
||||
- 公网用户统一走正式域名,不再自动切内网。
|
||||
- 服务端访问 MinIO 继续走内网 `192.168.58.20:9000`。
|
||||
- 展示型图片统一写入 `/gongxiangpan/public-display/`。
|
||||
- `/gongxiangpan/uploads/` 保留为混合区,不再默认承担展示资源职责。
|
||||
- `180.97.215.57` 已停用,不再作为正式资源入口或缓存入口。
|
||||
|
||||
## 📊 完整流量规则表
|
||||
## 2. 正式入口
|
||||
|
||||
### 1. 预览文件(首页/详情页显示)
|
||||
| 场景 | 正式入口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 主站 | `https://sc.jiansun.vip` | 唯一正式主站入口 |
|
||||
| 资源 / S3 | `https://s3.jiansun.vip` | 唯一正式资源入口 |
|
||||
| 内网生产机 | `192.168.58.20` | 只给服务端和内网运维链路使用 |
|
||||
|
||||
| 文件类型 | 存储位置 | 内网用户访问 | 外网用户访问 | 文件大小 | 规则依据 |
|
||||
|---------|---------|------------|------------|---------|---------|
|
||||
| **预览图** (previewUrl) | MinIO S3 | 直接访问内网MinIO<br>`http://192.168.58.20:9000/...` | 直接访问S3<br>`https://s3.yydd.ai/...` | <500KB | 文件小,直接访问可接受 |
|
||||
| **展示图** (displayImages) | MinIO S3 | 直接访问内网MinIO | 直接访问S3 | <500KB | 文件小,直接访问可接受 |
|
||||
| **视频封面** (videoCoverUrl) | MinIO S3 | 直接访问内网MinIO | 直接访问S3 | <500KB | 文件小,直接访问可接受 |
|
||||
| **视频预览片段** (videoPreviewClipUrl) | MinIO S3 | 直接访问内网MinIO | **通过代理**<br>`/api/proxy-media?url=...` | 5-10MB | 文件大,通过代理优化 |
|
||||
| **视频预览720P** (videoPreview540Url) | MinIO S3 | 直接访问内网MinIO | **通过代理**<br>`/api/proxy-media?url=...` | 10-20MB | 文件大,通过代理优化 |
|
||||
| **音频预览** (zipFileName) | 本地/S3 | 直接访问本地<br>`/uploads/audio-source-xxx.mp3`<br>或内网MinIO | **通过代理**<br>`/api/proxy-media?url=...`<br>或`/api/uploads/audio-source-xxx.mp3` | 2-10MB | 文件大,通过代理优化 |
|
||||
## 3. 访问规则
|
||||
|
||||
### 2. 下载文件(用户主动下载)
|
||||
### 3.1 公网用户
|
||||
|
||||
| 文件类型 | 存储位置 | 内网用户访问 | 外网用户访问 | 文件大小 | 规则依据 |
|
||||
|---------|---------|------------|------------|---------|---------|
|
||||
| **视频源文件** (videoSourceUrl) | 本地 `uploads/` | 直接下载<br>`/api/materials/download?id=xxx` | 通过下载API<br>`/api/materials/download?id=xxx` | 100MB-2GB | 必须通过API(权限控制) |
|
||||
| **音频源文件** (zipFileName) | 本地 `uploads/` | 直接下载<br>`/api/materials/download?id=xxx` | 通过下载API<br>`/api/materials/download?id=xxx` | 5-50MB | 必须通过API(权限控制) |
|
||||
| **压缩包** (zipFilePath) | 本地 `uploads/` | 直接下载<br>`/api/materials/download?id=xxx` | 通过下载API<br>`/api/materials/download?id=xxx` | 10MB-1GB | 必须通过API(权限控制) |
|
||||
| **老素材S3源文件** (zipFileName) | MinIO S3 | 通过下载API<br>服务器内网获取 | 通过下载API<br>服务器内网获取 | 10MB-1GB | 必须通过API(权限控制+内网优化) |
|
||||
| **产品全案资产** (MaterialAsset) | MinIO S3/本地 | 通过下载API<br>服务器内网获取 | 通过下载API<br>服务器内网获取 | 1-50MB | 必须通过API(权限控制+内网优化) |
|
||||
- 打开网站:走 `https://sc.jiansun.vip`
|
||||
- 看图:走 `https://s3.jiansun.vip/gongxiangpan/public-display/...`
|
||||
- 下载大文件:继续走业务下载接口或原有下载链路
|
||||
|
||||
### 3. 服务器操作(上传/处理)
|
||||
### 3.2 内网服务端
|
||||
|
||||
| 操作类型 | 目标位置 | 当前访问方式 | 优化后访问方式 | 规则依据 |
|
||||
|---------|---------|------------|--------------|---------|
|
||||
| **上传预览图到S3** | MinIO S3 | ❌ 通过FRP<br>`https://s3.yydd.ai` | ✅ 内网直连<br>`http://192.168.58.20:9000` | 消除FRP浪费 |
|
||||
| **上传视频转码文件到S3** | MinIO S3 | ❌ 通过FRP | ✅ 内网直连 | 消除FRP浪费 |
|
||||
| **从S3获取文件(下载API)** | MinIO S3 | ❌ 通过FRP | ✅ 内网直连 | 消除双重FRP |
|
||||
| **产品全案从S3获取资产** | MinIO S3 | ❌ 通过FRP | ✅ 内网直连 | 消除双重FRP |
|
||||
- 上传到 MinIO:走 `http://192.168.58.20:9000`
|
||||
- 下载 MinIO 对象:走 `http://192.168.58.20:9000`
|
||||
- 不通过公网域名回自己
|
||||
|
||||
---
|
||||
### 3.3 内网用户
|
||||
|
||||
## 🎯 统一规则制定
|
||||
- 正式访问口径同样优先走正式域名
|
||||
- 只有诊断、运维或服务端内部访问,才直接使用内网地址
|
||||
|
||||
### 规则1: 文件大小阈值
|
||||
## 4. 写入规则
|
||||
|
||||
| 文件大小 | 访问方式 | 说明 |
|
||||
|---------|---------|------|
|
||||
| **< 1MB** | 直接访问S3 | 小文件,FRP带宽足够 |
|
||||
| **1MB - 5MB** | 根据文件类型决定 | 图片直接访问,视频/音频通过代理 |
|
||||
| **> 5MB** | 通过代理 | 大文件,必须通过代理优化 |
|
||||
| 内容 | 写入位置 | 是否进入展示缓存 |
|
||||
| --- | --- | --- |
|
||||
| 白底图预览 | `/gongxiangpan/public-display/white-background/...` | 是 |
|
||||
| 任务-详情预览 | `/gongxiangpan/public-display/task-detail/...` | 是 |
|
||||
| 通用预览 / 展示图 | `/gongxiangpan/public-display/materials/...` | 是 |
|
||||
| 历史混合对象 | `/gongxiangpan/uploads/...` | 否,默认不按展示缓存处理 |
|
||||
| 压缩包 / 视频源文件 / 大下载对象 | 原业务路径 | 否 |
|
||||
|
||||
### 规则2: 文件类型分类
|
||||
## 5. 展示资源规则
|
||||
|
||||
| 文件类型 | 典型大小 | 外网访问方式 | 原因 |
|
||||
|---------|---------|------------|------|
|
||||
| **图片** (jpg/png/webp) | <500KB | 直接访问S3 | 文件小,FRP带宽足够 |
|
||||
| **视频预览** (mp4) | 5-20MB | **通过代理** | 文件大,需要优化 |
|
||||
| **音频** (mp3) | 2-10MB | **通过代理** | 文件大,需要优化 |
|
||||
| **源文件** (zip/mp4) | 10MB-2GB | 通过下载API | 必须通过API(权限+优化) |
|
||||
### 5.1 默认展示对象
|
||||
|
||||
### 规则3: 用户网络位置
|
||||
- `/gongxiangpan/public-display/` 下的图片对象
|
||||
- 现有素材库预览图
|
||||
- 现有 `product-package/*-preview` 图片对象
|
||||
|
||||
| 用户类型 | 预览文件访问 | 下载文件访问 | 说明 |
|
||||
|---------|------------|------------|------|
|
||||
| **内网用户** | 直接访问内网MinIO<br>`http://192.168.58.20:9000` | 通过下载API<br>(服务器内网获取) | 不走代理,最快 |
|
||||
| **外网用户** | 根据文件类型智能选择 | 通过下载API<br>(服务器内网获取) | 大文件走代理,小文件直接访问 |
|
||||
### 5.2 明确不作为展示资源处理的对象
|
||||
|
||||
---
|
||||
- 视频预览大文件
|
||||
- 视频源文件
|
||||
- 压缩包
|
||||
- 其它下载型大文件
|
||||
|
||||
## 🔧 实施方案
|
||||
## 6. 已下线或不再使用的口径
|
||||
|
||||
### 步骤1: 修改服务器S3配置(必须)
|
||||
- 首页自动探测并跳转 `192.168.58.20:4320`
|
||||
- `s3.yydd.ai` 作为正式资源域名
|
||||
- `180.97.221.140:2320` 作为正式应用入口
|
||||
- `180.97.221.140:2330` 作为正式 MinIO 入口
|
||||
- `180.97.215.57` 作为正式资源入口或缓存入口
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
environment:
|
||||
- S3_ENDPOINT=http://192.168.58.20:9000 # 改为内网地址
|
||||
```
|
||||
## 7. 当前验证标准
|
||||
|
||||
**影响范围**:
|
||||
- ✅ 所有服务器操作(上传、下载、处理)都使用内网地址
|
||||
- ✅ 消除服务器访问自己的MinIO走FRP的问题
|
||||
- ✅ 消除双重FRP问题
|
||||
### 7.1 展示资源
|
||||
|
||||
### 步骤2: 统一URL重写函数
|
||||
- 请求 `https://s3.jiansun.vip/gongxiangpan/public-display/...`
|
||||
- 结果应能正常返回资源或明确的对象不存在状态,不再要求 `X-Cache-Status: HIT`
|
||||
|
||||
**文件**: `lib/url-rewrite.ts`
|
||||
### 7.2 大文件
|
||||
|
||||
**新增函数**: `rewriteMediaUrlForClient(url, request, fileType)`
|
||||
- 请求 `Content-Length > 10MB` 的展示图、视频大文件或压缩包
|
||||
- 不再以 `X-Cache-Status: BYPASS` 作为当前正式门禁
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 统一的媒体URL重写规则
|
||||
*
|
||||
* @param url 原始URL
|
||||
* @param request NextRequest对象(用于检测用户网络位置)
|
||||
* @param fileType 文件类型:'image' | 'video' | 'audio' | 'source'
|
||||
* @returns 重写后的URL
|
||||
*/
|
||||
export function rewriteMediaUrlForClient(
|
||||
url: string | null | undefined,
|
||||
request?: NextRequest,
|
||||
fileType?: 'image' | 'video' | 'audio' | 'source'
|
||||
): string | null {
|
||||
if (!url) return null
|
||||
|
||||
// 检测用户网络位置
|
||||
const isIntranet = request ? detectIsIntranet(request) : false
|
||||
|
||||
// 内网用户:直接使用内网MinIO地址(不走代理)
|
||||
if (isIntranet) {
|
||||
return rewriteStorageUrlForClient(url, request)
|
||||
}
|
||||
|
||||
// 外网用户:根据文件类型决定
|
||||
const rewrittenUrl = rewriteStorageUrlForClient(url, request)
|
||||
if (!rewrittenUrl) return null
|
||||
|
||||
// 规则:视频/音频/源文件通过代理,图片直接访问
|
||||
if (fileType === 'video' || fileType === 'audio' || fileType === 'source') {
|
||||
return proxyHttpUrl(rewrittenUrl)
|
||||
}
|
||||
|
||||
// 图片:直接访问S3(文件小,影响不大)
|
||||
return rewrittenUrl
|
||||
}
|
||||
```
|
||||
### 7.3 主站入口
|
||||
|
||||
### 步骤3: 修改素材API(应用统一规则)
|
||||
- 打开 `https://sc.jiansun.vip`
|
||||
- 不再自动跳到 `192.168.58.20:4320`
|
||||
|
||||
**文件**: `app/api/materials/route.ts`
|
||||
### 7.4 历史旁路
|
||||
|
||||
```typescript
|
||||
// 修改前
|
||||
return rewriteMaterialStorageUrlsForClient(normalizeMaterial(formatted), request);
|
||||
- `2320` / `2330` 不再返回正式应用或正式 MinIO 内容
|
||||
|
||||
// 修改后
|
||||
const material = normalizeMaterial(formatted)
|
||||
const isIntranet = detectIsIntranet(request)
|
||||
## 8. 推荐排查顺序
|
||||
|
||||
// 统一规则:内网用户直接访问,外网用户根据文件类型智能选择
|
||||
const rewrittenMaterial = {
|
||||
...material,
|
||||
// 图片:直接访问(内网用户用内网地址,外网用户用S3地址)
|
||||
previewUrl: rewriteMediaUrlForClient(material.previewUrl, request, 'image'),
|
||||
videoCoverUrl: rewriteMediaUrlForClient(material.videoCoverUrl, request, 'image'),
|
||||
displayImages: material.displayImages?.map((img: string) =>
|
||||
rewriteMediaUrlForClient(img, request, 'image')
|
||||
),
|
||||
|
||||
// 视频:外网用户通过代理
|
||||
videoPreview540Url: rewriteMediaUrlForClient(material.videoPreview540Url, request, 'video'),
|
||||
videoPreviewClipUrl: rewriteMediaUrlForClient(material.videoPreviewClipUrl, request, 'video'),
|
||||
videoSourceUrl: rewriteMediaUrlForClient(material.videoSourceUrl, request, 'video'),
|
||||
}
|
||||
当用户反馈“公网慢”时,先按下面顺序判断:
|
||||
|
||||
return rewrittenMaterial
|
||||
```
|
||||
|
||||
### 步骤4: 处理音频文件(特殊)
|
||||
|
||||
**问题**:音频文件有两种存储方式
|
||||
- 新素材:存储在本地,通过 `/uploads/audio-source-xxx.mp3` 访问
|
||||
- 老素材:存储在S3,通过 `https://s3.yydd.ai/...` 访问
|
||||
|
||||
**解决方案**:
|
||||
1. **新素材(本地)**:外网用户通过 `/api/uploads/audio-source-xxx.mp3` 访问(已有API)
|
||||
2. **老素材(S3)**:外网用户通过 `/api/proxy-media?url=...` 访问
|
||||
|
||||
**修改位置**:
|
||||
- `components/home/MaterialCard.tsx`:音频URL处理
|
||||
- `app/share/[id]/page.tsx`:音频URL处理
|
||||
|
||||
```typescript
|
||||
// 音频URL处理逻辑
|
||||
let audioUrl = ''
|
||||
if (isAudioMaterial && material.zipFileName) {
|
||||
const zipFileName = material.zipFileName
|
||||
const isIntranet = /* 检测用户网络位置 */
|
||||
|
||||
if (zipFileName.startsWith('http://') || zipFileName.startsWith('https://')) {
|
||||
// 老素材:S3 URL
|
||||
if (isIntranet) {
|
||||
// 内网用户:直接使用内网MinIO地址
|
||||
audioUrl = rewriteStorageUrlForClient(zipFileName, request)
|
||||
} else {
|
||||
// 外网用户:通过代理
|
||||
audioUrl = proxyHttpUrl(zipFileName)
|
||||
}
|
||||
} else {
|
||||
// 新素材:本地文件
|
||||
if (isIntranet) {
|
||||
// 内网用户:直接访问
|
||||
audioUrl = `/uploads/${zipFileName}`
|
||||
} else {
|
||||
// 外网用户:通过API访问(已有 /api/uploads/[...path]/route.ts)
|
||||
audioUrl = `/api/uploads/${zipFileName}`
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整规则总结表
|
||||
|
||||
### 预览文件访问规则
|
||||
|
||||
| 文件类型 | 字段名 | 内网用户 | 外网用户 | 代理规则 |
|
||||
|---------|--------|---------|---------|---------|
|
||||
| **预览图** | previewUrl | 内网MinIO | 直接S3 | ❌ 不代理(<500KB) |
|
||||
| **展示图** | displayImages[] | 内网MinIO | 直接S3 | ❌ 不代理(<500KB) |
|
||||
| **视频封面** | videoCoverUrl | 内网MinIO | 直接S3 | ❌ 不代理(<500KB) |
|
||||
| **视频片段** | videoPreviewClipUrl | 内网MinIO | **代理** | ✅ 代理(5-10MB) |
|
||||
| **视频720P** | videoPreview540Url | 内网MinIO | **代理** | ✅ 代理(10-20MB) |
|
||||
| **音频预览** | zipFileName | 本地/内网MinIO | **代理/API** | ✅ 代理或API(2-10MB) |
|
||||
|
||||
### 下载文件访问规则
|
||||
|
||||
| 文件类型 | 存储位置 | 内网用户 | 外网用户 | 说明 |
|
||||
|---------|---------|---------|---------|------|
|
||||
| **视频源文件** | 本地 | 下载API | 下载API | 必须通过API(权限控制) |
|
||||
| **音频源文件** | 本地 | 下载API | 下载API | 必须通过API(权限控制) |
|
||||
| **压缩包** | 本地 | 下载API | 下载API | 必须通过API(权限控制) |
|
||||
| **老素材S3文件** | MinIO S3 | 下载API<br>(服务器内网获取) | 下载API<br>(服务器内网获取) | 必须通过API(权限+优化) |
|
||||
| **产品全案资产** | MinIO S3/本地 | 下载API<br>(服务器内网获取) | 下载API<br>(服务器内网获取) | 必须通过API(权限+优化) |
|
||||
|
||||
### 服务器操作规则
|
||||
|
||||
| 操作 | 目标 | 优化前 | 优化后 |
|
||||
|------|------|--------|--------|
|
||||
| **上传到S3** | MinIO | ❌ FRP | ✅ 内网直连 |
|
||||
| **从S3获取** | MinIO | ❌ FRP | ✅ 内网直连 |
|
||||
| **处理文件** | MinIO | ❌ FRP | ✅ 内网直连 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 统一规则的优势
|
||||
|
||||
### 1. 规则清晰
|
||||
- ✅ 按文件类型分类:图片、视频、音频、源文件
|
||||
- ✅ 按用户网络位置分类:内网、外网
|
||||
- ✅ 按文件大小分类:小文件直接访问,大文件通过代理
|
||||
|
||||
### 2. 易于管理
|
||||
- ✅ 所有规则集中在一个函数中:`rewriteMediaUrlForClient`
|
||||
- ✅ 修改规则只需修改一个地方
|
||||
- ✅ 添加新文件类型只需扩展函数参数
|
||||
|
||||
### 3. 性能优化
|
||||
- ✅ 内网用户:全部直接访问,最快
|
||||
- ✅ 外网用户:大文件通过代理,小文件直接访问,平衡速度和负载
|
||||
- ✅ 服务器操作:全部内网直连,消除FRP浪费
|
||||
|
||||
### 4. 向后兼容
|
||||
- ✅ 内网用户完全不受影响
|
||||
- ✅ 外网用户体验提升
|
||||
- ✅ 老素材仍然可以正常访问
|
||||
|
||||
---
|
||||
|
||||
## 📝 实施检查清单
|
||||
|
||||
### 必须修改
|
||||
- [ ] 修改 `S3_ENDPOINT` 为内网地址(docker-compose.yml)
|
||||
- [ ] 实现 `rewriteMediaUrlForClient` 函数(lib/url-rewrite.ts)
|
||||
- [ ] 修改素材列表API(app/api/materials/route.ts)
|
||||
- [ ] 修改素材详情API(app/api/materials/[id]/route.ts)
|
||||
- [ ] 修改分享页面API(app/api/materials/share/route.ts)
|
||||
|
||||
### 可选优化
|
||||
- [ ] 优化代理API性能(app/api/proxy-media/route.ts)
|
||||
- [ ] 添加缓存策略
|
||||
- [ ] 添加监控和日志
|
||||
|
||||
---
|
||||
|
||||
## 🔍 规则验证
|
||||
|
||||
### 测试场景
|
||||
|
||||
1. **内网用户访问预览**
|
||||
- ✅ 图片:直接访问内网MinIO,速度快
|
||||
- ✅ 视频:直接访问内网MinIO,速度快
|
||||
- ✅ 音频:直接访问本地或内网MinIO,速度快
|
||||
|
||||
2. **外网用户访问预览**
|
||||
- ✅ 图片:直接访问S3,可接受(<500KB)
|
||||
- ✅ 视频:通过代理,服务器内网获取,明显改善
|
||||
- ✅ 音频:通过代理或API,服务器内网获取,明显改善
|
||||
|
||||
3. **下载操作**
|
||||
- ✅ 所有用户:通过下载API,服务器内网获取,快
|
||||
- ✅ 权限控制:正常工作
|
||||
- ✅ 老素材:服务器内网获取,消除双重FRP
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**创建时间**: 2026-01-09
|
||||
**维护者**: AI Assistant
|
||||
1. 是主站慢,还是资源图慢。
|
||||
2. 如果是资源图慢,先看链接是不是 `s3.jiansun.vip`。
|
||||
3. 再看路径是不是 `/gongxiangpan/public-display/`。
|
||||
4. 再看 `s3.jiansun.vip` 是否解析到当前正式入口 `180.97.221.140`。
|
||||
5. 最后再看 `180.97.221.140 -> 127.0.0.1:2323 -> 192.168.58.20:9000` 回源链路。
|
||||
|
||||
254
docs/钉钉素材库接口对接文档.md
Normal file
254
docs/钉钉素材库接口对接文档.md
Normal file
@ -0,0 +1,254 @@
|
||||
# 钉钉素材库接口对接文档
|
||||
|
||||
## 1. 对接目标
|
||||
|
||||
钉钉流程填写以下 5 个字段后,调用素材库接口判断是否有符合条件的素材:
|
||||
|
||||
- 需求类型
|
||||
- 品牌
|
||||
- 场景类别
|
||||
- 产品类别
|
||||
- SPU
|
||||
|
||||
返回规则:
|
||||
|
||||
- 如果有符合素材,返回一个可用地址
|
||||
- 如果没有符合素材,返回:`该SPU下暂无素材`
|
||||
|
||||
---
|
||||
|
||||
## 2. 字段对应关系
|
||||
|
||||
钉钉字段和素材库字段对应如下:
|
||||
|
||||
- `需求类型` = `materialType`(素材类型)
|
||||
- `品牌` = `categoryName`(店铺)
|
||||
- `场景类别` = `sceneName`
|
||||
- `产品类别` = `productCategoryName`
|
||||
- `SPU` = `productModel`
|
||||
|
||||
注意:
|
||||
|
||||
- “品牌”在本项目里实际对应的是“店铺”
|
||||
- “需求类型”实际对应的是“素材类型”
|
||||
|
||||
---
|
||||
|
||||
## 3. 鉴权方式
|
||||
|
||||
结论:钉钉用户提交表单时,不需要、也不应该手动输入请求头。
|
||||
|
||||
建议钉钉负责人把密钥固定配置在接口地址里,用户只填写业务字段。
|
||||
|
||||
本次改动后,钉钉这两个接口同时支持两种鉴权方式:
|
||||
|
||||
- 推荐给钉钉低代码流程:固定 URL 参数 `apiKey`
|
||||
- 推荐给程序系统对接:固定请求头 `X-API-Key`
|
||||
|
||||
两种方式任选一种即可,不需要同时配置。
|
||||
|
||||
钉钉流程推荐使用:
|
||||
|
||||
```http
|
||||
apiKey=5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
也就是把 `apiKey` 作为固定 URL 参数放到接口地址中。
|
||||
|
||||
系统对系统调用仍然支持请求头方式:
|
||||
|
||||
```http
|
||||
X-API-Key: 5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 当前生产可用 Key 名称:`AI生图系统`
|
||||
- 当前生产可用 Key:`5073534d7a59babbc45397a8eac5943b`
|
||||
- 钉钉低代码流程如果不方便固定请求头,就使用 URL 参数 `apiKey`
|
||||
- `apiKey` 由钉钉流程管理员固定配置,不暴露给普通填表用户
|
||||
|
||||
钉钉负责人配置时要注意:
|
||||
|
||||
- `apiKey` 是固定参数,不要做成表单字段
|
||||
- 普通用户只需要填写或选择:需求类型、品牌、场景类别、产品类别、SPU
|
||||
- 如果钉钉侧有“连接器公共请求头”或“固定请求头”配置,也可以把 `X-API-Key` 配在那里
|
||||
- 如果没有固定请求头能力,就直接使用本文档里的 `?apiKey=...` 地址
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口 1:获取下拉选项
|
||||
|
||||
### 4.1 用途
|
||||
|
||||
给钉钉流程生成下拉选项,避免手填出错。
|
||||
|
||||
### 4.2 请求信息
|
||||
|
||||
钉钉推荐配置地址:
|
||||
|
||||
```http
|
||||
GET https://sc.jiansun.vip/api/external/dingtalk-material-options?apiKey=5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
不需要用户手填请求头。
|
||||
|
||||
如果钉钉侧可以配置固定请求头,也可以继续用:
|
||||
|
||||
```http
|
||||
X-API-Key: 5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
### 4.3 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"requirementTypes": ["任务-详情", "任务-页面", "任务-3D原图"],
|
||||
"brands": ["顾家家居", "林氏木业"],
|
||||
"sceneCategories": ["客厅", "卧室"],
|
||||
"productCategories": ["沙发", "床"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 字段说明
|
||||
|
||||
- `requirementTypes`:需求类型下拉
|
||||
- `brands`:品牌下拉,实际就是店铺
|
||||
- `sceneCategories`:场景类别下拉
|
||||
- `productCategories`:产品类别下拉
|
||||
|
||||
### 4.5 接入建议
|
||||
|
||||
钉钉流程应先调用这个接口,把以下字段做成下拉:
|
||||
|
||||
- 需求类型
|
||||
- 品牌
|
||||
- 场景类别
|
||||
- 产品类别
|
||||
|
||||
不建议手填。
|
||||
|
||||
---
|
||||
|
||||
## 5. 接口 2:查询是否有素材
|
||||
|
||||
### 5.1 用途
|
||||
|
||||
用户选择完 5 个字段后,调用这个接口判断有没有素材。
|
||||
|
||||
### 5.2 请求信息
|
||||
|
||||
钉钉推荐配置地址:
|
||||
|
||||
```http
|
||||
GET https://sc.jiansun.vip/api/external/dingtalk-material-match?apiKey=5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
不需要用户手填请求头。
|
||||
|
||||
如果钉钉侧可以配置固定请求头,也可以继续用:
|
||||
|
||||
```http
|
||||
X-API-Key: 5073534d7a59babbc45397a8eac5943b
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
- `apiKey`:固定鉴权密钥,由钉钉负责人固定配置,不给普通用户填写
|
||||
- `requirementType`:需求类型
|
||||
- `brand`:品牌(实际是店铺)
|
||||
- `sceneCategory`:场景类别
|
||||
- `productCategory`:产品类别
|
||||
- `spu`:SPU
|
||||
|
||||
### 5.3 请求示例
|
||||
|
||||
```http
|
||||
GET https://sc.jiansun.vip/api/external/dingtalk-material-match?apiKey=5073534d7a59babbc45397a8eac5943b&requirementType=任务-详情&brand=顾家家居&sceneCategory=客厅&productCategory=沙发&spu=SPU001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询接口返回示例
|
||||
|
||||
### 6.1 命中素材时
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"matched": true,
|
||||
"message": "查询成功",
|
||||
"spu": "SPU001",
|
||||
"requirementType": "任务-详情",
|
||||
"brand": "顾家家居",
|
||||
"sceneCategory": "客厅",
|
||||
"productCategory": "沙发",
|
||||
"address": "https://sc.jiansun.vip/uploads/previews/spu001.jpg",
|
||||
"materialId": "material-1",
|
||||
"updatedAt": "2026-04-30T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
钉钉侧重点关注:
|
||||
|
||||
- `matched = true`
|
||||
- `address` 有值
|
||||
|
||||
### 6.2 未命中素材时
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"matched": false,
|
||||
"message": "该SPU下暂无素材",
|
||||
"spu": "SPU001",
|
||||
"requirementType": "任务-详情",
|
||||
"brand": "顾家家居",
|
||||
"sceneCategory": "客厅",
|
||||
"productCategory": "沙发",
|
||||
"address": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
钉钉侧重点关注:
|
||||
|
||||
- `matched = false`
|
||||
- `message = 该SPU下暂无素材`
|
||||
|
||||
---
|
||||
|
||||
## 7. 推荐接入顺序
|
||||
|
||||
推荐按以下顺序接入:
|
||||
|
||||
1. 先调用“下拉选项接口”
|
||||
2. 用返回结果生成钉钉表单下拉
|
||||
3. 用户选择完 5 个字段后
|
||||
4. 再调用“素材匹配接口”
|
||||
5. 根据 `matched` 和 `address` 做后续流程判断
|
||||
|
||||
---
|
||||
|
||||
## 8. 注意事项
|
||||
|
||||
- `SPU` 为必填
|
||||
- 其余字段建议全部走下拉,不要手输
|
||||
- “品牌”不是独立品牌库字段,实际对应店铺
|
||||
- “需求类型”不是自由文本,实际对应素材类型
|
||||
- 如果接口返回 `INVALID_API_KEY`,说明 `apiKey` 参数或 `X-API-Key` 请求头没有正确配置
|
||||
- 普通填表用户不应看到 `apiKey` 字段;它应由钉钉流程管理员固定在接口地址或连接器配置里
|
||||
|
||||
---
|
||||
|
||||
## 9. 生产环境信息
|
||||
|
||||
- 生产域名:`https://sc.jiansun.vip`
|
||||
- 下拉选项接口:`/api/external/dingtalk-material-options`
|
||||
- 素材匹配接口:`/api/external/dingtalk-material-match`
|
||||
@ -85,9 +85,49 @@ function createIndexedDbMock(): IDBFactory {
|
||||
} as unknown as IDBFactory
|
||||
}
|
||||
|
||||
class MockXMLHttpRequest {
|
||||
static instances: MockXMLHttpRequest[] = []
|
||||
|
||||
timeout = 0
|
||||
status = 413
|
||||
responseText = ''
|
||||
upload = {
|
||||
onloadstart: null as (() => void) | null,
|
||||
onprogress: null as ((event: ProgressEvent) => void) | null,
|
||||
}
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
ontimeout: (() => void) | null = null
|
||||
headers: Record<string, string> = {}
|
||||
method = ''
|
||||
url = ''
|
||||
|
||||
constructor() {
|
||||
MockXMLHttpRequest.instances.push(this)
|
||||
}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers[name] = value
|
||||
}
|
||||
|
||||
send() {
|
||||
setTimeout(() => {
|
||||
this.upload.onloadstart?.()
|
||||
this.onload?.()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
describe('chunkUpload', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
MockXMLHttpRequest.instances = []
|
||||
|
||||
Object.defineProperty(global, 'indexedDB', {
|
||||
configurable: true,
|
||||
@ -100,6 +140,12 @@ describe('chunkUpload', () => {
|
||||
writable: true,
|
||||
value: jest.fn(),
|
||||
})
|
||||
|
||||
Object.defineProperty(global, 'XMLHttpRequest', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MockXMLHttpRequest,
|
||||
})
|
||||
})
|
||||
|
||||
test('空文件会在初始化前被直接拦截', async () => {
|
||||
@ -130,4 +176,38 @@ describe('chunkUpload', () => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
await expect(getAllPendingUploads()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test('upload/chunk 返回 413 时直接失败并清理会话', async () => {
|
||||
;(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ uploadId: 'upload-too-large' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ message: '清理成功' }),
|
||||
})
|
||||
|
||||
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
||||
|
||||
await expect(
|
||||
chunkUpload(
|
||||
file,
|
||||
{ materialType: '任务-详情' },
|
||||
{ maxRetries: 5, retryDelay: 0 },
|
||||
),
|
||||
).rejects.toThrow('上传分片超过入口服务器限制')
|
||||
|
||||
expect(MockXMLHttpRequest.instances).toHaveLength(1)
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2)
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/materials/upload/cleanup',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uploadId: 'upload-too-large' }),
|
||||
}),
|
||||
)
|
||||
await expect(getAllPendingUploads()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
203
lib/__tests__/dingtalk-material-match-route.test.ts
Normal file
203
lib/__tests__/dingtalk-material-match-route.test.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET } from '@/app/api/external/dingtalk-material-match/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { logApiCall, validateApiKey } from '@/lib/external-api'
|
||||
|
||||
jest.mock('@/lib/external-api', () => {
|
||||
const { NextResponse } = require('next/server')
|
||||
return {
|
||||
getApiKeyFromRequest: jest.fn(
|
||||
(request: { headers: Headers; nextUrl: URL }, options?: { allowQuery?: boolean }) =>
|
||||
request.headers.get('X-API-Key') ||
|
||||
(options?.allowQuery ? request.nextUrl.searchParams.get('apiKey') : null),
|
||||
),
|
||||
getClientIp: jest.fn(() => '127.0.0.1'),
|
||||
logApiCall: jest.fn().mockResolvedValue(undefined),
|
||||
successResponse: jest.fn((data: unknown, message?: string) =>
|
||||
NextResponse.json({
|
||||
success: true,
|
||||
...(message ? { message } : {}),
|
||||
data,
|
||||
}),
|
||||
),
|
||||
validateApiKey: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-app-url', () => ({
|
||||
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/url-rewrite', () => ({
|
||||
rewriteStorageUrlForClient: jest.fn((url?: string | null) => {
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return /^https?:\/\//i.test(url) ? url : `/${String(url).replace(/^\/+/, '')}`
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('external dingtalk-material-match route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.material.findFirst as jest.Mock).mockReset()
|
||||
;(logApiCall as jest.Mock).mockReset().mockResolvedValue(undefined)
|
||||
;(validateApiKey as jest.Mock).mockReset().mockResolvedValue({
|
||||
id: 'api-key-1',
|
||||
name: 'test-key',
|
||||
isActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
function createRequest(url: string, apiKey = 'test-key') {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
headers: new Headers({
|
||||
...(apiKey ? { 'X-API-Key': apiKey } : {}),
|
||||
'user-agent': 'jest',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('缺少 SPU 时应返回 400', async () => {
|
||||
const response = await GET(createRequest('https://example.com/api/external/dingtalk-material-match'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_SPU',
|
||||
message: 'SPU 为必填项',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('命中素材时应返回任意地址与匹配摘要', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({
|
||||
id: 'material-1',
|
||||
productModel: 'SPU001',
|
||||
materialType: '任务-详情',
|
||||
categoryName: '顾家家居',
|
||||
sceneName: '客厅',
|
||||
productCategoryName: '沙发',
|
||||
previewUrl: 'uploads/previews/spu001.jpg',
|
||||
videoPreview540Url: null,
|
||||
videoCoverUrl: null,
|
||||
zipFilePath: 'uploads/zip/spu001.zip',
|
||||
updatedAt: new Date('2026-04-30T08:00:00.000Z'),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/external/dingtalk-material-match?requirementType=任务-详情&brand=顾家家居&sceneCategory=客厅&productCategory=沙发&spu=spu001',
|
||||
),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toEqual({
|
||||
matched: true,
|
||||
message: '查询成功',
|
||||
spu: 'SPU001',
|
||||
materialId: 'material-1',
|
||||
requirementType: '任务-详情',
|
||||
brand: '顾家家居',
|
||||
sceneCategory: '客厅',
|
||||
productCategory: '沙发',
|
||||
address: 'https://sc.jiansun.vip/uploads/previews/spu001.jpg',
|
||||
updatedAt: '2026-04-30T08:00:00.000Z',
|
||||
})
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
productModel: 'SPU001',
|
||||
materialType: '任务-详情',
|
||||
categoryName: '顾家家居',
|
||||
sceneName: '客厅',
|
||||
productCategoryName: '沙发',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('只传 SPU 时也应查询,不强制要求其它筛选项', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({
|
||||
id: 'material-spu-only',
|
||||
productModel: 'SPU002',
|
||||
materialType: '抖音-千川素材',
|
||||
categoryName: '顾家家居',
|
||||
sceneName: '客厅',
|
||||
productCategoryName: '沙发',
|
||||
previewUrl: 'https://s3.example/preview.webp',
|
||||
videoPreview540Url: null,
|
||||
videoCoverUrl: null,
|
||||
zipFilePath: 'uploads/zip/spu002.zip',
|
||||
updatedAt: new Date('2026-05-07T08:00:00.000Z'),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/dingtalk-material-match?spu=spu002'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.matched).toBe(true)
|
||||
expect(prisma.material.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
productModel: 'SPU002',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('未命中时应返回该SPU下暂无素材', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(null)
|
||||
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/dingtalk-material-match?spu=spu001&brand=顾家家居'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toEqual({
|
||||
matched: false,
|
||||
message: '该SPU下暂无素材',
|
||||
spu: 'SPU001',
|
||||
requirementType: null,
|
||||
brand: '顾家家居',
|
||||
sceneCategory: null,
|
||||
productCategory: null,
|
||||
address: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('允许钉钉用固定 URL 参数传 apiKey,避免每次手填请求头', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(null)
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/external/dingtalk-material-match?apiKey=test-key&spu=spu001',
|
||||
'',
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(validateApiKey).toHaveBeenCalledWith('test-key')
|
||||
})
|
||||
})
|
||||
95
lib/__tests__/dingtalk-material-options-route.test.ts
Normal file
95
lib/__tests__/dingtalk-material-options-route.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET } from '@/app/api/external/dingtalk-material-options/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { logApiCall, validateApiKey } from '@/lib/external-api'
|
||||
|
||||
jest.mock('@/lib/external-api', () => {
|
||||
const { NextResponse } = require('next/server')
|
||||
return {
|
||||
getApiKeyFromRequest: jest.fn(
|
||||
(request: { headers: Headers; nextUrl: URL }, options?: { allowQuery?: boolean }) =>
|
||||
request.headers.get('X-API-Key') ||
|
||||
(options?.allowQuery ? request.nextUrl.searchParams.get('apiKey') : null),
|
||||
),
|
||||
getClientIp: jest.fn(() => '127.0.0.1'),
|
||||
logApiCall: jest.fn().mockResolvedValue(undefined),
|
||||
successResponse: jest.fn((data: unknown, message?: string) =>
|
||||
NextResponse.json({
|
||||
success: true,
|
||||
...(message ? { message } : {}),
|
||||
data,
|
||||
}),
|
||||
),
|
||||
validateApiKey: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
category: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
scene: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
productCategory: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('external dingtalk-material-options route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue({
|
||||
id: 'api-key-1',
|
||||
name: 'test-key',
|
||||
isActive: true,
|
||||
})
|
||||
;(prisma.category.findMany as jest.Mock).mockResolvedValue([
|
||||
{ name: '顾家家居' },
|
||||
{ name: '林氏木业' },
|
||||
])
|
||||
;(prisma.scene.findMany as jest.Mock).mockResolvedValue([
|
||||
{ name: '客厅' },
|
||||
{ name: '卧室' },
|
||||
])
|
||||
;(prisma.productCategory.findMany as jest.Mock).mockResolvedValue([
|
||||
{ name: '沙发' },
|
||||
{ name: '床' },
|
||||
])
|
||||
})
|
||||
|
||||
function createRequest(url: string, apiKey = 'test-key') {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
headers: new Headers({
|
||||
...(apiKey ? { 'X-API-Key': apiKey } : {}),
|
||||
'user-agent': 'jest',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('应返回钉钉下拉选项', async () => {
|
||||
const response = await GET(createRequest('https://example.com/api/external/dingtalk-material-options'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.requirementTypes).toContain('任务-详情')
|
||||
expect(body.data.brands).toEqual(['顾家家居', '林氏木业'])
|
||||
expect(body.data.sceneCategories).toEqual(['客厅', '卧室'])
|
||||
expect(body.data.productCategories).toEqual(['沙发', '床'])
|
||||
expect(logApiCall).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('允许钉钉用固定 URL 参数传 apiKey,避免每次手填请求头', async () => {
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/dingtalk-material-options?apiKey=test-key', ''),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(validateApiKey).toHaveBeenCalledWith('test-key')
|
||||
})
|
||||
})
|
||||
40
lib/__tests__/external-api-key.test.ts
Normal file
40
lib/__tests__/external-api-key.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { getApiKeyFromRequest } from '@/lib/external-api'
|
||||
|
||||
function createRequest(url: string, headers?: Record<string, string>) {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
headers: new Headers(headers),
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('getApiKeyFromRequest', () => {
|
||||
test('默认只读取 X-API-Key 请求头', () => {
|
||||
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key', {
|
||||
'X-API-Key': 'header-key',
|
||||
})
|
||||
|
||||
expect(getApiKeyFromRequest(request)).toBe('header-key')
|
||||
})
|
||||
|
||||
test('默认不读取 URL 参数,避免所有外部接口无意开放 query key', () => {
|
||||
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key')
|
||||
|
||||
expect(getApiKeyFromRequest(request)).toBeNull()
|
||||
})
|
||||
|
||||
test('显式允许时可读取钉钉固定 URL 参数 apiKey', () => {
|
||||
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key')
|
||||
|
||||
expect(getApiKeyFromRequest(request, { allowQuery: true })).toBe('query-key')
|
||||
})
|
||||
|
||||
test('请求头优先于 URL 参数', () => {
|
||||
const request = createRequest('https://example.com/api/external/demo?apiKey=query-key', {
|
||||
'X-API-Key': 'header-key',
|
||||
})
|
||||
|
||||
expect(getApiKeyFromRequest(request, { allowQuery: true })).toBe('header-key')
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,12 @@
|
||||
/**
|
||||
* 任务-页面删除清理回归测试
|
||||
* 任务类轻量素材删除清理回归测试
|
||||
*
|
||||
* 目的:确保任务-页面类型也会进入 MaterialAsset 清理链路,
|
||||
* 避免多张预览图的 S3 文件在永久删除后残留。
|
||||
* 目的:确保任务-页面、任务-其他这类依赖 MaterialAsset 的任务素材
|
||||
* 也会进入统一清理链路,避免预览图或压缩包残留。
|
||||
*/
|
||||
|
||||
describe('任务-页面清理覆盖测试', () => {
|
||||
test('collectMaterialFiles 的任务类型列表应包含任务-页面', async () => {
|
||||
describe('任务类轻量素材清理覆盖测试', () => {
|
||||
test('collectMaterialFiles 的任务类型列表应包含任务-页面和任务-其他', async () => {
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
@ -20,9 +20,10 @@ describe('任务-页面清理覆盖测试', () => {
|
||||
}
|
||||
|
||||
expect(firstTaskTypeDecl[0]).toContain("'任务-页面'")
|
||||
expect(firstTaskTypeDecl[0]).toContain("'任务-其他'")
|
||||
})
|
||||
|
||||
test('deleteMaterialRelatedRecords 的任务类型列表应包含任务-页面', async () => {
|
||||
test('deleteMaterialRelatedRecords 的任务类型列表应包含任务-页面和任务-其他', async () => {
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
@ -36,5 +37,6 @@ describe('任务-页面清理覆盖测试', () => {
|
||||
}
|
||||
|
||||
expect(allTaskTypeDecls[1]).toContain("'任务-页面'")
|
||||
expect(allTaskTypeDecls[1]).toContain("'任务-其他'")
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { PUT as putMaterialById } from '@/app/api/materials/[id]/route'
|
||||
import { GET as getMaterialById, PUT as putMaterialById } from '@/app/api/materials/[id]/route'
|
||||
import { PUT as putMaterialUpdate } from '@/app/api/materials/update/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { updateMaterialWithSharedRules } from '@/lib/material-update'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { canUserViewMaterial, isShareLinkAccess, isSuperAdmin } from '@/lib/department-permission'
|
||||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -15,12 +18,17 @@ jest.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {},
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
canUserViewMaterial: jest.fn(),
|
||||
isShareLinkAccess: jest.fn(),
|
||||
isSuperAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/task-detail-full-set', () => ({
|
||||
@ -40,8 +48,14 @@ describe('material edit routes', () => {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
department: '测试部门',
|
||||
managedDepartment: null,
|
||||
},
|
||||
})
|
||||
;(isShareLinkAccess as jest.Mock).mockReturnValue(false)
|
||||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||||
;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(deriveTaskDetailIsFullSet as jest.Mock).mockReturnValue(false)
|
||||
;(updateMaterialWithSharedRules as jest.Mock).mockResolvedValue({
|
||||
material: {
|
||||
id: 'material-1',
|
||||
@ -54,6 +68,7 @@ describe('material edit routes', () => {
|
||||
renderer: 'CR',
|
||||
authorName: '测试作者',
|
||||
taskNo: null,
|
||||
taskDetailSourceType: null,
|
||||
taskStatus: null,
|
||||
taskFetchedAt: null,
|
||||
isFullSet: false,
|
||||
@ -87,6 +102,7 @@ describe('material edit routes', () => {
|
||||
formData.append('authorName', '测试作者')
|
||||
formData.append('userId', 'user-1')
|
||||
formData.append('taskNo', '')
|
||||
formData.append('taskDetailSourceType', 'AI')
|
||||
return formData
|
||||
}
|
||||
|
||||
@ -132,4 +148,88 @@ describe('material edit routes', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('GET /api/materials/[id] 应将 assets 中的 BigInt fileSize 序列化为字符串', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: 'Amazon',
|
||||
productCategoryName: '椅子',
|
||||
materialType: '任务-AI原图',
|
||||
isAiGenerated: false,
|
||||
renderer: 'CR',
|
||||
authorName: '测试作者',
|
||||
previewUrl: 'https://s3.jiansun.vip/preview.webp',
|
||||
zipFileName: 'material.zip',
|
||||
zipFilePath: 'uploads/brand-vi/original.pdf',
|
||||
zipFileSize: BigInt(123),
|
||||
downloadCount: 1,
|
||||
likeCount: 2,
|
||||
displayImages: [],
|
||||
createdAt: new Date('2026-04-03T00:00:00.000Z'),
|
||||
videoSourceUrl: null,
|
||||
videoPreview540Url: null,
|
||||
videoPreviewClipUrl: null,
|
||||
videoCoverUrl: null,
|
||||
videoDuration: null,
|
||||
videoProcessingStatus: null,
|
||||
taskNo: 'TASK202603261129596600-01-01',
|
||||
taskStatus: 'fetched',
|
||||
taskFetchedAt: new Date('2026-04-03T00:00:01.000Z'),
|
||||
isFullSet: false,
|
||||
taskDetailSourceType: '渲染',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: '测试用户',
|
||||
realName: '测试用户',
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
assetType: 'zip',
|
||||
fileUrl: 'uploads/material.zip',
|
||||
fileName: 'material.zip',
|
||||
fileSize: BigInt(456),
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost/api/materials/material-1', {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const response = await getMaterialById(request, {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.zipFileSize).toBe('123')
|
||||
expect(body.assets[0].fileSize).toBe('456')
|
||||
expect(body.taskDetailSourceType).toBe('渲染')
|
||||
expect(body.sourceFileUrl).toBe('/api/uploads/brand-vi/original.pdf')
|
||||
})
|
||||
|
||||
test('PUT 路由应透传任务详情出图方式字段', async () => {
|
||||
const request = new NextRequest('http://localhost/api/materials/material-1', {
|
||||
method: 'PUT',
|
||||
body: createFormData(),
|
||||
})
|
||||
|
||||
await putMaterialById(request, {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
|
||||
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
taskDetailSourceType: 'AI',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
15
lib/__tests__/material-type-options.test.ts
Normal file
15
lib/__tests__/material-type-options.test.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { MATERIAL_TYPE_OPTIONS } from '@/lib/material-type-options'
|
||||
|
||||
describe('material-type-options', () => {
|
||||
test('视频类新名称会出现在素材类型选项里', () => {
|
||||
expect(MATERIAL_TYPE_OPTIONS).toContain('淘系-主图视频')
|
||||
expect(MATERIAL_TYPE_OPTIONS).toContain('淘系-逛逛视频')
|
||||
expect(MATERIAL_TYPE_OPTIONS).toContain('抖音-千川素材')
|
||||
expect(MATERIAL_TYPE_OPTIONS).toContain('抖音-账号维护')
|
||||
})
|
||||
|
||||
test('旧视频类型名称不再作为正式选项暴露', () => {
|
||||
expect(MATERIAL_TYPE_OPTIONS).not.toContain('主图视频')
|
||||
expect(MATERIAL_TYPE_OPTIONS).not.toContain('种草视频')
|
||||
})
|
||||
})
|
||||
@ -186,6 +186,90 @@ describe('material-update shared rules', () => {
|
||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('证书-检测报告允许只填写报告名称 / 检测对象', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
materialType: '证书-检测报告',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
productCategoryName: '板材',
|
||||
productModel: '',
|
||||
reportTitle: null,
|
||||
renderer: '',
|
||||
})
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...baseMaterial,
|
||||
...data,
|
||||
}))
|
||||
|
||||
const result = await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: '',
|
||||
reportTitle: 'E0级板材检测报告',
|
||||
categoryName: '',
|
||||
sceneName: '',
|
||||
platformName: '',
|
||||
productCategoryName: '板材',
|
||||
materialType: '证书-检测报告',
|
||||
renderer: '',
|
||||
authorName: '测试作者',
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(false)
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
productModel: '',
|
||||
reportTitle: 'E0级板材检测报告',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
materialType: '证书-检测报告',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('证书-检测报告同时缺少 SPU 和报告名称时应返回 400', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
materialType: '证书-检测报告',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
productCategoryName: '板材',
|
||||
productModel: '',
|
||||
reportTitle: null,
|
||||
renderer: '',
|
||||
})
|
||||
|
||||
const result = await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: '',
|
||||
reportTitle: '',
|
||||
categoryName: '',
|
||||
sceneName: '',
|
||||
platformName: '',
|
||||
productCategoryName: '板材',
|
||||
materialType: '证书-检测报告',
|
||||
renderer: '',
|
||||
authorName: '测试作者',
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
error: 'SPU 和 报告名称/检测对象 至少填写一个',
|
||||
status: 400,
|
||||
})
|
||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('非任务详情类型应强制将 isFullSet 保存为 false', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
@ -309,6 +393,117 @@ describe('material-update shared rules', () => {
|
||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('任务-详情编辑时应支持保存和清空出图方式', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
materialType: '任务-详情',
|
||||
taskDetailSourceType: '实拍',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
})
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...baseMaterial,
|
||||
...data,
|
||||
}))
|
||||
|
||||
await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'TASK-SPU-001',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-详情',
|
||||
renderer: '',
|
||||
authorName: '任务作者',
|
||||
userId: 'user-1',
|
||||
taskDetailSourceType: 'AI',
|
||||
},
|
||||
})
|
||||
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
taskDetailSourceType: 'AI',
|
||||
}),
|
||||
})
|
||||
|
||||
await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'TASK-SPU-001',
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-详情',
|
||||
renderer: '',
|
||||
authorName: '任务作者',
|
||||
userId: 'user-1',
|
||||
taskDetailSourceType: '',
|
||||
},
|
||||
})
|
||||
|
||||
expect(prisma.material.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
taskDetailSourceType: null,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('任务-其他编辑时不应强制要求店铺和场景,并应自动回写通用', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
...baseMaterial,
|
||||
materialType: '任务-其他',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
taskNo: 'TASK202603310001',
|
||||
productModel: 'TASK202603310001',
|
||||
})
|
||||
.mockResolvedValueOnce(null)
|
||||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...baseMaterial,
|
||||
...data,
|
||||
}))
|
||||
|
||||
const result = await updateMaterialWithSharedRules({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'user-1',
|
||||
operatorRole: 'USER',
|
||||
input: {
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '',
|
||||
sceneName: '',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-其他',
|
||||
renderer: '',
|
||||
authorName: '任务作者',
|
||||
userId: 'user-1',
|
||||
taskNo: 'task202603310001',
|
||||
},
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(false)
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: expect.objectContaining({
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
taskNo: 'TASK202603310001',
|
||||
materialType: '任务-其他',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('任务-详情编辑时应按已有资产自动回写 isFullSet', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
...baseMaterial,
|
||||
|
||||
@ -130,6 +130,7 @@ describe('materials route ai generated', () => {
|
||||
transcodeLastError: null,
|
||||
transcodeAttempts: 0,
|
||||
isAiGenerated: true,
|
||||
taskDetailSourceType: 'AI',
|
||||
user: {
|
||||
username: 'zhangsan',
|
||||
realName: '张三',
|
||||
@ -154,6 +155,7 @@ describe('materials route ai generated', () => {
|
||||
expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
isAiGenerated: true,
|
||||
taskDetailSourceType: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ -162,6 +164,7 @@ describe('materials route ai generated', () => {
|
||||
expect.objectContaining({
|
||||
id: 'material-1',
|
||||
isAiGenerated: true,
|
||||
taskDetailSourceType: 'AI',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
239
lib/__tests__/materials-upload-task-other-route.test.ts
Normal file
239
lib/__tests__/materials-upload-task-other-route.test.ts
Normal file
@ -0,0 +1,239 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import { POST } from '@/app/api/materials/upload/route'
|
||||
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
mkdir: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
stat: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
materialAsset: {
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
uploadToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/audio-processor', () => ({
|
||||
parseAudioMetadata: jest.fn(),
|
||||
isAudioFile: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/video-transcode-task', () => ({
|
||||
enqueueVideoTranscodeTask: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/image-processor', () => ({
|
||||
compressImage: jest.fn(),
|
||||
sanitizeFileName: jest.fn((value: string) => value),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/pdf-upload-handler', () => ({
|
||||
processPdfUpload: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/file-cleanup', () => ({
|
||||
deleteFiles: jest.fn().mockResolvedValue({ deletedCount: 0, failedCount: 0 }),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-cleanup', () => ({
|
||||
deleteMaterialRelatedRecords: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-log', () => ({
|
||||
createMaterialLog: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
buildMaterialPublicDisplayPath: jest.fn((fileName: string) => `materials/${fileName}`),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/product-package', () => ({
|
||||
validateTaskNo: jest.fn(() => true),
|
||||
isTaskNoExists: jest.fn().mockResolvedValue(false),
|
||||
purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({
|
||||
deletedMaterials: 0,
|
||||
deletedFiles: 0,
|
||||
failedFiles: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('materials upload route - 任务-其他', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
},
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
username: '安乔',
|
||||
})
|
||||
;(prisma.material.create as jest.Mock).mockResolvedValue({
|
||||
id: 'material-task-other-1',
|
||||
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
platformName: '',
|
||||
productCategoryName: '',
|
||||
materialType: '任务-其他',
|
||||
renderer: '',
|
||||
authorName: '安乔',
|
||||
zipFileName: 'source-package.zip',
|
||||
zipFilePath: 'uploads/material-1.zip',
|
||||
zipFileSize: BigInt(7),
|
||||
displayImages: [],
|
||||
videoSourceUrl: null,
|
||||
videoProcessingStatus: null,
|
||||
userId: 'user-1',
|
||||
user: {
|
||||
username: '安乔',
|
||||
},
|
||||
})
|
||||
;(prisma.material.update as jest.Mock).mockResolvedValue({})
|
||||
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-preview'))
|
||||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
|
||||
)
|
||||
;(sanitizeFileName as jest.Mock).mockImplementation((value: string) => value)
|
||||
})
|
||||
|
||||
function createRequest(formData: FormData) {
|
||||
return {
|
||||
formData: jest.fn().mockResolvedValue(formData),
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildTaskOtherFormData() {
|
||||
const formData = new FormData()
|
||||
formData.append('materialType', '任务-其他')
|
||||
formData.append('taskNo', 'TASK202603310001')
|
||||
formData.append('productModel', '')
|
||||
formData.append('categoryName', '')
|
||||
formData.append('sceneName', '')
|
||||
formData.append(
|
||||
'previewImage',
|
||||
new File([Buffer.from('preview-image')], 'preview.png', { type: 'image/png' }),
|
||||
)
|
||||
formData.append(
|
||||
'zipFile',
|
||||
new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }),
|
||||
)
|
||||
return formData
|
||||
}
|
||||
|
||||
test('缺少任务号时应立即返回失败', async () => {
|
||||
const formData = buildTaskOtherFormData()
|
||||
formData.set('taskNo', '')
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error).toBe('任务-其他必须填写任务号')
|
||||
expect(prisma.material.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('缺少预览图时应立即返回失败', async () => {
|
||||
const formData = buildTaskOtherFormData()
|
||||
formData.delete('previewImage')
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error).toBe('任务-其他必须上传至少一张预览图')
|
||||
expect(prisma.material.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('正常上传时应自动补通用店铺和场景,并保留原始压缩包文件名', async () => {
|
||||
const formData = buildTaskOtherFormData()
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(body.message).toBe('上传成功')
|
||||
expect(prisma.material.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
materialType: '任务-其他',
|
||||
zipFileName: 'source-package.zip',
|
||||
zipFilePath: expect.stringContaining('uploads'),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prisma.materialAsset.createMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
assetType: 'preview',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
assetType: 'zip',
|
||||
fileName: 'source-package.zip',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
expect(prisma.material.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'material-task-other-1' },
|
||||
data: expect.objectContaining({
|
||||
taskNo: 'TASK202603310001',
|
||||
taskStatus: 'completed',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(createMaterialLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
changes: expect.objectContaining({
|
||||
categoryName: expect.objectContaining({
|
||||
newValue: '通用',
|
||||
}),
|
||||
sceneName: expect.objectContaining({
|
||||
newValue: '通用',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
21
lib/__tests__/network-entry-cleanup.test.ts
Normal file
21
lib/__tests__/network-entry-cleanup.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
describe('network entry cleanup', () => {
|
||||
test('首页不应再保留自动切内网地址', () => {
|
||||
const filePath = path.join(process.cwd(), 'app', 'page.tsx')
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
|
||||
expect(content).not.toContain('192.168.58.20:4320')
|
||||
expect(content).not.toContain('intranet-auto-redirect')
|
||||
})
|
||||
|
||||
test('middleware 不应再保留旧公网旁路 2320 的正式口径', () => {
|
||||
const filePath = path.join(process.cwd(), 'middleware.ts')
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
|
||||
expect(content).not.toContain('180.97.221.140:2320')
|
||||
})
|
||||
})
|
||||
@ -230,4 +230,74 @@ describe('external product-assets route', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('任务-其他应继续走老接口地址,但返回轻量 files 结构', async () => {
|
||||
;(getMaterialByTaskNo as jest.Mock).mockResolvedValue({
|
||||
id: 'material-task-other-1',
|
||||
taskNo: 'TASK202603310001',
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '通用',
|
||||
sceneName: '通用',
|
||||
materialType: '任务-其他',
|
||||
taskStatus: TASK_STATUS.COMPLETED,
|
||||
isFullSet: false,
|
||||
deletedAt: null,
|
||||
previewUrl: 'https://s3.example/task-other-cover.webp',
|
||||
zipFileName: '任务其他源文件.zip',
|
||||
zipFileSize: BigInt(52428800),
|
||||
createdAt: new Date('2026-03-31T08:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-31T08:05:00.000Z'),
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-preview',
|
||||
assetType: 'preview',
|
||||
fileUrl: 'https://s3.example/task-other-cover.webp',
|
||||
fileName: 'cover.webp',
|
||||
fileSize: BigInt(1024),
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'asset-zip',
|
||||
assetType: 'zip',
|
||||
fileUrl: 'uploads/materials/task-other.zip',
|
||||
fileName: '任务其他源文件.zip',
|
||||
fileSize: BigInt(52428800),
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/product-assets?taskNo=TASK202603310001'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toEqual({
|
||||
materialId: 'material-task-other-1',
|
||||
materialType: '任务-其他',
|
||||
taskNo: 'TASK202603310001',
|
||||
productModel: 'TASK202603310001',
|
||||
categoryName: '通用',
|
||||
shopName: '通用',
|
||||
sceneName: '通用',
|
||||
isFullSet: false,
|
||||
status: TASK_STATUS.FETCHED,
|
||||
createdAt: '2026-03-31T08:00:00.000Z',
|
||||
updatedAt: '2026-03-31T08:05:00.000Z',
|
||||
files: [
|
||||
{
|
||||
id: 'material-task-other-1',
|
||||
url: 'rewritten:https://s3.example/task-other-cover.webp',
|
||||
downloadUrl: 'https://download.example/material-task-other-1',
|
||||
fileName: '任务其他源文件.zip',
|
||||
fileSize: 52428800,
|
||||
},
|
||||
],
|
||||
downloadUrls: {
|
||||
all: 'https://download.example/material-task-other-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
queueTaskDetailFullSetNotification,
|
||||
} from '@/lib/external-auto-listing'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { createZipFromFiles } from '@/lib/task-material-processor'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
@ -194,6 +195,41 @@ describe('product-package-completion', () => {
|
||||
).rejects.toThrow('至少需要 1 个产品条码')
|
||||
})
|
||||
|
||||
test('历史导入允许 SKU 图没有产品条码', async () => {
|
||||
;(createZipFromFiles as jest.Mock).mockResolvedValue({
|
||||
filePath: 'zip-sku-only',
|
||||
fileName: 'sku-only.zip',
|
||||
fileSize: 22,
|
||||
})
|
||||
|
||||
await expect(
|
||||
completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
operatorUserId: 'user-1',
|
||||
assets: {
|
||||
sku: [
|
||||
{
|
||||
fileUrl: 'https://img.example/sku.png',
|
||||
fileName: 'sku.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
barcodes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
allowSkuWithoutBarcodes: true,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
isFullSet: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('同一批次重复条码绑定两张 SKU 图时应拒绝', async () => {
|
||||
await expect(
|
||||
completeProductPackageUpload({
|
||||
|
||||
121
lib/__tests__/product-package-init-route.test.ts
Normal file
121
lib/__tests__/product-package-init-route.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import { POST } from '@/app/api/materials/upload/product-package/init/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isTaskNoExists, purgeDeletedMaterialsByTaskNo, validateTaskNo } from '@/lib/product-package'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/product-package', () => ({
|
||||
validateTaskNo: jest.fn(() => true),
|
||||
isTaskNoExists: jest.fn(() => false),
|
||||
purgeDeletedMaterialsByTaskNo: jest.fn(() => Promise.resolve({ deletedMaterials: 0, deletedFiles: 0, failedFiles: 0 })),
|
||||
}))
|
||||
|
||||
describe('product-package init route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
realName: '测试用户',
|
||||
email: 'tester@example.com',
|
||||
},
|
||||
})
|
||||
;(prisma.material.create as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('未填写出图方式时也应允许初始化任务详情上传', async () => {
|
||||
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
sceneName: '客厅',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(prisma.material.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
materialType: '任务-详情',
|
||||
taskDetailSourceType: null,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('非法出图方式应返回 400', async () => {
|
||||
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
taskDetailSourceType: '照片',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error).toContain('出图方式')
|
||||
expect(prisma.material.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('传入合法出图方式时应原样保存', async () => {
|
||||
const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productModel: 'SPU-001',
|
||||
categoryName: '旗舰店',
|
||||
taskNo: 'TASK202604230001',
|
||||
taskDetailSourceType: '渲染',
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(validateTaskNo).toHaveBeenCalledWith('TASK202604230001')
|
||||
expect(isTaskNoExists).toHaveBeenCalledWith('TASK202604230001')
|
||||
expect(purgeDeletedMaterialsByTaskNo).toHaveBeenCalledWith('TASK202604230001')
|
||||
expect(prisma.material.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
taskDetailSourceType: '渲染',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
46
lib/__tests__/public-display.test.ts
Normal file
46
lib/__tests__/public-display.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
buildMaterialPublicDisplayPath,
|
||||
buildPublicDisplayObjectPath,
|
||||
buildPublicDisplayUrl,
|
||||
buildTaskDetailPublicDisplayPath,
|
||||
buildWhiteBackgroundPublicDisplayPath,
|
||||
} from '@/lib/public-display'
|
||||
|
||||
describe('public display helpers', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.S3_PUBLIC_DOMAIN = 'https://s3.jiansun.vip/gongxiangpan'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('应统一生成 public-display 对象路径', () => {
|
||||
expect(buildPublicDisplayObjectPath('/task-detail/material-1/main/cover.webp')).toBe(
|
||||
'public-display/task-detail/material-1/main/cover.webp',
|
||||
)
|
||||
expect(buildPublicDisplayObjectPath('white-background\\material-1\\sku-1\\front.webp')).toBe(
|
||||
'public-display/white-background/material-1/sku-1/front.webp',
|
||||
)
|
||||
})
|
||||
|
||||
test('应基于当前 S3_PUBLIC_DOMAIN 生成 public-display 公网链接', () => {
|
||||
expect(buildPublicDisplayUrl('task-detail/material-1/detail/detail-1.webp')).toBe(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-1.webp',
|
||||
)
|
||||
})
|
||||
|
||||
test('应提供展示图常用路径构造函数', () => {
|
||||
expect(buildMaterialPublicDisplayPath('preview-1.webp')).toBe('materials/preview-1.webp')
|
||||
expect(buildTaskDetailPublicDisplayPath('material-1', 'detail', 'detail-1.webp')).toBe(
|
||||
'task-detail/material-1/detail/detail-1.webp',
|
||||
)
|
||||
expect(
|
||||
buildWhiteBackgroundPublicDisplayPath('material-1', 'sku-1', 'other/01-side.webp'),
|
||||
).toBe('white-background/material-1/sku-1/other/01-side.webp')
|
||||
})
|
||||
})
|
||||
134
lib/__tests__/regenerate-preview-route.test.ts
Normal file
134
lib/__tests__/regenerate-preview-route.test.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { POST } from '@/app/api/admin/regenerate-preview/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
materialAsset: {
|
||||
findMany: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('sharp', () =>
|
||||
jest.fn(() => ({
|
||||
metadata: jest.fn().mockResolvedValue({ width: 1200, height: 2400 }),
|
||||
})),
|
||||
)
|
||||
|
||||
jest.mock('@/lib/image-processor', () => ({
|
||||
sliceImage: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
...jest.requireActual('@/lib/public-display'),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('adm-zip', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
extractAllTo: jest.fn(),
|
||||
})),
|
||||
)
|
||||
|
||||
describe('admin regenerate-preview route', () => {
|
||||
const fsPromises = jest.requireMock('fs/promises') as {
|
||||
readFile: jest.Mock
|
||||
mkdir: jest.Mock
|
||||
rm: jest.Mock
|
||||
readdir: jest.Mock
|
||||
}
|
||||
const { sliceImage } = jest.requireMock('@/lib/image-processor') as {
|
||||
sliceImage: jest.Mock
|
||||
}
|
||||
let dateNowSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'admin-1' },
|
||||
})
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
})
|
||||
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
materialId: 'material-1',
|
||||
assetType: 'detail',
|
||||
fileUrl: 'uploads/materials/detail.zip',
|
||||
},
|
||||
])
|
||||
;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
|
||||
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
|
||||
fsPromises.mkdir.mockResolvedValue(undefined)
|
||||
fsPromises.rm.mockResolvedValue(undefined)
|
||||
fsPromises.readdir.mockResolvedValue(['detail.png'])
|
||||
fsPromises.readFile.mockResolvedValue(Buffer.from('detail-image'))
|
||||
sliceImage.mockResolvedValue([Buffer.from('slice-1'), Buffer.from('slice-2')])
|
||||
;(uploadPublicDisplayToS3 as jest.Mock)
|
||||
.mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-1.webp')
|
||||
.mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-2.webp')
|
||||
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1712222222222)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dateNowSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('管理员重建详情预览时应写入 public-display task-detail 前缀', async () => {
|
||||
const request = {
|
||||
json: jest.fn().mockResolvedValue({ materialId: 'material-1' }),
|
||||
} as any
|
||||
|
||||
const response = await POST(request)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(uploadPublicDisplayToS3).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
Buffer.from('slice-1'),
|
||||
'task-detail/material-1/detail/detail-preview-1712222222222-0-slice0.webp',
|
||||
'image/webp',
|
||||
)
|
||||
expect(uploadPublicDisplayToS3).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
Buffer.from('slice-2'),
|
||||
'task-detail/material-1/detail/detail-preview-1712222222222-1-slice1.webp',
|
||||
'image/webp',
|
||||
)
|
||||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||||
where: { id: 'material-1' },
|
||||
data: {
|
||||
previewUrl:
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/detail/detail-preview-1.webp',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -73,6 +73,24 @@ describe('taobao-detail-plus route', () => {
|
||||
expect(body.error.code).toBe('SERVICE_NOT_CONFIGURED')
|
||||
})
|
||||
|
||||
test('上游返回 404 时应转成商品不存在的人话提示', async () => {
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
success: false,
|
||||
error: '未找到该商品数据',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await GET(createRequest('https://example.com/api/taobao-detail-plus?itemId=930071432357'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(body.error.code).toBe('ITEM_NOT_FOUND')
|
||||
expect(body.error.message).toBe('未找到该商品数据')
|
||||
})
|
||||
|
||||
test('查询成功时应返回精简后的商品摘要', async () => {
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
@ -95,6 +113,13 @@ describe('taobao-detail-plus route', () => {
|
||||
{ url: 'https://img.example.com/1.jpg' },
|
||||
{ url: 'https://img.example.com/2.jpg' },
|
||||
],
|
||||
skus_json: [
|
||||
{ img: 'https://img.example.com/sku-1.jpg' },
|
||||
{ image: 'https://img.example.com/sku-2.jpg' },
|
||||
],
|
||||
desc_imgs_json: [
|
||||
{ url: 'https://img.example.com/desc-1.jpg' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
@ -115,6 +140,9 @@ describe('taobao-detail-plus route', () => {
|
||||
videoUrl: 'https://cloud.video.taobao.com/video.mp4',
|
||||
mainImageUrl: 'https://img.example.com/1.jpg',
|
||||
itemImages: ['https://img.example.com/1.jpg', 'https://img.example.com/2.jpg'],
|
||||
allItemImages: ['https://img.example.com/1.jpg', 'https://img.example.com/2.jpg'],
|
||||
skuImages: ['https://img.example.com/sku-1.jpg', 'https://img.example.com/sku-2.jpg'],
|
||||
descImages: ['https://img.example.com/desc-1.jpg'],
|
||||
itemImageCount: 5,
|
||||
skuCount: 25,
|
||||
attributeCount: 17,
|
||||
@ -130,4 +158,88 @@ describe('taobao-detail-plus route', () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('应兼容从 skuBase 和详情 HTML 中提取图片', async () => {
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
item_id: '867081550619',
|
||||
title: '测试商品',
|
||||
shop_name: '测试店铺',
|
||||
item_imgs_json: [
|
||||
{ url: 'https://img.example.com/main-1.jpg' },
|
||||
],
|
||||
raw_data_json: {
|
||||
skuBase: {
|
||||
props: [
|
||||
{
|
||||
values: [
|
||||
{ image: '//img.example.com/sku-a.png' },
|
||||
{ imgUrl: 'https://img.example.com/sku-b.png' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
descHtml:
|
||||
'<div><img src="https://img.example.com/desc-a.jpg" /><img src="//img.example.com/desc-b.webp" /></div>',
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await GET(createRequest('https://example.com/api/taobao-detail-plus?itemId=867081550619'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.skuImages).toEqual([
|
||||
'https://img.example.com/sku-a.png',
|
||||
'https://img.example.com/sku-b.png',
|
||||
])
|
||||
expect(body.data.descImages).toEqual([
|
||||
'https://img.example.com/desc-a.jpg',
|
||||
'https://img.example.com/desc-b.webp',
|
||||
])
|
||||
})
|
||||
|
||||
test('应兼容从 specs_json 和字符串化 JSON 字段中提取 SKU 图与详情图', async () => {
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
item_id: '867081550619',
|
||||
title: '测试商品 2',
|
||||
shop_name: '测试店铺 2',
|
||||
item_imgs_json: JSON.stringify([
|
||||
{ url: 'https://img.example.com/main-1.jpg' },
|
||||
]),
|
||||
specs_json: JSON.stringify({
|
||||
颜色分类: {
|
||||
a: ['胡桃色', 'http://img.example.com/spec-1.jpg'],
|
||||
b: ['纯白色', 'https://img.example.com/spec-2.png'],
|
||||
},
|
||||
}),
|
||||
raw_data_json: JSON.stringify({
|
||||
desc:
|
||||
'<div><img src="https://img.example.com/desc-from-raw.jpg" /></div>',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await GET(createRequest('https://example.com/api/taobao-detail-plus?itemId=867081550619'))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.itemImages).toEqual(['https://img.example.com/main-1.jpg'])
|
||||
expect(body.data.skuImages).toEqual([
|
||||
'http://img.example.com/spec-1.jpg',
|
||||
'https://img.example.com/spec-2.png',
|
||||
])
|
||||
expect(body.data.descImages).toEqual([
|
||||
'https://img.example.com/desc-from-raw.jpg',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
89
lib/__tests__/taobao-image-proxy-route.test.ts
Normal file
89
lib/__tests__/taobao-image-proxy-route.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET } from '@/app/api/taobao-detail-plus/image-proxy/route'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
const TINY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9VE3d2kAAAAASUVORK5CYII='
|
||||
|
||||
describe('taobao image proxy route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', name: 'tester' },
|
||||
})
|
||||
global.fetch = jest.fn() as jest.Mock
|
||||
})
|
||||
|
||||
function createRequest(url: string) {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('未登录时应返回 401', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/taobao-detail-plus/image-proxy?url=https%3A%2F%2Fimg.alicdn.com%2Fa.png',
|
||||
),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
test('图片来源域名不允许时应返回 400', async () => {
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/taobao-detail-plus/image-proxy?url=https%3A%2F%2Fevil.example.com%2Fa.png',
|
||||
),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('IMAGE_HOST_NOT_ALLOWED')
|
||||
})
|
||||
|
||||
test('允许的图片地址应返回 png 数据', async () => {
|
||||
const upstreamBuffer = Buffer.from(TINY_PNG_BASE64, 'base64')
|
||||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () =>
|
||||
upstreamBuffer.buffer.slice(
|
||||
upstreamBuffer.byteOffset,
|
||||
upstreamBuffer.byteOffset + upstreamBuffer.byteLength,
|
||||
),
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'https://example.com/api/taobao-detail-plus/image-proxy?url=https%3A%2F%2Fimg.alicdn.com%2Fa.png',
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('image/png')
|
||||
expect(Number(response.headers.get('x-image-width'))).toBeGreaterThan(0)
|
||||
expect(Number(response.headers.get('x-image-height'))).toBeGreaterThan(0)
|
||||
|
||||
const bodyBuffer = Buffer.from(await response.arrayBuffer())
|
||||
expect(bodyBuffer.length).toBeGreaterThan(0)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://img.alicdn.com/a.png',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
83
lib/__tests__/task-material-processor.test.ts
Normal file
83
lib/__tests__/task-material-processor.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { generatePreviewImages } from '@/lib/task-material-processor'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
import { uploadToS3 } from '@/lib/s3'
|
||||
import { compressImage } from '@/lib/image-processor'
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-sessions', () => ({
|
||||
getUploadedFile: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-sessions-redis', () => ({
|
||||
deleteUploadSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/image-processor', () => ({
|
||||
compressImage: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
...jest.requireActual('@/lib/public-display'),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
uploadToS3: jest.fn(),
|
||||
deleteFromS3: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('task-material-processor', () => {
|
||||
const fsPromises = jest.requireMock('fs/promises') as {
|
||||
readFile: jest.Mock
|
||||
}
|
||||
const { getUploadedFile } = jest.requireMock('@/lib/upload-sessions') as {
|
||||
getUploadedFile: jest.Mock
|
||||
}
|
||||
let dateNowSpy: jest.SpyInstance
|
||||
let mathRandomSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fsPromises.readFile.mockResolvedValue(Buffer.from('source-image'))
|
||||
getUploadedFile.mockResolvedValue('/tmp/uploaded/image.png')
|
||||
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-image'))
|
||||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-123.webp',
|
||||
)
|
||||
;(uploadToS3 as jest.Mock).mockResolvedValue(
|
||||
'https://s3.jiansun.vip/gongxiangpan/product-package/preview-123.webp',
|
||||
)
|
||||
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1711111111111)
|
||||
mathRandomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.123456789)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dateNowSpy.mockRestore()
|
||||
mathRandomSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('generatePreviewImages 应将任务类预览图写入 public-display materials 前缀', async () => {
|
||||
const result = await generatePreviewImages(
|
||||
[{ uploadId: 'upload-1', fileName: 'origin.png' }],
|
||||
'任务-3D原图',
|
||||
)
|
||||
|
||||
expect(result).toEqual([
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-123.webp',
|
||||
])
|
||||
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
|
||||
Buffer.from('compressed-image'),
|
||||
expect.stringMatching(/^materials\/preview-1711111111111-0-[a-z0-9]+\.webp$/),
|
||||
'image/webp',
|
||||
)
|
||||
expect(uploadToS3).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
50
lib/__tests__/video-material-types.test.ts
Normal file
50
lib/__tests__/video-material-types.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {
|
||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
||||
getCompatibleVideoMaterialTypes,
|
||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
TAOXI_MAIN_VIDEO_TYPE,
|
||||
isBatchVideoMaterialType,
|
||||
isSingleVideoUploadMaterialType,
|
||||
isVideoMaterialType,
|
||||
normalizeVideoMaterialType,
|
||||
} from '@/lib/video-material-types'
|
||||
|
||||
describe('video-material-types', () => {
|
||||
test('会将旧视频类型归一到新名称', () => {
|
||||
expect(normalizeVideoMaterialType('主图视频')).toBe(TAOXI_MAIN_VIDEO_TYPE)
|
||||
expect(normalizeVideoMaterialType('种草视频')).toBe(TAOXI_GUANGGUANG_VIDEO_TYPE)
|
||||
})
|
||||
|
||||
test('新旧视频类型会返回兼容权限候选列表', () => {
|
||||
expect(getCompatibleVideoMaterialTypes('种草视频')).toEqual(
|
||||
expect.arrayContaining(['种草视频', TAOXI_GUANGGUANG_VIDEO_TYPE]),
|
||||
)
|
||||
expect(getCompatibleVideoMaterialTypes(TAOXI_GUANGGUANG_VIDEO_TYPE)).toEqual(
|
||||
expect.arrayContaining(['种草视频', TAOXI_GUANGGUANG_VIDEO_TYPE]),
|
||||
)
|
||||
})
|
||||
|
||||
test('新老视频类型都会被识别为视频素材', () => {
|
||||
expect(isVideoMaterialType(TAOXI_MAIN_VIDEO_TYPE)).toBe(true)
|
||||
expect(isVideoMaterialType(TAOXI_GUANGGUANG_VIDEO_TYPE)).toBe(true)
|
||||
expect(isVideoMaterialType(DOUYIN_QIANCHUAN_VIDEO_TYPE)).toBe(true)
|
||||
expect(isVideoMaterialType(DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE)).toBe(true)
|
||||
expect(isVideoMaterialType('主图视频')).toBe(true)
|
||||
expect(isVideoMaterialType('种草视频')).toBe(true)
|
||||
expect(isVideoMaterialType('音乐')).toBe(false)
|
||||
})
|
||||
|
||||
test('只把淘系-逛逛视频识别为批量视频类型', () => {
|
||||
expect(isBatchVideoMaterialType(TAOXI_GUANGGUANG_VIDEO_TYPE)).toBe(true)
|
||||
expect(isBatchVideoMaterialType('种草视频')).toBe(true)
|
||||
expect(isBatchVideoMaterialType(TAOXI_MAIN_VIDEO_TYPE)).toBe(false)
|
||||
expect(isBatchVideoMaterialType(DOUYIN_QIANCHUAN_VIDEO_TYPE)).toBe(false)
|
||||
})
|
||||
|
||||
test('抖音新类型走单文件视频上传口径', () => {
|
||||
expect(isSingleVideoUploadMaterialType(DOUYIN_QIANCHUAN_VIDEO_TYPE)).toBe(true)
|
||||
expect(isSingleVideoUploadMaterialType(DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE)).toBe(true)
|
||||
expect(isSingleVideoUploadMaterialType(TAOXI_GUANGGUANG_VIDEO_TYPE)).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -11,8 +11,14 @@ import {
|
||||
|
||||
describe('white-background-preview helpers', () => {
|
||||
const tempRoot = path.join(process.cwd(), 'uploads', '__white-background-preview-tests__')
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.S3_PUBLIC_DOMAIN = 'https://s3.jiansun.vip/gongxiangpan'
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.env = { ...originalEnv }
|
||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@ -60,7 +66,7 @@ describe('white-background-preview helpers', () => {
|
||||
expect(previewMetadata.height).toBe(300)
|
||||
})
|
||||
|
||||
test('prefers preview path when derived preview file exists, otherwise falls back to original path', async () => {
|
||||
test('优先使用本地 preview 文件,否则回退到 public-display 链接', async () => {
|
||||
const originalAbsolutePath = path.join(
|
||||
tempRoot,
|
||||
'material-1',
|
||||
@ -82,7 +88,9 @@ describe('white-background-preview helpers', () => {
|
||||
await fs.mkdir(path.dirname(originalAbsolutePath), { recursive: true })
|
||||
await fs.writeFile(originalAbsolutePath, Buffer.from('original'))
|
||||
|
||||
expect(resolveWhiteBackgroundPreviewFileUrl(originalRelativePath)).toBe(`/${originalRelativePath}`)
|
||||
expect(resolveWhiteBackgroundPreviewFileUrl(originalRelativePath)).toBe(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/front.webp',
|
||||
)
|
||||
|
||||
await fs.mkdir(path.dirname(previewAbsolutePath), { recursive: true })
|
||||
await fs.writeFile(previewAbsolutePath, Buffer.from('preview'))
|
||||
|
||||
@ -5,6 +5,8 @@ import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
|
||||
import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
import { deleteFromS3 } from '@/lib/s3'
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
@ -56,6 +58,15 @@ jest.mock('@/lib/white-background-preview', () => ({
|
||||
buildWhiteBackgroundWebpVariants: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
...jest.requireActual('@/lib/public-display'),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
deleteFromS3: jest.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('white-background upload route', () => {
|
||||
const fsPromises = jest.requireMock('fs').promises as {
|
||||
mkdir: jest.Mock
|
||||
@ -94,12 +105,23 @@ describe('white-background upload route', () => {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
materialSku: {
|
||||
update: jest.fn().mockResolvedValue({
|
||||
id: 'sku-1',
|
||||
whiteBackgroundSourceType: '渲染',
|
||||
whiteBackgroundUploadedBy: 'user-1',
|
||||
whiteBackgroundUploadedAt: new Date('2026-04-30T08:00:00.000Z'),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
;(buildWhiteBackgroundWebpVariants as jest.Mock).mockResolvedValue({
|
||||
original: Buffer.from('original-webp'),
|
||||
preview: Buffer.from('preview-webp'),
|
||||
})
|
||||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
|
||||
)
|
||||
;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
@ -113,10 +135,11 @@ describe('white-background upload route', () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
test('uploads fixed white background image as original webp and preview webp', async () => {
|
||||
test('uploads fixed white background image as local original webp and public-display preview', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
formData.append('skuName', 'XG014799')
|
||||
formData.append('sourceType', '渲染')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
@ -130,18 +153,20 @@ describe('white-background upload route', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(buildWhiteBackgroundWebpVariants).toHaveBeenCalledTimes(1)
|
||||
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
|
||||
Buffer.from('preview-webp'),
|
||||
'white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
|
||||
'image/webp',
|
||||
)
|
||||
expect(fsPromises.writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(fsPromises.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°.webp'),
|
||||
Buffer.from('original-webp'),
|
||||
)
|
||||
expect(fsPromises.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\preview\\XG014799-鞋柜-右前45°.webp'),
|
||||
Buffer.from('preview-webp'),
|
||||
)
|
||||
expect(prisma.$transaction).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('cleans up written files when persistence fails after writing webp variants', async () => {
|
||||
test('sourceType 缺失时应返回 400', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
formData.append('skuName', 'XG014799')
|
||||
@ -149,6 +174,44 @@ describe('white-background upload route', () => {
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
|
||||
const response = await POST(createRequest(formData), {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
|
||||
})
|
||||
|
||||
test('sourceType 非法时应返回 400', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
formData.append('skuName', 'XG014799')
|
||||
formData.append('sourceType', '手绘')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
|
||||
const response = await POST(createRequest(formData), {
|
||||
params: { id: 'material-1' },
|
||||
})
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
|
||||
})
|
||||
|
||||
test('cleans up written files and uploaded public-display preview when persistence fails', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('skuId', 'sku-1')
|
||||
formData.append('skuName', 'XG014799')
|
||||
formData.append('sourceType', 'AI')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('db failed'))
|
||||
|
||||
const response = await POST(createRequest(formData), {
|
||||
@ -156,12 +219,12 @@ describe('white-background upload route', () => {
|
||||
})
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(fsPromises.unlink).toHaveBeenCalledTimes(2)
|
||||
expect(fsPromises.unlink).toHaveBeenCalledTimes(1)
|
||||
expect(fsPromises.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°.webp'),
|
||||
)
|
||||
expect(fsPromises.unlink).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads\\skus\\material-1\\white-background\\sku-1\\preview\\XG014799-鞋柜-右前45°.webp'),
|
||||
expect(deleteFromS3).toHaveBeenCalledWith(
|
||||
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
57
lib/certificate-report.ts
Normal file
57
lib/certificate-report.ts
Normal file
@ -0,0 +1,57 @@
|
||||
export const CERTIFICATE_REPORT_MATERIAL_TYPE = '证书-检测报告'
|
||||
|
||||
export const CERTIFICATE_REPORT_ASSET_TYPE = {
|
||||
PDF: 'report-pdf',
|
||||
IMAGE: 'report-image',
|
||||
} as const
|
||||
|
||||
export type CertificateReportAssetType =
|
||||
(typeof CERTIFICATE_REPORT_ASSET_TYPE)[keyof typeof CERTIFICATE_REPORT_ASSET_TYPE]
|
||||
|
||||
export type CertificateReportFileKind = 'pdf' | 'image'
|
||||
|
||||
export function isCertificateReportMaterialType(materialType: string | null | undefined): boolean {
|
||||
return String(materialType || '').trim() === CERTIFICATE_REPORT_MATERIAL_TYPE
|
||||
}
|
||||
|
||||
export function normalizeReportTitle(value: string | null | undefined): string | null {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
export function getCertificateReportDisplayTitle(input: {
|
||||
productModel?: string | null
|
||||
reportTitle?: string | null
|
||||
}): string {
|
||||
const productModel = String(input.productModel || '').trim()
|
||||
if (productModel) {
|
||||
return productModel
|
||||
}
|
||||
|
||||
const reportTitle = String(input.reportTitle || '').trim()
|
||||
if (reportTitle) {
|
||||
return reportTitle
|
||||
}
|
||||
|
||||
return '未命名报告'
|
||||
}
|
||||
|
||||
export function getCertificateReportFileKindByAssetType(assetType: string | null | undefined): CertificateReportFileKind | null {
|
||||
if (assetType === CERTIFICATE_REPORT_ASSET_TYPE.PDF) {
|
||||
return 'pdf'
|
||||
}
|
||||
|
||||
if (assetType === CERTIFICATE_REPORT_ASSET_TYPE.IMAGE) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function detectFileKindFromName(fileName: string | null | undefined): CertificateReportFileKind {
|
||||
const lowerName = String(fileName || '').trim().toLowerCase()
|
||||
if (lowerName.endsWith('.pdf')) {
|
||||
return 'pdf'
|
||||
}
|
||||
return 'image'
|
||||
}
|
||||
@ -32,25 +32,45 @@ interface ChunkUploadState {
|
||||
metadata?: Record<string, any> // 表单元数据(用于恢复时预填)
|
||||
}
|
||||
|
||||
interface UploadInitError extends Error {
|
||||
interface UploadHttpError extends Error {
|
||||
status?: number
|
||||
retryable?: boolean
|
||||
}
|
||||
|
||||
function createUploadInitError(message: string, status?: number, retryable: boolean = true): UploadInitError {
|
||||
const error = new Error(message) as UploadInitError
|
||||
function createUploadHttpError(message: string, status?: number, retryable: boolean = true): UploadHttpError {
|
||||
const error = new Error(message) as UploadHttpError
|
||||
error.status = status
|
||||
error.retryable = retryable
|
||||
return error
|
||||
}
|
||||
|
||||
function isRetryableUploadInitError(error: unknown): boolean {
|
||||
function isRetryableUploadError(error: unknown): boolean {
|
||||
if (error && typeof error === 'object' && 'retryable' in error) {
|
||||
return Boolean((error as UploadInitError).retryable)
|
||||
return Boolean((error as UploadHttpError).retryable)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isNonRetryableUploadError(error: unknown): boolean {
|
||||
return !isRetryableUploadError(error)
|
||||
}
|
||||
|
||||
function isRetryableHttpStatus(status: number): boolean {
|
||||
return status >= 500 || status === 408 || status === 429
|
||||
}
|
||||
|
||||
function getUploadHttpErrorMessage(action: string, status: number, fallback: string): string {
|
||||
if (status === 413) {
|
||||
return `${action}失败:上传分片超过入口服务器限制(413),请联系管理员检查公网入口上传大小限制`
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return `${action}失败:登录状态已失效或无权限,请重新登录后再试`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 存储上传状态到IndexedDB
|
||||
const DB_NAME = 'chunk-upload-db'
|
||||
const STORE_NAME = 'upload-states'
|
||||
@ -152,8 +172,8 @@ async function initUpload(fileName: string, fileSize: number, totalChunks: numbe
|
||||
// 忽略错误响应解析失败,保留默认文案
|
||||
}
|
||||
|
||||
const retryable = response.status >= 500 || response.status === 408 || response.status === 429
|
||||
throw createUploadInitError(errorMessage, response.status, retryable)
|
||||
const retryable = isRetryableHttpStatus(response.status)
|
||||
throw createUploadHttpError(errorMessage, response.status, retryable)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@ -253,7 +273,13 @@ async function uploadChunk(
|
||||
console.warn(`[ChunkUpload] ⚠️ Chunk ${chunkIndex} 会话不存在(可能已过期),需要重新初始化`)
|
||||
reject(new Error('UPLOAD_SESSION_EXPIRED'))
|
||||
} else {
|
||||
reject(new Error(`上传块 ${chunkIndex} 失败: ${xhr.status}`))
|
||||
const retryable = isRetryableHttpStatus(xhr.status)
|
||||
const message = getUploadHttpErrorMessage(
|
||||
`上传块 ${chunkIndex}`,
|
||||
xhr.status,
|
||||
`上传块 ${chunkIndex} 失败: ${xhr.status}`,
|
||||
)
|
||||
reject(createUploadHttpError(message, xhr.status, retryable))
|
||||
}
|
||||
}
|
||||
|
||||
@ -309,7 +335,8 @@ export async function completeUpload(
|
||||
throw new Error('UPLOAD_SESSION_EXPIRED')
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
const retryable = isRetryableHttpStatus(response.status)
|
||||
throw createUploadHttpError(errorMessage, response.status, retryable)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
@ -402,7 +429,7 @@ export async function chunkUpload(
|
||||
state.uploadId = await initUpload(file.name, file.size, totalChunks)
|
||||
await saveUploadState(state)
|
||||
} catch (error) {
|
||||
if (!isRetryableUploadInitError(error)) {
|
||||
if (!isRetryableUploadError(error)) {
|
||||
await deleteUploadState(fileId).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
@ -416,7 +443,7 @@ export async function chunkUpload(
|
||||
await saveUploadState(state)
|
||||
break
|
||||
} catch (e) {
|
||||
if (!isRetryableUploadInitError(e)) {
|
||||
if (!isRetryableUploadError(e)) {
|
||||
await deleteUploadState(fileId).catch(() => {})
|
||||
throw e
|
||||
}
|
||||
@ -609,6 +636,21 @@ export async function chunkUpload(
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (isNonRetryableUploadError(error)) {
|
||||
await deleteUploadState(fileId).catch(() => {})
|
||||
if (state.uploadId) {
|
||||
console.log('[ChunkUpload] 上传分片失败且不可重试,调用后端清理临时文件...')
|
||||
fetch('/api/materials/upload/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uploadId: state.uploadId })
|
||||
}).catch(err => {
|
||||
console.warn('[ChunkUpload] 清理临时文件失败(忽略):', err)
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// 如果是会话过期,尝试刷新会话(保留进度)
|
||||
if ((error as Error)?.message === 'UPLOAD_SESSION_EXPIRED') {
|
||||
@ -756,6 +798,18 @@ export async function chunkUpload(
|
||||
}
|
||||
throw new Error('上传会话已过期,请重新上传文件')
|
||||
}
|
||||
|
||||
if (isNonRetryableUploadError(error)) {
|
||||
await deleteUploadState(fileId).catch(() => {})
|
||||
if (state.uploadId) {
|
||||
fetch('/api/materials/upload/cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uploadId: state.uploadId })
|
||||
}).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
retries++
|
||||
if (retries < maxRetries) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { prisma } from './prisma';
|
||||
import { filterSelectableMaterialTypes, MATERIAL_TYPE_OPTIONS } from './material-type-options';
|
||||
import { getCompatibleVideoMaterialTypes, isVideoMaterialType } from './video-material-types';
|
||||
|
||||
/**
|
||||
* 判断是否是超级管理员(从数据库读取)
|
||||
@ -138,6 +139,33 @@ export interface DepartmentPermission {
|
||||
linkedToDepartment: string | null;
|
||||
}
|
||||
|
||||
async function findDepartmentPermissionRecord(
|
||||
department: string,
|
||||
materialType: string
|
||||
) {
|
||||
const normalizedType = String(materialType || '').trim();
|
||||
const lookupTypes = isVideoMaterialType(normalizedType)
|
||||
? getCompatibleVideoMaterialTypes(normalizedType)
|
||||
: [normalizedType];
|
||||
|
||||
for (const lookupType of lookupTypes) {
|
||||
const permission = await prisma.departmentMaterialPermission.findUnique({
|
||||
where: {
|
||||
department_materialType: {
|
||||
department,
|
||||
materialType: lookupType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (permission) {
|
||||
return permission;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取部门的实际权限(考虑关联部门和权限继承)
|
||||
*
|
||||
@ -154,25 +182,14 @@ export async function getDepartmentPermission(
|
||||
descendantMap?: Map<string, Set<string>>
|
||||
): Promise<DepartmentPermission | null> {
|
||||
// 先查询当前部门的权限配置
|
||||
const permission = await prisma.departmentMaterialPermission.findUnique({
|
||||
where: {
|
||||
department_materialType: {
|
||||
department,
|
||||
materialType,
|
||||
},
|
||||
},
|
||||
});
|
||||
const permission = await findDepartmentPermissionRecord(department, materialType);
|
||||
|
||||
// 如果有关联部门,从关联部门读取权限
|
||||
if (permission?.linkedToDepartment) {
|
||||
const linkedPermission = await prisma.departmentMaterialPermission.findUnique({
|
||||
where: {
|
||||
department_materialType: {
|
||||
department: permission.linkedToDepartment,
|
||||
materialType,
|
||||
},
|
||||
},
|
||||
});
|
||||
const linkedPermission = await findDepartmentPermissionRecord(
|
||||
permission.linkedToDepartment,
|
||||
materialType
|
||||
);
|
||||
|
||||
if (linkedPermission) {
|
||||
return {
|
||||
|
||||
137
lib/excel-export.ts
Normal file
137
lib/excel-export.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import ExcelJS from 'exceljs'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
interface ExportImageGroup {
|
||||
name: string
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
interface ExcelEmbedImage {
|
||||
buffer: ArrayBuffer
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const IMAGE_PREVIEW_COLUMN_WIDTH = 25
|
||||
const IMAGE_CELL_WIDTH_PX = 180
|
||||
const POINTS_PER_PIXEL = 0.75
|
||||
|
||||
function calculateImageDisplaySize(width: number, height: number) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return {
|
||||
width: IMAGE_CELL_WIDTH_PX,
|
||||
height: IMAGE_CELL_WIDTH_PX,
|
||||
}
|
||||
}
|
||||
|
||||
const scale = IMAGE_CELL_WIDTH_PX / width
|
||||
return {
|
||||
width: IMAGE_CELL_WIDTH_PX,
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
function calculateImageRowHeightPoints(imageHeightPx: number) {
|
||||
return Math.max(90, Math.round(imageHeightPx * POINTS_PER_PIXEL))
|
||||
}
|
||||
|
||||
async function fetchExcelEmbedImage(url: string): Promise<ExcelEmbedImage> {
|
||||
const proxyUrl = `/api/taobao-detail-plus/image-proxy?url=${encodeURIComponent(url)}`
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image proxy failed with ${response.status}`)
|
||||
}
|
||||
|
||||
const width = Number(response.headers.get('x-image-width') || 0)
|
||||
const height = Number(response.headers.get('x-image-height') || 0)
|
||||
|
||||
return {
|
||||
buffer: await response.arrayBuffer(),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTaobaoImagesToExcel(
|
||||
title: string,
|
||||
itemId: string,
|
||||
groups: ExportImageGroup[]
|
||||
) {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const worksheet = workbook.addWorksheet('商品图片明细')
|
||||
|
||||
// 设置列宽
|
||||
worksheet.columns = [
|
||||
{ header: '分区', key: 'group', width: 15 },
|
||||
{ header: '序号', key: 'index', width: 10 },
|
||||
{ header: '图片预览', key: 'image', width: IMAGE_PREVIEW_COLUMN_WIDTH },
|
||||
{ header: '原始链接', key: 'url', width: 60 }
|
||||
]
|
||||
|
||||
// 设置表头样式
|
||||
worksheet.getRow(1).font = { bold: true }
|
||||
worksheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' }
|
||||
|
||||
let currentRow = 2
|
||||
|
||||
for (const group of groups) {
|
||||
for (let i = 0; i < group.urls.length; i++) {
|
||||
const url = group.urls[i]
|
||||
const row = worksheet.addRow({
|
||||
group: i === 0 ? group.name : '',
|
||||
index: i + 1,
|
||||
url: url
|
||||
})
|
||||
|
||||
row.height = 90
|
||||
|
||||
try {
|
||||
const imageData = await fetchExcelEmbedImage(url)
|
||||
const displaySize = calculateImageDisplaySize(imageData.width, imageData.height)
|
||||
row.height = calculateImageRowHeightPoints(displaySize.height)
|
||||
|
||||
const imageId = workbook.addImage({
|
||||
buffer: imageData.buffer,
|
||||
extension: 'png',
|
||||
})
|
||||
|
||||
const imageAnchor = {
|
||||
tl: { col: 2, row: currentRow - 1 },
|
||||
ext: {
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
},
|
||||
editAs: 'oneCell'
|
||||
} as any
|
||||
|
||||
worksheet.addImage(imageId, imageAnchor)
|
||||
} catch (error) {
|
||||
console.error(`Failed to embed image: ${url}`, error)
|
||||
worksheet.getCell(currentRow, 3).value = '图片下载失败'
|
||||
}
|
||||
|
||||
currentRow++
|
||||
}
|
||||
|
||||
// 分组之间加个细微分隔
|
||||
if (group.urls.length > 0) {
|
||||
worksheet.addRow([])
|
||||
currentRow++
|
||||
}
|
||||
}
|
||||
|
||||
// 垂直居中所有单元格
|
||||
worksheet.eachRow((row) => {
|
||||
row.alignment = { vertical: 'middle', horizontal: 'center' }
|
||||
})
|
||||
|
||||
// 导出文件
|
||||
const buffer = await workbook.xlsx.writeBuffer()
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
saveAs(blob, `${itemId}_淘宝图片导出_${new Date().getTime()}.xlsx`)
|
||||
}
|
||||
@ -104,10 +104,30 @@ export async function validateApiKey(apiKey: string | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头获取 API Key
|
||||
* 从请求中获取 API Key。
|
||||
*
|
||||
* 默认只接受请求头;少数低代码平台不方便配置固定 Header 时,
|
||||
* 可由具体路由显式开启 URL 参数兼容。
|
||||
*/
|
||||
export function getApiKeyFromRequest(request: NextRequest): string | null {
|
||||
return request.headers.get('X-API-Key');
|
||||
export function getApiKeyFromRequest(
|
||||
request: NextRequest,
|
||||
options?: { allowQuery?: boolean },
|
||||
): string | null {
|
||||
const headerKey = request.headers.get('X-API-Key')?.trim();
|
||||
if (headerKey) {
|
||||
return headerKey;
|
||||
}
|
||||
|
||||
if (!options?.allowQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryKey =
|
||||
request.nextUrl.searchParams.get('apiKey') ||
|
||||
request.nextUrl.searchParams.get('xApiKey') ||
|
||||
request.nextUrl.searchParams.get('api_key');
|
||||
|
||||
return queryKey?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
844
lib/history-material-import.ts
Normal file
844
lib/history-material-import.ts
Normal file
@ -0,0 +1,844 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { completeProductPackageUpload, type ProductPackageImageInput } from '@/lib/product-package-completion'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set'
|
||||
import path from 'path'
|
||||
import { promises as fs } from 'fs'
|
||||
import crypto from 'crypto'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
|
||||
const SKIPPED_FILE_EXTENSIONS = new Set([
|
||||
'.xlsx',
|
||||
'.xls',
|
||||
'.psd',
|
||||
'.psb',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
'.zip',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.pdf',
|
||||
'.log',
|
||||
'.db',
|
||||
'.lnk',
|
||||
])
|
||||
|
||||
const IGNORED_DIR_PATTERNS = [
|
||||
/@eadir/i,
|
||||
/策划方案/i,
|
||||
/产品资料/i,
|
||||
/模特图/i,
|
||||
/废稿/i,
|
||||
/字体/i,
|
||||
/勿用/i,
|
||||
/DingTalkAppData/i,
|
||||
/^PsTool$/i,
|
||||
/^Cache$/i,
|
||||
/RECYCLE/i,
|
||||
]
|
||||
|
||||
const CANDIDATE_DIR_PATTERNS = [
|
||||
/详情全套/i,
|
||||
/全套详情/i,
|
||||
/视觉全套/i,
|
||||
/平面全套/i,
|
||||
/全案/i,
|
||||
/全套a图/i,
|
||||
/全套视觉方案/i,
|
||||
/全套视觉策划/i,
|
||||
/全套视觉实拍方案/i,
|
||||
/全套视觉/i,
|
||||
]
|
||||
const MAIN_HINT_PATTERNS = [/主图/i, /新主图/i]
|
||||
const SKU_HINT_PATTERNS = [/sku/i, /sku图/i]
|
||||
const DETAIL_HINT_PATTERNS = [/详情/i, /详情图/i, /详情页/i]
|
||||
const MAIN_FILE_EXCLUDE_PATTERNS = [/功能主图/i, /微详情/i]
|
||||
const DETAIL_FILE_EXCLUDE_PATTERNS = [/模特/i]
|
||||
const STORE_DIR_IGNORES = new Set([
|
||||
'.recycle',
|
||||
'@eaDir',
|
||||
'$NSEC.RECYCLE.BIN',
|
||||
'DingTalkAppData',
|
||||
'PsTool',
|
||||
'营销策划方案',
|
||||
'3D66',
|
||||
])
|
||||
const execFileAsync = promisify(execFile)
|
||||
const HISTORY_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ZIP_TIMEOUT_MS || 180000)
|
||||
const HISTORY_ALL_ZIP_TIMEOUT_MS = Number(process.env.HISTORY_IMPORT_ALL_ZIP_TIMEOUT_MS || 300000)
|
||||
|
||||
export type HistoryImportStatus = 'ready' | 'needs_review' | 'skipped'
|
||||
|
||||
export interface HistoryImportRecord {
|
||||
fingerprint: string
|
||||
spu: string
|
||||
sourcePath: string
|
||||
relativeSourcePath: string
|
||||
guessedStore: string
|
||||
productCategoryName: string
|
||||
authorName: string
|
||||
storeConfidence: number
|
||||
storeCandidates: string[]
|
||||
mainCount: number
|
||||
skuCount: number
|
||||
detailCount: number
|
||||
status: HistoryImportStatus
|
||||
skipReason: string | null
|
||||
mainImages: ProductPackageImageInput[]
|
||||
skuImages: ProductPackageImageInput[]
|
||||
detailImages: ProductPackageImageInput[]
|
||||
seenInYya: boolean
|
||||
}
|
||||
|
||||
export interface ScanHistoryMaterialsOptions {
|
||||
materialsRoot: string
|
||||
cpRoot?: string
|
||||
yyaRoot?: string
|
||||
limit?: number
|
||||
spuFilter?: string[]
|
||||
}
|
||||
|
||||
export interface ImportHistoryMaterialsOptions {
|
||||
records: HistoryImportRecord[]
|
||||
operator: {
|
||||
id: string
|
||||
username: string
|
||||
realName: string | null
|
||||
}
|
||||
apply?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ImportHistoryMaterialsResultItem {
|
||||
fingerprint: string
|
||||
spu: string
|
||||
sourcePath: string
|
||||
guessedStore: string
|
||||
materialId: string | null
|
||||
status: 'imported' | 'dry_run' | 'skipped' | 'failed'
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface StoreIndex {
|
||||
cpStoresBySpu: Map<string, Map<string, number>>
|
||||
yyaSpus: Set<string>
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
fullPath: string
|
||||
relativePath: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
function normalizeSpu(value: string) {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
function isSpuName(name: string) {
|
||||
return /^[A-Za-z]{1,5}\d{3,5}$/i.test(name.trim())
|
||||
}
|
||||
|
||||
function matchesAny(input: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(input))
|
||||
}
|
||||
|
||||
function shouldIgnoreDir(fullPath: string) {
|
||||
return matchesAny(fullPath, IGNORED_DIR_PATTERNS)
|
||||
}
|
||||
|
||||
function shouldSkipFile(filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return SKIPPED_FILE_EXTENSIONS.has(ext) || !IMAGE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
function makeFingerprint(spu: string, sourcePath: string) {
|
||||
return crypto.createHash('sha1').update(`${normalizeSpu(spu)}|${sourcePath}`).digest('hex')
|
||||
}
|
||||
|
||||
function scoreStoreByPath(sourcePath: string, stores: string[]) {
|
||||
const hits = new Map<string, number>()
|
||||
for (const store of stores) {
|
||||
if (!store || store === '其他') {
|
||||
continue
|
||||
}
|
||||
if (sourcePath.includes(store)) {
|
||||
hits.set(store, (hits.get(store) || 0) + 3)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
async function existsDir(dirPath: string) {
|
||||
try {
|
||||
const stat = await fs.stat(dirPath)
|
||||
return stat.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function walkDirectories(root: string, maxDepth = 6) {
|
||||
const result: string[] = []
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
if (current.depth > maxDepth) {
|
||||
continue
|
||||
}
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullPath = path.join(current.dir, entry.name)
|
||||
if (shouldIgnoreDir(fullPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(fullPath)
|
||||
queue.push({ dir: fullPath, depth: current.depth + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function walkFiles(root: string, maxDepth = 5) {
|
||||
const result: FileEntry[] = []
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
if (current.depth > maxDepth) {
|
||||
continue
|
||||
}
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current.dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnoreDir(fullPath)) {
|
||||
continue
|
||||
}
|
||||
queue.push({ dir: fullPath, depth: current.depth + 1 })
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldSkipFile(fullPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath)
|
||||
result.push({
|
||||
fullPath,
|
||||
relativePath: path.relative(root, fullPath),
|
||||
fileName: entry.name,
|
||||
fileSize: stat.size,
|
||||
})
|
||||
} catch {
|
||||
// ignore unreadable file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function buildStoreIndex(
|
||||
cpRoot: string | undefined,
|
||||
yyaRoot: string | undefined,
|
||||
spus: string[],
|
||||
): Promise<StoreIndex> {
|
||||
const cpStoresBySpu = new Map<string, Map<string, number>>()
|
||||
const yyaSpus = new Set<string>()
|
||||
const normalizedSpus = Array.from(new Set(spus.map(normalizeSpu)))
|
||||
|
||||
if (cpRoot && (await existsDir(cpRoot)) && normalizedSpus.length > 0) {
|
||||
const fastMatches = await searchPathsByRipgrep(cpRoot, normalizedSpus)
|
||||
if (fastMatches.length > 0) {
|
||||
for (const matchPath of fastMatches) {
|
||||
const relativePath = path.relative(cpRoot, matchPath)
|
||||
const [storeName] = relativePath.split(path.sep)
|
||||
if (!storeName || STORE_DIR_IGNORES.has(storeName)) {
|
||||
continue
|
||||
}
|
||||
const normalizedPath = matchPath.toUpperCase()
|
||||
for (const spu of normalizedSpus) {
|
||||
if (!normalizedPath.includes(spu)) {
|
||||
continue
|
||||
}
|
||||
let stores = cpStoresBySpu.get(spu)
|
||||
if (!stores) {
|
||||
stores = new Map<string, number>()
|
||||
cpStoresBySpu.set(spu, stores)
|
||||
}
|
||||
stores.set(storeName, (stores.get(storeName) || 0) + 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const storeDirs = await fs.readdir(cpRoot, { withFileTypes: true })
|
||||
for (const entry of storeDirs) {
|
||||
if (!entry.isDirectory() || STORE_DIR_IGNORES.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
const storeName = entry.name.trim()
|
||||
const storePath = path.join(cpRoot, entry.name)
|
||||
const directories = await walkDirectories(storePath, 5)
|
||||
for (const dirPath of directories) {
|
||||
const normalizedPath = dirPath.toUpperCase()
|
||||
for (const spu of normalizedSpus) {
|
||||
if (!normalizedPath.includes(spu)) {
|
||||
continue
|
||||
}
|
||||
let stores = cpStoresBySpu.get(spu)
|
||||
if (!stores) {
|
||||
stores = new Map<string, number>()
|
||||
cpStoresBySpu.set(spu, stores)
|
||||
}
|
||||
stores.set(storeName, (stores.get(storeName) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (yyaRoot && (await existsDir(yyaRoot)) && normalizedSpus.length > 0) {
|
||||
const fastMatches = await searchPathsByRipgrep(yyaRoot, normalizedSpus)
|
||||
if (fastMatches.length > 0) {
|
||||
for (const matchPath of fastMatches) {
|
||||
const normalizedPath = matchPath.toUpperCase()
|
||||
for (const spu of normalizedSpus) {
|
||||
if (normalizedPath.includes(spu)) {
|
||||
yyaSpus.add(spu)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const directories = await walkDirectories(yyaRoot, 6)
|
||||
for (const dirPath of directories) {
|
||||
const normalizedPath = dirPath.toUpperCase()
|
||||
for (const spu of normalizedSpus) {
|
||||
if (normalizedPath.includes(spu)) {
|
||||
yyaSpus.add(spu)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cpStoresBySpu,
|
||||
yyaSpus,
|
||||
}
|
||||
}
|
||||
|
||||
async function searchPathsByRipgrep(root: string, spus: string[]) {
|
||||
try {
|
||||
const normalizedSpus = Array.from(
|
||||
new Set(
|
||||
spus
|
||||
.map((spu) => normalizeSpu(spu))
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
|
||||
if (normalizedSpus.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const args = ['--files', root]
|
||||
for (const spu of normalizedSpus) {
|
||||
args.push('-g', `*${spu}*`)
|
||||
args.push('-g', `*${spu.toLowerCase()}*`)
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
'rg',
|
||||
args,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function pickStore(spu: string, sourcePath: string, storeIndex: StoreIndex) {
|
||||
const cpStores = storeIndex.cpStoresBySpu.get(normalizeSpu(spu))
|
||||
const pathHits = scoreStoreByPath(sourcePath, cpStores ? Array.from(cpStores.keys()) : [])
|
||||
|
||||
if (cpStores) {
|
||||
for (const [store, count] of Array.from(cpStores.entries())) {
|
||||
pathHits.set(store, (pathHits.get(store) || 0) + count)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = Array.from(pathHits.entries()).sort((a, b) => b[1] - a[1])
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
guessedStore: '其他',
|
||||
storeConfidence: 0,
|
||||
storeCandidates: [],
|
||||
}
|
||||
}
|
||||
|
||||
const [bestStore, bestScore] = candidates[0]
|
||||
const secondScore = candidates[1]?.[1] || 0
|
||||
const storeConfidence = bestScore <= 0 ? 0 : Math.min(1, (bestScore - secondScore + 1) / (bestScore + 1))
|
||||
|
||||
return {
|
||||
guessedStore: bestStore || '其他',
|
||||
storeConfidence,
|
||||
storeCandidates: candidates.map(([store]) => store),
|
||||
}
|
||||
}
|
||||
|
||||
function findSkuCode(spu: string, fileName: string, sortOrder: number) {
|
||||
const normalizedSpu = normalizeSpu(spu)
|
||||
const normalizedName = fileName.toUpperCase()
|
||||
const exactMatch = normalizedName.match(new RegExp(`(${normalizedSpu}\\d{2,3})`, 'i'))
|
||||
if (exactMatch?.[1]) {
|
||||
return exactMatch[1].toUpperCase()
|
||||
}
|
||||
|
||||
const skuMatch = normalizedName.match(/SKU[_-]?(\d{1,3})/i)
|
||||
if (skuMatch?.[1]) {
|
||||
return `${normalizedSpu}${skuMatch[1].padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return `${normalizedSpu}${String(sortOrder + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function classifyFileGroup(relativePath: string) {
|
||||
const normalized = relativePath.replace(/\\/g, '/')
|
||||
if (MAIN_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
return null
|
||||
}
|
||||
if (DETAIL_FILE_EXCLUDE_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (matchesAny(normalized, SKU_HINT_PATTERNS)) {
|
||||
return 'sku' as const
|
||||
}
|
||||
if (matchesAny(normalized, DETAIL_HINT_PATTERNS)) {
|
||||
return 'detail' as const
|
||||
}
|
||||
if (matchesAny(normalized, MAIN_HINT_PATTERNS)) {
|
||||
return 'main' as const
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sortByNaturalFileName<T extends { fileName: string }>(items: T[]) {
|
||||
return [...items].sort((left, right) =>
|
||||
left.fileName.localeCompare(right.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }),
|
||||
)
|
||||
}
|
||||
|
||||
function buildAssetInputs(spu: string, files: FileEntry[], group: 'main' | 'sku' | 'detail') {
|
||||
const sorted = sortByNaturalFileName(files)
|
||||
const seenSkuCodes = new Map<string, number>()
|
||||
|
||||
return sorted.map((file, index) => {
|
||||
if (group !== 'sku') {
|
||||
return {
|
||||
localFilePath: file.fullPath,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
sortOrder: index,
|
||||
}
|
||||
}
|
||||
|
||||
const baseSkuCode = findSkuCode(spu, file.fileName, index)
|
||||
const conflictCount = seenSkuCodes.get(baseSkuCode) || 0
|
||||
seenSkuCodes.set(baseSkuCode, conflictCount + 1)
|
||||
const skuCode = conflictCount === 0 ? baseSkuCode : `${baseSkuCode}-${conflictCount + 1}`
|
||||
|
||||
return {
|
||||
localFilePath: file.fullPath,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
sortOrder: index,
|
||||
skuCode,
|
||||
barcodes: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function findCandidateDirs(spuDir: string) {
|
||||
const allDirs = await walkDirectories(spuDir, 4)
|
||||
const fullSetDirs = allDirs.filter((dirPath) => matchesAny(path.basename(dirPath), CANDIDATE_DIR_PATTERNS))
|
||||
const sorted = Array.from(new Set(fullSetDirs)).sort((left, right) => left.length - right.length)
|
||||
const collapsed: string[] = []
|
||||
for (const candidate of sorted) {
|
||||
const normalizedCandidate = `${path.normalize(candidate)}${path.sep}`
|
||||
const hasAncestor = collapsed.some((existing) => normalizedCandidate.startsWith(`${path.normalize(existing)}${path.sep}`))
|
||||
if (!hasAncestor) {
|
||||
collapsed.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed
|
||||
}
|
||||
|
||||
async function buildRecordsForSpuDir(spuDir: string, materialsRoot: string, storeIndex: StoreIndex) {
|
||||
const spu = normalizeSpu(path.basename(spuDir))
|
||||
const candidateDirs = await findCandidateDirs(spuDir)
|
||||
const records: HistoryImportRecord[] = []
|
||||
|
||||
for (const candidateDir of candidateDirs) {
|
||||
if (shouldIgnoreDir(candidateDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const files = await walkFiles(candidateDir, 5)
|
||||
const grouped = {
|
||||
main: [] as FileEntry[],
|
||||
sku: [] as FileEntry[],
|
||||
detail: [] as FileEntry[],
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const group = classifyFileGroup(file.relativePath)
|
||||
if (!group) {
|
||||
continue
|
||||
}
|
||||
grouped[group].push(file)
|
||||
}
|
||||
|
||||
const totalImages = grouped.main.length + grouped.sku.length + grouped.detail.length
|
||||
if (totalImages === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const isFullSet = isTaskDetailFullSetFromUploadGroups({
|
||||
main: grouped.main,
|
||||
sku: grouped.sku,
|
||||
detail: grouped.detail,
|
||||
})
|
||||
const storeGuess = pickStore(spu, candidateDir, storeIndex)
|
||||
const status: HistoryImportStatus = !isFullSet
|
||||
? 'skipped'
|
||||
: storeGuess.storeCandidates.length > 1 && storeGuess.storeConfidence < 0.4
|
||||
? 'needs_review'
|
||||
: 'ready'
|
||||
|
||||
records.push({
|
||||
fingerprint: makeFingerprint(spu, candidateDir),
|
||||
spu,
|
||||
sourcePath: candidateDir,
|
||||
relativeSourcePath: path.relative(materialsRoot, candidateDir),
|
||||
guessedStore: storeGuess.guessedStore,
|
||||
productCategoryName: '',
|
||||
authorName: '历史导入',
|
||||
storeConfidence: storeGuess.storeConfidence,
|
||||
storeCandidates: storeGuess.storeCandidates,
|
||||
mainCount: grouped.main.length,
|
||||
skuCount: grouped.sku.length,
|
||||
detailCount: grouped.detail.length,
|
||||
status,
|
||||
skipReason: isFullSet ? null : '不满足项目内全套定义:需同时拥有主图、SKU 图、详情图',
|
||||
mainImages: buildAssetInputs(spu, grouped.main, 'main'),
|
||||
skuImages: buildAssetInputs(spu, grouped.sku, 'sku'),
|
||||
detailImages: buildAssetInputs(spu, grouped.detail, 'detail'),
|
||||
seenInYya: storeIndex.yyaSpus.has(spu),
|
||||
})
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
records.push({
|
||||
fingerprint: makeFingerprint(spu, spuDir),
|
||||
spu,
|
||||
sourcePath: spuDir,
|
||||
relativeSourcePath: path.relative(materialsRoot, spuDir),
|
||||
guessedStore: '其他',
|
||||
productCategoryName: '',
|
||||
authorName: '历史导入',
|
||||
storeConfidence: 0,
|
||||
storeCandidates: [],
|
||||
mainCount: 0,
|
||||
skuCount: 0,
|
||||
detailCount: 0,
|
||||
status: 'skipped',
|
||||
skipReason: '未识别到可导入的主图、SKU 图或详情图',
|
||||
mainImages: [],
|
||||
skuImages: [],
|
||||
detailImages: [],
|
||||
seenInYya: storeIndex.yyaSpus.has(spu),
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
export async function scanHistoryMaterials(options: ScanHistoryMaterialsOptions) {
|
||||
const materialsRoot = options.materialsRoot
|
||||
const spuFilter = options.spuFilter?.map(normalizeSpu)
|
||||
const spuDirs = await findSpuDirectories(materialsRoot, spuFilter)
|
||||
|
||||
const limitedSpuDirs = options.limit ? spuDirs.slice(0, options.limit) : spuDirs
|
||||
const storeIndex = await buildStoreIndex(
|
||||
options.cpRoot,
|
||||
options.yyaRoot,
|
||||
limitedSpuDirs.map((dirPath) => path.basename(dirPath)),
|
||||
)
|
||||
|
||||
const records: HistoryImportRecord[] = []
|
||||
for (const spuDir of limitedSpuDirs) {
|
||||
const spuRecords = await buildRecordsForSpuDir(spuDir, materialsRoot, storeIndex)
|
||||
records.push(...spuRecords)
|
||||
}
|
||||
|
||||
return records.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, 'zh-Hans-CN'))
|
||||
}
|
||||
|
||||
async function findSpuDirectories(materialsRoot: string, spuFilter?: string[]) {
|
||||
if (spuFilter && spuFilter.length > 0) {
|
||||
const fastFiles = await searchPathsByRipgrep(materialsRoot, spuFilter)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
if (fastFiles.length > 0) {
|
||||
for (const filePath of fastFiles) {
|
||||
const absoluteFilePath = path.isAbsolute(filePath) ? filePath : path.join(materialsRoot, filePath)
|
||||
const parts = path.normalize(absoluteFilePath).split(path.sep)
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
const part = normalizeSpu(parts[index])
|
||||
if (!isSpuName(part)) {
|
||||
continue
|
||||
}
|
||||
if (!spuFilter.includes(part)) {
|
||||
continue
|
||||
}
|
||||
const resolvedDir = parts.slice(0, index + 1).join(path.sep)
|
||||
dirs.add(resolvedDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dirs.size > 0) {
|
||||
return Array.from(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
const allDirs = await walkDirectories(materialsRoot, 5)
|
||||
return allDirs.filter((dirPath) => {
|
||||
const spu = normalizeSpu(path.basename(dirPath))
|
||||
if (!isSpuName(spu)) {
|
||||
return false
|
||||
}
|
||||
return !spuFilter || spuFilter.length === 0 || spuFilter.includes(spu)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureCategoryAndScene(defaultCategory: string, defaultScene: string) {
|
||||
const [category, scene] = await Promise.all([
|
||||
prisma.category.findUnique({ where: { name: defaultCategory } }),
|
||||
prisma.scene.findUnique({ where: { name: defaultScene } }),
|
||||
])
|
||||
|
||||
if (!category) {
|
||||
await prisma.category.create({ data: { name: defaultCategory } })
|
||||
}
|
||||
if (!scene) {
|
||||
await prisma.scene.create({ data: { name: defaultScene } })
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCategoryAndSceneNames(categoryName: string, sceneName: string) {
|
||||
const normalizedCategoryName = categoryName.trim() || '其他'
|
||||
const normalizedSceneName = sceneName.trim() || '其他'
|
||||
|
||||
const [category, scene] = await Promise.all([
|
||||
prisma.category.findUnique({ where: { name: normalizedCategoryName } }),
|
||||
prisma.scene.findUnique({ where: { name: normalizedSceneName } }),
|
||||
])
|
||||
|
||||
if (!category) {
|
||||
await prisma.category.create({ data: { name: normalizedCategoryName } })
|
||||
}
|
||||
if (!scene) {
|
||||
await prisma.scene.create({ data: { name: normalizedSceneName } })
|
||||
}
|
||||
}
|
||||
|
||||
async function hasImportedFingerprint(fingerprint: string) {
|
||||
const existing = await prisma.materialLog.findFirst({
|
||||
where: {
|
||||
action: 'HISTORY_IMPORT',
|
||||
changes: {
|
||||
contains: fingerprint,
|
||||
} as any,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return Boolean(existing)
|
||||
}
|
||||
|
||||
async function createHistoryImportMaterial(record: HistoryImportRecord, operatorUserId: string) {
|
||||
return prisma.material.create({
|
||||
data: {
|
||||
productModel: record.spu,
|
||||
categoryName: record.guessedStore || '其他',
|
||||
sceneName: '其他',
|
||||
platformName: '',
|
||||
productCategoryName: record.productCategoryName || '',
|
||||
materialType: '任务-详情',
|
||||
previewUrl: '',
|
||||
authorName: record.authorName || '历史导入',
|
||||
userId: operatorUserId,
|
||||
renderer: '',
|
||||
isFullSet: false,
|
||||
isAiGenerated: false,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
export async function importHistoryMaterials(options: ImportHistoryMaterialsOptions) {
|
||||
await ensureCategoryAndScene('其他', '其他')
|
||||
|
||||
const candidates = options.records
|
||||
.filter((record) => {
|
||||
if (options.apply) {
|
||||
return record.status === 'ready'
|
||||
}
|
||||
return record.status !== 'skipped'
|
||||
})
|
||||
.slice(0, options.limit || options.records.length)
|
||||
|
||||
const results: ImportHistoryMaterialsResultItem[] = []
|
||||
for (const record of candidates) {
|
||||
if (await hasImportedFingerprint(record.fingerprint)) {
|
||||
results.push({
|
||||
fingerprint: record.fingerprint,
|
||||
spu: record.spu,
|
||||
sourcePath: record.sourcePath,
|
||||
guessedStore: record.guessedStore,
|
||||
materialId: null,
|
||||
status: 'skipped',
|
||||
reason: '该来源目录已导入过',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!options.apply) {
|
||||
results.push({
|
||||
fingerprint: record.fingerprint,
|
||||
spu: record.spu,
|
||||
sourcePath: record.sourcePath,
|
||||
guessedStore: record.guessedStore,
|
||||
materialId: null,
|
||||
status: 'dry_run',
|
||||
reason: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCategoryAndSceneNames(record.guessedStore || '其他', '其他')
|
||||
const material = await createHistoryImportMaterial(record, options.operator.id)
|
||||
await completeProductPackageUpload({
|
||||
materialId: material.id,
|
||||
taskNo: null,
|
||||
operatorUserId: options.operator.id,
|
||||
assets: {
|
||||
main: record.mainImages,
|
||||
sku: record.skuImages,
|
||||
detail: record.detailImages,
|
||||
},
|
||||
options: {
|
||||
allowSkuWithoutBarcodes: true,
|
||||
zipTimeoutMs: HISTORY_ZIP_TIMEOUT_MS,
|
||||
allZipTimeoutMs: HISTORY_ALL_ZIP_TIMEOUT_MS,
|
||||
},
|
||||
})
|
||||
|
||||
await createMaterialLog({
|
||||
materialId: material.id,
|
||||
userId: options.operator.id,
|
||||
action: 'HISTORY_IMPORT',
|
||||
changes: {
|
||||
fingerprint: record.fingerprint,
|
||||
sourcePath: record.sourcePath,
|
||||
guessedStore: record.guessedStore,
|
||||
storeCandidates: record.storeCandidates,
|
||||
seenInYya: record.seenInYya,
|
||||
},
|
||||
})
|
||||
|
||||
results.push({
|
||||
fingerprint: record.fingerprint,
|
||||
spu: record.spu,
|
||||
sourcePath: record.sourcePath,
|
||||
guessedStore: record.guessedStore,
|
||||
materialId: material.id,
|
||||
status: 'imported',
|
||||
reason: null,
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
fingerprint: record.fingerprint,
|
||||
spu: record.spu,
|
||||
sourcePath: record.sourcePath,
|
||||
guessedStore: record.guessedStore,
|
||||
materialId: null,
|
||||
status: 'failed',
|
||||
reason: error instanceof Error ? error.message : '导入失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function resolveImportOperator(identifier: string) {
|
||||
const normalized = identifier.trim()
|
||||
if (!normalized) {
|
||||
throw new Error('缺少导入执行人标识')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
archivedAt: null,
|
||||
OR: [{ email: normalized }, { username: normalized }, { realName: normalized }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
realName: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`未找到导入执行人:${identifier}`)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
677
lib/history-sku-csv-import.ts
Normal file
677
lib/history-sku-csv-import.ts
Normal file
@ -0,0 +1,677 @@
|
||||
import path from 'path'
|
||||
import { promises as fs } from 'fs'
|
||||
import crypto from 'crypto'
|
||||
import type { ProductPackageImageInput } from '@/lib/product-package-completion'
|
||||
import type { HistoryImportRecord, HistoryImportStatus } from '@/lib/history-material-import'
|
||||
import { parseSPU } from '@/lib/spu-parser'
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
|
||||
const SKIPPED_FILE_EXTENSIONS = new Set([
|
||||
'.xlsx',
|
||||
'.xls',
|
||||
'.psd',
|
||||
'.psb',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
'.zip',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.pdf',
|
||||
'.log',
|
||||
'.db',
|
||||
'.lnk',
|
||||
])
|
||||
|
||||
const IGNORED_DIR_PATTERNS = [
|
||||
/@eadir/i,
|
||||
/策划方案/i,
|
||||
/产品资料/i,
|
||||
/模特图/i,
|
||||
/废稿/i,
|
||||
/字体/i,
|
||||
/勿用/i,
|
||||
/DingTalkAppData/i,
|
||||
/^PsTool$/i,
|
||||
/^Cache$/i,
|
||||
/RECYCLE/i,
|
||||
]
|
||||
const EXCLUDED_FILE_NAME_PATTERNS = [
|
||||
/通道/i,
|
||||
/遮罩/i,
|
||||
/\balpha\b/i,
|
||||
/材质/i,
|
||||
/测试/i,
|
||||
/过程/i,
|
||||
/副本/i,
|
||||
/拷贝/i,
|
||||
/导出/i,
|
||||
/未修/i,
|
||||
/旧版/i,
|
||||
/mask/i,
|
||||
]
|
||||
|
||||
const PRIMARY_SPU_TOKEN_PATTERN = /([A-Za-z]{2,3}\d{4})/g
|
||||
const FALLBACK_SPU_TOKEN_PATTERN = /([A-Za-z]{1,5}\d{3,5})/g
|
||||
const IGNORED_SPU_PREFIXES = ['TASK', 'SKU', 'IMG']
|
||||
const AUTHOR_SEGMENT_EXCLUDES = new Set([
|
||||
'亿家木',
|
||||
'亿家达',
|
||||
'卓和',
|
||||
'卓禾',
|
||||
'朴美',
|
||||
'橡野里',
|
||||
'欧意朗',
|
||||
'索菲亚',
|
||||
'顾家',
|
||||
'详情全套',
|
||||
'详情全案',
|
||||
'视觉全套',
|
||||
'平面全套',
|
||||
'详情',
|
||||
'详情页',
|
||||
'主图',
|
||||
])
|
||||
|
||||
type ParsedSkuCsvRow = {
|
||||
sourceRoot: string
|
||||
spu: string
|
||||
directoryName: string
|
||||
relativePath: string
|
||||
windowsPath: string
|
||||
skuDirPath: string
|
||||
parentPath: string
|
||||
grandparentPath: string
|
||||
siblingDetailDirNames: string[]
|
||||
siblingMainDirNames: string[]
|
||||
siblingDirNames: string[]
|
||||
}
|
||||
|
||||
type FileEntry = {
|
||||
fullPath: string
|
||||
relativePath: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export interface BuildHistoryImportRecordsFromSkuCsvOptions {
|
||||
csvPath: string
|
||||
limit?: number
|
||||
sourceRoots?: string[]
|
||||
visualCenterAuthors?: string[]
|
||||
}
|
||||
|
||||
function normalizeSpu(value: string) {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
function matchesIgnoredDir(fullPath: string) {
|
||||
return IGNORED_DIR_PATTERNS.some((pattern) => pattern.test(fullPath))
|
||||
}
|
||||
|
||||
function shouldSkipFile(filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const fileName = path.basename(filePath)
|
||||
return (
|
||||
SKIPPED_FILE_EXTENSIONS.has(ext) ||
|
||||
!IMAGE_EXTENSIONS.has(ext) ||
|
||||
EXCLUDED_FILE_NAME_PATTERNS.some((pattern) => pattern.test(fileName))
|
||||
)
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string) {
|
||||
const result: string[] = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index]
|
||||
const next = line[index + 1]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && next === '"') {
|
||||
current += '"'
|
||||
index += 1
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === ',' && !inQuotes) {
|
||||
result.push(current)
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
result.push(current)
|
||||
return result
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string) {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaderIndexes(header: string[]) {
|
||||
const normalized = header.map((item) => item.replace(/^\uFEFF/, ''))
|
||||
const pickIndex = (name: string) => normalized.indexOf(name)
|
||||
|
||||
return {
|
||||
sourceRoot: pickIndex('source_root(来源根路径)'),
|
||||
spu: pickIndex('spu(SPU)'),
|
||||
directoryName: pickIndex('directory_name(目录名称)'),
|
||||
relativePath: pickIndex('relative_path(相对路径)'),
|
||||
windowsPath: pickIndex('windows_path(Windows路径)'),
|
||||
skuDirPath: pickIndex('sku_dir_path(SKU目录完整路径)'),
|
||||
parentPath: pickIndex('parent_path(上级目录)'),
|
||||
grandparentPath: pickIndex('grandparent_path(上上级目录)'),
|
||||
siblingDetailDirNames: pickIndex('sibling_detail_dir_names_json(同级详情目录)'),
|
||||
siblingMainDirNames: pickIndex('sibling_main_dir_names_json(同级主图目录)'),
|
||||
siblingDirNames: pickIndex('sibling_dir_names_json(同级全部目录)'),
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSkuCsv(csvPath: string) {
|
||||
const raw = await fs.readFile(csvPath, 'utf8')
|
||||
const lines = raw.split(/\r?\n/).filter((line) => line.length > 0)
|
||||
if (lines.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const header = parseCsvLine(lines[0])
|
||||
const indexes = getHeaderIndexes(header)
|
||||
|
||||
return lines.slice(1).map((line) => {
|
||||
const row = parseCsvLine(line)
|
||||
return {
|
||||
sourceRoot: row[indexes.sourceRoot] || '',
|
||||
spu: normalizeSpu(row[indexes.spu] || ''),
|
||||
directoryName: row[indexes.directoryName] || '',
|
||||
relativePath: row[indexes.relativePath] || '',
|
||||
windowsPath: row[indexes.windowsPath] || '',
|
||||
skuDirPath: row[indexes.skuDirPath] || '',
|
||||
parentPath: row[indexes.parentPath] || '',
|
||||
grandparentPath: row[indexes.grandparentPath] || '',
|
||||
siblingDetailDirNames: parseJsonArray(row[indexes.siblingDetailDirNames] || ''),
|
||||
siblingMainDirNames: parseJsonArray(row[indexes.siblingMainDirNames] || ''),
|
||||
siblingDirNames: parseJsonArray(row[indexes.siblingDirNames] || ''),
|
||||
} satisfies ParsedSkuCsvRow
|
||||
})
|
||||
}
|
||||
|
||||
function inferSpuFromText(value: string) {
|
||||
const text = String(value || '')
|
||||
const isStandaloneToken = (matchIndex: number, token: string) => {
|
||||
const prevChar = matchIndex > 0 ? text[matchIndex - 1] : ''
|
||||
const nextChar = text[matchIndex + token.length] || ''
|
||||
return !/[A-Za-z]/.test(prevChar) && !/[A-Za-z0-9]/.test(nextChar)
|
||||
}
|
||||
|
||||
PRIMARY_SPU_TOKEN_PATTERN.lastIndex = 0
|
||||
const primaryMatches = Array.from(text.matchAll(PRIMARY_SPU_TOKEN_PATTERN))
|
||||
if (primaryMatches.length > 0) {
|
||||
for (let index = primaryMatches.length - 1; index >= 0; index -= 1) {
|
||||
const currentMatch = primaryMatches[index]
|
||||
const token = normalizeSpu(currentMatch?.[1] || '')
|
||||
const matchIndex = currentMatch?.index ?? -1
|
||||
if (
|
||||
token &&
|
||||
matchIndex >= 0 &&
|
||||
isStandaloneToken(matchIndex, currentMatch?.[1] || '') &&
|
||||
!IGNORED_SPU_PREFIXES.some((prefix) => token.startsWith(prefix))
|
||||
) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FALLBACK_SPU_TOKEN_PATTERN.lastIndex = 0
|
||||
const fallbackMatches = Array.from(text.matchAll(FALLBACK_SPU_TOKEN_PATTERN))
|
||||
if (fallbackMatches.length > 0) {
|
||||
for (let index = fallbackMatches.length - 1; index >= 0; index -= 1) {
|
||||
const currentMatch = fallbackMatches[index]
|
||||
const token = normalizeSpu(currentMatch?.[1] || '')
|
||||
const matchIndex = currentMatch?.index ?? -1
|
||||
if (
|
||||
token &&
|
||||
matchIndex >= 0 &&
|
||||
isStandaloneToken(matchIndex, currentMatch?.[1] || '') &&
|
||||
!IGNORED_SPU_PREFIXES.some((prefix) => token.startsWith(prefix))
|
||||
) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function hasStandaloneSpuToken(text: string, token: string) {
|
||||
const source = String(text || '')
|
||||
const normalizedToken = normalizeSpu(token)
|
||||
if (!source || !normalizedToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const upperSource = source.toUpperCase()
|
||||
const upperToken = normalizedToken.toUpperCase()
|
||||
let searchIndex = upperSource.indexOf(upperToken)
|
||||
while (searchIndex >= 0) {
|
||||
const prevChar = searchIndex > 0 ? upperSource[searchIndex - 1] : ''
|
||||
const nextChar = upperSource[searchIndex + upperToken.length] || ''
|
||||
if (!/[A-Z]/.test(prevChar) && !/[A-Z0-9]/.test(nextChar)) {
|
||||
return true
|
||||
}
|
||||
searchIndex = upperSource.indexOf(upperToken, searchIndex + 1)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function inferSpu(row: ParsedSkuCsvRow) {
|
||||
const candidates = [
|
||||
row.relativePath,
|
||||
row.skuDirPath,
|
||||
row.parentPath,
|
||||
row.grandparentPath,
|
||||
row.windowsPath,
|
||||
]
|
||||
|
||||
if (row.spu) {
|
||||
const normalizedRowSpu = normalizeSpu(row.spu)
|
||||
const rowSpuLooksReliable =
|
||||
normalizedRowSpu &&
|
||||
!IGNORED_SPU_PREFIXES.some((prefix) => normalizedRowSpu.startsWith(prefix)) &&
|
||||
candidates.some((candidate) => hasStandaloneSpuToken(candidate, normalizedRowSpu))
|
||||
|
||||
if (rowSpuLooksReliable) {
|
||||
return normalizedRowSpu
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const inferred = inferSpuFromText(candidate)
|
||||
if (inferred) {
|
||||
return inferred
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractSkuSuffixFromFileName(fileName: string) {
|
||||
const normalizedName = String(fileName || '').toUpperCase()
|
||||
|
||||
const fullSkuMatch = normalizedName.match(/([A-Z]{2,5}\d{6,7})/)
|
||||
if (fullSkuMatch?.[1]) {
|
||||
return fullSkuMatch[1].slice(-2)
|
||||
}
|
||||
|
||||
const skuHintMatch = normalizedName.match(/SKU[_-]?(\d{1,2})/i)
|
||||
if (skuHintMatch?.[1]) {
|
||||
return skuHintMatch[1].padStart(2, '0')
|
||||
}
|
||||
|
||||
const pureNumberMatch = normalizedName.match(/(?:^|[^0-9])(\d{6})(?:[^0-9]|$)/)
|
||||
if (pureNumberMatch?.[1]) {
|
||||
return pureNumberMatch[1].slice(-2)
|
||||
}
|
||||
|
||||
const trailingNumberMatch = normalizedName.match(/(?:^|[^0-9])(\d{1,2})(?:[^0-9]|$)/)
|
||||
if (trailingNumberMatch?.[1]) {
|
||||
return trailingNumberMatch[1].padStart(2, '0')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function pickPreferredDirName(names: string[], type: 'detail' | 'main') {
|
||||
const deduped = Array.from(new Set(names.map((name) => name.trim()).filter(Boolean)))
|
||||
if (deduped.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (type === 'detail') {
|
||||
const detailPriority = ['详情页', '详情', 'images']
|
||||
for (const preferred of detailPriority) {
|
||||
const exact = deduped.find((name) => name === preferred)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
return deduped[0]
|
||||
}
|
||||
|
||||
const mainPriority = ['主图', '新主图']
|
||||
for (const preferred of mainPriority) {
|
||||
const exact = deduped.find((name) => name === preferred)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
return deduped[0]
|
||||
}
|
||||
|
||||
async function walkFiles(root: string, maxDepth = 4) {
|
||||
const result: FileEntry[] = []
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
if (current.depth > maxDepth) {
|
||||
continue
|
||||
}
|
||||
|
||||
let entries: any[]
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = String(entry.name)
|
||||
const fullPath = path.join(current.dir, entryName)
|
||||
if (entry.isDirectory()) {
|
||||
if (matchesIgnoredDir(fullPath)) {
|
||||
continue
|
||||
}
|
||||
queue.push({ dir: fullPath, depth: current.depth + 1 })
|
||||
continue
|
||||
}
|
||||
|
||||
if (shouldSkipFile(fullPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath)
|
||||
result.push({
|
||||
fullPath,
|
||||
relativePath: path.relative(root, fullPath),
|
||||
fileName: entryName,
|
||||
fileSize: stat.size,
|
||||
})
|
||||
} catch {
|
||||
// ignore unreadable file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function dedupeFiles(files: FileEntry[]) {
|
||||
const seen = new Set<string>()
|
||||
const result: FileEntry[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const key = path.normalize(file.fullPath).toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
result.push(file)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function sortByNaturalFileName<T extends { fileName: string }>(items: T[]) {
|
||||
return [...items].sort((left, right) =>
|
||||
left.fileName.localeCompare(right.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }),
|
||||
)
|
||||
}
|
||||
|
||||
function findSkuCode(spu: string, fileName: string, sortOrder: number) {
|
||||
const normalizedSpu = normalizeSpu(spu)
|
||||
const normalizedName = fileName.toUpperCase()
|
||||
const exactMatch = normalizedSpu ? normalizedName.match(new RegExp(`(${normalizedSpu}\\d{2})`, 'i')) : null
|
||||
if (exactMatch?.[1]) {
|
||||
return exactMatch[1].toUpperCase()
|
||||
}
|
||||
|
||||
const extractedSuffix = extractSkuSuffixFromFileName(fileName)
|
||||
if (normalizedSpu && extractedSuffix) {
|
||||
return `${normalizedSpu}${extractedSuffix}`
|
||||
}
|
||||
|
||||
return normalizedSpu ? `${normalizedSpu}${String(sortOrder + 1).padStart(2, '0')}` : `SKU${String(sortOrder + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function buildAssetInputs(spu: string, files: FileEntry[], group: 'main' | 'sku' | 'detail') {
|
||||
const sorted = sortByNaturalFileName(files)
|
||||
const seenSkuCodes = new Map<string, number>()
|
||||
|
||||
return sorted.map((file, index) => {
|
||||
if (group !== 'sku') {
|
||||
return {
|
||||
localFilePath: file.fullPath,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
sortOrder: index,
|
||||
} satisfies ProductPackageImageInput
|
||||
}
|
||||
|
||||
const baseSkuCode = findSkuCode(spu, file.fileName, index)
|
||||
const conflictCount = seenSkuCodes.get(baseSkuCode) || 0
|
||||
seenSkuCodes.set(baseSkuCode, conflictCount + 1)
|
||||
const skuCode = conflictCount === 0 ? baseSkuCode : `${baseSkuCode}-${conflictCount + 1}`
|
||||
|
||||
return {
|
||||
localFilePath: file.fullPath,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
sortOrder: index,
|
||||
skuCode,
|
||||
barcodes: [],
|
||||
} satisfies ProductPackageImageInput
|
||||
})
|
||||
}
|
||||
|
||||
function inferStoreBySpu(spu: string, row?: ParsedSkuCsvRow) {
|
||||
const normalizedSpu = normalizeSpu(spu)
|
||||
const storeProbeText = [
|
||||
normalizedSpu,
|
||||
row?.relativePath || '',
|
||||
row?.skuDirPath || '',
|
||||
row?.parentPath || '',
|
||||
row?.grandparentPath || '',
|
||||
row?.windowsPath || '',
|
||||
]
|
||||
.join('\n')
|
||||
.toUpperCase()
|
||||
|
||||
if (storeProbeText.includes('GBGJ')) {
|
||||
return {
|
||||
guessedStore: '顾家',
|
||||
storeConfidence: 1,
|
||||
storeCandidates: ['顾家'],
|
||||
}
|
||||
}
|
||||
|
||||
if (storeProbeText.includes('GBSFY')) {
|
||||
return {
|
||||
guessedStore: '索菲亚',
|
||||
storeConfidence: 1,
|
||||
storeCandidates: ['索菲亚'],
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSpu.startsWith('GBGJ')) {
|
||||
return {
|
||||
guessedStore: '顾家',
|
||||
storeConfidence: 1,
|
||||
storeCandidates: ['顾家'],
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSpu.startsWith('GBSFY')) {
|
||||
return {
|
||||
guessedStore: '索菲亚',
|
||||
storeConfidence: 1,
|
||||
storeCandidates: ['索菲亚'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
guessedStore: '其他',
|
||||
storeConfidence: 0,
|
||||
storeCandidates: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAuthorToken(value: string) {
|
||||
return value.replace(/[\s()()_\-]+/g, '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function matchVisualCenterAuthor(row: ParsedSkuCsvRow, visualCenterAuthors: string[]) {
|
||||
const authors = visualCenterAuthors
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => ({
|
||||
original: item,
|
||||
normalized: normalizeAuthorToken(item),
|
||||
}))
|
||||
|
||||
if (authors.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const segments = row.relativePath
|
||||
.split('/')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||
const segment = segments[index]
|
||||
const normalizedSegment = normalizeAuthorToken(segment)
|
||||
if (!normalizedSegment) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const author of authors) {
|
||||
if (!author.normalized) {
|
||||
continue
|
||||
}
|
||||
if (normalizedSegment.includes(author.normalized)) {
|
||||
return author.original
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function inferAuthorFromRow(row: ParsedSkuCsvRow, visualCenterAuthors: string[]) {
|
||||
const matchedAuthor = matchVisualCenterAuthor(row, visualCenterAuthors)
|
||||
if (matchedAuthor) {
|
||||
return matchedAuthor
|
||||
}
|
||||
|
||||
const candidates = row.relativePath
|
||||
.split('/')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = candidates[index]
|
||||
if (/^(sku|newsku|新版sku|新sku)$/i.test(candidate)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const chineseMatches = candidate.match(/[\u4e00-\u9fa5]{2,4}/g) || []
|
||||
for (const item of chineseMatches.reverse()) {
|
||||
if (!item || AUTHOR_SEGMENT_EXCLUDES.has(item) || /详情|视觉|平面|主图/.test(item)) {
|
||||
continue
|
||||
}
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
return '历史导入'
|
||||
}
|
||||
|
||||
function makeFingerprint(spu: string, sourcePath: string) {
|
||||
return crypto.createHash('sha1').update(`${normalizeSpu(spu)}|${sourcePath}`).digest('hex')
|
||||
}
|
||||
|
||||
export async function buildHistoryImportRecordsFromSkuCsv(
|
||||
options: BuildHistoryImportRecordsFromSkuCsvOptions,
|
||||
) {
|
||||
const rows = await parseSkuCsv(options.csvPath)
|
||||
const filteredRows = rows.filter((row) => {
|
||||
if (!options.sourceRoots || options.sourceRoots.length === 0) {
|
||||
return true
|
||||
}
|
||||
return options.sourceRoots.includes(row.sourceRoot)
|
||||
})
|
||||
|
||||
const limitedRows = options.limit ? filteredRows.slice(0, options.limit) : filteredRows
|
||||
const visualCenterAuthors = Array.from(
|
||||
new Set((options.visualCenterAuthors || []).map((item) => item.trim()).filter(Boolean)),
|
||||
)
|
||||
const records: HistoryImportRecord[] = []
|
||||
|
||||
for (const row of limitedRows) {
|
||||
const spu = inferSpu(row)
|
||||
const detailDirName = pickPreferredDirName(row.siblingDetailDirNames, 'detail')
|
||||
const mainDirName = pickPreferredDirName(row.siblingMainDirNames, 'main')
|
||||
|
||||
const skuFiles = dedupeFiles(await walkFiles(row.skuDirPath, 4))
|
||||
const detailFiles = detailDirName ? dedupeFiles(await walkFiles(path.join(row.parentPath, detailDirName), 4)) : []
|
||||
const mainFiles = mainDirName ? dedupeFiles(await walkFiles(path.join(row.parentPath, mainDirName), 4)) : []
|
||||
|
||||
const storeGuess = inferStoreBySpu(spu, row)
|
||||
const hasFullSet = mainFiles.length > 0 && skuFiles.length > 0 && detailFiles.length > 0
|
||||
const spuParseResult = parseSPU(spu)
|
||||
const productCategoryName = spuParseResult.categoryMatched ? spuParseResult.categoryName : ''
|
||||
const authorName = inferAuthorFromRow(row, visualCenterAuthors)
|
||||
|
||||
let status: HistoryImportStatus = 'ready'
|
||||
let skipReason: string | null = null
|
||||
|
||||
if (!hasFullSet) {
|
||||
status = 'skipped'
|
||||
skipReason = '不满足历史导入要求:需同时拥有同级主图、SKU 图、详情图'
|
||||
} else if (!spu) {
|
||||
status = 'needs_review'
|
||||
skipReason = 'SPU 无法自动识别'
|
||||
}
|
||||
|
||||
records.push({
|
||||
fingerprint: makeFingerprint(spu || row.directoryName || 'UNKNOWN', row.skuDirPath),
|
||||
spu,
|
||||
sourcePath: row.skuDirPath,
|
||||
relativeSourcePath: row.relativePath,
|
||||
guessedStore: storeGuess.guessedStore,
|
||||
productCategoryName,
|
||||
authorName,
|
||||
storeConfidence: storeGuess.storeConfidence,
|
||||
storeCandidates: storeGuess.storeCandidates,
|
||||
mainCount: mainFiles.length,
|
||||
skuCount: skuFiles.length,
|
||||
detailCount: detailFiles.length,
|
||||
status,
|
||||
skipReason,
|
||||
mainImages: buildAssetInputs(spu, mainFiles, 'main'),
|
||||
skuImages: buildAssetInputs(spu, skuFiles, 'sku'),
|
||||
detailImages: buildAssetInputs(spu, detailFiles, 'detail'),
|
||||
seenInYya: row.sourceRoot.includes('/mnt/history-yya'),
|
||||
})
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
import path from 'path'
|
||||
import {
|
||||
buildPublicDisplayUrl,
|
||||
buildWhiteBackgroundPublicDisplayPathFromAssetPath,
|
||||
} from './public-display'
|
||||
import { prisma } from './prisma'
|
||||
import {
|
||||
LEGACY_STANDARD_MODEL_WHITE_BG_ASSET_TYPE,
|
||||
@ -106,7 +110,7 @@ export async function collectMaterialFiles(
|
||||
fileUrls.push(...normalizeDisplayImages((material as any).displayImages))
|
||||
|
||||
// 任务类素材都可能存在 MaterialAsset 记录。
|
||||
const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
|
||||
const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '证书-检测报告'];
|
||||
if (taskMaterialTypes.includes(materialType)) {
|
||||
const assets = await prisma.materialAsset.findMany({
|
||||
where: { materialId },
|
||||
@ -165,6 +169,11 @@ export async function collectMaterialFiles(
|
||||
for (const asset of whiteBgAssets) {
|
||||
if (asset.fileUrl) {
|
||||
fileUrls.push(asset.fileUrl)
|
||||
const publicDisplayPath = buildWhiteBackgroundPublicDisplayPathFromAssetPath(asset.fileUrl)
|
||||
const publicDisplayUrl = publicDisplayPath ? buildPublicDisplayUrl(publicDisplayPath) : null
|
||||
if (publicDisplayUrl) {
|
||||
fileUrls.push(publicDisplayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +198,7 @@ export async function deleteMaterialRelatedRecords(
|
||||
): Promise<{ deletedAssets?: number; deletedSkus?: number }> {
|
||||
const result: { deletedAssets?: number; deletedSkus?: number } = {}
|
||||
|
||||
const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
|
||||
const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '证书-检测报告'];
|
||||
if (taskMaterialTypes.includes(materialType)) {
|
||||
const deleteResult = await prisma.materialAsset.deleteMany({
|
||||
where: { materialId },
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { normalizeVideoMaterialType } from './video-material-types'
|
||||
|
||||
/**
|
||||
* 素材类型映射工具
|
||||
* 用于将老类型映射到新类型,确保代码兼容性
|
||||
@ -13,7 +15,7 @@ export function mapMaterialType(type: string): string {
|
||||
'详情页': '详情模板',
|
||||
};
|
||||
|
||||
return typeMap[type] || type;
|
||||
return normalizeVideoMaterialType(typeMap[type] || type);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,15 +50,22 @@ export function is3DSceneType(materialType: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查素材类型是否为任务-3D原图/AI原图/实拍原图/视频截图/页面
|
||||
* 检查素材类型是否为任务类轻量素材
|
||||
*/
|
||||
export function is3DOriginalType(materialType: string): boolean {
|
||||
return ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '任务-页面'].includes(materialType);
|
||||
return ['任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', '任务-页面', '任务-其他'].includes(materialType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查素材类型是否为证书-检测报告
|
||||
*/
|
||||
export function isCertificateReportType(materialType: string): boolean {
|
||||
return materialType === '证书-检测报告';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查素材类型是否需要查询资产列表(任务-详情 或 任务-3D原图等)
|
||||
*/
|
||||
export function needsAssetsList(materialType: string): boolean {
|
||||
return isProductPackageType(materialType) || is3DOriginalType(materialType);
|
||||
return isProductPackageType(materialType) || is3DOriginalType(materialType) || isCertificateReportType(materialType);
|
||||
}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { is3DOriginalType, isProductPackageType } from './material-type-mapper'
|
||||
import {
|
||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
TAOXI_MAIN_VIDEO_TYPE,
|
||||
} from './video-material-types'
|
||||
|
||||
export const MATERIAL_TYPE_OPTIONS = [
|
||||
'3D场景',
|
||||
@ -10,14 +16,18 @@ export const MATERIAL_TYPE_OPTIONS = [
|
||||
'小红书种草',
|
||||
'主图模版',
|
||||
'SKU模版',
|
||||
'主图视频',
|
||||
TAOXI_MAIN_VIDEO_TYPE,
|
||||
'安装视频',
|
||||
'种草视频',
|
||||
TAOXI_GUANGGUANG_VIDEO_TYPE,
|
||||
DOUYIN_QIANCHUAN_VIDEO_TYPE,
|
||||
DOUYIN_ACCOUNT_MAINTENANCE_VIDEO_TYPE,
|
||||
'音乐',
|
||||
'标准模型',
|
||||
'品牌VI灵感',
|
||||
'证书-检测报告',
|
||||
'任务-详情',
|
||||
'任务-页面',
|
||||
'任务-其他',
|
||||
'任务-3D原图',
|
||||
'任务-AI原图',
|
||||
'任务-实拍原图',
|
||||
|
||||
@ -21,9 +21,12 @@ import {
|
||||
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
|
||||
import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report'
|
||||
|
||||
export interface MaterialUpdateInput {
|
||||
productModel: string
|
||||
reportTitle?: string | null
|
||||
categoryName?: string | null
|
||||
sceneName?: string | null
|
||||
platformName?: string | null
|
||||
@ -33,6 +36,7 @@ export interface MaterialUpdateInput {
|
||||
authorName: string
|
||||
userId?: string | null
|
||||
taskNo?: string | null
|
||||
taskDetailSourceType?: string | null
|
||||
// 兼容旧调用方传参,实际保存时会按资产自动计算
|
||||
isFullSet?: boolean | string | null
|
||||
}
|
||||
@ -167,7 +171,9 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
|
||||
const material = editable.material as any
|
||||
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
|
||||
const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType')
|
||||
const productModel = normalizeString(input.productModel)
|
||||
const reportTitle = normalizeReportTitle(input.reportTitle)
|
||||
const materialType = normalizeString(input.materialType)
|
||||
const authorName = normalizeString(input.authorName)
|
||||
const shouldUseGeneralCategoryScene = shouldAutoFillGeneral(materialType)
|
||||
@ -176,8 +182,18 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
const platformName = normalizeOptionalString(input.platformName)
|
||||
const productCategoryName = normalizeOptionalString(input.productCategoryName)
|
||||
const taskNo = normalizeTaskNo(input.taskNo)
|
||||
const rawTaskDetailSourceType = input.taskDetailSourceType
|
||||
const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType)
|
||||
|
||||
if (!productModel || !materialType || !authorName) {
|
||||
if (!materialType || !authorName) {
|
||||
return { error: '缺少必填字段', status: 400 as const }
|
||||
}
|
||||
|
||||
if (isCertificateReportMaterialType(materialType)) {
|
||||
if (!productModel && !reportTitle) {
|
||||
return { error: 'SPU 和 报告名称/检测对象 至少填写一个', status: 400 as const }
|
||||
}
|
||||
} else if (!productModel) {
|
||||
return { error: '缺少必填字段', status: 400 as const }
|
||||
}
|
||||
|
||||
@ -200,6 +216,18 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasTaskDetailSourceTypeField &&
|
||||
rawTaskDetailSourceType != null &&
|
||||
normalizeString(rawTaskDetailSourceType) &&
|
||||
!taskDetailSourceType
|
||||
) {
|
||||
return {
|
||||
error: '出图方式只能是 实拍、渲染、AI 或留空',
|
||||
status: 400 as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (taskNo) {
|
||||
const existingTaskNo = await prisma.material.findFirst({
|
||||
where: {
|
||||
@ -242,6 +270,7 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
const updateData: Record<string, any> = {
|
||||
previewUrl,
|
||||
productModel,
|
||||
reportTitle: isCertificateReportMaterialType(materialType) ? reportTitle : null,
|
||||
categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName),
|
||||
sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName),
|
||||
platformName: platformName ?? '',
|
||||
@ -252,6 +281,10 @@ export async function updateMaterialWithSharedRules(options: UpdateMaterialOptio
|
||||
? normalizeOptionalString(input.renderer) || material.renderer || getDefaultRenderer(materialType)
|
||||
: material.renderer,
|
||||
taskNo: hasTaskNoField ? taskNo : material.taskNo,
|
||||
taskDetailSourceType:
|
||||
materialType === '任务-详情'
|
||||
? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null)
|
||||
: null,
|
||||
isFullSet: supportsIsFullSet(materialType)
|
||||
? deriveTaskDetailIsFullSet(materialType, material.assets, Boolean(material.isFullSet))
|
||||
: false,
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
* 素材字段验证和处理的统一逻辑
|
||||
*/
|
||||
|
||||
// 材质、音乐、标准模型、品牌VI灵感不需要店铺和场景(自动填充为"通用")
|
||||
export const AUTO_GENERAL_TYPES: string[] = ['材质', '音乐', '品牌VI灵感'];
|
||||
// 这些类型不需要店铺和场景,系统会自动填充为“通用”
|
||||
export const AUTO_GENERAL_TYPES: string[] = ['材质', '音乐', '品牌VI灵感', '任务-其他', '证书-检测报告'];
|
||||
|
||||
/**
|
||||
* 根据素材类型判断是否需要renderer字段
|
||||
@ -23,8 +23,8 @@ export function requiresCategoryAndScene(materialType: string): boolean {
|
||||
* 根据素材类型判断是否需要productCategoryName字段
|
||||
*/
|
||||
export function requiresProductCategoryName(materialType: string): boolean {
|
||||
// 只有标准模型需要品类(品牌VI灵感改为可选)
|
||||
return materialType === '标准模型';
|
||||
// 标准模型和证书-检测报告需要品类
|
||||
return materialType === '标准模型' || materialType === '证书-检测报告';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,14 +5,14 @@
|
||||
* 依赖:需要系统安装 poppler-utils (pdftoppm)
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { readFile, unlink, writeFile, open } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
interface PdfProcessResult {
|
||||
success: boolean
|
||||
@ -39,16 +39,6 @@ export async function generatePdfPreview(
|
||||
try {
|
||||
console.log(`[PDF] 开始处理PDF文件: ${pdfFilePath}`)
|
||||
|
||||
// ⚠️ 安全修复:验证文件路径,防止命令注入(R40)
|
||||
// 只允许安全字符:字母、数字、下划线、连字符、点、斜杠、反斜杠、冒号、空格、括号
|
||||
const safePathPattern = /^[a-zA-Z0-9_\-./\\:() ]+$/
|
||||
if (!safePathPattern.test(pdfFilePath)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF文件路径包含非法字符'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有提供输出路径,生成唯一的临时文件
|
||||
// 使用 crypto.randomBytes 确保并发上传时不会冲突
|
||||
const randomSuffix = require('crypto').randomBytes(8).toString('hex')
|
||||
@ -77,13 +67,21 @@ export async function generatePdfPreview(
|
||||
// 提取时使用较大的尺寸,确保有足够的像素用于后续处理
|
||||
// 只指定宽度,高度自动按比例缩放,保持PDF原始长宽比
|
||||
const extractSize = Math.max(maxWidth, maxHeight) * 2
|
||||
const command = `pdftoppm -f 1 -l 1 -scale-to ${extractSize} -jpeg "${pdfFilePath}" "${baseOutputPath}"`
|
||||
|
||||
console.log(`[PDF] 执行命令: pdftoppm -f 1 -l 1 -scale-to ${extractSize} -jpeg ...`)
|
||||
|
||||
try {
|
||||
// R14修复:添加超时控制 - PDF处理最多10分钟
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
const { stderr } = await execFileAsync('pdftoppm', [
|
||||
'-f',
|
||||
'1',
|
||||
'-l',
|
||||
'1',
|
||||
'-scale-to',
|
||||
String(extractSize),
|
||||
'-jpeg',
|
||||
pdfFilePath,
|
||||
baseOutputPath,
|
||||
], {
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large PDFs
|
||||
timeout: 10 * 60 * 1000, // 10分钟超时
|
||||
})
|
||||
@ -100,7 +98,11 @@ export async function generatePdfPreview(
|
||||
}
|
||||
}
|
||||
// 检查是否是命令不存在
|
||||
if (execError?.code === 127 || execError?.message?.includes('pdftoppm')) {
|
||||
if (
|
||||
execError?.code === 127 ||
|
||||
execError?.code === 'ENOENT' ||
|
||||
execError?.message?.includes('pdftoppm')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PDF处理工具未安装,请安装 poppler-utils 包'
|
||||
|
||||
@ -24,6 +24,7 @@ import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
export interface ProductPackageImageInput {
|
||||
uploadId?: string
|
||||
fileUrl?: string
|
||||
localFilePath?: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sortOrder?: number
|
||||
@ -34,6 +35,7 @@ export interface ProductPackageImageInput {
|
||||
export interface ProductPackageSourceInput {
|
||||
uploadId?: string
|
||||
fileUrl?: string
|
||||
localFilePath?: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
}
|
||||
@ -48,6 +50,11 @@ export interface CompleteProductPackageUploadInput {
|
||||
detail?: ProductPackageImageInput[]
|
||||
psd?: ProductPackageSourceInput[]
|
||||
}
|
||||
options?: {
|
||||
allowSkuWithoutBarcodes?: boolean
|
||||
zipTimeoutMs?: number
|
||||
allZipTimeoutMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface GeneratedProductPackagePreviewAsset {
|
||||
@ -62,7 +69,10 @@ function normalizeBarcodeValue(barcode: string | null | undefined) {
|
||||
return String(barcode || '').trim()
|
||||
}
|
||||
|
||||
function normalizeSkuPreviewAssets(assets: ProductPackageImageInput[] | undefined) {
|
||||
function normalizeSkuPreviewAssets(
|
||||
assets: ProductPackageImageInput[] | undefined,
|
||||
options?: { allowSkuWithoutBarcodes?: boolean },
|
||||
) {
|
||||
if (!assets || assets.length === 0) {
|
||||
return []
|
||||
}
|
||||
@ -79,7 +89,7 @@ function normalizeSkuPreviewAssets(assets: ProductPackageImageInput[] | undefine
|
||||
new Set((asset.barcodes || []).map((barcode) => normalizeBarcodeValue(barcode)).filter(Boolean)),
|
||||
)
|
||||
|
||||
if (barcodes.length === 0) {
|
||||
if (barcodes.length === 0 && !options?.allowSkuWithoutBarcodes) {
|
||||
throw new Error(`SKU 预览图 ${asset.fileName} 至少需要 1 个产品条码`)
|
||||
}
|
||||
|
||||
@ -100,11 +110,11 @@ function normalizeSkuPreviewAssets(assets: ProductPackageImageInput[] | undefine
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadToTempFile(prefix: string, asset: ProductPackageImageInput | ProductPackageSourceInput) {
|
||||
if (!asset.fileUrl) {
|
||||
throw new Error(`缺少文件地址: ${asset.fileName}`)
|
||||
}
|
||||
|
||||
async function writeAssetToTempFile(
|
||||
prefix: string,
|
||||
asset: ProductPackageImageInput | ProductPackageSourceInput,
|
||||
buffer: Buffer,
|
||||
) {
|
||||
const tempPath = path.join(
|
||||
process.cwd(),
|
||||
'uploads',
|
||||
@ -113,6 +123,19 @@ async function downloadToTempFile(prefix: string, asset: ProductPackageImageInpu
|
||||
)
|
||||
|
||||
await mkdir(path.dirname(tempPath), { recursive: true })
|
||||
await writeFile(tempPath, buffer)
|
||||
return tempPath
|
||||
}
|
||||
|
||||
async function downloadToTempFile(prefix: string, asset: ProductPackageImageInput | ProductPackageSourceInput) {
|
||||
if (asset.localFilePath) {
|
||||
const buffer = await readFile(asset.localFilePath)
|
||||
return writeAssetToTempFile(prefix, asset, buffer)
|
||||
}
|
||||
|
||||
if (!asset.fileUrl) {
|
||||
throw new Error(`缺少文件地址: ${asset.fileName}`)
|
||||
}
|
||||
|
||||
const response = await fetch(asset.fileUrl)
|
||||
if (!response.ok) {
|
||||
@ -120,8 +143,34 @@ async function downloadToTempFile(prefix: string, asset: ProductPackageImageInpu
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
await writeFile(tempPath, buffer)
|
||||
return tempPath
|
||||
return writeAssetToTempFile(prefix, asset, buffer)
|
||||
}
|
||||
|
||||
async function readAssetBuffer(
|
||||
asset: ProductPackageImageInput | ProductPackageSourceInput,
|
||||
type: 'main' | 'sku' | 'detail' | 'psd',
|
||||
) {
|
||||
if (asset.localFilePath) {
|
||||
return readFile(asset.localFilePath)
|
||||
}
|
||||
|
||||
if (asset.fileUrl) {
|
||||
const response = await fetch(asset.fileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载${type}预览原图失败: ${asset.fileName}`)
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
if (asset.uploadId) {
|
||||
const filePath = await getUploadedFile(asset.uploadId)
|
||||
if (!filePath) {
|
||||
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
|
||||
}
|
||||
return readFile(filePath)
|
||||
}
|
||||
|
||||
throw new Error(`无效的${type}资产: ${asset.fileName}`)
|
||||
}
|
||||
|
||||
async function collectAssetFiles(
|
||||
@ -152,7 +201,7 @@ async function collectAssetFiles(
|
||||
continue
|
||||
}
|
||||
|
||||
if (!asset.fileUrl) {
|
||||
if (!asset.fileUrl && !asset.localFilePath) {
|
||||
throw new Error(`资产参数无效: ${asset.fileName}`)
|
||||
}
|
||||
|
||||
@ -186,21 +235,7 @@ async function generatePreviewsFromAssets(
|
||||
const asset = assets[index]
|
||||
let buffer: Buffer
|
||||
|
||||
if (asset.fileUrl) {
|
||||
const response = await fetch(asset.fileUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载${type}预览原图失败: ${asset.fileName}`)
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer())
|
||||
} else if (asset.uploadId) {
|
||||
const filePath = await getUploadedFile(asset.uploadId)
|
||||
if (!filePath) {
|
||||
throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`)
|
||||
}
|
||||
buffer = await readFile(filePath)
|
||||
} else {
|
||||
throw new Error(`无效的${type}资产: ${asset.fileName}`)
|
||||
}
|
||||
buffer = await readAssetBuffer(asset, type)
|
||||
|
||||
const metadata = await sharp.default(buffer).metadata()
|
||||
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
|
||||
@ -260,7 +295,7 @@ async function movePsdFilesToPermanentDir(
|
||||
|
||||
for (const psd of assets) {
|
||||
if (!psd.uploadId) {
|
||||
if (!psd.fileUrl) {
|
||||
if (!psd.fileUrl && !psd.localFilePath) {
|
||||
throw new Error(`PSD 资产参数无效: ${psd.fileName}`)
|
||||
}
|
||||
|
||||
@ -308,19 +343,22 @@ async function movePsdFilesToPermanentDir(
|
||||
}
|
||||
|
||||
export async function completeProductPackageUpload(input: CompleteProductPackageUploadInput) {
|
||||
const { materialId, taskNo, assets, operatorUserId } = input
|
||||
const { materialId, taskNo, assets, operatorUserId, options } = input
|
||||
const tempFiles: string[] = []
|
||||
const uploadIds: string[] = []
|
||||
const uploadedPreviewUrls: string[] = []
|
||||
const createdZipPaths: string[] = []
|
||||
const normalizedAssets = {
|
||||
main: assets.main || [],
|
||||
sku: normalizeSkuPreviewAssets(assets.sku),
|
||||
sku: normalizeSkuPreviewAssets(assets.sku, options),
|
||||
detail: assets.detail || [],
|
||||
psd: assets.psd || [],
|
||||
}
|
||||
const derivedIsFullSet = isTaskDetailFullSetFromUploadGroups(normalizedAssets)
|
||||
|
||||
const zipTimeoutMs = options?.zipTimeoutMs && options.zipTimeoutMs > 0 ? options.zipTimeoutMs : 30000
|
||||
const allZipTimeoutMs = options?.allZipTimeoutMs && options.allZipTimeoutMs > 0 ? options.allZipTimeoutMs : 60000
|
||||
|
||||
if (!materialId || !assets) {
|
||||
throw new Error('缺少必填参数')
|
||||
}
|
||||
@ -384,6 +422,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
'主图',
|
||||
authorName,
|
||||
categoryName,
|
||||
zipTimeoutMs,
|
||||
)
|
||||
createdZipPaths.push(zipInfos.main.filePath)
|
||||
zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName })
|
||||
@ -398,6 +437,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
'SKU图',
|
||||
authorName,
|
||||
categoryName,
|
||||
zipTimeoutMs,
|
||||
)
|
||||
createdZipPaths.push(zipInfos.sku.filePath)
|
||||
zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName })
|
||||
@ -412,6 +452,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
'详情图',
|
||||
authorName,
|
||||
categoryName,
|
||||
zipTimeoutMs,
|
||||
)
|
||||
createdZipPaths.push(zipInfos.detail.filePath)
|
||||
zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName })
|
||||
@ -422,6 +463,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
materialId,
|
||||
material.productModel || 'product',
|
||||
'任务-详情',
|
||||
allZipTimeoutMs,
|
||||
)
|
||||
createdZipPaths.push(zipInfos.all.filePath)
|
||||
|
||||
@ -429,11 +471,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
|
||||
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
|
||||
|
||||
const cardPreviewUrl =
|
||||
previewAssets.main[0]?.fileUrl ||
|
||||
previewAssets.sku[0]?.fileUrl ||
|
||||
previewAssets.detail[0]?.fileUrl ||
|
||||
material.previewUrl
|
||||
const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl
|
||||
|
||||
await assignTaskNoOnComplete(materialId, normalizedTaskNo)
|
||||
await prisma.material.update({
|
||||
|
||||
@ -60,6 +60,14 @@ export function buildTaskDetailPublicDisplayPath(
|
||||
return normalizeRelativePath(`task-detail/${materialId}/${group}/${fileName}`)
|
||||
}
|
||||
|
||||
export function buildCertificateReportPublicDisplayPath(
|
||||
materialId: string,
|
||||
group: 'pdf' | 'image' | 'preview',
|
||||
fileName: string,
|
||||
): string {
|
||||
return normalizeRelativePath(`certificate-report/${materialId}/${group}/${fileName}`)
|
||||
}
|
||||
|
||||
export function buildWhiteBackgroundPublicDisplayPath(
|
||||
materialId: string,
|
||||
skuId: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user