chore: sync latest project source

This commit is contained in:
chenpei 2026-05-07 16:37:22 +08:00
parent f675abe5ee
commit 8c39d96249
136 changed files with 11130 additions and 1607 deletions

View File

@ -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:*)"
]
}
}

View File

@ -43,7 +43,9 @@ cache/**
.env*.local
# 用户上传的文件(关键优化!)
uploads
# 这里只排除项目根目录的真实上传文件,不能误伤 app/api/uploads、app/uploads 这类路由目录
/uploads
/uploads/**
# Prisma 引擎缓存(不需要在构建上下文中)
prisma/engine-cache

16
.gitignore vendored
View File

@ -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
View File

@ -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` 表

View File

@ -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

View File

@ -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)
}

View File

@ -238,4 +238,4 @@ export async function GET(req: NextRequest) {
{ status: 500 }
)
}
}
}

View File

@ -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 }
)
}
}
}

View 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',
)
}
}

View 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',
)
}
}

View File

@ -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] ========== 任务类型素材下载 ==========`);

View File

@ -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',

View 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',
)
}
}

View File

@ -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),

View File

@ -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: {

View File

@ -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 数组

View File

@ -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 失败',

View File

@ -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

View 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)

View File

@ -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,

View File

@ -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',
),
})),
};

View File

@ -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(

View 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 })
}
}

View 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 })
}
}

View File

@ -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,
});

View File

@ -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,

View 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 },
)
}
}

View File

@ -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'

View File

@ -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>
)
}

View File

@ -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}

View 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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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 = ''

View File

@ -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"

View File

@ -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">

View 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>
)
}

View 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()
})
})

View File

@ -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"

View File

@ -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)

View File

@ -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">

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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>

View 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>
)
}

View File

@ -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()
})
})

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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 || '',

View File

@ -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,
'音乐',
]

View 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')
})
})

View File

@ -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
}

View File

@ -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、以及给一组联调样本。**

View File

@ -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/` 继续可访问,但不再作为默认展示路径。**

View File

@ -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`

View File

@ -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` 结果层使用

View 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)

View 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/`

View File

@ -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 回源链路

View File

@ -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` 回源链路。

View 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`

View File

@ -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([])
})
})

View 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')
})
})

View 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')
})
})

View 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')
})
})

View File

@ -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("'任务-其他'")
})
})

View File

@ -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',
}),
}),
)
})
})

View 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('种草视频')
})
})

View File

@ -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,

View File

@ -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',
}),
)
})

View 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: '通用',
}),
}),
}),
)
})
})

View 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')
})
})

View File

@ -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',
},
})
})
})

View File

@ -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({

View 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: '渲染',
}),
})
})
})

View 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')
})
})

View 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',
},
})
})
})

View File

@ -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',
])
})
})

View 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',
}),
)
})
})

View 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()
})
})

View 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)
})
})

View File

@ -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'))

View File

@ -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
View 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'
}

View File

@ -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) {

View File

@ -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
View 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`)
}

View File

@ -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;
}
/**

View 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
}

View 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
}

View File

@ -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 },

View File

@ -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);
}

View File

@ -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原图',
'任务-实拍原图',

View File

@ -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,

View File

@ -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 === '证书-检测报告';
}
/**

View File

@ -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 包'

View File

@ -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({

View File

@ -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