修复清理逻辑并同步项目文档

This commit is contained in:
chenpei 2026-03-06 14:14:50 +08:00
parent 752b7bf999
commit 6aab2f305e
59 changed files with 6454 additions and 865 deletions

111
AGENTS.md Normal file
View File

@ -0,0 +1,111 @@
# gongxiangp2 的 Codex 项目规则
适用范围:本文件仅对当前项目生效。
## 1. 项目背景
- 本项目是线上运行中的内部素材平台,稳定性优先于速度。
- 当前默认以“新素材链路”为主,不把“新老素材兼容”作为默认核心门禁。
- 只有在任务明确涉及历史数据、旧下载路径、旧存储迁移时,才重新拉高“老素材兼容”优先级。
## 2. 基本工作原则
- 默认使用中文说明,优先自然语言,少量必要代码示例。
- 先做只读摸底,再决定是否修改。
- 修改后必须验证,先跑最相关的验证,再视情况扩大范围。
- 不触碰其他应用配置,不把项目规则写入全局 `Codex` 配置。
## 3. 项目内 Multi-agents 规则
当任务满足以下任一条件时,优先启用内置多代理:
- 任务同时涉及前后端、接口与页面联动。
- 任务涉及上线前审查、发布门禁、回归检查。
- 任务需要同时做“功能正确性 + UI 统一性 + 发布风险”判断。
- 任务跨越多个目录,单代理容易遗漏影响范围。
当用户明确写出以下任一表达时,默认必须真正进入多代理流程,而不是只解释角色定义:
- “按项目的审查模式处理”
- “按项目的修复模式处理”
- “按项目的发布模式处理”
- “用 Multi-agents 处理”
- “启用项目团队”
默认团队结构:
- `orchestrator`:主控代理,负责拆解任务、分配角色、汇总结论。
- `backend`:后端代理,负责 `app/api/**`、`lib/**`、`prisma/**` 风险审查与修复建议。
- `frontend`:前端代理,负责 `app/**` 页面层、`components/**`、`hooks/**` 交互回归。
- `ui_auditor`:UI 统一规范审查员,负责视觉系统和组件一致性审查。
- `release_gate`:发布守门代理,负责按证据给出可上线/有条件上线/不可上线结论。
对外沟通规则:
- 只有 `orchestrator` 直接和用户对话。
- 其它子代理只负责内部执行、审查、汇总,不直接面向用户输出。
- 用户默认把需求交给 `orchestrator`;由 `orchestrator` 自行决定是否派发、派给谁、何时汇总。
- 除非用户明确要求查看子代理原始结果,否则对外只输出主控汇总后的结论。
按需附加角色:
- `explorer`:跨文件侦察员,用于快速搜集上下文、文件归属和影响范围。
- `worker`:定点执行代理,用于在职责明确后修改小范围文件。
## 4. 角色分工边界
- `backend` 默认优先处理:上传、下载、转码、权限、批量操作、后台统计、外部接口。
- `frontend` 默认优先处理:管理后台页面、上传组件、分享页、首页、交互状态。
- `ui_auditor` 默认不先改代码,先审查视觉问题;只有明确要求修 UI 时才参与改动建议。
- `release_gate` 只做汇总和判定,不直接承担业务实现。
- `worker` 只能接收已经明确边界的子任务,避免和主代理重复改动。
自动派单规则:
- 用户只需要把任务交给 `orchestrator`,不需要手动指定团队成员。
- `orchestrator` 根据任务类型自动决定是否启用团队,以及启用哪些角色。
- 默认分配逻辑:
- 单一页面或纯 UI 小改动:`frontend` + `ui_auditor`
- 纯接口、下载、上传、转码、权限、批量操作:`backend`
- 前后端联动:`backend` + `frontend`
- 前后端联动且涉及视觉统一:`backend` + `frontend` + `ui_auditor`
- 上线前审查、高风险任务、改动范围大:`backend` + `frontend` + `ui_auditor` + `release_gate`
- 影响范围不清:先派 `explorer` 摸底,再决定后续角色
- 用户不负责选人,角色编组由 `orchestrator` 内部完成。
高风险边界规则:
- 下面这些任务默认视为高风险:
- 涉及数据库写入、删除、恢复、批量更新、批量删除
- 涉及权限判断、角色判断、下载资格、管理员能力
- 涉及上传、下载、转码、外部接口、批量操作
- 涉及后台统计、日志、导出、审计记录
- 涉及可能影响线上已有数据、用户可见结果或管理后台操作结果的改动
- 高风险任务默认至少派出:`backend`
- 如果高风险任务同时影响页面或交互,再追加:`frontend`
- 如果高风险任务还影响视觉一致性,再追加:`ui_auditor`
- 如果任务准备上线、范围较大、或存在不确定性,再追加:`release_gate`
- 只要任务落入高风险边界,`orchestrator` 默认采用更保守的判断和验证策略。
UI 基线规则:
- 当前项目的 UI 统一标准默认以首页为基线。
- 首页对应文件:`app/page.tsx`
- `ui_auditor` 在审查其它页面时,优先对齐首页的视觉语言、主按钮风格、信息层级、间距节奏和状态反馈。
- 如果某个后台页面或特殊业务页面需要偏离首页风格,必须说明原因和影响范围。
## 5. 输出要求
多代理任务的输出默认按下面结构收敛:
- `发现的问题`
- `严重级别 P0 / P1 / P2`
- `影响范围`
- `修复建议`
- `验证清单`
- `最终结论`
## 6. 建议先读的文档
- `项目文档.md`
- `docs/CODEX_MULTI_AGENTS.md`

298
README.md
View File

@ -1,22 +1,117 @@
# 由壹点3D素材
# 由壹点 3D 素材平台
一个现代化的3D素材分享平台,采用苹果官方UI设计规范。
当前仓库对应的是一个已经在线上运行的业务项目,不是演示性质的素材站。项目围绕素材上传、管理、下载、分享、权限控制和运维诊断构建,当前代码已经明显超出早期 README 里那种“注册登录 + 预览图 + 压缩包”的简单形态。
> 本 README 已于 2026-03-06 按当前代码结构重写,优先反映现状。
## 项目概览
- 基于 Next.js 14 App Router 的素材协作与分发系统
- 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径
- 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路
- 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布
## 当前核心能力
### 1. 用户与登录
- 账号密码登录
- 钉钉登录与账号补全流程
- NextAuth 会话管理
- 个人资料页、个人素材、收藏与点赞
### 2. 素材浏览与分发
- 首页搜索、筛选、分页加载
- 收藏、点赞、排行榜、分享页
- 下载前校验与前端客户端下载兜底
- 素材分享链接与外部下载接口
### 3. 素材上传与处理
- 分片上传、断点续传、上传会话管理
- 支持多种素材类型上传,当前代码中已覆盖:
- 3D 原图
- AI 原图
- 实拍图
- 视频截图
- 任务页素材
- 商品套包
- 标准模特白底图
- SKU 维度文件管理、排序、批量下载
- 详情图 / 素材资产管理
- 商品套包未完成上传的中止与回滚
- 视频转码、封面 / 预览片段生成、转码队列与重试
### 4. 后台与权限
- 素材管理、用户管理、分类管理
- 部门管理与部门素材权限控制
- 数据统计、统计导出、素材操作日志
- API Key、API 日志、系统设置、回收站、超级管理员相关能力
### 5. 运行与诊断
- 网络诊断页面与接口
- 健康检查接口与内存检查接口
- 快速性能检查接口
- 内外网 URL 重写与下载链路适配
## 技术栈
- **前端**: Next.js 14 + React 18 + TypeScript + TailwindCSS
- **认证**: NextAuth.js
- **数据库**: PostgreSQL + Prisma ORM
- **存储**: MinIO S3 (预览图) + 本地存储 (压缩包)
- 前端:Next.js 14、React 18、TypeScript、Tailwind CSS
- 认证:NextAuth.js
- 数据库:PostgreSQL + Prisma ORM
- 对象存储:S3 / MinIO
- 本地文件:`uploads` 目录
- 缓存 / 会话:Redis(可选,上传会话优先使用)
- 文件与媒体处理:`sharp`、`jszip`、`adm-zip`、`archiver`
- 测试:Jest + Testing Library
## 功能特性
## 目录结构
- ✅ 用户注册/登录系统
- ✅ 素材上传(预览图 + 压缩包)
- ✅ 素材浏览与下载
- ✅ 管理员后台管理
- ✅ 用户管理
- ✅ 苹果风格UI设计
```text
gongxiangp2/
├── app/ # 页面、App Router、API 路由
│ ├── admin/ # 管理后台页面
│ ├── api/ # 业务 API
│ ├── login/ # 登录页
│ ├── register/ # 注册页
│ ├── share/ # 分享页
│ └── page.tsx # 首页
├── components/ # 通用组件、后台组件、上传组件
├── lib/ # 业务工具、上传/下载/转码/权限逻辑
├── prisma/ # Prisma schema
├── scripts/ # 运维/修复/清理脚本
├── docs/ # 网络、团队协作等项目文档
├── uploads/ # 本地上传与处理中间文件
└── README.md
```
## 数据模型概览
当前 Prisma 模型已经不止早期的 `User` / `Material` 两张表,核心模型包括但不限于:
- `User`
- `Material`
- `MaterialSku`
- `MaterialAsset`
- `MaterialLog`
- `MaterialDownloadLog`
- `Department`
- `DepartmentMaterialPermission`
- `ApiKey`
- `ApiCallLog`
- `VideoTranscodeProgress`
- `SystemSettings`
角色枚举当前包含:
- `USER`
- `ADMIN`
- `DEPT_ADMIN`
- `SYSTEM_ADMIN`
## 快速开始
@ -28,115 +123,148 @@ npm install
### 2. 配置环境变量
复制 `.env.local` 文件并填入你的配置:
仓库当前没有维护独立的 `.env.example`,请自行准备本地环境变量文件,并且不要把真实密钥再次提交到仓库。
最小必需:
```bash
# Database
DATABASE_URL=your_postgresql_url
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=replace-this-with-a-strong-secret
```
# NextAuth
NEXTAUTH_SECRET=your_secret_key
常用配置:
```bash
# 站点地址
NEXTAUTH_URL=http://localhost:3000
APP_PUBLIC_URL=http://localhost:3000
# MinIO S3 Storage
S3_ENDPOINT=your_s3_endpoint
S3_BUCKET=your_bucket_name
S3_ACCESS_KEY_ID=your_access_key
S3_SECRET_ACCESS_KEY=your_secret_key
S3_REGION=us-east-1
S3_PUBLIC_DOMAIN=your_public_domain
S3_FILE_PATH=your_file_path/
# Local storage
# 本地文件目录
UPLOAD_DIR=./uploads
# S3 / MinIO
S3_ENDPOINT=http://127.0.0.1:9000
S3_BUCKET=your-bucket
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_REGION=us-east-1
S3_PUBLIC_DOMAIN=https://your-s3-domain
S3_FILE_PATH=your-prefix/
# Redis(上传会话)
USE_REDIS=true
REDIS_URL=redis://127.0.0.1:6379
# 钉钉登录
DINGTALK_CLIENT_ID=your-dingtalk-app-key
DINGTALK_CLIENT_SECRET=your-dingtalk-app-secret
NEXT_PUBLIC_DINGTALK_CLIENT_ID=your-dingtalk-app-key
# 下载与外部接口
DOWNLOAD_TOKEN_SECRET=replace-this
ADMIN_API_KEY=replace-this-if-needed
# 内外网链路改写
SERVER_IP=127.0.0.1
MINIO_API_PORT=9000
# 视频转码(按需)
VIDEO_TRANSCODE_MAX_CONCURRENT=2
VIDEO_TRANSCODE_TIMEOUT=3600000
VIDEO_PREVIEW_RESOLUTION=720
VIDEO_CLIP_DURATION=10
VIDEO_PREVIEW_CRF=20
VIDEO_CLIP_CRF=24
VIDEO_MAX_TEMP_FILE_SIZE=10737418240
# AI 聊天(按需)
GEMINI_API_KEY=your-api-key
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
GEMINI_MODEL=gemini-2.0-flash-exp
```
### 3. 初始化数据库
```bash
# 生成 Prisma Client
npm run prisma:generate
# 推送数据库模式
npm run prisma:push
```
### 4. 创建管理员账号
### 4. 初始化管理员账号
首次运行后,注册一个账号,然后在数据库中手动将该用户的 `role` 字段设置为 `ADMIN`。
首次启动后,可以先注册一个普通账号,再在数据库里把角色改成 `ADMIN`。
```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';
UPDATE users
SET role = 'ADMIN'
WHERE email = 'your@email.com';
```
### 5. 启动开发服务器
### 5. 启动开发环境
```bash
npm run dev
```
访问 http://localhost:3000
默认访问地址:
## 项目结构
```
gongxiangp2/
├── app/ # Next.js App Router
│ ├── api/ # API 路由
│ │ ├── auth/ # 认证相关
│ │ ├── materials/ # 素材相关
│ │ └── admin/ # 管理员相关
│ ├── login/ # 登录页面
│ ├── register/ # 注册页面
│ ├── admin/ # 管理员页面
│ └── page.tsx # 首页
├── components/ # React 组件
│ ├── Header.tsx
│ └── UploadModal.tsx
├── lib/ # 工具库
│ ├── prisma.ts # Prisma 客户端
│ ├── auth.ts # NextAuth 配置
│ └── s3.ts # S3 存储服务
├── prisma/ # Prisma 配置
│ └── schema.prisma # 数据库模式
└── uploads/ # 本地上传文件存储
```text
http://localhost:3000
```
## 数据库模型
## 常用脚本
### User (用户)
- id: UUID
- email: 邮箱
- username: 用户名
- password: 密码 (加密)
- role: 角色 (USER/ADMIN)
```bash
npm run dev
npm run build
npm run start
npm run lint
npm run test
npm run test:watch
npm run test:coverage
npm run prisma:generate
npm run prisma:push
npm run prisma:studio
```
### Material (素材)
- id: UUID
- previewUrl: 预览图URL (S3)
- productModel: 商品型号
- sceneName: 场景名称
- authorName: 作者花名
- zipFileName: 压缩包文件名
- zipFilePath: 压缩包本地路径
- userId: 用户ID (外键)
## 部署说明
## 部署
### 生产环境构建
### 本地或单机生产启动
```bash
npm run build
npm start
npm run start
```
### 环境变量
### Docker / Compose
确保在生产环境中设置所有必要的环境变量,特别是:
- `NEXTAUTH_SECRET`: 使用强随机字符串
- `NEXTAUTH_URL`: 设置为生产域名
仓库内已经包含以下部署文件:
## License
- `Dockerfile`
- `Dockerfile.base`
- `docker-compose.yml`
- `docker-compose.shadow.yml`
MIT
但当前线上环境并不只依赖容器本身,还依赖网络入口、FRP、域名与反向代理链路。因此如果要碰部署问题,建议连同项目文档一起看,而不是只看 Compose 文件。
### 发布前重点核对
- 登录是否正常
- 首页筛选、上传、下载、分享是否正常
- 管理后台核心页签是否可用
- 视频转码队列是否异常堆积
- `/api/health` 是否正常
- 如果改了网络或下载链路,再验证一次内外网访问
## 相关文档
- `docs/NETWORK_TOPOLOGY.md`:当前网络拓扑与故障排查
- `docs/CODEX_MULTI_AGENTS.md`:项目内 Codex 团队协作规则
- `部署前安全检查清单.md`:上线前检查项
- `项目文档.md`:历史项目资料
## 说明
- 当前仓库 `package.json` 为 `private: true`
- 本仓库目前没有单独声明开源许可证,不应继续沿用旧 README 中的 `MIT` 说法
- 项目已在线上运行,任何会影响上传、下载、权限、批量删除、资源链路的改动,都应先验证再发布

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Header from '@/components/Header'
@ -10,6 +10,7 @@ import MaterialManagement from '@/components/admin/MaterialManagement'
import UserManagement from '@/components/admin/UserManagement'
import CategoryManagement from '@/components/admin/CategoryManagement'
import DepartmentManagement from '@/components/admin/DepartmentManagement'
import DataStatisticsTab from '@/components/admin/DataStatisticsTab'
// 类型定义
interface User {
@ -96,9 +97,22 @@ interface Department {
export default function AdminPage() {
const { data: session, status } = useSession()
const router = useRouter()
const sessionUser = session?.user as {
role?: string
managedDepartment?: string | null
department?: string | null
isSuperAdmin?: boolean
email?: string | null
} | undefined
const currentUserRole = sessionUser?.role || ''
const canAccessAdminPage = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN' || currentUserRole === 'DEPT_ADMIN'
const canManageSystemTabs = currentUserRole === 'ADMIN' || currentUserRole === 'SYSTEM_ADMIN'
const departmentScopeName = String(sessionUser?.managedDepartment || sessionUser?.department || '').trim() || null
const canSelectDepartmentInStats = canManageSystemTabs
// Tab状态
const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'permissions' | 'superAdmins'>('materials')
const [activeTab, setActiveTab] = useState<'materials' | 'users' | 'categories' | 'departments' | 'dataStats' | 'permissions' | 'superAdmins'>('dataStats')
const [loading, setLoading] = useState(true)
// 数据状态
@ -141,42 +155,32 @@ export default function AdminPage() {
setToast({ message, type })
}, [])
// 权限检查
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
const fetchDepartments = useCallback(async () => {
try {
// 阶段三:获取包含层级关系的部门数据
const response = await fetch('/api/departments?includeHierarchy=true')
if (response.ok) {
const data = await response.json()
setDepartments(data)
// 默认所有部门收起
setCollapsedDepartments(new Set(data.map((d: Department) => d.id)))
}
} catch (error) {
console.error('Error fetching departments:', error)
}
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN') {
router.push('/')
return
}
}, [status, session, router])
// 数据加载
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, activeTab, showRecycleBin])
// 切换回收站时清空选中状态和搜索词
useEffect(() => {
setSelectedMaterials(new Set())
setMaterialSearchTerm('') // 清空搜索词,避免状态混乱
}, [showRecycleBin])
}, [])
const fetchData = useCallback(async () => {
setLoading(true)
try {
if (activeTab === 'materials') {
if (activeTab === 'dataStats' || !canManageSystemTabs) {
return
} else if (activeTab === 'materials') {
setCurrentMaterialsPage(1)
setDisplayedMaterialsCount(24)
setHasMoreMaterials(true)
const url = showRecycleBin
const url = showRecycleBin
? `/api/materials?page=1&pageSize=${materialsPageSize}&deleted=true`
: `/api/materials?page=1&pageSize=${materialsPageSize}`
const response = await fetch(url)
@ -218,22 +222,40 @@ export default function AdminPage() {
} finally {
setLoading(false)
}
}, [activeTab, showRecycleBin, materialsPageSize])
}, [activeTab, canManageSystemTabs, fetchDepartments, showRecycleBin])
const fetchDepartments = useCallback(async () => {
try {
// 阶段三:获取包含层级关系的部门数据
const response = await fetch('/api/departments?includeHierarchy=true')
if (response.ok) {
const data = await response.json()
setDepartments(data)
// 默认所有部门收起
setCollapsedDepartments(new Set(data.map((d: Department) => d.id)))
}
} catch (error) {
console.error('Error fetching departments:', error)
// 权限检查
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
return
}
}, [])
if (status === 'authenticated' && !canAccessAdminPage) {
router.push('/')
return
}
}, [status, canAccessAdminPage, router])
useEffect(() => {
if (status === 'authenticated' && !canManageSystemTabs && activeTab !== 'dataStats') {
setActiveTab('dataStats')
}
}, [activeTab, canManageSystemTabs, status])
// 数据加载
useEffect(() => {
if (status === 'authenticated') {
fetchData()
}
}, [status, fetchData])
// 切换回收站时清空选中状态和搜索词
useEffect(() => {
void showRecycleBin
setSelectedMaterials(new Set())
setMaterialSearchTerm('') // 清空搜索词,避免状态混乱
}, [showRecycleBin])
// 加载更多素材
const loadMoreMaterials = useCallback(async () => {
@ -256,7 +278,7 @@ export default function AdminPage() {
// 去重:过滤掉已经存在的素材,并同步更新 displayedMaterialsCount
setMaterials(prev => {
const existingIds = new Set(prev.map(m => m.id))
const uniqueNewMaterials = newMaterials.filter((m: any) => !existingIds.has(m.id))
const uniqueNewMaterials = newMaterials.filter((m: { id: string }) => !existingIds.has(m.id))
// 同步更新 displayedMaterialsCount(使用实际去重后的数量)
setDisplayedMaterialsCount(prev.length + uniqueNewMaterials.length)
return [...prev, ...uniqueNewMaterials]
@ -272,7 +294,7 @@ export default function AdminPage() {
} finally {
setIsLoadingMoreMaterials(false)
}
}, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialsPageSize, materialSearchTerm])
}, [isLoadingMoreMaterials, hasMoreMaterials, currentMaterialsPage, showRecycleBin, materialSearchTerm])
// 切换部门折叠状态
const toggleDepartmentCollapse = useCallback((departmentName: string) => {
@ -309,7 +331,7 @@ export default function AdminPage() {
)
}
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || !canAccessAdminPage) {
return null
}
@ -331,93 +353,128 @@ export default function AdminPage() {
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">系统管理</h1>
<button
onClick={() => router.push('/admin/settings')}
className="btn-primary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
外观设置
</button>
{canManageSystemTabs && (
<button
type="button"
onClick={() => router.push('/admin/settings')}
className="btn-primary flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<title>外观设置</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
外观设置
</button>
)}
</div>
{/* Tabs */}
<div className="flex space-x-4 border-b border-gray-200 dark:border-gray-700 mb-8">
<button
type="button"
onClick={() => setActiveTab('materials')}
onClick={() => setActiveTab('dataStats')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'materials'
activeTab === 'dataStats'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
素材管理
数据统计
</button>
<button
type="button"
onClick={() => setActiveTab('users')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'users'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
用户管理
</button>
<button
type="button"
onClick={() => setActiveTab('categories')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'categories'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
分类管理
</button>
<button
type="button"
onClick={() => setActiveTab('departments')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'departments'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
部门管理
</button>
{(session.user as any)?.isSuperAdmin && (
{canManageSystemTabs && (
<>
<button
type="button"
onClick={() => setActiveTab('permissions')}
onClick={() => setActiveTab('materials')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'permissions'
activeTab === 'materials'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
部门权限配置
素材管理
</button>
<button
type="button"
onClick={() => setActiveTab('superAdmins')}
onClick={() => setActiveTab('users')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'superAdmins'
activeTab === 'users'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
超级管理员
用户管理
</button>
<button
type="button"
onClick={() => setActiveTab('categories')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'categories'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
分类管理
</button>
<button
type="button"
onClick={() => setActiveTab('departments')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'departments'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
部门管理
</button>
{sessionUser?.isSuperAdmin && (
<>
<button
type="button"
onClick={() => setActiveTab('permissions')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'permissions'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
部门权限配置
</button>
<button
type="button"
onClick={() => setActiveTab('superAdmins')}
className={`pb-4 px-4 font-medium transition-colors ${
activeTab === 'superAdmins'
? 'text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
超级管理员
</button>
</>
)}
</>
)}
</div>
{/* 数据统计 Tab */}
{activeTab === 'dataStats' && (
<DataStatisticsTab
showToast={showToast}
canViewLogs={canManageSystemTabs}
canSelectDepartment={canSelectDepartmentInStats}
departmentScopeName={departmentScopeName}
/>
)}
{/* 素材管理 Tab */}
{activeTab === 'materials' && (
{canManageSystemTabs && activeTab === 'materials' && (
<MaterialManagement
materials={materials}
setMaterials={setMaterials}
@ -441,7 +498,7 @@ export default function AdminPage() {
)}
{/* 用户管理 Tab */}
{activeTab === 'users' && (
{canManageSystemTabs && activeTab === 'users' && (
<UserManagement
users={users}
setUsers={setUsers}
@ -459,7 +516,7 @@ export default function AdminPage() {
)}
{/* 分类管理 Tab */}
{activeTab === 'categories' && (
{canManageSystemTabs && activeTab === 'categories' && (
<CategoryManagement
categories={categories}
setCategories={setCategories}
@ -474,7 +531,7 @@ export default function AdminPage() {
)}
{/* 部门管理 Tab */}
{activeTab === 'departments' && (
{canManageSystemTabs && activeTab === 'departments' && (
<DepartmentManagement
departments={departments}
setDepartments={setDepartments}
@ -484,7 +541,7 @@ export default function AdminPage() {
)}
{/* 部门权限配置 Tab */}
{activeTab === 'permissions' && (session.user as any)?.isSuperAdmin && (
{canManageSystemTabs && activeTab === 'permissions' && sessionUser?.isSuperAdmin && (
<DepartmentPermissionsTab
showToast={showToast}
departments={departments}
@ -492,7 +549,7 @@ export default function AdminPage() {
)}
{/* 超级管理员 Tab */}
{activeTab === 'superAdmins' && (session.user as any)?.isSuperAdmin && (
{canManageSystemTabs && activeTab === 'superAdmins' && sessionUser?.isSuperAdmin && (
<SuperAdminsTab
showToast={showToast}
/>

View File

@ -0,0 +1,176 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isDepartmentAdmin, isSystemAdmin } from '@/lib/department-permission'
import { getMaterialUploadStats } from '@/lib/admin-material-stats'
import { prisma } from '@/lib/prisma'
interface StatsSessionUser {
role?: string | null
managedDepartment?: string | null
department?: string | null
}
function toDateStart(dateString: string): Date {
return new Date(`${dateString}T00:00:00`)
}
function toDateEnd(dateString: string): Date {
return new Date(`${dateString}T23:59:59.999`)
}
function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) {
return ''
}
const stringValue = String(value)
if (/[",\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
function formatDateTime(value: Date): string {
return value.toLocaleString('sv-SE', {
timeZone: 'Asia/Shanghai',
hour12: false,
})
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const sessionUser = session?.user as StatsSessionUser | undefined
const role = sessionUser?.role
const canViewAllDepartments = isSystemAdmin(role)
const canViewDepartmentScope = isDepartmentAdmin(role)
const canViewStats = canViewAllDepartments || canViewDepartmentScope
const scopeDepartmentName = canViewDepartmentScope
? String(sessionUser?.managedDepartment || sessionUser?.department || '').trim()
: null
if (!session?.user || !canViewStats) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
if (canViewDepartmentScope && !scopeDepartmentName) {
return NextResponse.json({ error: '当前账号未绑定部门,无法导出统计数据' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
const departmentKey = searchParams.get('departmentKey')
const stats = await getMaterialUploadStats({
startDate,
endDate,
departmentKey,
scopeDepartmentName,
})
const selectedUserIds = Array.from(new Set(stats.selectedUsers.map((user) => user.userId)))
if (selectedUserIds.length === 0) {
const lines = ['素材ID,上传时间,产品型号,素材类型,店铺,场景,平台,品类,作者,上传账号,上传姓名,上传部门,下载次数,点赞数']
const csvContent = `\uFEFF${lines.join('\n')}`
const rangeStart = startDate || 'all'
const rangeEnd = endDate || 'all'
const fileName = `material-details-${rangeStart}-${rangeEnd}.csv`
return new NextResponse(csvContent, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
},
})
}
const createdAtFilter: { gte?: Date; lte?: Date } = {}
if (startDate) {
createdAtFilter.gte = toDateStart(startDate)
}
if (endDate) {
createdAtFilter.lte = toDateEnd(endDate)
}
const materials = await prisma.material.findMany({
where: {
deletedAt: null,
userId: { in: selectedUserIds },
...(createdAtFilter.gte || createdAtFilter.lte ? { createdAt: createdAtFilter } : {}),
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
productModel: true,
materialType: true,
categoryName: true,
sceneName: true,
platformName: true,
productCategoryName: true,
authorName: true,
createdAt: true,
downloadCount: true,
likeCount: true,
user: {
select: {
username: true,
realName: true,
department: true,
departmentRef: {
select: {
name: true,
},
},
},
},
},
})
const lines: string[] = []
lines.push('素材ID,上传时间,产品型号,素材类型,店铺,场景,平台,品类,作者,上传账号,上传姓名,上传部门,下载次数,点赞数')
for (const material of materials) {
const departmentName = material.user.departmentRef?.name || material.user.department || '未分配部门'
lines.push(
[
material.id,
formatDateTime(material.createdAt),
material.productModel,
material.materialType,
material.categoryName,
material.sceneName,
material.platformName,
material.productCategoryName,
material.authorName,
material.user.username,
material.user.realName || '',
departmentName,
material.downloadCount,
material.likeCount,
]
.map(escapeCsvValue)
.join(',')
)
}
const csvContent = `\uFEFF${lines.join('\n')}`
const rangeStart = startDate || 'all'
const rangeEnd = endDate || 'all'
const fileName = `material-details-${rangeStart}-${rangeEnd}.csv`
return new NextResponse(csvContent, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
},
})
} catch (error) {
console.error('[Admin Data Stats Export] GET error:', error)
return NextResponse.json({ error: '导出 CSV 失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,59 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isDepartmentAdmin, isSystemAdmin } from '@/lib/department-permission'
import { getMaterialUploadStats } from '@/lib/admin-material-stats'
interface StatsSessionUser {
role?: string | null
managedDepartment?: string | null
department?: string | null
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const sessionUser = session?.user as StatsSessionUser | undefined
const role = sessionUser?.role
const canViewAllDepartments = isSystemAdmin(role)
const canViewDepartmentScope = isDepartmentAdmin(role)
const canViewStats = canViewAllDepartments || canViewDepartmentScope
const scopeDepartmentName = canViewDepartmentScope
? String(sessionUser?.managedDepartment || sessionUser?.department || '').trim()
: null
if (!session?.user || !canViewStats) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
if (canViewDepartmentScope && !scopeDepartmentName) {
return NextResponse.json({ error: '当前账号未绑定部门,无法查看统计数据' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
const departmentKey = searchParams.get('departmentKey')
const stats = await getMaterialUploadStats({
startDate,
endDate,
departmentKey,
scopeDepartmentName,
})
return NextResponse.json({
success: true,
data: stats,
meta: {
canViewAllDepartments,
scopeDepartmentName,
},
})
} catch (error) {
console.error('[Admin Data Stats] GET error:', error)
return NextResponse.json({ error: '获取数据统计失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,237 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { safeJsonParse } from '@/lib/safe-json'
const VALID_ACTIONS = new Set(['ALL', 'UPLOAD', 'UPDATE', 'DELETE', 'DOWNLOAD', 'RESTORE'])
function toDateStart(dateString: string): Date {
return new Date(`${dateString}T00:00:00`)
}
function toDateEnd(dateString: string): Date {
return new Date(`${dateString}T23:59:59.999`)
}
function normalizeKeyword(keyword: string | null): string {
return (keyword || '').trim()
}
function includesKeyword(value: string | null | undefined, keyword: string): boolean {
if (!value) return false
return value.toLowerCase().includes(keyword.toLowerCase())
}
function getSnapshotMaterialInfo(changes: Record<string, unknown> | null): {
productModel?: string
materialType?: string
} {
if (!changes) return {}
const snapshot = changes.materialSnapshot
if (!snapshot || typeof snapshot !== 'object') return {}
const info = snapshot as Record<string, unknown>
return {
productModel: typeof info.productModel === 'string' ? info.productModel : undefined,
materialType: typeof info.materialType === 'string' ? info.materialType : undefined,
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !isSystemAdmin((session.user as any).role)) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const requestedAction = (searchParams.get('action') || 'ALL').toUpperCase()
const action = VALID_ACTIONS.has(requestedAction) ? requestedAction : 'ALL'
const page = Math.max(Number(searchParams.get('page') || '1'), 1)
const pageSize = Math.min(Math.max(Number(searchParams.get('pageSize') || '20'), 1), 100)
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
const keyword = normalizeKeyword(searchParams.get('keyword'))
const materialLogWhere: any = {}
const downloadLogWhere: any = {}
if (startDate || endDate) {
const timestampFilter: { gte?: Date; lte?: Date } = {}
if (startDate) {
timestampFilter.gte = toDateStart(startDate)
}
if (endDate) {
timestampFilter.lte = toDateEnd(endDate)
}
materialLogWhere.timestamp = timestampFilter
downloadLogWhere.downloadedAt = timestampFilter
}
if (action === 'ALL') {
materialLogWhere.action = { not: 'DOWNLOAD' }
} else if (action !== 'DOWNLOAD') {
materialLogWhere.action = action
}
const includeMaterialLogs = action !== 'DOWNLOAD'
const includeDownloadLogs = action === 'ALL' || action === 'DOWNLOAD'
const fetchLimit = keyword ? 5000 : page * pageSize
const [materialLogTotal, downloadLogTotal, materialLogs, downloadLogs] = await Promise.all([
includeMaterialLogs ? prisma.materialLog.count({ where: materialLogWhere }) : Promise.resolve(0),
includeDownloadLogs ? prisma.materialDownloadLog.count({ where: downloadLogWhere }) : Promise.resolve(0),
includeMaterialLogs
? prisma.materialLog.findMany({
where: materialLogWhere,
orderBy: { timestamp: 'desc' },
take: fetchLimit,
include: {
user: {
select: {
id: true,
username: true,
realName: true,
department: true,
departmentId: true,
departmentRef: {
select: {
name: true,
},
},
},
},
},
})
: Promise.resolve([]),
includeDownloadLogs
? prisma.materialDownloadLog.findMany({
where: downloadLogWhere,
orderBy: { downloadedAt: 'desc' },
take: fetchLimit,
include: {
user: {
select: {
id: true,
username: true,
realName: true,
department: true,
departmentId: true,
departmentRef: {
select: {
name: true,
},
},
},
},
},
})
: Promise.resolve([]),
])
const materialIds = Array.from(
new Set([
...materialLogs.map((log) => log.materialId),
...downloadLogs.map((log) => log.materialId),
])
)
const materials = materialIds.length
? await prisma.material.findMany({
where: { id: { in: materialIds } },
select: {
id: true,
productModel: true,
materialType: true,
},
})
: []
const materialMap = new Map(materials.map((material) => [material.id, material]))
const formattedMaterialLogs = materialLogs.map((log) => {
const parsedChanges = log.changes
? safeJsonParse<Record<string, unknown>>(log.changes, {}, false)
: null
const relatedMaterial = materialMap.get(log.materialId)
const snapshotInfo = getSnapshotMaterialInfo(parsedChanges)
return {
id: `material-${log.id}`,
source: 'material_log',
action: log.action,
timestamp: log.timestamp.toISOString(),
user: {
id: log.user.id,
username: log.user.username,
realName: log.user.realName,
departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门',
},
material: {
id: log.materialId,
productModel: relatedMaterial?.productModel || snapshotInfo.productModel || '已删除素材',
materialType: relatedMaterial?.materialType || snapshotInfo.materialType || '未知类型',
},
changes: parsedChanges,
}
})
const formattedDownloadLogs = downloadLogs.map((log) => ({
id: `download-${log.id}`,
source: 'download_log',
action: 'DOWNLOAD',
timestamp: log.downloadedAt.toISOString(),
user: {
id: log.user.id,
username: log.user.username,
realName: log.user.realName,
departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门',
},
material: {
id: log.materialId,
productModel: materialMap.get(log.materialId)?.productModel || '已删除素材',
materialType: materialMap.get(log.materialId)?.materialType || '未知类型',
},
changes: null,
}))
const mergedLogs = [...formattedMaterialLogs, ...formattedDownloadLogs].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
)
const filteredLogs = keyword
? mergedLogs.filter((log) => {
return (
includesKeyword(log.user.username, keyword) ||
includesKeyword(log.user.realName, keyword) ||
includesKeyword(log.user.departmentName, keyword) ||
includesKeyword(log.material.id, keyword) ||
includesKeyword(log.material.productModel, keyword) ||
includesKeyword(log.material.materialType, keyword)
)
})
: mergedLogs
const offset = (page - 1) * pageSize
const paginatedLogs = filteredLogs.slice(offset, offset + pageSize)
const total = keyword ? filteredLogs.length : materialLogTotal + downloadLogTotal
return NextResponse.json({
success: true,
data: {
logs: paginatedLogs,
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
})
} catch (error) {
console.error('[Admin Material Operation Logs] GET error:', error)
return NextResponse.json({ error: '获取素材操作日志失败' }, { status: 500 })
}
}

View File

@ -5,6 +5,8 @@ import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { deleteFiles } from '@/lib/file-cleanup'
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'
import { createMaterialLog } from '@/lib/material-log'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
// UUID 格式校验正则
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@ -28,6 +30,11 @@ export async function POST(request: NextRequest) {
}
const { ids, permanent } = await request.json()
const operatorId = (session.user as any).id
if (!operatorId) {
return NextResponse.json({ error: '缺少操作人信息' }, { status: 401 })
}
// 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0) {
@ -45,6 +52,9 @@ export async function POST(request: NextRequest) {
if (permanent) {
// ========== 永久删除:物理删除文件 + 删除数据库记录 ==========
console.log(`[BatchDelete] 开始永久删除 ${ids.length} 条素材...`)
// 确保下载日志表已应用“删除素材后保留日志”策略
await ensureMaterialDownloadLogsTable()
// 1. 先查询所有素材(获取文件路径和素材类型)
const materials = await prisma.material.findMany({
@ -62,6 +72,35 @@ export async function POST(request: NextRequest) {
for (const material of materials) {
const materialType = (material as any).materialType || ''
// 记录永久删除日志(先记录,避免数据库删除后日志级联删除前丢失上下文)
await createMaterialLog({
materialId: material.id,
userId: operatorId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: materialType || null,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: 'PERMANENTLY_DELETED',
},
deleteMode: {
oldValue: material.deletedAt ? 'soft' : 'active',
newValue: 'permanent',
},
materialType: {
oldValue: materialType || null,
newValue: materialType || null,
},
},
})
// 收集该素材的所有文件
const fileInfo = await collectMaterialFiles(material.id, material)
@ -128,7 +167,7 @@ export async function POST(request: NextRequest) {
// 查询素材(需要处理任务号)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
select: { id: true, taskNo: true }
select: { id: true, taskNo: true, productModel: true, materialType: true }
})
if (materials.length === 0) {
@ -136,17 +175,47 @@ export async function POST(request: NextRequest) {
}
const now = Date.now()
const deletedAt = new Date()
await Promise.all(materials.map((m, index) =>
prisma.material.update({
where: { id: m.id },
data: {
deletedAt: new Date(),
deletedAt,
// 如果有任务号,加上删除时间戳后缀使其作废(防止重复)
...(m.taskNo ? { taskNo: `${m.taskNo}_DELETED_${now}_${index}` } : {})
}
})
))
await Promise.all(
materials.map((material) =>
createMaterialLog({
materialId: material.id,
userId: operatorId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: material.materialType,
},
},
deletedAt: {
oldValue: null,
newValue: deletedAt.toISOString(),
},
deleteMode: {
oldValue: 'active',
newValue: 'soft',
},
},
})
)
)
console.log(`[BatchDelete] 软删除完成: ${materials.length} 条素材`)
return NextResponse.json({

View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { transcodeQueue } from '@/lib/video-transcode-queue'
export const dynamic = 'force-dynamic'
const DEFAULT_PREVIEW_LIMIT = 20
const MAX_PREVIEW_LIMIT = 100
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
const role = (session?.user as unknown as { role?: string } | null)?.role
if (!session || role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const limitParam = searchParams.get('limit')
let previewLimit = Number.parseInt(limitParam || '', 10)
if (Number.isNaN(previewLimit) || previewLimit <= 0) {
previewLimit = DEFAULT_PREVIEW_LIMIT
} else {
previewLimit = Math.min(previewLimit, MAX_PREVIEW_LIMIT)
}
const status = transcodeQueue.getStatus()
const processingPreview = status.processingIds.slice(0, previewLimit)
const waitingPreview = status.waiting.slice(0, previewLimit)
const progressSnapshot = Array.from(status.progress.entries())
.slice(0, previewLimit)
.map(([materialId, value]) => ({
materialId,
progress: value,
stage: status.stages.get(materialId) ?? null,
}))
return NextResponse.json({
queueLength: status.queueLength,
processingCount: status.processingCount,
maxConcurrent: status.maxConcurrent,
processingPreview,
waitingPreview,
previewLimit,
timestamp: new Date().toISOString(),
progress: progressSnapshot,
})
} catch (error) {
console.error('[Admin] Video transcode queue status error:', error)
return NextResponse.json({ error: '获取转码队列状态失败' }, { status: 500 })
}
}

View File

@ -81,17 +81,24 @@ export async function GET(request: NextRequest) {
if (isProductPackageType(material.materialType)) {
// 任务-详情:使用预先生成的zip文件
// 下载全部:使用 all.zip
// 下载全部:优先使用 all.zip;缺失时动态打包兜底
if (downloadType === 'all') {
const allZipAsset = material.assets.find((a: any) => a.assetType === 'all');
if (!allZipAsset) {
if (allZipAsset) {
console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`);
return await downloadSingleFile(allZipAsset);
}
const fallbackAssets = getAssetsForDownload(material.assets || [], 'all');
if (fallbackAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ASSETS', message: '没有可下载的文件' } },
{ status: 404 }
);
}
console.log(`[external/download] 使用 all.zip: ${allZipAsset.fileUrl}`);
return await downloadSingleFile(allZipAsset);
console.warn('[external/download] all.zip 缺失,使用 main/sku/detail 动态打包兜底');
return await downloadAsZip(fallbackAssets, material.taskNo || material.id, 'all');
}
// 下载分类:使用 main.zip / sku.zip / detail.zip
@ -153,18 +160,38 @@ export async function GET(request: NextRequest) {
{ status: 400 }
);
} else {
// 任务-3D原图/AI原图/实拍原图:下载zip文件
const zipAssets = material.assets.filter((a: any) => a.assetType === 'zip');
if (zipAssets.length === 0) {
return NextResponse.json(
{ success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } },
{ status: 404 }
);
// 任务-3D原图/AI原图/实拍原图:逐个尝试 zip 资产,失败时回退通用下载链路
const zipAssets = (material.assets || []).filter((a: any) => a.assetType === 'zip');
let lastNotFoundResponse: NextResponse | null = null;
for (const zipAsset of zipAssets) {
const response = await downloadSingleFile(zipAsset);
if (response.status < 400) {
return response;
}
if (response.status !== 404) {
return response;
}
lastNotFoundResponse = response;
console.warn(`[external/download] 任务 zip 资产不可用,尝试下一个: ${zipAsset.fileUrl}`);
}
// 返回第一个zip文件
const zipAsset = zipAssets[0];
return await downloadSingleFile(zipAsset);
console.warn('[external/download] zip 资产均失败,回退到通用下载链路');
const fallbackResponse = await downloadNonProductPackageMaterial(material, downloadType, request);
if (fallbackResponse.status < 400) {
return fallbackResponse;
}
if (lastNotFoundResponse) {
return lastNotFoundResponse;
}
return NextResponse.json(
{ success: false, error: { code: 'NO_ZIP_FILE', message: '没有可下载的压缩包' } },
{ status: 404 }
);
}
}
@ -205,14 +232,12 @@ function getAssetsForDownload(assets: any[], downloadType: string) {
* ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24)
*/
async function downloadSingleFile(asset: any) {
const fileUrl = asset.fileUrl;
const fileName = asset.fileName || path.basename(fileUrl);
// 判断是本地文件还是远程文件
if (fileUrl.startsWith('/uploads/') || fileUrl.startsWith('uploads/')) {
// 本地文件
const localPath = path.join(process.cwd(), fileUrl.replace(/^\//, ''));
const normalizedFileUrl = normalizeAssetFileUrl(asset?.fileUrl);
const fileName = resolveAssetFileName(asset, normalizedFileUrl);
const localPath = resolveAssetLocalPath(asset?.fileUrl);
// 本地文件
if (localPath) {
if (!fs.existsSync(localPath)) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } },
@ -248,10 +273,12 @@ async function downloadSingleFile(asset: any) {
'Content-Length': fileStats.size.toString(),
},
});
} else {
// 远程文件(S3)
}
// 远程文件(S3)
if (isHttpUrl(normalizedFileUrl)) {
try {
const response = await fetch(fileUrl);
const response = await fetch(normalizedFileUrl);
if (!response.ok) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '文件不存在' } },
@ -277,6 +304,11 @@ async function downloadSingleFile(asset: any) {
);
}
}
return NextResponse.json(
{ success: false, error: { code: 'INVALID_FILE_URL', message: '文件地址无效' } },
{ status: 400 }
);
}
/**
@ -397,6 +429,56 @@ function isHttpUrl(url?: string | null): boolean {
return /^https?:\/\//i.test(url.trim());
}
/**
* 规范化资产 fileUrl(统一斜杠并去除首尾空格)
*/
function normalizeAssetFileUrl(fileUrl?: string | null): string {
if (!fileUrl || typeof fileUrl !== 'string') return '';
return fileUrl.trim().replace(/\\/g, '/');
}
/**
* 将资产 fileUrl 解析为本地绝对路径(仅针对非 http(s) 地址)
*/
function resolveAssetLocalPath(fileUrl?: string | null): string | null {
if (!fileUrl || typeof fileUrl !== 'string') return null;
const normalizedUrl = normalizeAssetFileUrl(fileUrl);
if (!normalizedUrl || isHttpUrl(normalizedUrl)) {
return null;
}
if (normalizedUrl.startsWith('/uploads/')) {
return path.join(process.cwd(), normalizedUrl.slice(1));
}
if (path.isAbsolute(fileUrl)) {
return fileUrl;
}
const relativePath = normalizedUrl.replace(/^\.?\//, '');
return path.join(process.cwd(), relativePath);
}
/**
* 推断下载文件名
*/
function resolveAssetFileName(asset: any, normalizedFileUrl: string): string {
if (asset?.fileName && typeof asset.fileName === 'string' && asset.fileName.trim()) {
return asset.fileName;
}
if (isHttpUrl(normalizedFileUrl)) {
try {
return path.basename(new URL(normalizedFileUrl).pathname) || 'download-file';
} catch {
return path.basename(normalizedFileUrl) || 'download-file';
}
}
return path.basename(normalizedFileUrl) || 'download-file';
}
/**
* 下载非任务-详情类型的素材
* 复用 /api/materials/download 的逻辑,但跳过权限检查

View File

@ -22,7 +22,7 @@ import {
TASK_STATUS,
ASSET_TYPE,
} from '@/lib/product-package';
import { generateDownloadUrl, DownloadType } from '@/lib/download-token';
import { generateDownloadUrl } from '@/lib/download-token';
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite';
import { isProductPackageType } from '@/lib/material-type-mapper';
import path from 'path';
@ -151,8 +151,8 @@ export async function GET(request: NextRequest) {
});
}
// 生成资产列表(带下载链接)
const formatAsset = (asset: any, downloadType: DownloadType) => {
// 生成资产列表(仅返回资源信息,不返回分类型下载链接)
const formatAsset = (asset: any) => {
try {
// 验证资产数据
if (!asset || !asset.id) {
@ -172,15 +172,9 @@ export async function GET(request: NextRequest) {
}
const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request);
const downloadUrl = generateDownloadUrl(baseUrl, {
materialId: material.id,
downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType,
assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined,
});
return {
id: asset.id,
url: rewrittenUrl,
downloadUrl,
fileName: asset.fileName || '',
fileSize: Number(asset.fileSize || 0),
...(asset.assetType === ASSET_TYPE.DETAIL && { sortOrder: asset.sortOrder || 0 }),
@ -198,6 +192,11 @@ export async function GET(request: NextRequest) {
}
};
const allDownloadUrl = generateDownloadUrl(baseUrl, {
materialId: material.id,
downloadType: 'all',
});
const responseData = {
taskNo: material.taskNo,
productModel: material.productModel,
@ -207,17 +206,16 @@ export async function GET(request: NextRequest) {
createdAt: material.createdAt.toISOString(),
updatedAt: material.updatedAt.toISOString(),
assets: {
main: mainAssets.map((a: any) => formatAsset(a, 'main')),
sku: skuAssets.map((a: any) => formatAsset(a, 'sku')),
detail: detailAssets.map((a: any) => formatAsset(a, 'detail')),
psd: psdAssets.map((a: any) => formatAsset(a, 'psd')),
main: mainAssets.map((a: any) => formatAsset(a)),
sku: skuAssets.map((a: any) => formatAsset(a)),
detail: detailAssets.map((a: any) => formatAsset(a)),
psd: psdAssets.map((a: any) => formatAsset(a)),
},
// 兼容字段:给任务平台一个“单一”的下载链接(全部打包)。
// 避免返回 main/sku/detail 等下载链接,导致对方做可用性校验时被 404(NO_ASSETS) 拦截。
downloadUrl: allDownloadUrl,
downloadUrls: {
all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), // 任务平台主要使用这个
main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }),
sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }),
detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }),
psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : null,
all: allDownloadUrl, // 任务平台只需要这个
},
};

View File

@ -10,6 +10,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getAssetsByMaterialId, addAsset, deleteAsset } from '@/lib/product-package';
import { createMaterialLog } from '@/lib/material-log';
/**
* GET - 获取资产列表
@ -117,6 +118,30 @@ export async function POST(
sortOrder,
});
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
assetOperation: {
oldValue: null,
newValue: 'ADD',
},
assetType: {
oldValue: null,
newValue: asset.assetType,
},
assetFileName: {
oldValue: null,
newValue: asset.fileName || null,
},
assetFileUrl: {
oldValue: null,
newValue: asset.fileUrl,
},
},
});
return NextResponse.json({
success: true,
data: asset,
@ -181,7 +206,13 @@ export async function DELETE(
// 验证资产是否属于该素材
const asset = await prisma.materialAsset.findUnique({
where: { id: assetId },
select: { materialId: true },
select: {
id: true,
materialId: true,
assetType: true,
fileUrl: true,
fileName: true,
},
});
if (!asset || asset.materialId !== materialId) {
@ -194,6 +225,34 @@ export async function DELETE(
// 删除资产
await deleteAsset(assetId);
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
assetOperation: {
oldValue: null,
newValue: 'DELETE',
},
assetId: {
oldValue: asset.id,
newValue: asset.id,
},
assetType: {
oldValue: asset.assetType,
newValue: asset.assetType,
},
assetFileName: {
oldValue: asset.fileName || null,
newValue: null,
},
assetFileUrl: {
oldValue: asset.fileUrl,
newValue: null,
},
},
});
return NextResponse.json({
success: true,
message: '删除成功',

View File

@ -8,6 +8,7 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { updateAssetSortOrder } from '@/lib/product-package';
import { createMaterialLog } from '@/lib/material-log';
export async function PUT(
request: NextRequest,
@ -63,7 +64,10 @@ export async function PUT(
materialId,
assetType,
},
select: { id: true },
select: {
id: true,
sortOrder: true,
},
});
if (assets.length !== assetIds.length) {
@ -76,6 +80,58 @@ export async function PUT(
// 更新排序
await updateAssetSortOrder(orders);
const oldOrder = [...assets]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((asset) => ({ assetId: asset.id, sortOrder: asset.sortOrder }));
const newOrder = orders
.map((order: { assetId: string; sortOrder: number }) => ({
assetId: order.assetId,
sortOrder: order.sortOrder,
}))
.sort((a, b) => a.sortOrder - b.sortOrder);
const oldSortMap = new Map(assets.map((asset) => [asset.id, asset.sortOrder]));
const changedItems = newOrder
.map((item) => {
const oldSortOrder = oldSortMap.get(item.assetId);
if (oldSortOrder === undefined || oldSortOrder === item.sortOrder) return null;
return {
assetId: item.assetId,
oldSortOrder,
newSortOrder: item.sortOrder,
};
})
.filter(
(item): item is { assetId: string; oldSortOrder: number; newSortOrder: number } => item !== null
);
if (changedItems.length > 0) {
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
assetOperation: {
oldValue: null,
newValue: 'REORDER',
},
assetType: {
oldValue: null,
newValue: assetType,
},
assetOrder: {
oldValue: oldOrder,
newValue: newOrder,
},
changedItems: {
oldValue: null,
newValue: changedItems,
},
},
});
}
return NextResponse.json({
success: true,
message: '排序更新成功',

View File

@ -7,6 +7,34 @@ import {
isShareLinkAccess,
} from '@/lib/department-permission';
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation';
import { createMaterialLog } from '@/lib/material-log';
function buildMaterialChanges(oldMaterial: any, newData: Record<string, any>) {
const changes: Record<string, { oldValue: any; newValue: any }> = {};
const normalize = (value: any) => {
if (value instanceof Date) {
return value.toISOString();
}
if (value === undefined) {
return null;
}
return value;
};
for (const [key, value] of Object.entries(newData)) {
if (key === 'updatedAt') continue;
const oldValue = normalize(oldMaterial[key]);
const newValue = normalize(value);
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
changes[key] = { oldValue, newValue };
}
}
return changes;
}
// 解析 displayImages 字段
function parseDisplayImages(displayImages: any): string[] {
@ -380,6 +408,16 @@ export async function PUT(
where: { id: params.id },
data: updateData,
});
const changes = buildMaterialChanges(material, updateData);
if (Object.keys(changes).length > 0) {
await createMaterialLog({
materialId: params.id,
userId,
action: 'UPDATE',
changes,
});
}
return NextResponse.json({
success: true,

View File

@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma'
import { createReadStream, existsSync } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
// 下载单个 SKU 的压缩包
// ⚠️ 安全修复:使用流式传输替代同步读取,防止大文件导致内存溢出(R24)
@ -15,6 +16,9 @@ export async function GET(
{ params }: { params: { id: string; skuId: string } }
) {
try {
const { searchParams } = new URL(request.url)
const validateOnly = searchParams.get('validate') === '1'
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
@ -32,10 +36,15 @@ export async function GET(
}
const { skuId } = params
const materialId = params.id
// 获取 SKU 信息
const sku = await prisma.materialSku.findUnique({
where: { id: skuId }
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
materialId,
deletedAt: null,
}
})
if (!sku) {
@ -76,6 +85,20 @@ export async function GET(
const fileName = `${sku.skuName}.zip`
const fileStats = await stat(filePath)
if (validateOnly) {
fileStream.destroy()
return NextResponse.json({
success: true,
fileName,
fileSize: fileStats.size,
contentType: 'application/zip',
})
}
recordSkuDownloadLog(materialId, userId).catch((error) => {
console.error('[SKU Download] 记录下载日志失败:', error)
})
// 返回文件流
return new NextResponse(stream, {
headers: {
@ -89,3 +112,24 @@ export async function GET(
return NextResponse.json({ error: '下载失败' }, { status: 500 })
}
}
async function recordSkuDownloadLog(materialId: string, userId: string) {
await ensureMaterialDownloadLogsTable()
await Promise.all([
prisma.materialDownloadLog.create({
data: {
materialId,
userId,
downloadedAt: new Date(),
},
}),
prisma.material.update({
where: { id: materialId },
data: {
downloadCount: {
increment: 1,
},
},
}),
])
}

View File

@ -6,6 +6,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
import { createMaterialLog } from '@/lib/material-log'
// 获取单个 SKU 详情
export async function GET(
@ -105,6 +106,64 @@ export async function PUT(
data: updateData
})
const changes: Record<string, { oldValue: unknown; newValue: unknown }> = {
skuOperation: {
oldValue: null,
newValue: 'UPDATE',
},
}
if (updateData.skuName !== undefined && existingSku.skuName !== updatedSku.skuName) {
changes.skuName = {
oldValue: existingSku.skuName,
newValue: updatedSku.skuName,
}
}
if (updateData.previewUrl !== undefined && (existingSku as any).previewUrl !== (updatedSku as any).previewUrl) {
changes.skuPreviewUrl = {
oldValue: (existingSku as any).previewUrl || null,
newValue: (updatedSku as any).previewUrl || null,
}
}
if (updateData.fileName !== undefined && (existingSku as any).fileName !== (updatedSku as any).fileName) {
changes.skuFileName = {
oldValue: (existingSku as any).fileName || null,
newValue: (updatedSku as any).fileName || null,
}
}
if (updateData.filePath !== undefined && existingSku.filePath !== updatedSku.filePath) {
changes.skuFilePath = {
oldValue: existingSku.filePath,
newValue: updatedSku.filePath,
}
}
if (updateData.fileSize !== undefined && existingSku.fileSize.toString() !== updatedSku.fileSize.toString()) {
changes.skuFileSize = {
oldValue: existingSku.fileSize.toString(),
newValue: updatedSku.fileSize.toString(),
}
}
if (updateData.sortOrder !== undefined && existingSku.sortOrder !== updatedSku.sortOrder) {
changes.skuSortOrder = {
oldValue: existingSku.sortOrder,
newValue: updatedSku.sortOrder,
}
}
if (Object.keys(changes).length > 1) {
await createMaterialLog({
materialId: updatedSku.materialId,
userId,
action: 'UPDATE',
changes,
})
}
// 返回时统一字段命名
return NextResponse.json({
id: updatedSku.id,
@ -165,13 +224,39 @@ export async function DELETE(
}
// 软删除:只标记删除时间,不删除文件(文件保留在服务器,可通过回收站恢复)
const deletedAt = new Date()
await prisma.materialSku.update({
where: { id: skuId },
data: {
deletedAt: new Date()
deletedAt
} as any
})
await createMaterialLog({
materialId: existingSku.materialId,
userId,
action: 'UPDATE',
changes: {
skuOperation: {
oldValue: null,
newValue: 'DELETE',
},
skuId: {
oldValue: existingSku.id,
newValue: existingSku.id,
},
skuName: {
oldValue: existingSku.skuName,
newValue: existingSku.skuName,
},
deletedAt: {
oldValue: existingSku.deletedAt ? existingSku.deletedAt.toISOString() : null,
newValue: deletedAt.toISOString(),
},
},
})
return NextResponse.json({ success: true, message: 'SKU 已删除' })
} catch (error) {
console.error('删除 SKU 失败:', error)

View File

@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createReadStream } from 'fs'
import path from 'path'
import JSZip from 'jszip'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
// SKU批量下载API
export async function POST(
@ -12,6 +12,22 @@ export async function POST(
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const userId = (session.user as any).id
const user = await prisma.user.findUnique({
where: { id: userId },
select: { canDownload: true },
})
if (!user?.canDownload) {
return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 })
}
const materialId = params.id
const body = await request.json()
const { skuIds } = body
@ -95,6 +111,10 @@ export async function POST(
const filename = `${material.productModel}_SKU批量下载_${Date.now()}.zip`
recordBatchDownloadLog(materialId, userId).catch((error) => {
console.error('[SKU Batch Download] 记录下载日志失败:', error)
})
return new NextResponse(zipBuffer as any, {
headers: {
'Content-Type': 'application/zip',
@ -111,3 +131,24 @@ export async function POST(
}
}
async function recordBatchDownloadLog(materialId: string, userId: string) {
await ensureMaterialDownloadLogsTable()
await Promise.all([
prisma.materialDownloadLog.create({
data: {
materialId,
userId,
downloadedAt: new Date(),
},
}),
prisma.material.update({
where: { id: materialId },
data: {
downloadCount: {
increment: 1,
},
},
}),
])
}

View File

@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
// 批量更新 SKU 排序
export async function PUT(
@ -46,6 +47,29 @@ export async function PUT(
return NextResponse.json({ error: '请提供 SKU ID 列表' }, { status: 400 })
}
const existingSkus = await prisma.materialSku.findMany({
where: {
id: { in: skuIds },
materialId,
deletedAt: null,
},
select: {
id: true,
skuName: true,
sortOrder: true,
},
})
if (existingSkus.length !== skuIds.length) {
return NextResponse.json({ error: '部分 SKU 不存在或不属于该素材' }, { status: 400 })
}
const oldOrder = [...existingSkus]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((sku) => sku.id)
const oldOrderMap = new Map(existingSkus.map((sku) => [sku.id, sku]))
// 批量更新排序
const updates = skuIds.map((skuId: string, index: number) =>
prisma.materialSku.update({
@ -56,6 +80,41 @@ export async function PUT(
await prisma.$transaction(updates)
const changedItems = skuIds
.map((skuId, index) => {
const previous = oldOrderMap.get(skuId)
if (!previous || previous.sortOrder === index) return null
return {
skuId,
skuName: previous.skuName,
oldSortOrder: previous.sortOrder,
newSortOrder: index,
}
})
.filter((item): item is { skuId: string; skuName: string; oldSortOrder: number; newSortOrder: number } => item !== null)
if (changedItems.length > 0) {
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
skuOperation: {
oldValue: null,
newValue: 'REORDER',
},
skuOrder: {
oldValue: oldOrder,
newValue: skuIds,
},
changedItems: {
oldValue: null,
newValue: changedItems,
},
},
})
}
return NextResponse.json({ success: true, message: '排序已更新' })
} catch (error) {
console.error('更新 SKU 排序失败:', error)

View File

@ -5,6 +5,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log'
// 获取素材的所有 SKU 列表
export async function GET(
@ -69,6 +70,8 @@ export async function POST(
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const userId = (session.user as any).id
const materialId = params.id
// 检查素材是否存在且是标准模型类型
@ -114,6 +117,34 @@ export async function POST(
} as any // 临时类型断言,因为 Prisma Client 可能还未完全更新
})
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
skuOperation: {
oldValue: null,
newValue: 'CREATE',
},
skuId: {
oldValue: null,
newValue: sku.id,
},
skuName: {
oldValue: null,
newValue: sku.skuName,
},
skuFileName: {
oldValue: null,
newValue: (sku as any).fileName || null,
},
skuSortOrder: {
oldValue: null,
newValue: sku.sortOrder,
},
},
})
// 返回时转换为前端期望的字段名
return NextResponse.json({
id: sku.id,

View File

@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { promises as fs } from 'fs'
import path from 'path'
import { randomBytes } from 'crypto'
import JSZip from 'jszip'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { sanitizeFileName } from '@/lib/image-processor'
import {
STANDARD_MODEL_WHITE_BG_VIEWS,
getMissingWhiteBackgroundViews,
type StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
import { createMaterialLog } from '@/lib/material-log'
export const runtime = 'nodejs'
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now()
const random = randomBytes(8).toString('hex')
return `${prefix}-${timestamp}-${random}${ext}`
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
const userId = (session.user as any).id
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { id: true, materialType: true, userId: true },
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 })
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
})
if (material.userId !== userId && user?.role !== 'ADMIN') {
return NextResponse.json({ error: '没有权限上传白底图' }, { status: 403 })
}
const formData = await request.formData()
const skuId = ((formData.get('skuId') as string) || '').trim()
const skuName = ((formData.get('skuName') as string) || '').trim()
if (!skuId) {
return NextResponse.json({ error: '缺少SKU ID' }, { status: 400 })
}
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
materialId,
deletedAt: null,
},
select: { id: true, skuName: true },
})
if (!sku) {
return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 })
}
const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => {
acc[view.key] = formData.get(view.fieldName) as File | null
return acc
}, {} as Partial<Record<StandardModelWhiteBgKey, File | null>>)
const missingViews = getMissingWhiteBackgroundViews(whiteBackgroundImages)
if (missingViews.length > 0) {
const missingLabels = STANDARD_MODEL_WHITE_BG_VIEWS
.filter((view) => missingViews.includes(view.key))
.map((view) => view.label)
return NextResponse.json(
{ error: `白底图需一次上传完整6张,缺少:${missingLabels.join('、')}` },
{ status: 400 },
)
}
const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId, 'white-background')
await fs.mkdir(uploadDir, { recursive: true })
const zip = new JSZip()
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const imageFile = whiteBackgroundImages[view.key]
if (!imageFile) {
throw new Error(`白底图缺失:${view.label}`)
}
const ext = path.extname(imageFile.name) || '.jpg'
const normalizedName = sanitizeFileName(view.label).replace(/\s+/g, '-').toLowerCase()
const targetName = `${normalizedName}${ext}`
const imageBuffer = Buffer.from(await imageFile.arrayBuffer())
zip.file(targetName, imageBuffer)
}
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
})
const safeSkuPrefix = sanitizeFileName(skuName || sku.skuName || 'standard-model')
const whiteBgZipName = generateUniqueFilename(`${safeSkuPrefix}-white-bg`, '.zip')
const absolutePath = path.join(uploadDir, whiteBgZipName)
const relativePath = path.join('uploads', 'skus', materialId, 'white-background', whiteBgZipName)
await fs.writeFile(absolutePath, zipBuffer)
const skuAssetType = `standard-model-white-bg-zip:${skuId}`
const existingAssets = await prisma.materialAsset.findMany({
where: {
materialId,
assetType: skuAssetType,
},
select: { id: true, fileUrl: true },
})
for (const asset of existingAssets) {
if (!asset.fileUrl || asset.fileUrl.startsWith('http://') || asset.fileUrl.startsWith('https://')) {
continue
}
const oldPath = path.isAbsolute(asset.fileUrl)
? asset.fileUrl
: path.join(process.cwd(), asset.fileUrl)
try {
await fs.unlink(oldPath)
} catch {
// 忽略旧文件不存在等清理异常
}
}
if (existingAssets.length > 0) {
await prisma.materialAsset.deleteMany({
where: {
materialId,
assetType: skuAssetType,
},
})
}
await prisma.materialAsset.create({
data: {
materialId,
assetType: skuAssetType,
fileUrl: relativePath,
fileName: whiteBgZipName,
fileSize: BigInt(zipBuffer.length),
sortOrder: 0,
},
})
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
whiteBackgroundZip: {
oldValue: existingAssets.length > 0 ? 'replaced' : null,
newValue: whiteBgZipName,
},
},
})
return NextResponse.json({
success: true,
fileName: whiteBgZipName,
filePath: relativePath.replace(/\\/g, '/'),
fileSize: zipBuffer.length,
})
} catch (error) {
console.error('上传白底图失败:', error)
return NextResponse.json({ error: '上传白底图失败' }, { status: 500 })
}
}

View File

@ -21,6 +21,7 @@ import {
createDownloadError,
} from '@/lib/download-errors';
import { isProductPackageType } from '@/lib/material-type-mapper';
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
// GET - 下载素材
export async function GET(request: NextRequest) {
@ -61,6 +62,8 @@ export async function GET(request: NextRequest) {
const downloadType = searchParams.get('type'); // all/main/sku/detail/psd
const assetId = searchParams.get('assetId'); // 单个资产ID(用于单个PSD文件下载)
const validateOnly = searchParams.get('validate') === '1';
if (!materialId) {
return NextResponse.json(
createDownloadError(DownloadErrorCode.INVALID_DOWNLOAD_TYPE, '缺少素材ID'),
@ -142,7 +145,7 @@ export async function GET(request: NextRequest) {
// 任务-详情:处理分类下载
if (isProductPackageType(material.materialType) && downloadType) {
return handleProductPackageDownload(material, downloadType, assetId);
return handleProductPackageDownload(material, downloadType, assetId, validateOnly);
}
// 其他类型:返回zipFilePath对应的文件
@ -158,7 +161,7 @@ export async function GET(request: NextRequest) {
filePath = path.join(process.cwd(), filePath);
}
return serveFile(filePath, material, downloadType || '源文件');
return serveFile(filePath, material, downloadType || '源文件', validateOnly);
} catch (error) {
console.error('[Download] Error:', error);
return NextResponse.json(
@ -169,7 +172,12 @@ export async function GET(request: NextRequest) {
}
// 处理任务-详情分类下载
async function handleProductPackageDownload(material: any, downloadType: string, assetId?: string | null) {
async function handleProductPackageDownload(
material: any,
downloadType: string,
assetId?: string | null,
validateOnly: boolean = false
) {
console.log(`[Download] ========== 任务-详情下载 ==========`);
console.log(`[Download] materialId=${material.id}, downloadType=${downloadType}, assetId=${assetId}`);
@ -214,11 +222,11 @@ async function handleProductPackageDownload(material: any, downloadType: string,
console.log(`[Download] 文件路径: ${absolutePath}`);
console.log(`[Download] 文件名: ${asset.fileName}`);
return serveFile(absolutePath, material, downloadType === 'all' ? '全部' : downloadType);
return serveFile(absolutePath, material, downloadType === 'all' ? '全部' : downloadType, validateOnly);
}
// 通用文件服务函数
async function serveFile(filePath: string, material: any, fileNameSuffix: string) {
async function serveFile(filePath: string, material: any, fileNameSuffix: string, validateOnly: boolean = false) {
// R29修复:路径遍历保护
const { isPathSafe, isPathInAllowedDirs, getAllowedFileDirectories } = await import('@/lib/path-security');
@ -272,6 +280,16 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
].filter(Boolean);
const filename = `${filenameParts.join('_')}${ext}`;
if (validateOnly) {
fileStream.destroy();
return NextResponse.json({
success: true,
fileName: filename,
fileSize: fileStats.size,
contentType: mimeType,
});
}
console.log(`[Download] 开始下载: ${filename}, size=${fileStats.size} bytes`);
// 修复:记录下载日志并更新下载次数(在后台异步执行)
@ -303,6 +321,8 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
// 记录下载日志(异步执行,不阻塞下载)
async function recordDownloadLog(materialId: string) {
try {
await ensureMaterialDownloadLogsTable();
const session = await getServerSession(authOptions);
if (!session?.user) {
return; // 分享链接访问不记录日志
@ -315,7 +335,6 @@ async function recordDownloadLog(materialId: string) {
// 写入下载日志
prisma.materialDownloadLog.create({
data: {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
materialId,
userId,
downloadedAt: new Date(),

View File

@ -7,6 +7,7 @@ import { deleteFromS3, isS3Url } from '@/lib/s3';
import { deleteFiles } from '@/lib/file-cleanup';
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup';
import { createMaterialLog } from '@/lib/material-log';
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
import { normalizeUrl, normalizeMaterial } from '@/lib/utils';
import { rewriteMaterialStorageUrlsForClient, rewriteMediaUrlForClient } from '@/lib/url-rewrite';
import { detectIsIntranet } from '@/lib/network-detection';
@ -986,11 +987,13 @@ export async function DELETE(request: NextRequest) {
// 软删除
if (!permanent) {
const deletedAt = new Date();
// 注意:软删除时,文件仍然保留,以便恢复时可以重新使用
// 但如果是产品全案,需要确保 MaterialAsset 记录仍然存在(用于恢复时)
await prisma.material.update({
where: { id },
data: { deletedAt: new Date() },
data: { deletedAt },
});
// 记录日志
@ -999,9 +1002,21 @@ export async function DELETE(request: NextRequest) {
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: false,
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: material.materialType,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: deletedAt.toISOString(),
},
deleteMode: {
oldValue: material.deletedAt ? 'deleted' : 'active',
newValue: 'soft',
},
},
});
@ -1011,15 +1026,30 @@ export async function DELETE(request: NextRequest) {
// 永久删除
{
// 确保下载日志表已应用“删除素材后保留日志”策略
await ensureMaterialDownloadLogsTable();
// 记录日志
await createMaterialLog({
materialId: id,
userId,
action: 'DELETE',
changes: {
permanent: {
oldValue: false,
newValue: true,
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: material.materialType,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: 'PERMANENTLY_DELETED',
},
deleteMode: {
oldValue: material.deletedAt ? 'soft' : 'active',
newValue: 'permanent',
},
},
});

View File

@ -1,7 +1,7 @@
// 视频转码API - 完整版本(支持自动恢复)
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { transcodeQueue, cleanupTranscodeResidues } from '@/lib/video-transcode-queue';
import { transcodeQueue, cleanupTranscodeResidues, VIDEO_MATERIAL_TYPES } from '@/lib/video-transcode-queue';
import { executeTranscodeTask, enqueueVideoTranscodeTask } from '@/lib/video-transcode-task';
export const dynamic = 'force-dynamic';
@ -10,6 +10,23 @@ export const maxDuration = 3600; // 1小时
// 恢复标志:确保只执行一次
let recoveryChecked = false;
async function countInterruptedVideoTasks(): Promise<number> {
try {
return await prisma.material.count({
where: {
materialType: { in: VIDEO_MATERIAL_TYPES },
videoProcessingStatus: 'processing',
videoSourceUrl: { not: null },
deletedAt: null,
},
});
} catch (error) {
console.error('[Transcode] Failed to count interrupted video tasks:', error);
// 返回 -1 代表统计失败,后续逻辑会谨慎地触发兜底恢复
return -1;
}
}
/**
* R1修复:懒加载恢复中断的转码任务
* - 优先从新的 VideoTranscodeProgress 表恢复(持久化队列状态)
@ -28,14 +45,25 @@ async function ensureRecoveryCompleted(): Promise<void> {
// R1修复:优先使用新的持久化队列恢复
let recovered = await transcodeQueue.recoverOnStartup(executeTranscodeTask);
// 如果新表没有数据,降级到旧方法(向后兼容)
// 如果新表没有数据,并且存在真正被中断的任务,再降级到旧方法
if (recovered === 0) {
console.log('[Transcode] No tasks in new progress table, falling back to legacy recovery...');
const { recovered: legacyRecovered, requeued } = await transcodeQueue.recoverInterruptedTasks(
executeTranscodeTask,
cleanupTranscodeResidues
);
recovered = legacyRecovered + requeued;
const interruptedCount = await countInterruptedVideoTasks();
if (interruptedCount > 0 || interruptedCount === -1) {
if (interruptedCount > 0) {
console.log(`[Transcode] Found ${interruptedCount} interrupted processing tasks, running legacy recovery...`);
} else {
console.warn('[Transcode] Unable to verify interrupted tasks, running legacy recovery as a safeguard...');
}
const { recovered: legacyRecovered, requeued } = await transcodeQueue.recoverInterruptedTasks(
executeTranscodeTask,
cleanupTranscodeResidues,
{ videoTypes: VIDEO_MATERIAL_TYPES }
);
recovered += legacyRecovered + requeued;
} else {
console.log('[Transcode] No interrupted processing tasks detected, skipping legacy fallback.');
}
}
console.log(`[Transcode] Recovery completed: ${recovered} tasks recovered`);
@ -150,8 +178,7 @@ export async function POST(request: NextRequest) {
}
// 检查是否为视频类型
const videoTypes = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频'];
if (!videoTypes.includes((material as any).materialType)) {
if (!VIDEO_MATERIAL_TYPES.includes((material as any).materialType)) {
return NextResponse.json(
{ error: '此素材不是视频类型' },
{ status: 400 }

View File

@ -5,6 +5,34 @@ import { prisma } from '@/lib/prisma';
import { deleteFromS3, uploadToS3 } from '@/lib/s3';
import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import { requiresCategoryAndScene, requiresRenderer, requiresProductCategoryName, getDefaultRenderer } from '@/lib/material-validation';
import { createMaterialLog } from '@/lib/material-log';
function buildMaterialChanges(oldMaterial: any, newData: Record<string, any>) {
const changes: Record<string, { oldValue: any; newValue: any }> = {};
const normalize = (value: any) => {
if (value instanceof Date) {
return value.toISOString();
}
if (value === undefined) {
return null;
}
return value;
};
for (const [key, value] of Object.entries(newData)) {
if (key === 'updatedAt') continue;
const oldValue = normalize(oldMaterial[key]);
const newValue = normalize(value);
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
changes[key] = { oldValue, newValue };
}
}
return changes;
}
// PUT /api/materials/update - 更新素材(包括预览图)
export async function PUT(request: NextRequest) {
@ -147,6 +175,16 @@ export async function PUT(request: NextRequest) {
},
},
});
const changes = buildMaterialChanges(material, updateData);
if (Object.keys(changes).length > 0) {
await createMaterialLog({
materialId: id,
userId,
action: 'UPDATE',
changes,
});
}
const response = {
...updatedMaterial,

View File

@ -9,6 +9,7 @@ import {
cleanupOnError,
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
// POST - 完成3D原图上传
export async function POST(request: NextRequest) {
@ -119,6 +120,27 @@ export async function POST(request: NextRequest) {
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
// 步骤6: 记录上传日志
await createMaterialLog({
materialId: materialId!,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-3D原图',
},
previewCount: {
oldValue: 0,
newValue: uploadedPreviewUrls.length,
},
zipFileName: {
oldValue: null,
newValue: zipInfo.fileName,
},
},
});
console.log('[3D Original] Complete success!');
return NextResponse.json({
success: true,

View File

@ -9,6 +9,7 @@ import {
cleanupOnError,
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
// POST - 完成AI原图上传
export async function POST(request: NextRequest) {
@ -119,6 +120,27 @@ export async function POST(request: NextRequest) {
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
// 步骤6: 记录上传日志
await createMaterialLog({
materialId: materialId!,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-AI原图',
},
previewCount: {
oldValue: 0,
newValue: uploadedPreviewUrls.length,
},
zipFileName: {
oldValue: null,
newValue: zipInfo.fileName,
},
},
});
console.log('[AI Original] Complete success!');
return NextResponse.json({
success: true,

View File

@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { deleteUploadSession, getUploadSession } from '@/lib/upload-sessions'
import path from 'path'
import { rm, unlink } from 'fs/promises'
interface AbortRequestBody {
materialId?: string
uploadIds?: string[]
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const userId = (session?.user as { id?: string } | undefined)?.id
if (!userId) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
}
const body = (await req.json()) as AbortRequestBody
const materialId = body.materialId?.trim()
const uploadIds = Array.isArray(body.uploadIds)
? body.uploadIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
: []
if (!materialId) {
return NextResponse.json({ success: false, error: '缺少materialId' }, { status: 400 })
}
const material = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
userId: true,
materialType: true,
previewUrl: true,
zipFilePath: true,
},
})
if (!material) {
return NextResponse.json({ success: true, deleted: false, reason: 'material_not_found' })
}
if (material.userId !== userId) {
return NextResponse.json({ success: false, error: '无权限操作此素材' }, { status: 403 })
}
if (material.materialType !== '任务-详情') {
return NextResponse.json({ success: true, deleted: false, reason: 'not_task_detail' })
}
const assetsCount = await prisma.materialAsset.count({ where: { materialId } })
if (assetsCount > 0 || material.previewUrl || material.zipFilePath) {
return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' })
}
for (const uploadId of uploadIds) {
const uploadSession = await getUploadSession(uploadId)
if (uploadSession?.fileName) {
const assembledPath = path.join(process.cwd(), 'uploads', 'assembled', `${uploadId}-${uploadSession.fileName}`)
await unlink(assembledPath).catch(() => undefined)
}
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId)
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
await deleteUploadSession(uploadId).catch(() => undefined)
}
await prisma.material.delete({ where: { id: materialId } })
return NextResponse.json({
success: true,
deleted: true,
materialId,
cleanedUploadIds: uploadIds.length,
})
} catch (error) {
console.error('[product-package/abort] error:', error)
return NextResponse.json({ success: false, error: '回滚未完成素材失败' }, { status: 500 })
}
}

View File

@ -17,6 +17,7 @@ import {
ASSET_TYPE,
} from '@/lib/task-material-processor';
import { getUploadedFile } from '@/lib/upload-sessions';
import { createMaterialLog } from '@/lib/material-log';
import path from 'path';
import { mkdir, writeFile, readFile } from 'fs/promises';
@ -148,11 +149,12 @@ export async function POST(request: NextRequest) {
if (asset.uploadId) {
// 模式1:分片上传,从临时目录读取
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
mainFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
if (!filePath) {
throw new Error(`主图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`);
}
mainFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
} else if (asset.fileUrl) {
// 模式2:直接上传到S3,下载到临时目录
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `main-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
@ -168,6 +170,8 @@ export async function POST(request: NextRequest) {
mainFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
} else {
throw new Error(`主图资产参数无效: ${asset.fileName}`);
}
}
}
@ -177,11 +181,12 @@ export async function POST(request: NextRequest) {
for (const asset of assets.sku) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
skuFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
if (!filePath) {
throw new Error(`SKU图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`);
}
skuFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
} else if (asset.fileUrl) {
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `sku-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
await mkdir(path.dirname(tempPath), { recursive: true });
@ -195,6 +200,8 @@ export async function POST(request: NextRequest) {
skuFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
} else {
throw new Error(`SKU图资产参数无效: ${asset.fileName}`);
}
}
}
@ -204,11 +211,12 @@ export async function POST(request: NextRequest) {
for (const asset of assets.detail) {
if (asset.uploadId) {
const filePath = await getUploadedFile(asset.uploadId);
if (filePath) {
detailFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
if (!filePath) {
throw new Error(`详情图文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`);
}
detailFiles.push({ path: filePath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
} else if (asset.fileUrl) {
const tempPath = path.join(process.cwd(), 'uploads', 'temp', `detail-${Date.now()}-${Math.random().toString(36).substring(2, 9)}-${asset.fileName}`);
await mkdir(path.dirname(tempPath), { recursive: true });
@ -222,6 +230,8 @@ export async function POST(request: NextRequest) {
detailFiles.push({ path: tempPath, name: asset.fileName, size: asset.fileSize });
tempFiles.push(tempPath);
} else {
throw new Error(`详情图资产参数无效: ${asset.fileName}`);
}
}
}
@ -419,31 +429,35 @@ export async function POST(request: NextRequest) {
if (psd.uploadId) {
// 从临时目录获取PSD文件
const tempFilePath = await getUploadedFile(psd.uploadId);
if (tempFilePath) {
// 生成永久文件名和路径
const timestamp = Date.now();
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_');
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`;
const permanentFilePath = path.join(psdDir, permanentFileName);
const relativePath = path.join('uploads', 'materials', permanentFileName);
// 移动文件到永久目录
try {
await rename(tempFilePath, permanentFilePath);
console.log(`[Product Package] PSD文件已移动: ${tempFilePath} -> ${permanentFilePath}`);
} catch (err) {
// 如果跨设备移动失败,使用复制+删除
await copyFile(tempFilePath, permanentFilePath);
tempFiles.push(tempFilePath); // 标记为需要清理
console.log(`[Product Package] PSD文件已复制: ${tempFilePath} -> ${permanentFilePath}`);
}
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName, // 保留原始文件名
fileSize: psd.fileSize,
});
if (!tempFilePath) {
throw new Error(`PSD文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`);
}
// 生成永久文件名和路径
const timestamp = Date.now();
const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_');
const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}`;
const permanentFilePath = path.join(psdDir, permanentFileName);
const relativePath = path.join('uploads', 'materials', permanentFileName);
// 移动文件到永久目录
try {
await rename(tempFilePath, permanentFilePath);
console.log(`[Product Package] PSD文件已移动: ${tempFilePath} -> ${permanentFilePath}`);
} catch (err) {
// 如果跨设备移动失败,使用复制+删除
await copyFile(tempFilePath, permanentFilePath);
tempFiles.push(tempFilePath); // 标记为需要清理
console.log(`[Product Package] PSD文件已复制: ${tempFilePath} -> ${permanentFilePath}`);
}
psdPermanentPaths.push({
filePath: relativePath,
fileName: psd.fileName, // 保留原始文件名
fileSize: psd.fileSize,
});
} else if (!psd.fileUrl) {
throw new Error(`PSD资产参数无效: ${psd.fileName}`);
}
}
console.log(`[Product Package] ${psdPermanentPaths.length} 个PSD文件已移动到永久目录`);
@ -492,26 +506,37 @@ export async function POST(request: NextRequest) {
await cleanupTempFiles(tempFiles, uploadIds);
// 记录日志
await prisma.materialLog.create({
data: {
materialId,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: JSON.stringify({
...(taskNo && taskNo.trim() ? { taskNo } : {}),
assetCounts: {
await createMaterialLog({
materialId,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-详情',
},
taskNo: {
oldValue: null,
newValue: taskNo && taskNo.trim() ? taskNo : null,
},
assetCounts: {
oldValue: null,
newValue: {
main: mainFiles.length,
sku: skuFiles.length,
detail: detailFiles.length,
psd: assets.psd?.length || 0,
},
zipSizes: {
},
zipSizes: {
oldValue: null,
newValue: {
main: zipInfos.main?.fileSize || 0,
sku: zipInfos.sku?.fileSize || 0,
detail: zipInfos.detail?.fileSize || 0,
all: zipInfos.all.fileSize,
},
}),
},
},
});

View File

@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma';
import { validateTaskNo, isTaskNoExists, TASK_STATUS } from '@/lib/product-package';
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup';
import { deleteFiles } from '@/lib/file-cleanup';
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log';
export async function POST(request: NextRequest) {
try {
@ -76,6 +77,9 @@ export async function POST(request: NextRequest) {
// 删除关联记录
await deleteMaterialRelatedRecords(deletedMaterial.id, (deletedMaterial as any).materialType);
// 确保下载日志表已应用“删除素材后保留日志”策略
await ensureMaterialDownloadLogsTable();
// 从数据库永久删除
await prisma.material.delete({

View File

@ -9,6 +9,7 @@ import {
cleanupOnError,
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
// POST - 完成实拍原图上传
export async function POST(request: NextRequest) {
@ -119,6 +120,27 @@ export async function POST(request: NextRequest) {
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
// 步骤6: 记录上传日志
await createMaterialLog({
materialId: materialId!,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-实拍原图',
},
previewCount: {
oldValue: 0,
newValue: uploadedPreviewUrls.length,
},
zipFileName: {
oldValue: null,
newValue: zipInfo.fileName,
},
},
});
console.log('[Real Photo] Complete success!');
return NextResponse.json({
success: true,

View File

@ -14,6 +14,7 @@ import { compressImage, sanitizeFileName } from '@/lib/image-processor';
import { processPdfUpload } from '@/lib/pdf-upload-handler';
import { deleteFiles } from '@/lib/file-cleanup';
import { deleteMaterialRelatedRecords } from '@/lib/material-cleanup';
import { createMaterialLog } from '@/lib/material-log';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -37,6 +38,134 @@ const SKU_TEMPLATE_PLACEHOLDER = (() => {
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
const VIDEO_TYPES = new Set(['视频', 'VIDEO', '主图视频', '安装视频', '种草视频'])
const AUDIO_TYPES = new Set(['音乐'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp'])
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac'])
function normalizeExtension(ext: string): string {
if (!ext) return ''
return ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`
}
function detectExtFromMime(mime?: string | null): string | null {
if (!mime) return null
const map: Record<string, string> = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/x-matroska': '.mkv',
'video/x-msvideo': '.avi',
'video/3gpp': '.3gp',
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/flac': '.flac',
'audio/mp4': '.m4a',
'audio/aac': '.aac',
}
return map[mime] || null
}
async function detectExtFromFile(filePath?: string): Promise<string | null> {
if (!filePath) return null
try {
const absolute = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath)
const handle = await fs.open(absolute, 'r')
try {
const buffer = Buffer.alloc(16)
await handle.read(buffer, 0, 16, 0)
const signature = buffer.toString('hex')
if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) {
return '.mp4'
}
if (buffer.slice(0, 4).toString('ascii') === 'RIFF') {
return '.avi'
}
if (signature.startsWith('1a45dfa3')) {
return '.mkv'
}
if (buffer.slice(0, 3).toString('ascii') === 'ID3') {
return '.mp3'
}
if (buffer.slice(0, 4).toString('ascii') === 'fLaC') {
return '.flac'
}
return null
} finally {
await handle.close()
}
} catch (error) {
console.warn('[Upload] Failed to detect extension from file:', filePath, error)
return null
}
}
async function ensureMediaExtension(params: {
extCandidate: string
mimeType?: string | null
filePath?: string
fileBuffer?: Buffer | null
materialType: string
}): Promise<string> {
const { extCandidate, mimeType, filePath, fileBuffer, materialType } = params
const targetSet = VIDEO_TYPES.has(materialType) ? VIDEO_EXTENSIONS : AUDIO_TYPES.has(materialType) ? AUDIO_EXTENSIONS : null
if (!targetSet) {
return extCandidate || '.zip'
}
if (fileBuffer && fileBuffer.length >= 16) {
const signature = fileBuffer.toString('hex', 0, 16)
const ftyp = fileBuffer.slice(4, 8).toString('ascii')
const riff = fileBuffer.slice(0, 4).toString('ascii')
const id3 = fileBuffer.slice(0, 3).toString('ascii')
const flac = fileBuffer.slice(0, 4).toString('ascii')
if (ftyp.startsWith('ftyp') && targetSet.has('.mp4')) return '.mp4'
if (riff === 'RIFF' && targetSet.has('.avi')) return '.avi'
if (signature.startsWith('1a45dfa3') && targetSet.has('.mkv')) return '.mkv'
if (id3 === 'ID3' && targetSet.has('.mp3')) return '.mp3'
if (flac === 'fLaC' && targetSet.has('.flac')) return '.flac'
}
const normalized = normalizeExtension(extCandidate)
if (normalized && targetSet.has(normalized)) {
return normalized
}
const mimeExt = detectExtFromMime(mimeType)
if (mimeExt && targetSet.has(mimeExt)) {
return mimeExt
}
const detected = await detectExtFromFile(filePath)
if (detected && targetSet.has(detected)) {
return detected
}
return VIDEO_TYPES.has(materialType) ? '.mp4' : '.mp3'
}
function isVideoMaterial(materialType: string) {
return VIDEO_TYPES.has(materialType)
}
function isAudioMaterial(materialType: string) {
return AUDIO_TYPES.has(materialType)
}
function normalizeMaterialType(materialTypeRaw?: string | null): string {
const trimmed = (materialTypeRaw || '').trim()
if (!trimmed) {
return '3D场景'
}
if (trimmed.toLowerCase() === 'video') {
return 'VIDEO'
}
return trimmed
}
// 生成唯一文件名
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now();
@ -79,7 +208,8 @@ export async function POST(request: NextRequest) {
const sceneName = formData.get('sceneName') as string;
const platformName = (formData.get('platformName') as string) || '';
const productCategoryName = (formData.get('productCategoryName') as string) || '';
const materialType = (formData.get('materialType') as string) || '3D场景';
const materialTypeRaw = formData.get('materialType') as string | null;
const materialType = normalizeMaterialType(materialTypeRaw);
const rendererParam = formData.get('renderer') as string | null;
// 统一处理renderer:3D场景必须有renderer,其他类型可选
const renderer = requiresRenderer(materialType)
@ -98,6 +228,7 @@ export async function POST(request: NextRequest) {
categoryName,
sceneName,
materialType,
materialTypeRaw,
});
// 使用统一的验证逻辑验证必填字段
@ -330,11 +461,31 @@ export async function POST(request: NextRequest) {
sourcePath = existingFilePath;
const stat = await fs.stat(sourcePath);
zipFileSize = BigInt(stat.size);
ext = path.extname(originalFileName || sourcePath) || '.zip';
ext = path.extname(originalFileName || sourcePath);
console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size);
}
const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType);
const isVideoOrAudio = isVideoMaterial(materialType) || isAudioMaterial(materialType);
if (isVideoMaterial(materialType)) {
ext = await ensureMediaExtension({
extCandidate: ext,
mimeType: zipFile?.type,
filePath: sourcePath || existingFilePath,
fileBuffer: buffer,
materialType,
});
} else if (isAudioMaterial(materialType)) {
ext = await ensureMediaExtension({
extCandidate: ext,
mimeType: zipFile?.type,
filePath: sourcePath || existingFilePath,
fileBuffer: buffer,
materialType,
});
} else {
ext = normalizeExtension(ext) || '.zip';
}
// 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件
const uploadDir = process.env.UPLOAD_DIR || './uploads';
@ -473,7 +624,7 @@ export async function POST(request: NextRequest) {
// 创建数据库记录(包含所有字段,确保数据不丢失)
// 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放
const videoSourceUrl =
['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName
isVideoMaterial(materialType) && zipFileName
? `/uploads/${zipFileName}`
: null;
@ -602,9 +753,38 @@ export async function POST(request: NextRequest) {
// taskNo写入失败不影响上传流程,只记录错误
}
}
// 记录上传日志
await createMaterialLog({
materialId: material.id,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
productModel: {
oldValue: null,
newValue: productModel,
},
materialType: {
oldValue: null,
newValue: materialType,
},
categoryName: {
oldValue: null,
newValue: categoryName,
},
sceneName: {
oldValue: null,
newValue: sceneName,
},
taskNo: {
oldValue: null,
newValue: taskNo || null,
},
},
});
// 验证文件是否存在(特别是音乐和视频文件)
if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(materialType))) {
if (zipFilePath && (materialType === '音乐' || isVideoMaterial(materialType))) {
try {
const stats = await fs.stat(zipFilePath);
console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size });

View File

@ -9,6 +9,7 @@ import {
cleanupOnError,
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
// POST - 完成任务-页面上传
export async function POST(request: NextRequest) {
@ -119,6 +120,27 @@ export async function POST(request: NextRequest) {
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
// 步骤6: 记录上传日志
await createMaterialLog({
materialId: materialId!,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-页面',
},
previewCount: {
oldValue: 0,
newValue: uploadedPreviewUrls.length,
},
zipFileName: {
oldValue: null,
newValue: zipInfo.fileName,
},
},
});
console.log('[Task Page] Complete success!');
return NextResponse.json({
success: true,

View File

@ -9,6 +9,7 @@ import {
cleanupOnError,
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
// POST - 完成视频截图上传
export async function POST(request: NextRequest) {
@ -119,6 +120,27 @@ export async function POST(request: NextRequest) {
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
// 步骤6: 记录上传日志
await createMaterialLog({
materialId: materialId!,
userId: (session.user as any).id,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: '任务-视频截图',
},
previewCount: {
oldValue: 0,
newValue: uploadedPreviewUrls.length,
},
zipFileName: {
oldValue: null,
newValue: zipInfo.fileName,
},
},
});
console.log('[Video Screenshot] Complete success!');
return NextResponse.json({
success: true,

View File

@ -11,6 +11,7 @@ import VideoPlayerModal from '@/components/VideoPlayerModal'
import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import { parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
// 首页拆分组件
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
@ -106,9 +107,7 @@ export default function Home() {
]
const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string }[]>([])
const [isTransitionLoading, setIsTransitionLoading] = useState(false)
// 修复:使用 Map 存储每个素材的下载进度,避免进度条跳动问题
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const downloadProgressMap = useRef<Map<string, number>>(new Map())
const [downloadProgress, setDownloadProgress] = useState(0)
const [selectedMonth, setSelectedMonth] = useState('')
const [months, setMonths] = useState<Array<{ value: string; label: string; count: number }>>([])
@ -1378,103 +1377,36 @@ export default function Home() {
}
}
const handleDownload = (id: string) => {
// 如果当前素材正在下载,忽略重复点击
if (downloadingId === id) {
console.log(`[Download] 素材 ${id} 正在下载中,忽略重复点击`)
return
const handleDownloadByDirectLink = (id: string) => {
const runDownload = async () => {
if (downloadingId === id) {
return
}
setDownloadingId(id)
setDownloadProgress(10)
try {
const downloadUrl = `/api/materials/download?id=${encodeURIComponent(id)}`
await validateAndTriggerDownload(downloadUrl)
setDownloadProgress(100)
setMaterials(prev =>
prev.map(item =>
item.id === id ? { ...item, downloadCount: (item.downloadCount || 0) + 1 } : item
)
)
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} finally {
setTimeout(() => {
setDownloadingId(null)
setDownloadProgress(0)
}, 300)
}
}
setDownloadingId(id)
// 初始化该素材的下载进度为 0
downloadProgressMap.current.set(id, 0)
setDownloadProgress(0)
const xhr = new XMLHttpRequest()
// 监听下载进度
// 注意:直接使用 id 参数,不要使用 downloadingId 状态(闭包陷阱)
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
// 更新进度(id 是闭包参数,值是正确的)
downloadProgressMap.current.set(id, percentComplete)
setDownloadProgress(percentComplete)
}
})
xhr.addEventListener('load', () => {
// 清理进度记录
downloadProgressMap.current.delete(id)
if (xhr.status === 200) {
try {
// 从响应头获取 Content-Type(视频、音频等不同类型)
const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream'
const blob = new Blob([xhr.response], { type: contentType })
// 从响应头获取文件名
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material' // 默认文件名,扩展名从服务器获取
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
}
}
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
// 更新本地状态的下载次数
setMaterials(prev => prev.map(m =>
m.id === id ? { ...m, downloadCount: (m.downloadCount || 0) + 1 } : m
))
} catch (error) {
console.error('下载处理失败:', error)
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
}
} else {
// 当 responseType 为 arraybuffer 时,需要将响应转换为文本来解析错误
try {
const decoder = new TextDecoder('utf-8')
const errorText = decoder.decode(xhr.response)
const error = errorText ? JSON.parse(errorText) : { error: '下载失败' }
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} catch {
const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' })
showToast(message, { variant: 'error' })
}
}
// 清理下载状态
setDownloadingId(null)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
// 清理进度记录
downloadProgressMap.current.delete(id)
const { message } = parseDownloadError({ code: 'NETWORK_ERROR' })
showToast(message, { variant: 'error' })
// 清理下载状态
setDownloadingId(null)
setDownloadProgress(0)
})
xhr.open('GET', `/api/materials/download?id=${id}`)
xhr.responseType = 'arraybuffer'
xhr.send()
void runDownload()
}
const handleLike = async (id: string) => {
@ -1812,7 +1744,7 @@ export default function Home() {
searchKeyword={searchKeyword}
hasFilters={!!(searchKeyword || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform)}
onAudioPlay={handleAudioPlay}
onDownload={handleDownload}
onDownload={handleDownloadByDirectLink}
onLike={handleLike}
onFavorite={handleFavorite}
onShare={handleShare}

View File

@ -15,6 +15,7 @@ import AudioVisualizer from '@/components/AudioVisualizer'
import ProductPackageDetail from '@/components/detail/ProductPackageDetail'
import ThreeDOriginalDetail from '@/components/detail/ThreeDOriginalDetail'
import { parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, parseFetchError } from '@/lib/error-handler'
interface Material {
@ -440,86 +441,35 @@ export default function SharePage() {
}
}
// 下载整个素材(非标准模型类型,或标准模型类型但未选中SKU时)
const handleDownload = () => {
if (!material) return
// 如果是标准模型类型且有选中的SKU,则批量下载SKU
if (material.materialType === '标准模型' && selectedSkuIds.size > 0) {
handleBatchDownloadSkus()
const handleDownloadByDirectLink = () => {
if (!material) {
return
}
// 否则下载整个素材
setDownloading(true)
setDownloadProgress(0)
if (material.materialType === '标准模型' && selectedSkuIds.size > 0) {
void handleBatchDownloadSkus()
return
}
const xhr = new XMLHttpRequest()
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
setDownloadProgress(percentComplete)
const runDownload = async () => {
setDownloading(true)
setDownloadProgress(10)
try {
const downloadUrl = `/api/materials/download?id=${encodeURIComponent(material.id)}`
await validateAndTriggerDownload(downloadUrl)
setDownloadProgress(100)
} catch (error) {
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} finally {
setTimeout(() => {
setDownloading(false)
setDownloadProgress(0)
}, 300)
}
})
}
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const blob = new Blob([xhr.response], { type: 'application/zip' })
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material.zip'
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
}
}
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
// 延迟清理 blob URL,确保下载已经开始
setTimeout(() => {
a.remove()
window.URL.revokeObjectURL(url)
}, 100)
} catch (error) {
console.error('下载处理失败:', error)
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
}
} else {
// 当 responseType 为 arraybuffer 时,需要将响应转换为文本来解析错误
try {
const decoder = new TextDecoder('utf-8')
const errorText = decoder.decode(xhr.response)
const errorData = errorText ? JSON.parse(errorText) : { error: '下载失败' }
const { message } = parseDownloadError(errorData)
showToast(message, { variant: 'error' })
} catch {
const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' })
showToast(message, { variant: 'error' })
}
}
setDownloading(false)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
const { message } = parseDownloadError({ code: 'NETWORK_ERROR' })
showToast(message, { variant: 'error' })
setDownloading(false)
setDownloadProgress(0)
})
xhr.open('GET', `/api/materials/download?id=${material.id}`)
xhr.responseType = 'arraybuffer'
xhr.send()
void runDownload()
}
if (loading) {
@ -582,65 +532,17 @@ export default function SharePage() {
canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
showToast={showToast}
onDownload={async (type, assetId) => {
// 处理下载
let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`;
if (assetId) {
downloadUrl += `&assetId=${assetId}`;
}
// 使用 fetch 下载,这样可以监听下载完成并重置状态
try {
const response = await fetch(downloadUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: '下载失败' }));
const { message } = parseDownloadError(errorData);
throw new Error(message);
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
if (assetId) {
directDownloadUrl += `&assetId=${assetId}`
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 根据类型和assetId生成文件名
if (assetId && type === 'psd') {
// 单个PSD文件下载,从响应头获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
a.download = fileName;
} else {
a.download = 'source.psd';
}
} else {
a.download = 'source.psd';
}
} else {
// 批量下载,使用ZIP格式
const typeNames: Record<string, string> = {
all: '全部',
main: '主图',
sku: 'SKU图',
detail: '详情图',
psd: 'PSD源文件',
};
a.download = `${material.productModel}_${typeNames[type] || type}.zip`;
}
document.body.appendChild(a);
a.click();
// 延迟清理 blob URL,确保下载已经开始
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
// 下载成功,通知组件重置状态
return Promise.resolve();
await validateAndTriggerDownload(directDownloadUrl)
return Promise.resolve()
} catch (error) {
console.error('下载失败:', error);
const { message } = parseDownloadError(error);
showToast(message, { variant: 'error' });
return Promise.reject(error);
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return Promise.reject(error)
}
}}
onBack={() => router.back()}
@ -663,49 +565,17 @@ export default function SharePage() {
canDownloadSource={material.canDownloadSource ?? false}
showToast={showToast}
onDownload={async (type, assetId) => {
// 处理下载,使用与ProductPackageDetail相同的下载API
let downloadUrl = `/api/materials/download?id=${material.id}&type=${type}`;
if (assetId) {
downloadUrl += `&assetId=${assetId}`;
}
try {
const response = await fetch(downloadUrl);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: '下载失败' }));
const { message } = parseDownloadError(errorData);
throw new Error(message);
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
if (assetId) {
directDownloadUrl += `&assetId=${assetId}`
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 从响应头获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition) {
const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (fileNameMatch && fileNameMatch[1]) {
const fileName = decodeURIComponent(fileNameMatch[1].replace(/['"]/g, ''));
a.download = fileName;
} else {
a.download = `${material.productModel}_source.zip`;
}
} else {
a.download = `${material.productModel}_source.zip`;
}
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
return Promise.resolve();
await validateAndTriggerDownload(directDownloadUrl)
return Promise.resolve()
} catch (error) {
console.error('下载失败:', error);
const { message } = parseDownloadError(error);
showToast(message, { variant: 'error' });
return Promise.reject(error);
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return Promise.reject(error)
}
}}
onBack={() => router.back()}
@ -1069,7 +939,7 @@ export default function SharePage() {
{/* 操作按钮 - 统一样式 */}
<div className="space-y-3 pt-2">
<button
onClick={handleDownload}
onClick={handleDownloadByDirectLink}
disabled={downloading}
className="w-full btn-primary py-3 text-base font-medium relative overflow-hidden disabled:opacity-75"
>

View File

@ -5,7 +5,7 @@ import Link from 'next/link'
import { signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Session } from 'next-auth'
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react'
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal'
@ -226,6 +226,11 @@ export default function Header({ session }: HeaderProps) {
return `${hr}h${min % 60}min`
}
const handleLogoHomeClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
window.location.assign('/')
}
return (
<>
{/* 系统维护通知横幅 */}
@ -240,7 +245,7 @@ export default function Header({ session }: HeaderProps) {
<div className="container mx-auto px-4 py-4 max-w-7xl">
<div className="flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-3">
<Link href="/" onClick={handleLogoHomeClick} className="flex items-center space-x-3">
<Image
src={headerLogoUrl}
alt={siteName}
@ -823,4 +828,3 @@ export default function Header({ session }: HeaderProps) {
</>
)
}

View File

@ -5,6 +5,7 @@ import Image from 'next/image'
import { useToast } from '@/hooks/useToast'
import ToastBubble from './ToastBubble'
import { parseDownloadError } from '@/lib/download-errors'
import { validateAndTriggerDownload } from '@/lib/client-download'
// SKU 数据类型
interface Sku {
@ -94,26 +95,9 @@ export default function SkuList({ materialId, canEdit, onEditClick, onSelectionC
const handleDownloadSku = async (sku: Sku) => {
try {
setDownloadingSkuId(sku.id)
const response = await fetch(`/api/materials/${materialId}/skus/${sku.id}/download`)
if (!response.ok) {
const error = await response.json()
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
return
}
// 获取文件并下载
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = sku.zipFileName || `${sku.skuName}.zip`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
const directDownloadUrl = `/api/materials/${materialId}/skus/${sku.id}/download`
await validateAndTriggerDownload(directDownloadUrl)
} catch (error) {
console.error('下载 SKU 失败:', error)
const { message } = parseDownloadError(error)

File diff suppressed because it is too large Load Diff

View File

@ -259,6 +259,31 @@ export default function ProductPackageUploader({
return result.uploadId;
};
const MAX_FILE_CONCURRENCY = 2;
const runWithConcurrency = async <TInput, TOutput>(
items: TInput[],
limit: number,
worker: (item: TInput, index: number) => Promise<TOutput>
): Promise<TOutput[]> => {
if (items.length === 0) return [];
const safeLimit = Math.max(1, Math.min(limit, items.length));
const results: TOutput[] = new Array(items.length);
let nextIndex = 0;
const consume = async () => {
while (nextIndex < items.length) {
const current = nextIndex;
nextIndex += 1;
results[current] = await worker(items[current], current);
}
};
await Promise.all(Array.from({ length: safeLimit }, () => consume()));
return results;
};
// 提交上传
const handleSubmit = async () => {
// 验证必填字段(任务号改为可选,不提示)
@ -284,6 +309,10 @@ export default function ProductPackageUploader({
setProcessingStep('');
setError('');
let createdMaterialId: string | null = null;
const uploadedFileIds: string[] = [];
let completed = false;
try {
// 1. 初始化上传
setProcessingStep('初始化上传...');
@ -307,6 +336,7 @@ export default function ProductPackageUploader({
const { data: initData } = await initResponse.json();
const materialId = initData.materialId;
createdMaterialId = materialId;
// 2. 上传所有文件(并行上传以提高速度)
const totalFiles =
@ -326,10 +356,10 @@ export default function ProductPackageUploader({
): Promise<Array<{ fileName: string; fileSize: number; uploadId: string; sortOrder: number }>> => {
if (assets.length === 0) return [];
// 并行上传所有文件(使用分片上传)
const uploadPromises = assets.map(async (asset, index) => {
return runWithConcurrency(assets, MAX_FILE_CONCURRENCY, async (asset, index) => {
try {
const uploadId = await uploadFileWithChunks(asset.file);
uploadedFileIds.push(uploadId);
// 只在上传完成时更新进度
updateProgress();
return {
@ -343,19 +373,17 @@ export default function ProductPackageUploader({
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
}
});
// 等待所有文件上传完成
return await Promise.all(uploadPromises);
};
// 并行上传PSD文件(使用分片上传)
const uploadPsdFiles = async (): Promise<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
if (psdFiles.length === 0) return [];
const uploadPromises = psdFiles.map(async (psdFileItem, index) => {
return runWithConcurrency(psdFiles, MAX_FILE_CONCURRENCY, async (psdFileItem, index) => {
try {
const psdFile = psdFileItem.file;
const uploadId = await uploadFileWithChunks(psdFile);
uploadedFileIds.push(uploadId);
// 只在上传完成时更新进度
updateProgress();
return {
@ -368,17 +396,20 @@ export default function ProductPackageUploader({
throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
}
});
return await Promise.all(uploadPromises);
};
// 并行上传所有类型的资产(主图、SKU图、详情图、PSD文件同时上传)
const [mainAssets, skuAssets, detailAssets, psdAssetsArray] = await Promise.all([
uploadAssets(mainImages, 'main'),
uploadAssets(skuImages, 'sku'),
uploadAssets(detailImages, 'detail'),
uploadPsdFiles(),
]);
// 顺序上传不同资产类型,避免一次性触发过多并发请求导致会话不稳定
setProcessingStep('上传主图中...');
const mainAssets = await uploadAssets(mainImages, 'main');
setProcessingStep('上传SKU图中...');
const skuAssets = await uploadAssets(skuImages, 'sku');
setProcessingStep('上传详情图中...');
const detailAssets = await uploadAssets(detailImages, 'detail');
setProcessingStep('上传PSD中...');
const psdAssetsArray = await uploadPsdFiles();
// PSD资产格式:支持数组(complete接口支持数组格式)
const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined;
@ -415,13 +446,22 @@ export default function ProductPackageUploader({
setUploadProgress(100);
setProcessingStep('完成!');
completed = true;
// 清理预览URL
const cleanup = () => {
mainImages.forEach(img => URL.revokeObjectURL(img.preview));
skuImages.forEach(img => URL.revokeObjectURL(img.preview));
detailImages.forEach(img => URL.revokeObjectURL(img.preview));
psdFiles.forEach(img => URL.revokeObjectURL(img.preview));
mainImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
skuImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
detailImages.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
psdFiles.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
};
cleanup();
@ -429,8 +469,24 @@ export default function ProductPackageUploader({
setProcessingStep('');
onSuccess();
}, 500);
} catch (error: any) {
} catch (error: unknown) {
console.error('上传失败:', error);
if (createdMaterialId && !completed) {
try {
await fetch('/api/materials/upload/product-package/abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId: createdMaterialId,
uploadIds: uploadedFileIds,
}),
});
} catch (abortError) {
console.error('[Upload] 失败回滚未完成素材失败:', abortError);
}
}
const { parseApiError } = await import('@/lib/error-handler');
const { message } = parseApiError(error);
setError(message);
@ -487,9 +543,10 @@ export default function ProductPackageUploader({
{/* 主图上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label htmlFor="main-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
主图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isMainDragging
@ -502,6 +559,7 @@ export default function ProductPackageUploader({
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
>
<input
id="main-images-input"
ref={mainInputRef}
type="file"
accept="image/*"
@ -529,10 +587,11 @@ export default function ProductPackageUploader({
<button
type="button"
onClick={() => mainInputRef.current?.click()}
disabled={uploading}
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
disabled={uploading}
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
>
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加主图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
@ -545,9 +604,10 @@ export default function ProductPackageUploader({
{/* SKU图上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label htmlFor="sku-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isSkuDragging
@ -560,6 +620,7 @@ export default function ProductPackageUploader({
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
>
<input
id="sku-images-input"
ref={skuInputRef}
type="file"
accept="image/*"
@ -587,10 +648,11 @@ export default function ProductPackageUploader({
<button
type="button"
onClick={() => skuInputRef.current?.click()}
disabled={uploading}
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
disabled={uploading}
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
>
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加SKU图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
@ -603,9 +665,10 @@ export default function ProductPackageUploader({
{/* 详情图上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label htmlFor="detail-images-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
详情图
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isDetailDragging
@ -618,6 +681,7 @@ export default function ProductPackageUploader({
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
>
<input
id="detail-images-input"
ref={detailInputRef}
type="file"
accept="image/*"
@ -630,6 +694,7 @@ export default function ProductPackageUploader({
<div className="flex items-center space-x-4 mb-4">
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加详情图</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
@ -661,9 +726,10 @@ export default function ProductPackageUploader({
{/* PSD上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label htmlFor="psd-files-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
PSD源文件(可选)
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPsdDragging
@ -678,6 +744,7 @@ export default function ProductPackageUploader({
}}
>
<input
id="psd-files-input"
ref={psdInputRef}
type="file"
accept=".psd,.zip,.rar,.7z"
@ -691,6 +758,7 @@ export default function ProductPackageUploader({
{psdFiles.length > 0 ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>PSD文件</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="absolute bottom-1 right-1 bg-blue-500 text-white text-xs rounded-full w-6 h-6 flex items-center justify-center">
@ -700,6 +768,7 @@ export default function ProductPackageUploader({
) : (
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>添加PSD文件</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>

View File

@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import {
STANDARD_MODEL_WHITE_BG_VIEWS,
type StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
interface StandardModelWhiteBackgroundUploadProps {
imageUrls: Partial<Record<StandardModelWhiteBgKey, string>>
onSelectImage: (key: StandardModelWhiteBgKey, file: File) => void
onRemoveImage: (key: StandardModelWhiteBgKey) => void
}
export default function StandardModelWhiteBackgroundUpload({
imageUrls,
onSelectImage,
onRemoveImage,
}: StandardModelWhiteBackgroundUploadProps) {
const [draggingKey, setDraggingKey] = useState<StandardModelWhiteBgKey | null>(null)
const handleDragEnter = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
event.preventDefault()
event.stopPropagation()
setDraggingKey(key)
}
const handleDragLeave = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
event.preventDefault()
event.stopPropagation()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const { clientX, clientY } = event
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
if (draggingKey === key) {
setDraggingKey(null)
}
}
}
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
}
const handleDrop = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
event.preventDefault()
event.stopPropagation()
setDraggingKey(null)
const file = event.dataTransfer.files?.[0]
if (!file) {
return
}
onSelectImage(key, file)
}
return (
<div>
<p className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
产品白底图(6张) *
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => {
const inputId = `white-bg-${view.key}`
const imageUrl = imageUrls[view.key]
const isDragging = draggingKey === view.key
return (
<div key={view.key} className="space-y-2">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300">{view.label}</p>
<input
id={inputId}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
onSelectImage(view.key, file)
}
event.currentTarget.value = ''
}}
/>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要处理拖拽上传事件 */}
<div
className={`border-2 border-dashed rounded-apple p-3 transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => handleDragEnter(event, view.key)}
onDragLeave={(event) => handleDragLeave(event, view.key)}
onDragOver={handleDragOver}
onDrop={(event) => handleDrop(event, view.key)}
>
{imageUrl ? (
<div className="space-y-2">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={imageUrl}
alt={view.label}
fill
unoptimized
className="object-cover"
/>
</div>
<div className="flex gap-2">
<label
htmlFor={inputId}
className="flex-1 text-center text-xs px-2 py-1 rounded-apple bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-pointer transition-colors"
>
更换
</label>
<button
type="button"
className="text-xs px-2 py-1 rounded-apple bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
onClick={() => onRemoveImage(view.key)}
>
删除
</button>
</div>
<p className="text-[11px] text-blue-600 dark:text-blue-400">支持拖拽图片替换</p>
</div>
) : (
<label
htmlFor={inputId}
className="group w-full h-24 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex flex-col items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer"
>
<svg className="w-5 h-5 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>上传{view.label}</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</span>
</label>
)}
</div>
</div>
)
})}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
白底图需一次上传完整6张,系统会自动打包为白底图压缩包用于下载。
</p>
<p className="mt-1 text-xs text-blue-600 dark:text-blue-400">
💡 每个角度支持拖拽上传,拖入后会自动替换对应位置图片。
</p>
</div>
)
}

View File

@ -121,7 +121,11 @@ export async function completeBatchUpload(uploadId: string, metadata: Record<str
// 第二步:创建素材记录(使用合并后的文件路径)
const formData = new FormData()
formData.append('existingFilePath', existingFilePath)
formData.append('originalFileName', originalFileName)
if (originalFileName) {
formData.append('originalFileName', originalFileName)
} else if (metadata.fileName) {
formData.append('originalFileName', metadata.fileName)
}
formData.append('productModel', metadata.productModel)
formData.append('categoryName', metadata.categoryName || '通用')
formData.append('sceneName', metadata.sceneName || '通用')

345
docs/CODEX_MULTI_AGENTS.md Normal file
View File

@ -0,0 +1,345 @@
# Codex Multi-agents 团队方案
本文件只服务当前项目,不写入全局配置。
## 1. 团队目标
把 `Codex 5.4` 的内置多代理能力,升级成适合本项目的“项目级协作流程”,重点解决:
- 任务拆分更准
- 审查范围更全
- UI 统一性有专门审查
- 发布前有明确门禁
- 输出格式统一,方便复核
## 2. 团队结构
### 核心 5 角
1. `orchestrator`
- 拆解任务
- 选择需要出动的角色
- 汇总冲突意见
- 给最终执行顺序
- 作为团队唯一对外接口,直接和用户沟通
2. `backend`
- 审查接口、数据、存储、转码、权限、日志链路
- 重点目录:`app/api/**`、`lib/**`、`prisma/**`
3. `frontend`
- 审查页面交互、状态流、用户路径
- 重点目录:`app/**`、`components/**`、`hooks/**`
4. `ui_auditor`
- 审查颜色、字体、间距、圆角、阴影、组件状态一致性
- 默认优先输出问题,不直接主导业务改动
5. `release_gate`
- 汇总所有角色输出
- 按证据判定:
- `可上线`
- `有条件上线`
- `不可上线`
## 2.1 对话方式
- 用户默认只和 `orchestrator` 对话。
- `orchestrator` 负责理解需求、追问缺口、决定是否派发子代理。
- 子代理默认不直接对用户说话,只向 `orchestrator` 回传结果。
- `orchestrator` 对外输出的内容应是整理后的统一结论,而不是把子代理原文直接堆给用户。
### 辅助 2 角
6. `explorer`
- 适合先做跨文件摸底、找影响范围、找入口文件
- 不承担最终结论
7. `worker`
- 适合在边界明确后,处理单一子任务的修改
- 不能替代主控做总调度
## 3. 三种工作模式
### A. 审查模式
适用场景:
- 上线前检查
- 大改前摸底
- 不确定该不该动代码
推荐编组:
- `orchestrator`
- `backend`
- `frontend`
- `ui_auditor`
- `release_gate`
### B. 修复模式
适用场景:
- 已经确定问题所在,需要定点修改
- 既要修功能,也要看 UI 回归
推荐编组:
- `orchestrator`
- `explorer`
- `backend` 或 `frontend`
- `worker`
- 需要时追加 `ui_auditor`
### C. 发布模式
适用场景:
- 发版前最终门禁
- 需要形成可复核结论
推荐编组:
- `orchestrator`
- `backend`
- `frontend`
- `ui_auditor`
- `release_gate`
## 4. 调度规则
本项目默认采用“用户只对 `orchestrator` 提需求,由 `orchestrator` 自动派单”的方式。
### 自动派单总原则
- 用户不需要手动指定角色。
- `orchestrator` 负责判断是否启用团队,以及需要出动哪些角色。
- 如果任务很小,允许不启用完整团队。
- 如果任务高风险、影响范围大、或准备上线,优先扩大编组。
### 仅后端类任务
例如:
- 下载
- 上传
- 转码
- 批量删除
- 外部接口
- 管理后台统计接口
推荐角色:
- `orchestrator`
- `backend`
- 必要时加 `release_gate`
### 仅前端类任务
例如:
- 首页
- 分享页
- 上传弹窗
- 管理后台页面
推荐角色:
- `orchestrator`
- `frontend`
- `ui_auditor`
### 全链路联动任务
例如:
- 上传后展示异常
- 后台统计与页面表现同时改
- 下载接口和前端下载入口一起变
推荐角色:
- `orchestrator`
- `backend`
- `frontend`
- `ui_auditor`
- `release_gate`
### 影响范围不清的任务
例如:
- 不确定问题在前端还是后端
- 不确定影响几个目录
- 只是看到现象,还没看到根因
推荐角色:
- `orchestrator`
- `explorer`
说明:
- 先摸底,再扩编
- 避免一开始全员出动造成重复劳动
## 5. 每个角色的标准输出
### `orchestrator`
- 任务目标
- 角色分派
- 依赖关系
- 验收标准
- 风险分级
### `backend`
- 发现的问题
- P0 / P1 / P2
- 影响接口或数据范围
- 修复建议
- 验证清单
### `frontend`
- 发现的问题
- P0 / P1 / P2
- 影响页面和交互路径
- 修复建议
- 验证清单
### `ui_auditor`
- 视觉一致性问题
- 组件一致性问题
- 页面级问题
- 必须统一项
- 可迭代项
### `release_gate`
- 门禁汇总
- 是否可上线
- 阻塞项
- 上线前必须完成项
- 上线后观察项
## 6. 发布判定规则
- 任一 `P0` 未关闭:`不可上线`
- 关键链路未验证:`不可上线`
- 只有可控 `P1`,且有回滚方案:`有条件上线`
- 无阻塞项,验证通过:`可上线`
## 6.2 默认门禁严格度
- 当前项目默认采用“偏严格”的发布门禁。
- 原因:
- 项目已经在线上运行
- 任务经常跨前后端和 UI
- 用户默认不是程序员,应该尽量减少“技术上能上线、实际很难判断风险”的情况
默认解释如下:
- `严格拦截`
- 只要有 `P0`
- 只要关键链路没验证
- 只要没有明确回滚路径
- 结论就是 `不可上线`
- `有限放行`
- 如果只有少量 `P1`
- 且不影响核心链路
- 且有明确回滚方案和后续跟踪项
- 可以给 `有条件上线`
- `正常放行`
- 没有 `P0`
- 核心链路验证通过
- UI 没有明显破坏统一性的问题
- 就给 `可上线`
## 6.3 高风险边界
当前项目默认把下面这些改动视为高风险:
- 数据类
- 数据库写入
- 批量更新
- 批量删除
- 数据恢复
- 会影响现有线上数据结果的逻辑调整
- 权限类
- 登录后权限判断
- 角色判断
- 管理员能力
- 下载资格
- 外部接口访问控制
- 批量与资源类
- 上传
- 下载
- 转码
- 批量操作
- 外部接口
- 后台导出
- 操作日志与审计记录
对应规则:
- 命中高风险边界时,`orchestrator` 默认至少启用 `backend`
- 同时影响页面体验时,追加 `frontend`
- 同时影响视觉统一时,追加 `ui_auditor`
- 需要上线判断、范围大、或风险不清时,追加 `release_gate`
- 高风险任务默认采用更保守的门禁和验证要求
## 6.1 UI 基线
- 当前项目的 UI 统一标准默认以首页为基线。
- 基线文件:`app/page.tsx`
- `ui_auditor` 审查其它页面时,默认对齐以下维度:
- 视觉语言
- 主按钮样式
- 标题与信息层级
- 间距与留白节奏
- loading / empty / error / success 状态反馈
- 如果某个页面因为管理后台密度、特殊业务流程或设备限制必须偏离首页风格,需要单独说明理由,不默认视为统一标准。
## 7. 推荐用法
进入本项目后,如果要启用内置多代理,建议以当前项目目录启动,并在任务里明确写出采用哪种模式。
当用户明确写出下面这些触发语时,`orchestrator` 默认应真正派发子代理,而不是只停留在口头说明:
- “按项目的审查模式处理这次改动”
- “按项目的修复模式处理这次任务”
- “按项目的发布模式做门禁”
- “用项目团队处理”
- “启用 Multi-agents”
示例表达:
- “按项目的审查模式处理这次改动。”
- “按项目的修复模式先拆任务,再定点修改。”
- “按项目的发布模式做一次完整门禁。”
如果当前 `Codex` 会话未启用多代理能力,可在启动时临时开启,不写入全局:
`codex --enable multi_agent -C "D:\AI Code\gongxiangp2"`
## 8. 目前还不能自动决定的四件事
这些问题我已经预留好团队位置,但还需要你和我一个一个定:
1. UI 统一标准到底以哪套页面为基线
2. 发布门禁的阈值要多严格
3. 哪些任务必须全队出动,哪些只需要局部编组
4. 数据、权限、批量操作的高风险边界怎么划
当前已确定第 1 项:UI 基线默认以首页为准。
当前已确定第 2 项:发布门禁默认采用偏严格标准。
当前已确定第 3 项:用户只和 `orchestrator` 对话,由 `orchestrator` 自动派单。
当前已确定第 4 项:数据、权限、批量操作、资源链路相关改动默认视为高风险。

222
docs/NETWORK_TOPOLOGY.md Normal file
View File

@ -0,0 +1,222 @@
# 网络拓扑文档
> 最后更新:2026-03-04
---
## 🗺️ 网络架构概览(当前生效)
```
┌───────────────────────────┐
│ 公网用户 │
└─────────────┬─────────────┘
│
▼
┌────────────────────────────────────┐
│ 香港入口服务器 103.118.245.6 │
│ - Nginx: 80/443 │
│ - FRPS: 7001 │
│ - FRP vhost: 8080/8443 │
│ - Dashboard: 7500 │
└────────────────┬───────────────────┘
│ FRP (token)
▼
┌────────────────────────────────────┐
│ 生产服务器 192.168.58.20 │
│ - Next.js: 4320 (容器映射) │
│ - MinIO: 9000 │
│ - frpc-s3.service (主链路) │
│ - frpc.service (南京备用,已停用) │
└────────────────────────────────────┘
备用(非主链路):
南京 FRP 180.97.221.140(7000/2320/2330)当前仅保留为历史备用线路。
```
---
## 🌐 域名解析
| 域名 | DNS 指向 | 实际服务 | 用途 |
|-----|---------|---------|------|
| 3d.yydd.ai | 香港 (103.118.245.6) | Next.js 应用(经香港入口转发) | 主站访问 |
| s3.yydd.ai | 香港 (103.118.245.6) | MinIO 存储桶(经香港入口转发) | 文件存储/下载 |
### DNS 管理说明
- DNS 托管在 Cloudflare(权威 NS:`holly.ns.cloudflare.com`、`simon.ns.cloudflare.com`)
- 域名变更需在 Cloudflare Zone(`yydd.ai`)内操作
- 当前运维终端默认无 Cloudflare 管理权限时,需联系域名管理员执行切换
---
## 🔌 FRP 穿透配置
### 当前服务模型(systemd)
| 节点 | 服务 | 配置文件 | 角色 |
|-----|------|---------|------|
| 生产 192.168.58.20 | `frpc-s3.service` | `/etc/frp/frpc-s3.toml` | 主链路(3d + s3 → 香港) |
| 生产 192.168.58.20 | `frpc.service` | `/etc/frp/frpc.toml` | 南京备用链路(当前停用) |
| 香港 103.118.245.6 | `frps-minio.service` | `/root/frp-minio/frps.toml` | FRPS 服务端 |
> 说明:`frpc` 二进制会有工作线程,进程数可大于 1;以 `systemctl is-active` 与日志为准。
>
> 运行备注:`frpc.service`(南京备用链路)当前已停用,不影响 `frpc-s3.service`(香港主链路)对外服务;如需回切可手动启用。
### 关键配置(脱敏示例)
#### `/etc/frp/frpc-s3.toml`(生产,主链路)
```toml
serverAddr = "103.118.245.6"
serverPort = 7001
auth.method = "token"
auth.token = "<REDACTED>"
[[proxies]]
name = "minio-s3-hk"
type = "http"
localIP = "192.168.58.20"
localPort = 9000
customDomains = ["s3.yydd.ai"]
[[proxies]]
name = "3d-main-hk"
type = "http"
localIP = "192.168.58.20"
localPort = 4320
customDomains = ["3d.yydd.ai"]
```
#### `/root/frp-minio/frps.toml`(香港,服务端)
```toml
bindPort = 7001
vhostHTTPPort = 8080
vhostHTTPSPort = 8443
[auth]
token = "<REDACTED>"
[webServer]
addr = "0.0.0.0"
port = 7500
user = "admin"
password = "<REDACTED>"
```
---
## 🚪 端口映射(当前)
### 生产服务器 (192.168.58.20)
| 服务 | 端口 | 说明 |
|-----|------|------|
| Next.js 容器 | 4320 | 3D 主站应用入口(内网) |
| MinIO | 9000 | 对象存储服务 |
| PostgreSQL | 5432 | 内网数据库 |
| Redis | 6379 | 内网缓存 |
### 香港入口服务器 (103.118.245.6)
| 端口 | 服务 | 说明 |
|-----|------|------|
| 80 | Nginx | HTTP 入口(跳 HTTPS) |
| 443 | Nginx + Let’s Encrypt | 3d/s3 统一 HTTPS 入口 |
| 7001 | FRPS bindPort | FRP 控制通道 |
| 8080 | FRPS vhostHTTPPort | FRP HTTP 转发 |
| 8443 | FRPS vhostHTTPSPort | FRP HTTPS 转发 |
| 7500 | FRPS Dashboard | 运维面板(需加固来源) |
### 南京 FRP 服务器 (180.97.221.140)
| 端口 | 服务 | 说明 |
|-----|------|------|
| 7000 | FRP Server | 南京旧线路控制端口 |
| 2320 | 3d 旧代理口 | 历史主站链路(备用) |
| 2330 | s3 旧代理口 | 历史存储链路(备用) |
---
## 📊 服务访问路径(当前)
### 3d.yydd.ai(主站)
```
用户 → DNS(香港) → Nginx(香港:443) → FRPS(香港:8080/8443) → frpc-s3(生产) → Next.js(4320)
```
### s3.yydd.ai(存储)
```
用户 → DNS(香港) → Nginx(香港:443) → FRPS(香港:8080/8443) → frpc-s3(生产) → MinIO(9000)
```
---
## 🔧 故障排查(更新后)
### 常用检查命令
```bash
# 1) 生产侧 FRP 主链路
ssh root@192.168.58.20 "systemctl is-active frpc-s3 && tail -n 80 /tmp/frpc-s3.log"
# 2) 香港侧 FRPS + Nginx
ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "systemctl is-active frps-minio nginx"
ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "ss -tlnp | grep -E ':7001 |:8080 |:8443 |:443 '"
# 3) 端到端验证
curl -sS -o /dev/null -w "3d:%{http_code} ssl=%{ssl_verify_result}\n" https://3d.yydd.ai
curl -sS -o /dev/null -w "s3:%{http_code} ssl=%{ssl_verify_result}\n" https://s3.yydd.ai
```
### 常见问题
| 问题 | 可能原因 | 解决方案 |
|-----|---------|---------|
| 3d.yydd.ai 502/超时 | 香港 `frps-minio` 未运行或端口冲突 | `systemctl status frps-minio`,排查 `7500` 占用并重启 |
| s3.yydd.ai 不可用 | `frpc-s3` 未连接香港 | 检查 `/tmp/frpc-s3.log` 是否 `login success` + `start proxy success` |
| HTTPS 证书报错 | Nginx 证书未覆盖双域名 | 检查 `/etc/letsencrypt/live/s3.yydd.ai/fullchain.pem` SAN |
| 访问慢/抖动 | 跨境链路波动 | 观察 `frpc-s3` 重连频率,必要时启用备用线路 |
### 重启流程(systemd)
```bash
# 生产:重启香港主链路 frpc
ssh root@192.168.58.20 "systemctl restart frpc-s3 && systemctl status frpc-s3 --no-pager -l | sed -n '1,60p'"
# 生产:启用南京备用链路(仅回切时)
ssh root@192.168.58.20 "systemctl enable --now frpc && systemctl status frpc --no-pager -l | sed -n '1,60p'"
# 香港:重启 FRPS
ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "systemctl restart frps-minio && systemctl status frps-minio --no-pager -l | sed -n '1,60p'"
# 香港:重载 Nginx(改证书/站点后)
ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6 "nginx -t && systemctl reload nginx"
```
---
## 🔐 服务器凭据与连接方式
| 服务器 | IP | 推荐 SSH 方式 | 备注 |
|-------|-----|-------------|------|
| 生产服务器 | 192.168.58.20 | `ssh prod` | 主服务节点 |
| 香港入口 | 103.118.245.6 | `ssh -i C:/Users/54972/.ssh/id_rsa_103.118.245.6 root@103.118.245.6` | 已切换为新密钥 |
| 南京 FRP | 180.97.221.140 | `plink -ssh root@180.97.221.140 -pw "密码"` | 备用链路 |
| 构建服务器 | 192.168.42.134 | `ssh build` | 镜像构建 |
> 详细密钥清单与服务器说明见 `CLAUDE.md`。
---
## 📝 变更历史
| 日期 | 变更内容 | 操作人 |
|-----|---------|-------|
| 2026-02-23 | 创建文档,记录初始网络架构 | Claude |
| 2026-03-04 | 3d 主链路切到香港,证书覆盖 3d+s3,文档改为 systemd 运维模型 | Codex |

View File

@ -0,0 +1,63 @@
import {
buildValidateDownloadUrl,
validateAndTriggerDownload,
} from '@/lib/client-download'
describe('client-download', () => {
beforeEach(() => {
jest.restoreAllMocks()
document.body.innerHTML = ''
})
test('buildValidateDownloadUrl 应在原 URL 上追加 validate=1', () => {
const url = '/api/materials/download?id=abc&type=all'
const result = buildValidateDownloadUrl(url)
expect(result).toBe('/api/materials/download?id=abc&type=all&validate=1')
})
test('validate 成功后应触发直连下载', async () => {
jest.useFakeTimers()
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
})
global.fetch = fetchMock as any
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
await validateAndTriggerDownload('/api/materials/download?id=mid-1')
expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/download?id=mid-1&validate=1',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
})
)
expect(clickSpy).toHaveBeenCalledTimes(1)
jest.runAllTimers()
jest.useRealTimers()
})
test('validate 失败时不应触发下载,并抛出后端错误', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
json: async () => ({ code: 'FORBIDDEN', error: '无权限下载该素材' }),
})
global.fetch = fetchMock as any
const clickSpy = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
await expect(
validateAndTriggerDownload('/api/materials/download?id=mid-2')
).rejects.toMatchObject({ code: 'FORBIDDEN' })
expect(clickSpy).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,18 @@
import { normalizeDisplayImages } from '../material-cleanup'
describe('displayImages 清理兼容性测试', () => {
test('支持 String[] 格式', () => {
const result = normalizeDisplayImages([' https://a.com/1.webp ', '', 'https://a.com/2.webp'])
expect(result).toEqual([' https://a.com/1.webp ', 'https://a.com/2.webp'])
})
test('支持 JSON 字符串格式', () => {
const result = normalizeDisplayImages('["https://a.com/1.webp","https://a.com/2.webp"]')
expect(result).toEqual(['https://a.com/1.webp', 'https://a.com/2.webp'])
})
test('支持单个 URL 字符串格式', () => {
const result = normalizeDisplayImages('https://a.com/only.webp')
expect(result).toEqual(['https://a.com/only.webp'])
})
})

View File

@ -0,0 +1,36 @@
/**
* 标准模型清理回归测试
*
* 目的:防止素材永久删除时遗漏已软删除 SKU 的文件。
*/
describe('标准模型清理覆盖测试', () => {
test('collectMaterialFiles 查询标准模型 SKU 时不应过滤 deletedAt', async () => {
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, '../material-cleanup.ts')
const content = fs.readFileSync(filePath, 'utf-8')
const standardModelBlockMatch = content.match(/if \(materialType === '标准模型'\)[\s\S]*?relatedRecords\.assets = whiteBgAssets\.length;/)
expect(standardModelBlockMatch).not.toBeNull()
if (!standardModelBlockMatch) {
throw new Error('未找到标准模型清理代码块')
}
const standardModelBlock = standardModelBlockMatch[0]
expect(standardModelBlock).toContain('materialId')
expect(standardModelBlock).not.toContain('deletedAt: null')
})
test('白底图资产清理应同时覆盖旧键与SKU级新键', async () => {
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, '../material-cleanup.ts')
const content = fs.readFileSync(filePath, 'utf-8')
expect(content).toContain("assetType: 'standard-model-white-bg-zip'")
expect(content).toContain("assetType: { startsWith: 'standard-model-white-bg-zip:' }")
})
})

View File

@ -0,0 +1,40 @@
/**
* 任务-页面删除清理回归测试
*
* 目的:确保任务-页面类型也会进入 MaterialAsset 清理链路,
* 避免多张预览图的 S3 文件在永久删除后残留。
*/
describe('任务-页面清理覆盖测试', () => {
test('collectMaterialFiles 的任务类型列表应包含任务-页面', async () => {
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, '../material-cleanup.ts')
const content = fs.readFileSync(filePath, 'utf-8')
const firstTaskTypeDecl = content.match(/const taskMaterialTypes = \[[^\]]+\];/)
expect(firstTaskTypeDecl).not.toBeNull()
if (!firstTaskTypeDecl) {
throw new Error('未找到 collectMaterialFiles 的任务类型列表')
}
expect(firstTaskTypeDecl[0]).toContain("'任务-页面'")
})
test('deleteMaterialRelatedRecords 的任务类型列表应包含任务-页面', async () => {
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, '../material-cleanup.ts')
const content = fs.readFileSync(filePath, 'utf-8')
const allTaskTypeDecls = content.match(/const taskMaterialTypes = \[[^\]]+\];/g)
expect(allTaskTypeDecls).not.toBeNull()
if (!allTaskTypeDecls || allTaskTypeDecls.length < 2) {
throw new Error('未找到 deleteMaterialRelatedRecords 的任务类型列表')
}
expect(allTaskTypeDecls[1]).toContain("'任务-页面'")
})
})

View File

@ -0,0 +1,62 @@
import {
STANDARD_MODEL_WHITE_BG_VIEWS,
getMissingWhiteBackgroundViews,
hasCompleteWhiteBackgroundSet,
getWhiteBackgroundFieldName,
hasReachedStandardModelSkuLimit,
} from '@/lib/standard-model-white-background'
describe('standard-model-white-background helpers', () => {
const createMockFile = (name: string): File => ({ name } as unknown as File)
test('returns missing views when not all six images are provided', () => {
const files = {
leftTop: createMockFile('left-top.jpg'),
rightTop: createMockFile('right-top.jpg'),
front: createMockFile('front.jpg'),
left: null,
right: null,
back: null,
}
const missing = getMissingWhiteBackgroundViews(files)
expect(missing).toEqual(['left', 'right', 'back'])
})
test('recognizes complete white background image set', () => {
const files = {
leftTop: createMockFile('left-top.jpg'),
rightTop: createMockFile('right-top.jpg'),
front: createMockFile('front.jpg'),
left: createMockFile('left.jpg'),
right: createMockFile('right.jpg'),
back: createMockFile('back.jpg'),
}
expect(hasCompleteWhiteBackgroundSet(files)).toBe(true)
expect(getMissingWhiteBackgroundViews(files)).toEqual([])
})
test('exports six canonical white background views', () => {
expect(STANDARD_MODEL_WHITE_BG_VIEWS.map((item) => item.key)).toEqual([
'leftTop',
'rightTop',
'front',
'left',
'right',
'back',
])
})
test('builds backend field names from view key', () => {
expect(getWhiteBackgroundFieldName('leftTop')).toBe('whiteBgLeftTop')
expect(getWhiteBackgroundFieldName('front')).toBe('whiteBgFront')
})
test('enforces single SKU limit for standard model', () => {
expect(hasReachedStandardModelSkuLimit(0)).toBe(false)
expect(hasReachedStandardModelSkuLimit(1)).toBe(true)
expect(hasReachedStandardModelSkuLimit(3)).toBe(true)
})
})

577
lib/admin-material-stats.ts Normal file
View File

@ -0,0 +1,577 @@
import { prisma } from '@/lib/prisma'
export interface MaterialStatsFilters {
startDate?: string | null
endDate?: string | null
departmentKey?: string | null
scopeDepartmentName?: string | null
}
export interface MaterialUserUploadStat {
userId: string
username: string
realName: string | null
departmentName: string
departmentKey: string
uploadCount: number
percentage?: number
}
export interface MaterialDepartmentUploadStat {
departmentKey: string
departmentId: string | null
departmentName: string
uploadCount: number
userCount: number
percentage: number
users: MaterialUserUploadStat[]
}
export interface MaterialTrendPoint {
bucket: string
count: number
}
export interface MaterialTypeUploadStat {
materialType: string
uploadCount: number
percentage: number
}
export interface MaterialTypeDistributionStat {
materialType: string
uploadCount: number
percentage: number
users: MaterialUserUploadStat[]
}
export interface MaterialDistributionSection<TItem> {
granularity: 'month' | 'day'
period: string
label: string
totalUploads: number
items: TItem[]
}
export interface MaterialUploadStatsResult {
filters: {
startDate: string | null
endDate: string | null
departmentKey: string | null
}
summary: {
totalUploads: number
totalDepartments: number
totalUsers: number
}
departments: MaterialDepartmentUploadStat[]
selectedDepartmentName: string
selectedUsers: MaterialUserUploadStat[]
dailyUploads: MaterialTrendPoint[]
monthlyUploads: MaterialTrendPoint[]
materialTypeStats: MaterialTypeUploadStat[]
departmentDistribution: MaterialDistributionSection<MaterialDepartmentUploadStat>
materialTypeDistribution: MaterialDistributionSection<MaterialTypeDistributionStat>
}
interface UserWithDepartment {
id: string
username: string
realName: string | null
department: string
departmentId: string | null
departmentRef: {
id: string
name: string
} | null
}
const UNASSIGNED_DEPARTMENT_KEY = 'unassigned'
const UNASSIGNED_DEPARTMENT_NAME = '未分配部门'
function toDateStart(dateString: string): Date {
return new Date(`${dateString}T00:00:00`)
}
function toDateEnd(dateString: string): Date {
return new Date(`${dateString}T23:59:59.999`)
}
function normalizeValue(value: unknown): string {
if (value === null || value === undefined) return ''
return String(value).trim()
}
function resolveDepartment(
user: UserWithDepartment,
departmentById: Map<string, { id: string; name: string }>,
departmentByName: Map<string, { id: string; name: string }>
): { departmentKey: string; departmentId: string | null; departmentName: string } {
if (user.departmentRef?.id && user.departmentRef?.name) {
return {
departmentKey: `dept:${user.departmentRef.id}`,
departmentId: user.departmentRef.id,
departmentName: user.departmentRef.name,
}
}
if (user.departmentId) {
const matchedDepartment = departmentById.get(user.departmentId)
if (matchedDepartment) {
return {
departmentKey: `dept:${matchedDepartment.id}`,
departmentId: matchedDepartment.id,
departmentName: matchedDepartment.name,
}
}
}
const departmentNameFromUser = normalizeValue(user.department)
if (departmentNameFromUser) {
const matchedDepartment = departmentByName.get(departmentNameFromUser)
if (matchedDepartment) {
return {
departmentKey: `dept:${matchedDepartment.id}`,
departmentId: matchedDepartment.id,
departmentName: matchedDepartment.name,
}
}
return {
departmentKey: `name:${departmentNameFromUser}`,
departmentId: null,
departmentName: departmentNameFromUser,
}
}
return {
departmentKey: UNASSIGNED_DEPARTMENT_KEY,
departmentId: null,
departmentName: UNASSIGNED_DEPARTMENT_NAME,
}
}
function toBucketKey(date: Date, granularity: 'month' | 'day'): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
if (granularity === 'month') {
return `${year}-${month}`
}
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildDistributionLabel(startDate?: string | null, endDate?: string | null): string {
if (startDate && endDate) {
return `${startDate} 至 ${endDate}`
}
if (startDate) {
return `${startDate} 至今`
}
if (endDate) {
return `截至 ${endDate}`
}
return '全部时间'
}
function resolveSelectedDepartmentName(
departmentKey: string | null | undefined,
selectedDepartmentName: string | null | undefined,
defaultName: string,
departmentById: Map<string, { id: string; name: string }>
): string {
if (!departmentKey) {
return defaultName
}
if (selectedDepartmentName) {
return selectedDepartmentName
}
if (departmentKey === UNASSIGNED_DEPARTMENT_KEY) {
return UNASSIGNED_DEPARTMENT_NAME
}
if (departmentKey.startsWith('name:')) {
const name = departmentKey.slice('name:'.length).trim()
return name || defaultName
}
if (departmentKey.startsWith('dept:')) {
const departmentId = departmentKey.slice('dept:'.length).trim()
const department = departmentId ? departmentById.get(departmentId) : null
return department?.name || defaultName
}
return defaultName
}
export async function getMaterialUploadStats(
filters: MaterialStatsFilters
): Promise<MaterialUploadStatsResult> {
const createdAtFilter: { gte?: Date; lte?: Date } = {}
if (filters.startDate) {
createdAtFilter.gte = toDateStart(filters.startDate)
}
if (filters.endDate) {
createdAtFilter.lte = toDateEnd(filters.endDate)
}
const materialWhere: { deletedAt: null; createdAt?: { gte?: Date; lte?: Date } } = {
deletedAt: null,
}
if (createdAtFilter.gte || createdAtFilter.lte) {
materialWhere.createdAt = createdAtFilter
}
const groupedUploads = await prisma.material.groupBy({
by: ['userId'],
_count: { id: true },
where: materialWhere,
})
const userIds = groupedUploads.map((item) => item.userId)
const [users, departments] = await Promise.all([
userIds.length
? prisma.user.findMany({
where: { id: { in: userIds } },
select: {
id: true,
username: true,
realName: true,
department: true,
departmentId: true,
departmentRef: {
select: {
id: true,
name: true,
},
},
},
})
: Promise.resolve([]),
prisma.department.findMany({
select: {
id: true,
name: true,
},
}),
])
const userMap = new Map(users.map((user) => [user.id, user]))
const departmentById = new Map(departments.map((department) => [department.id, department]))
const departmentByName = new Map(departments.map((department) => [department.name, department]))
const resolvedDepartmentByUserId = new Map<string, { departmentKey: string; departmentId: string | null; departmentName: string }>()
for (const user of users) {
resolvedDepartmentByUserId.set(user.id, resolveDepartment(user, departmentById, departmentByName))
}
const scopeDepartmentName = normalizeValue(filters.scopeDepartmentName)
const allowedUserIds = new Set<string>()
const departmentStatsMap = new Map<string, MaterialDepartmentUploadStat>()
for (const uploadGroup of groupedUploads) {
const user = userMap.get(uploadGroup.userId)
if (!user) {
continue
}
const resolvedDepartment = resolvedDepartmentByUserId.get(user.id)
if (!resolvedDepartment) {
continue
}
if (scopeDepartmentName && resolvedDepartment.departmentName !== scopeDepartmentName) {
continue
}
allowedUserIds.add(user.id)
if (!departmentStatsMap.has(resolvedDepartment.departmentKey)) {
departmentStatsMap.set(resolvedDepartment.departmentKey, {
departmentKey: resolvedDepartment.departmentKey,
departmentId: resolvedDepartment.departmentId,
departmentName: resolvedDepartment.departmentName,
uploadCount: 0,
userCount: 0,
percentage: 0,
users: [],
})
}
const uploadCount = uploadGroup._count.id
const departmentStat = departmentStatsMap.get(resolvedDepartment.departmentKey)
if (!departmentStat) {
continue
}
departmentStat.uploadCount += uploadCount
departmentStat.users.push({
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount,
})
}
const departmentStats = Array.from(departmentStatsMap.values())
for (const departmentStat of departmentStats) {
departmentStat.users.sort((a, b) => b.uploadCount - a.uploadCount)
departmentStat.userCount = departmentStat.users.length
}
departmentStats.sort((a, b) => b.uploadCount - a.uploadCount)
const totalUploads = departmentStats.reduce((sum, departmentStat) => sum + departmentStat.uploadCount, 0)
for (const departmentStat of departmentStats) {
departmentStat.percentage = totalUploads > 0
? Number(((departmentStat.uploadCount / totalUploads) * 100).toFixed(2))
: 0
for (const user of departmentStat.users) {
user.percentage = departmentStat.uploadCount > 0
? Number(((user.uploadCount / departmentStat.uploadCount) * 100).toFixed(2))
: 0
}
}
const hasDepartmentFilter = Boolean(normalizeValue(filters.departmentKey))
const selectedDepartment = hasDepartmentFilter
? departmentStats.find((department) => department.departmentKey === filters.departmentKey)
: null
const allUsers = departmentStats
.flatMap((department) => department.users)
.sort((a, b) => b.uploadCount - a.uploadCount)
const selectedUsers = hasDepartmentFilter
? (selectedDepartment ? selectedDepartment.users : [])
: allUsers
const selectedUserIds = Array.from(new Set(selectedUsers.map((user) => user.userId)))
const materialSnapshots = selectedUserIds.length
? await prisma.material.findMany({
where: {
deletedAt: null,
userId: { in: selectedUserIds },
...(materialWhere.createdAt ? { createdAt: materialWhere.createdAt } : {}),
},
select: {
createdAt: true,
materialType: true,
userId: true,
},
})
: []
const filteredSnapshots = materialSnapshots
const selectedTotalUploads = selectedUsers.reduce((sum, user) => sum + user.uploadCount, 0)
const dailyMap = new Map<string, number>()
const monthlyMap = new Map<string, number>()
const materialTypeMap = new Map<string, number>()
const departmentDistributionMap = new Map<string, {
departmentKey: string
departmentId: string | null
departmentName: string
uploadCount: number
users: Map<string, MaterialUserUploadStat>
}>()
const materialTypeDistributionMap = new Map<string, {
materialType: string
uploadCount: number
users: Map<string, MaterialUserUploadStat>
}>()
for (const item of filteredSnapshots) {
const createdAt = new Date(item.createdAt)
const dayKey = toBucketKey(createdAt, 'day')
const monthKey = toBucketKey(createdAt, 'month')
const materialTypeKey = normalizeValue(item.materialType) || '未知类型'
dailyMap.set(dayKey, (dailyMap.get(dayKey) || 0) + 1)
monthlyMap.set(monthKey, (monthlyMap.get(monthKey) || 0) + 1)
materialTypeMap.set(materialTypeKey, (materialTypeMap.get(materialTypeKey) || 0) + 1)
const user = userMap.get(item.userId)
const resolvedDepartment = resolvedDepartmentByUserId.get(item.userId)
if (!user || !resolvedDepartment) {
continue
}
if (!departmentDistributionMap.has(resolvedDepartment.departmentKey)) {
departmentDistributionMap.set(resolvedDepartment.departmentKey, {
departmentKey: resolvedDepartment.departmentKey,
departmentId: resolvedDepartment.departmentId,
departmentName: resolvedDepartment.departmentName,
uploadCount: 0,
users: new Map(),
})
}
const departmentItem = departmentDistributionMap.get(resolvedDepartment.departmentKey)
if (!departmentItem) {
continue
}
departmentItem.uploadCount += 1
const existingDepartmentUser = departmentItem.users.get(user.id)
if (existingDepartmentUser) {
existingDepartmentUser.uploadCount += 1
} else {
departmentItem.users.set(user.id, {
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount: 1,
})
}
if (!materialTypeDistributionMap.has(materialTypeKey)) {
materialTypeDistributionMap.set(materialTypeKey, {
materialType: materialTypeKey,
uploadCount: 0,
users: new Map(),
})
}
const materialTypeItem = materialTypeDistributionMap.get(materialTypeKey)
if (!materialTypeItem) {
continue
}
materialTypeItem.uploadCount += 1
const existingMaterialTypeUser = materialTypeItem.users.get(user.id)
if (existingMaterialTypeUser) {
existingMaterialTypeUser.uploadCount += 1
} else {
materialTypeItem.users.set(user.id, {
userId: user.id,
username: user.username,
realName: user.realName,
departmentName: resolvedDepartment.departmentName,
departmentKey: resolvedDepartment.departmentKey,
uploadCount: 1,
})
}
}
const dailyUploads = Array.from(dailyMap.entries())
.map(([bucket, count]) => ({ bucket, count }))
.sort((a, b) => a.bucket.localeCompare(b.bucket))
const monthlyUploads = Array.from(monthlyMap.entries())
.map(([bucket, count]) => ({ bucket, count }))
.sort((a, b) => a.bucket.localeCompare(b.bucket))
const materialTypeStats = Array.from(materialTypeMap.entries())
.map(([materialType, uploadCount]) => ({
materialType,
uploadCount,
percentage: selectedTotalUploads > 0 ? Number(((uploadCount / selectedTotalUploads) * 100).toFixed(2)) : 0,
}))
.sort((a, b) => b.uploadCount - a.uploadCount)
const departmentDistributionItemsBase = Array.from(departmentDistributionMap.values())
const departmentDistributionTotal = departmentDistributionItemsBase.reduce((sum, item) => sum + item.uploadCount, 0)
const departmentDistributionItems: MaterialDepartmentUploadStat[] = departmentDistributionItemsBase
.map((item) => {
const users = Array.from(item.users.values())
.sort((a, b) => b.uploadCount - a.uploadCount)
.map((user) => ({
...user,
percentage: item.uploadCount > 0
? Number(((user.uploadCount / item.uploadCount) * 100).toFixed(2))
: 0,
}))
return {
departmentKey: item.departmentKey,
departmentId: item.departmentId,
departmentName: item.departmentName,
uploadCount: item.uploadCount,
userCount: users.length,
percentage: departmentDistributionTotal > 0
? Number(((item.uploadCount / departmentDistributionTotal) * 100).toFixed(2))
: 0,
users,
}
})
.sort((a, b) => b.uploadCount - a.uploadCount)
const materialTypeDistributionItemsBase = Array.from(materialTypeDistributionMap.values())
const materialTypeDistributionTotal = materialTypeDistributionItemsBase.reduce((sum, item) => sum + item.uploadCount, 0)
const materialTypeDistributionItems: MaterialTypeDistributionStat[] = materialTypeDistributionItemsBase
.map((item) => {
const users = Array.from(item.users.values())
.sort((a, b) => b.uploadCount - a.uploadCount)
.map((user) => ({
...user,
percentage: item.uploadCount > 0
? Number(((user.uploadCount / item.uploadCount) * 100).toFixed(2))
: 0,
}))
return {
materialType: item.materialType,
uploadCount: item.uploadCount,
percentage: materialTypeDistributionTotal > 0
? Number(((item.uploadCount / materialTypeDistributionTotal) * 100).toFixed(2))
: 0,
users,
}
})
.sort((a, b) => b.uploadCount - a.uploadCount)
return {
filters: {
startDate: filters.startDate || null,
endDate: filters.endDate || null,
departmentKey: filters.departmentKey || null,
},
summary: {
totalUploads: hasDepartmentFilter ? selectedTotalUploads : totalUploads,
totalDepartments: hasDepartmentFilter ? (selectedDepartment ? 1 : 0) : departmentStats.length,
totalUsers: selectedUsers.length,
},
departments: departmentStats,
selectedDepartmentName: resolveSelectedDepartmentName(
filters.departmentKey || null,
selectedDepartment?.departmentName || null,
scopeDepartmentName || '全部部门',
departmentById
),
selectedUsers,
dailyUploads,
monthlyUploads,
materialTypeStats,
departmentDistribution: {
granularity: 'month',
period: '',
label: buildDistributionLabel(filters.startDate, filters.endDate),
totalUploads: departmentDistributionTotal,
items: departmentDistributionItems,
},
materialTypeDistribution: {
granularity: 'month',
period: '',
label: buildDistributionLabel(filters.startDate, filters.endDate),
totalUploads: materialTypeDistributionTotal,
items: materialTypeDistributionItems,
},
}
}

63
lib/client-download.ts Normal file
View File

@ -0,0 +1,63 @@
/**
* 前端下载工具:
* 1) 先调用 validate=1 做权限/文件可用性校验
* 2) 再通过浏览器原生附件下载,避免 blob: 协议兼容性问题
*/
export interface DownloadValidationError {
code?: string
error?: string
details?: string
}
/**
* 为下载 URL 追加校验参数
*/
export function buildValidateDownloadUrl(downloadUrl: string): string {
const url = new URL(downloadUrl, window.location.origin)
url.searchParams.set('validate', '1')
return `${url.pathname}${url.search}${url.hash}`
}
/**
* 触发浏览器原生下载(使用服务端 Content-Disposition)
*/
export function triggerAttachmentDownload(downloadUrl: string): void {
const link = document.createElement('a')
link.href = downloadUrl
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 延迟移除,确保浏览器已经接管下载任务
setTimeout(() => {
link.remove()
}, 500)
}
/**
* 先校验再下载,失败时抛出后端错误对象
*/
export async function validateAndTriggerDownload(downloadUrl: string): Promise<void> {
const validateUrl = buildValidateDownloadUrl(downloadUrl)
const response = await fetch(validateUrl, {
method: 'GET',
cache: 'no-store',
credentials: 'include',
})
if (!response.ok) {
const fallbackError: DownloadValidationError = {
code: 'UNKNOWN_ERROR',
error: '下载失败',
}
let errorData: DownloadValidationError = fallbackError
try {
errorData = await response.json()
} catch {}
throw errorData
}
triggerAttachmentDownload(downloadUrl)
}

View File

@ -1,161 +1,210 @@
/**
* 统一的素材文件清理逻辑
* 根据不同的素材类型,收集所有需要删除的文件
* 根据不同的素材类型,收集所有需要删除的文件和关联记录。
*/
import { prisma } from './prisma';
import path from 'path';
import path from 'path'
import { prisma } from './prisma'
export interface MaterialFileInfo {
fileUrls: string[];
directories: string[];
fileUrls: string[]
directories: string[]
relatedRecords?: {
assets?: number; // MaterialAsset 记录数
skus?: number; // MaterialSku 记录数
};
assets?: number
skus?: number
}
}
export function normalizeDisplayImages(displayImages: unknown): string[] {
if (!displayImages) {
return []
}
if (Array.isArray(displayImages)) {
return displayImages.filter(
(img): img is string => typeof img === 'string' && img.trim().length > 0,
)
}
if (typeof displayImages === 'string') {
try {
const parsed = JSON.parse(displayImages)
if (Array.isArray(parsed)) {
return parsed.filter(
(img): img is string => typeof img === 'string' && img.trim().length > 0,
)
}
} catch {
// 旧数据可能直接存成单个 URL 字符串,这里降级兼容。
}
return displayImages.trim() ? [displayImages] : []
}
return []
}
/**
* 收集素材的所有文件路径(根据素材类型)
* @param materialId 素材ID
* @param material 素材对象(可选,如果不提供则从数据库查询)
* @returns 文件信息
* 收集素材的所有文件路径
* @param materialId 素材 ID
* @param material 素材对象,可选;不传时会自动查询
*/
export async function collectMaterialFiles(
materialId: string,
material?: any
material?: any,
): Promise<MaterialFileInfo> {
const fileUrls: string[] = [];
const directories: string[] = [];
const relatedRecords: { assets?: number; skus?: number } = {};
const fileUrls: string[] = []
const directories: string[] = []
const relatedRecords: { assets?: number; skus?: number } = {}
// 如果未提供素材对象,从数据库查询
if (!material) {
material = await prisma.material.findUnique({
where: { id: materialId },
});
})
}
if (!material) {
return { fileUrls: [], directories: [], relatedRecords };
return { fileUrls: [], directories: [], relatedRecords }
}
const materialType = (material as any).materialType || '';
const materialType = String((material as any).materialType || '')
// 1. 通用字段(所有素材类型都可能有)
if ((material as any).zipFilePath) {
fileUrls.push((material as any).zipFilePath);
fileUrls.push((material as any).zipFilePath)
}
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== (material as any).zipFilePath) {
fileUrls.push((material as any).videoSourceUrl);
if (
(material as any).videoSourceUrl &&
(material as any).videoSourceUrl !== (material as any).zipFilePath
) {
fileUrls.push((material as any).videoSourceUrl)
}
if ((material as any).previewUrl) {
fileUrls.push((material as any).previewUrl);
fileUrls.push((material as any).previewUrl)
}
if ((material as any).videoPreview540Url) {
fileUrls.push((material as any).videoPreview540Url);
fileUrls.push((material as any).videoPreview540Url)
}
if ((material as any).videoPreviewClipUrl) {
fileUrls.push((material as any).videoPreviewClipUrl);
fileUrls.push((material as any).videoPreviewClipUrl)
}
if ((material as any).videoCoverUrl) {
fileUrls.push((material as any).videoCoverUrl);
fileUrls.push((material as any).videoCoverUrl)
}
if (Array.isArray((material as any).displayImages) && (material as any).displayImages.length > 0) {
fileUrls.push(...(material as any).displayImages.filter((img: string) => img && img.trim()));
}
fileUrls.push(...normalizeDisplayImages((material as any).displayImages))
// 2. 任务类型素材特殊处理(任务-详情、任务-3D原图、任务-AI原图、任务-实拍原图、任务-视频截图)
const taskMaterialTypes = ['任务-详情', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
// 任务类素材都可能存在 MaterialAsset 记录。
const taskMaterialTypes = ['任务-详情', '任务-页面', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
if (taskMaterialTypes.includes(materialType)) {
// 查询资产文件
const assets = await prisma.materialAsset.findMany({
where: { materialId },
select: { fileUrl: true },
});
relatedRecords.assets = assets.length;
})
relatedRecords.assets = assets.length
for (const asset of assets) {
if (asset.fileUrl) {
fileUrls.push(asset.fileUrl);
fileUrls.push(asset.fileUrl)
}
}
// 任务-详情:PSD 文件目录
if (materialType === '任务-详情') {
const psdDir = path.join(process.cwd(), 'uploads', 'product-package', materialId);
directories.push(psdDir);
if (materialType === '任务-详情' || materialType === '任务-页面') {
directories.push(path.join(process.cwd(), 'uploads', 'product-package', materialId))
}
}
// 3. 标准模型特殊处理(SKU 文件)
if (materialType === '标准模型') {
const skus = await prisma.materialSku.findMany({
where: {
where: {
materialId,
deletedAt: null, // 只查询未删除的 SKU
} as any,
select: {
},
select: {
filePath: true,
previewUrl: true,
},
});
relatedRecords.skus = skus.length;
})
relatedRecords.skus = skus.length
for (const sku of skus) {
if (sku.filePath) {
fileUrls.push(sku.filePath);
fileUrls.push(sku.filePath)
}
if (sku.previewUrl) {
fileUrls.push(sku.previewUrl);
fileUrls.push(sku.previewUrl)
}
}
// 兼容旧版整素材白底图 ZIP 键,以及新版 SKU 级白底图 ZIP 键。
const whiteBgAssets = await prisma.materialAsset.findMany({
where: {
materialId,
OR: [
{ assetType: 'standard-model-white-bg-zip' },
{ assetType: { startsWith: 'standard-model-white-bg-zip:' } },
],
},
select: { fileUrl: true },
})
relatedRecords.assets = whiteBgAssets.length;
for (const asset of whiteBgAssets) {
if (asset.fileUrl) {
fileUrls.push(asset.fileUrl)
}
}
directories.push(path.join(process.cwd(), 'uploads', 'skus', materialId, 'white-background'))
}
// 去重
const uniqueFileUrls = Array.from(new Set(fileUrls.filter(f => f && f.trim())));
const uniqueDirectories = Array.from(new Set(directories.filter(d => d && d.trim())));
return {
fileUrls: uniqueFileUrls,
directories: uniqueDirectories,
fileUrls: Array.from(new Set(fileUrls.filter((fileUrl) => fileUrl && fileUrl.trim()))),
directories: Array.from(new Set(directories.filter((dir) => dir && dir.trim()))),
relatedRecords,
};
}
}
/**
* 删除素材的关联记录(MaterialAsset, MaterialSku 等)
* @param materialId 素材ID
* 删除素材关联记录
* @param materialId 素材 ID
* @param materialType 素材类型
*/
export async function deleteMaterialRelatedRecords(
materialId: string,
materialType: string
materialType: string,
): Promise<{ deletedAssets?: number; deletedSkus?: number }> {
const result: { 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 },
});
result.deletedAssets = deleteResult.count;
})
result.deletedAssets = deleteResult.count
}
// 标准模型:删除 SKU 记录(外键约束会自动处理,但显式删除更清晰)
if (materialType === '标准模型') {
const deleteResult = await prisma.materialSku.deleteMany({
const deleteSkuResult = await prisma.materialSku.deleteMany({
where: { materialId },
});
result.deletedSkus = deleteResult.count;
})
result.deletedSkus = deleteSkuResult.count
const deleteWhiteBgResult = await prisma.materialAsset.deleteMany({
where: {
materialId,
OR: [
{ assetType: 'standard-model-white-bg-zip' },
{ assetType: { startsWith: 'standard-model-white-bg-zip:' } },
],
},
})
result.deletedAssets = (result.deletedAssets || 0) + deleteWhiteBgResult.count
}
return result;
return result
}

View File

@ -48,6 +48,10 @@ export async function ensureMaterialDownloadLogsTable() {
`CREATE INDEX IF NOT EXISTS "material_download_logs_downloadedAt_idx" ON "material_download_logs" ("downloadedAt" DESC)`
)
// 审计日志保留策略:删除素材时保留下载日志,不允许 materialId 外键级联删除
await executeSafely('ALTER TABLE "material_download_logs" DROP CONSTRAINT IF EXISTS "material_download_logs_materialId_fkey"')
await executeSafely('ALTER TABLE "material_download_logs" DROP CONSTRAINT IF EXISTS "MaterialDownloadLog_materialId_fkey"')
downloadLogsTableEnsured = true
console.log('[DownloadLog] material_download_logs table ensured.')
}

View File

@ -45,22 +45,12 @@ async function initializeMaterialLogTable() {
await executeRawSQL(
'CREATE INDEX IF NOT EXISTS "material_logs_timestamp_idx" ON "material_logs" ("timestamp")'
);
// 添加外键约束
await executeRawSQL(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'material_logs_materialId_fkey'
) THEN
ALTER TABLE "material_logs"
ADD CONSTRAINT "material_logs_materialId_fkey"
FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END
$$;
`);
// 审计日志保留策略:删除素材时保留日志记录,不允许 materialId 外键级联删除
await executeRawSQL('ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"');
await executeRawSQL('ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "MaterialLog_materialId_fkey"');
// userId 仍保留外键约束,用于保证操作人数据完整性
await executeRawSQL(`
DO $$
BEGIN

View File

@ -0,0 +1,45 @@
export interface StandardModelWhiteBgView {
key: StandardModelWhiteBgKey
label: string
fieldName: string
}
export type StandardModelWhiteBgKey =
| 'leftTop'
| 'rightTop'
| 'front'
| 'left'
| 'right'
| 'back'
export const STANDARD_MODEL_WHITE_BG_VIEWS: StandardModelWhiteBgView[] = [
{ key: 'leftTop', label: '左侧俯视', fieldName: 'whiteBgLeftTop' },
{ key: 'rightTop', label: '右侧俯视', fieldName: 'whiteBgRightTop' },
{ key: 'front', label: '正视图', fieldName: 'whiteBgFront' },
{ key: 'left', label: '左视图', fieldName: 'whiteBgLeft' },
{ key: 'right', label: '右视图', fieldName: 'whiteBgRight' },
{ key: 'back', label: '后视图', fieldName: 'whiteBgBack' },
]
export function getWhiteBackgroundFieldName(key: StandardModelWhiteBgKey): string {
const target = STANDARD_MODEL_WHITE_BG_VIEWS.find((item) => item.key === key)
return target ? target.fieldName : `whiteBg${key}`
}
export function getMissingWhiteBackgroundViews(
files: Partial<Record<StandardModelWhiteBgKey, File | null>>,
): StandardModelWhiteBgKey[] {
return STANDARD_MODEL_WHITE_BG_VIEWS
.map((item) => item.key)
.filter((key) => !files[key])
}
export function hasCompleteWhiteBackgroundSet(
files: Partial<Record<StandardModelWhiteBgKey, File | null>>,
): boolean {
return getMissingWhiteBackgroundViews(files).length === 0
}
export function hasReachedStandardModelSkuLimit(existingSkuCount: number): boolean {
return existingSkuCount >= 1
}

View File

@ -15,6 +15,8 @@
* - H.264 编码:3-4 并发(CPU 占用 60-80%)
*/
export const VIDEO_MATERIAL_TYPES = ['视频', 'VIDEO', '主图视频', '安装视频', '种草视频']
interface QueuedTask {
materialId: string
videoSourceUrl: string
@ -45,6 +47,12 @@ interface MaterialTasks {
reject: (error: Error) => void
}
interface RecoverInterruptedTasksOptions {
requeuePending?: boolean
limit?: number
videoTypes?: string[]
}
class VideoTranscodeQueue {
private queue: QueuedTask[] = []
private processing: Set<string> = new Set()
@ -190,7 +198,6 @@ class VideoTranscodeQueue {
await prisma.videoTranscodeProgress.deleteMany({
where: {
materialId,
status: { in: ['completed', 'failed'] }
}
})
} catch (error) {
@ -205,7 +212,7 @@ class VideoTranscodeQueue {
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount?: number) => Promise<void>
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>
): Promise<void> {
// 如果已经在处理中,直接返回
if (this.processing.has(materialId)) {
@ -260,7 +267,7 @@ class VideoTranscodeQueue {
* 处理队列中的任务
*/
private async process(
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount?: number) => Promise<void>
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>
): Promise<void> {
try {
// 如果已达到最大并发数,等待
@ -293,9 +300,9 @@ class VideoTranscodeQueue {
console.log(`[TranscodeQueue] 🚀 Starting task for material ${task.materialId} (queue: ${this.queue.length} waiting, ${this.processing.size}/${this.maxConcurrent} processing)`)
// 执行任务(传递 retryCount=0 作为初始重试次数)
// 注意:这里不 await,让任务异步执行,队列继续处理下一个任务
taskFn(task.materialId, task.videoSourceUrl, task.needsCoverImage, 0)
// 执行任务(内部自行处理重试逻辑)
// 注意:这里不 await,让任务异步执行,队列继续处理下一个任务
taskFn(task.materialId, task.videoSourceUrl, task.needsCoverImage)
.then(() => {
// 转码成功,清除进度信息
this.clearProgress(task.materialId)
@ -350,7 +357,7 @@ class VideoTranscodeQueue {
* @returns 恢复的任务数量
*/
async recoverOnStartup(
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount?: number) => Promise<void>
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>
): Promise<number> {
console.log('[TranscodeQueue] Starting recovery from database...')
const tasks = await this.loadWaitingTasks()
@ -388,11 +395,12 @@ class VideoTranscodeQueue {
* 更新转码进度
*/
updateProgress(materialId: string, progress: number, stage?: 'preview' | 'clip' | 'cover') {
this.progress.set(materialId, progress)
const normalized = Math.min(100, Math.max(0, progress))
this.progress.set(materialId, normalized)
if (stage) {
this.stages.set(materialId, stage)
// R1修复:异步保存到数据库(不阻塞)
this.saveStageProgress(materialId, stage, 'processing', progress)
const status: 'processing' | 'completed' = normalized >= 100 ? 'completed' : 'processing'
this.saveStageProgress(materialId, stage, status, normalized)
.catch(err => console.error('[TranscodeQueue] Failed to save progress:', err))
}
}
@ -430,110 +438,138 @@ class VideoTranscodeQueue {
* @param cleanupFn 清理残留文件的函数
*/
async recoverInterruptedTasks(
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean, retryCount?: number) => Promise<void>,
cleanupFn: (material: { id: string; videoPreview540Url: string | null; videoPreviewClipUrl: string | null; videoCoverUrl: string | null }) => Promise<void>
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>,
cleanupFn: (material: { id: string; videoPreview540Url: string | null; videoPreviewClipUrl: string | null; videoCoverUrl: string | null }) => Promise<void>,
options: RecoverInterruptedTasksOptions = {}
): Promise<{ recovered: number; requeued: number }> {
const { prisma } = await import('@/lib/prisma')
const prisma = await this.getPrisma()
const { requeuePending = false, limit, videoTypes = VIDEO_MATERIAL_TYPES } = options
const materialTypesFilter = videoTypes.length > 0 ? videoTypes : undefined
let recovered = 0
let requeued = 0
try {
// 1. 查询所有 processing 状态的视频(部署中断时留下的)
const processingWhere: Record<string, unknown> = {
videoProcessingStatus: 'processing',
videoSourceUrl: { not: null },
deletedAt: null,
}
if (materialTypesFilter) {
processingWhere.materialType = { in: materialTypesFilter }
}
const processingMaterials = await prisma.material.findMany({
where: {
videoProcessingStatus: 'processing',
videoSourceUrl: { not: null },
deletedAt: null,
} as any,
where: processingWhere,
select: {
id: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoCoverUrl: true,
videoSourceUrl: true,
} as any,
},
})
console.log(`[TranscodeQueue] Found ${processingMaterials.length} interrupted tasks (processing status)`)
// 2. 清理残留文件并重置状态为 pending
const recoveredPayloads: Array<{ materialId: string; videoSourceUrl: string; needsCoverImage: boolean }> = []
for (const material of processingMaterials) {
const materialId = String(material.id)
const preview540Url = material.videoPreview540Url ? String(material.videoPreview540Url) : null
const previewClipUrl = material.videoPreviewClipUrl ? String(material.videoPreviewClipUrl) : null
const coverUrl = material.videoCoverUrl ? String(material.videoCoverUrl) : null
const sourceUrl = material.videoSourceUrl ? String(material.videoSourceUrl) : ''
const needsCoverImage = !material.videoCoverUrl
try {
// 提取字段以避免类型错误
const materialId = String(material.id)
const preview540Url = material.videoPreview540Url ? String(material.videoPreview540Url) : null
const previewClipUrl = material.videoPreviewClipUrl ? String(material.videoPreviewClipUrl) : null
const coverUrl = material.videoCoverUrl ? String(material.videoCoverUrl) : null
// 清理残留文件(类型转换以匹配 cleanupFn 的期望)
await cleanupFn({
id: materialId,
videoPreview540Url: preview540Url,
videoPreviewClipUrl: previewClipUrl,
videoCoverUrl: coverUrl,
} as any)
// 重置状态为 pending,清除错误信息
})
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'pending',
transcodeLastError: null,
} as any,
},
})
recovered++
console.log(`[TranscodeQueue] Recovered task: ${material.id}`)
if (sourceUrl) {
recoveredPayloads.push({
materialId,
videoSourceUrl: sourceUrl,
needsCoverImage,
})
} else {
console.warn(`[TranscodeQueue] Skipping recovered task ${materialId}: missing videoSourceUrl`)
}
} catch (error) {
console.error(`[TranscodeQueue] Failed to recover task ${material.id}:`, error)
console.error(`[TranscodeQueue] Failed to recover task ${materialId}:`, error)
}
}
// 3. 查询所有 pending 状态的视频,按时间排序
const pendingMaterials = await prisma.material.findMany({
where: {
for (const payload of recoveredPayloads) {
try {
await this.add(payload.materialId, payload.videoSourceUrl, payload.needsCoverImage, taskFn)
} catch (error) {
console.error(`[TranscodeQueue] Failed to requeue recovered task ${payload.materialId}:`, error)
}
}
if (requeuePending) {
const pendingWhere: Record<string, unknown> = {
videoProcessingStatus: 'pending',
videoSourceUrl: { not: null },
deletedAt: null,
} as any,
orderBy: { createdAt: 'asc' }, // 按创建时间排序,保持原始队列顺序
select: {
id: true,
videoSourceUrl: true,
videoCoverUrl: true,
} as any,
})
}
if (materialTypesFilter) {
pendingWhere.materialType = { in: materialTypesFilter }
}
console.log(`[TranscodeQueue] Found ${pendingMaterials.length} pending tasks to requeue`)
const pendingQueryOptions: Record<string, unknown> = {
where: pendingWhere,
orderBy: { createdAt: 'asc' },
select: {
id: true,
videoSourceUrl: true,
videoCoverUrl: true,
},
}
// 4. 按顺序重新入队(但不立即执行,等待队列调度)
for (const material of pendingMaterials) {
try {
// 提取字段以避免类型错误
if (typeof limit === 'number' && limit > 0) {
pendingQueryOptions.take = limit
}
const pendingMaterials = await prisma.material.findMany(pendingQueryOptions)
console.log(`[TranscodeQueue] Found ${pendingMaterials.length} pending tasks to requeue (option enabled)`)
for (const material of pendingMaterials) {
const materialId = String(material.id)
const videoSourceUrl = material.videoSourceUrl ? String(material.videoSourceUrl) : null
const needsCoverImage = !material.videoCoverUrl
if (!videoSourceUrl) {
console.warn(`[TranscodeQueue] Skipping material ${materialId}: no videoSourceUrl`)
const sourceUrl = material.videoSourceUrl ? String(material.videoSourceUrl) : ''
if (!sourceUrl) {
console.warn(`[TranscodeQueue] Skipping pending material ${materialId}: no videoSourceUrl`)
continue
}
// 重新加入队列(队列会自动调度执行)
await this.add(
materialId,
videoSourceUrl,
needsCoverImage,
taskFn
)
requeued++
} catch (error) {
console.error(`[TranscodeQueue] Failed to requeue task ${material.id}:`, error)
try {
await this.add(materialId, sourceUrl, !material.videoCoverUrl, taskFn)
requeued++
} catch (error) {
console.error(`[TranscodeQueue] Failed to requeue pending task ${materialId}:`, error)
}
}
} else {
console.log('[TranscodeQueue] Pending task requeue disabled; skipped scanning pending list')
}
console.log(`[TranscodeQueue] Recovery completed: recovered ${recovered} tasks, requeued ${requeued} tasks`)
console.log(`[TranscodeQueue] Recovery completed: recovered ${recovered} tasks, requeued ${requeued} pending tasks`)
} catch (error) {
console.error('[TranscodeQueue] Recovery failed:', error)
}

View File

@ -3,99 +3,151 @@ import { prisma } from '@/lib/prisma'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { processVideoTranscode } from '@/lib/video-processor'
const MAX_TRANSCODE_RETRIES = 3
class NonRetryableTranscodeError extends Error {
constructor(message: string) {
super(message)
this.name = 'NonRetryableTranscodeError'
}
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function isNonRetryableTranscodeError(error: unknown): boolean {
if (error instanceof NonRetryableTranscodeError) {
return true
}
const code = (error as { code?: string } | undefined)?.code
if (code && code.toUpperCase() === 'ENOENT') {
return true
}
const message = extractErrorMessage(error)
return message.includes('视频文件不存在') || message.includes('ENOENT')
}
/**
* 实际的转码任务处理函数
*
* 注意:retryCount 由队列控制,外部一般不用直接传
* 内部包含最多 3 次重试逻辑,队列外部无需处理 retry。
*/
export async function executeTranscodeTask(
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
retryCount: number = 0
fallbackVideoSourceUrl: string,
needsCoverImage: boolean
): Promise<void> {
const MAX_RETRIES = 3
let attempt = 0
try {
console.log(`[Transcode] Starting task for material ${materialId} (attempt ${retryCount + 1}/${MAX_RETRIES})`)
while (attempt < MAX_TRANSCODE_RETRIES) {
attempt += 1
// 更新状态为处理中
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'processing',
transcodeLastError: null,
} as any,
})
try {
console.log(`[Transcode] Starting task for material ${materialId} (attempt ${attempt}/${MAX_TRANSCODE_RETRIES})`)
// 执行转码
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir: './uploads/transcoded',
materialId,
needsCoverImage,
onProgress: (progress, stage) => {
console.log(`[Transcode] Material ${materialId} - ${stage}: ${progress}%`)
// 更新转码队列的进度信息
transcodeQueue.updateProgress(materialId, progress, stage)
},
})
const latestMaterial = await prisma.material.findUnique({
where: { id: materialId },
select: {
videoSourceUrl: true,
videoCoverUrl: true,
previewUrl: true,
},
})
// 查询当前素材,避免覆盖用户手动上传的预览图
const currentMaterial = await prisma.material.findUnique({
where: { id: materialId },
select: {
previewUrl: true,
} as any,
})
const videoSourceUrl = latestMaterial?.videoSourceUrl || fallbackVideoSourceUrl
if (!videoSourceUrl) {
throw new NonRetryableTranscodeError('视频文件不存在')
}
// 如果用户没有自己上传预览图,而转码生成了封面,则同步一份到 previewUrl
const nextPreviewUrl =
(currentMaterial as any)?.previewUrl && (currentMaterial as any).previewUrl.trim() !== ''
? (currentMaterial as any).previewUrl
: result.coverUrl || null
const coverStillNeeded = needsCoverImage || !latestMaterial?.videoCoverUrl
// 更新数据库
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoCoverUrl: result.coverUrl || undefined,
videoDuration: result.duration,
transcodeLastError: null,
transcodeAttempts: retryCount + 1,
// 同步预览图字段:仅在原来没有 previewUrl 时才使用自动生成的封面
previewUrl: nextPreviewUrl || undefined,
} as any,
})
const fileExists = await validateVideoFile(videoSourceUrl)
if (!fileExists) {
throw new NonRetryableTranscodeError(`视频文件不存在: ${videoSourceUrl}`)
}
console.log(`[Transcode] Task completed for material ${materialId}`)
} catch (error: any) {
const errorMsg = error?.message || String(error)
console.error(`[Transcode] Task failed for material ${materialId}:`, errorMsg)
// 如果还有重试次数,重新入队
if (retryCount < MAX_RETRIES - 1) {
console.log(`[Transcode] Retrying material ${materialId} (${retryCount + 1}/${MAX_RETRIES})`)
await transcodeQueue.add(
materialId,
videoSourceUrl,
needsCoverImage,
(id, url, needsCover) => executeTranscodeTask(id, url, needsCover, retryCount + 1)
)
} else {
// 重试次数用尽,标记为失败
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'failed',
transcodeLastError: errorMsg.substring(0, 500),
transcodeAttempts: retryCount + 1,
} as any,
videoProcessingStatus: 'processing',
transcodeLastError: null,
},
})
console.error(`[Transcode] Task failed permanently for material ${materialId} after ${MAX_RETRIES} attempts`)
const result = await processVideoTranscode({
inputPath: videoSourceUrl,
outputDir: './uploads/transcoded',
materialId,
needsCoverImage: coverStillNeeded,
onProgress: (progress, stage) => {
console.log(`[Transcode] Material ${materialId} - ${stage}: ${progress}%`)
transcodeQueue.updateProgress(materialId, progress, stage)
},
})
const nextPreviewUrl = latestMaterial?.previewUrl && latestMaterial.previewUrl.trim() !== ''
? latestMaterial.previewUrl
: result.coverUrl || null
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'done',
videoPreview540Url: result.preview540Url,
videoPreviewClipUrl: result.clipUrl,
videoCoverUrl: result.coverUrl || undefined,
videoDuration: result.duration,
transcodeLastError: null,
transcodeAttempts: attempt,
previewUrl: nextPreviewUrl || undefined,
},
})
console.log(`[Transcode] Task completed for material ${materialId}`)
return
} catch (error) {
const errorMsg = extractErrorMessage(error)
console.error(`[Transcode] Task attempt ${attempt} failed for material ${materialId}:`, errorMsg)
const shouldRetry = attempt < MAX_TRANSCODE_RETRIES && !isNonRetryableTranscodeError(error)
if (!shouldRetry) {
await prisma.material.update({
where: { id: materialId },
data: {
videoProcessingStatus: 'failed',
transcodeLastError: errorMsg.substring(0, 500),
transcodeAttempts: attempt,
},
})
const finalError = error instanceof Error ? error : new Error(errorMsg)
throw finalError
}
await prisma.material.update({
where: { id: materialId },
data: {
transcodeLastError: errorMsg.substring(0, 500),
transcodeAttempts: attempt,
},
})
console.warn(`[Transcode] Retrying material ${materialId} (${attempt}/${MAX_TRANSCODE_RETRIES})`)
}
}
}

View File

@ -0,0 +1,152 @@
param(
[int]$Minutes = 60,
[string]$FrpHost = "frp",
[string]$ProdHost = "prod"
)
$ErrorActionPreference = "Stop"
function Get-FrpWindowJson {
param(
[string]$RemoteHost,
[int]$WindowMinutes
)
$cmd = @"
python3 - <<'PY'
from datetime import datetime, timedelta
import json, re
minutes = $WindowMinutes
cut = datetime.now() - timedelta(minutes=minutes)
timeline = {}
for ln in open('/var/log/yydd-net-probe-frp.log', 'r', errors='ignore'):
m = re.match(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+status=(\w+)\s+login=(\S+)\s+csrf=(\S+)\s+upstream2320=(\S+)\s+frps_anom=(\S+)\s+nginx_anom=(\S+)', ln.strip())
if not m:
continue
ts = datetime.strptime(m.group(1), '%Y-%m-%d %H:%M:%S')
if ts < cut:
continue
minute = ts.strftime('%Y-%m-%d %H:%M')
timeline[minute] = {
'status': m.group(2),
'login': m.group(3),
'csrf': m.group(4),
'upstream': m.group(5),
'frps_anom': m.group(6),
'nginx_anom': m.group(7),
'ts': ts.strftime('%H:%M:%S')
}
print(json.dumps({'timeline': timeline}, ensure_ascii=True))
PY
"@
return (& ssh -o BatchMode=yes -o ConnectTimeout=8 $RemoteHost $cmd)
}
function Get-ProdWindowJson {
param(
[string]$RemoteHost,
[int]$WindowMinutes
)
$cmd = @"
python3 - <<'PY'
from datetime import datetime, timedelta
import json, re
minutes = $WindowMinutes
cut = datetime.now() - timedelta(minutes=minutes)
timeline = {}
for ln in open('/root/frp/yydd-net-probe-prod.log', 'r', errors='ignore'):
m = re.match(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+status=(\w+)\s+app_health=(\S+)\s+health_code=(\S+)\s+frps_tcp=(\S+)\s+frpc_count=(\S+)\s+frpc_anom=(\S+)\s+ext_login=(\S+)', ln.strip())
if not m:
continue
ts = datetime.strptime(m.group(1), '%Y-%m-%d %H:%M:%S')
if ts < cut:
continue
minute = ts.strftime('%Y-%m-%d %H:%M')
timeline[minute] = {
'status': m.group(2),
'app_health': m.group(3),
'health_code': m.group(4),
'frps_tcp': m.group(5),
'frpc_count': m.group(6),
'frpc_anom': m.group(7),
'ext_login': m.group(8),
'ts': ts.strftime('%H:%M:%S')
}
print(json.dumps({'timeline': timeline}, ensure_ascii=True))
PY
"@
return (& ssh -o BatchMode=yes -o ConnectTimeout=8 $RemoteHost $cmd)
}
function Judge-Minute {
param(
$Frp,
$Prod
)
if ($null -eq $Frp -and $null -eq $Prod) { return "NO_DATA" }
if ($null -eq $Frp) { return "FRP_LOG_GAP" }
if ($null -eq $Prod) { return "PROD_LOG_GAP" }
if ($Frp.status -eq "FAIL" -and $Prod.status -eq "FAIL") {
return "LINK_OR_ENTRY_FAIL"
}
if ($Frp.status -eq "FAIL" -and $Prod.status -eq "OK") {
return "FRP_EDGE_LAYER_FAIL"
}
if ($Frp.status -eq "OK" -and $Prod.status -eq "FAIL") {
return "PROD_SIDE_OR_PATH_FAIL"
}
if ($Frp.status -eq "WARN" -or $Prod.status -eq "WARN") {
return "WARN_NEED_CHECK"
}
return "OK"
}
Write-Host "=== YYDD minute-level network timeline (last $Minutes minutes) ==="
Write-Host "FRP host: $FrpHost, PROD host: $ProdHost"
$frpJson = Get-FrpWindowJson -RemoteHost $FrpHost -WindowMinutes $Minutes | ConvertFrom-Json
$prodJson = Get-ProdWindowJson -RemoteHost $ProdHost -WindowMinutes $Minutes | ConvertFrom-Json
$allMinutes = @($frpJson.timeline.PSObject.Properties.Name + $prodJson.timeline.PSObject.Properties.Name | Sort-Object -Unique)
if ($allMinutes.Count -eq 0) {
Write-Host "No probe samples found. Check both timers first."
exit 1
}
$rows = foreach ($minute in $allMinutes) {
$frp = $frpJson.timeline.$minute
$prod = $prodJson.timeline.$minute
[PSCustomObject]@{
minute = $minute
frp = if ($null -ne $frp) { $frp.status } else { "-" }
prod = if ($null -ne $prod) { $prod.status } else { "-" }
judge = Judge-Minute -Frp $frp -Prod $prod
frp_detail = if ($null -ne $frp) { "login=$($frp.login),csrf=$($frp.csrf),up=$($frp.upstream),anom=$($frp.frps_anom)/$($frp.nginx_anom)" } else { "-" }
prod_detail = if ($null -ne $prod) { "health=$($prod.app_health),tcp=$($prod.frps_tcp),cnt=$($prod.frpc_count),anom=$($prod.frpc_anom),ext=$($prod.ext_login)" } else { "-" }
}
}
$rows | Sort-Object minute | Format-Table -AutoSize
Write-Host ""
Write-Host "=== Rules ==="
Write-Host "- LINK_OR_ENTRY_FAIL: both FRP and PROD fail in same minute."
Write-Host "- FRP_EDGE_LAYER_FAIL: only FRP fails; check Nginx/FRPS/upstream 2320."
Write-Host "- PROD_SIDE_OR_PATH_FAIL: FRP is OK but PROD fails; check PROD->FRP path/FRPC."
Write-Host "- WARN_NEED_CHECK: service still works but anomaly counters exist."

View File

@ -0,0 +1,17 @@
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function main() {
const result = await prisma.videoTranscodeProgress.deleteMany({})
console.log(`已清理 video_transcode_progresses 表中的 ${result.count} 条记录`)
}
main()
.catch((error) => {
console.error('清理失败:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,125 @@
const path = require('path')
const fs = require('fs/promises')
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const VIDEO_TYPES = ['主图视频', '安装视频', '种草视频']
function detectExtFromHeader(buffer) {
if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) {
return '.mp4'
}
if (buffer.slice(0, 4).toString('ascii') === 'RIFF') {
return '.avi'
}
if (buffer.slice(0, 4).toString('ascii') === 'fLaC') {
return '.flac'
}
if (buffer.slice(0, 3).toString('ascii') === 'ID3') {
return '.mp3'
}
return null
}
function resolvePath(filePath) {
if (!filePath) return ''
return path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath)
}
function buildStoredPath(originalStoredPath, newName) {
if (!originalStoredPath) {
return `uploads/${newName}`
}
const dir = path.dirname(originalStoredPath)
return path.join(dir, newName).replace(/\\/g, '/').replace(/^\.\//, '')
}
function buildVideoSourceUrl(videoSourceUrl, newName) {
if (!videoSourceUrl) {
return `/uploads/${newName}`
}
if (videoSourceUrl.startsWith('/uploads/')) {
const segments = videoSourceUrl.split('/')
segments[segments.length - 1] = newName
return segments.join('/')
}
if (videoSourceUrl.startsWith('uploads/')) {
return `uploads/${newName}`
}
return videoSourceUrl
}
async function main() {
const records = await prisma.material.findMany({
where: {
materialType: { in: VIDEO_TYPES },
zipFileName: { endsWith: '.zip' },
deletedAt: null,
},
select: {
id: true,
zipFileName: true,
zipFilePath: true,
videoSourceUrl: true,
},
})
if (records.length === 0) {
console.log('没有需要修复的视频文件')
return
}
let fixedCount = 0
let skippedMissing = 0
for (const record of records) {
try {
const absolutePath = resolvePath(record.zipFilePath)
const fileHandle = await fs.open(absolutePath, 'r')
const buffer = Buffer.alloc(16)
await fileHandle.read(buffer, 0, 16, 0)
await fileHandle.close()
const detectedExt = detectExtFromHeader(buffer) || '.mp4'
const newName = record.zipFileName.replace(/\.zip$/i, detectedExt)
const newAbsolutePath = path.join(path.dirname(absolutePath), newName)
try {
await fs.access(newAbsolutePath)
console.warn(`目标文件已存在,跳过重命名: ${newAbsolutePath}`)
} catch {
await fs.rename(absolutePath, newAbsolutePath)
}
const storedPath = buildStoredPath(record.zipFilePath, newName)
const sourceUrl = buildVideoSourceUrl(record.videoSourceUrl, newName)
await prisma.material.update({
where: { id: record.id },
data: {
zipFileName: newName,
zipFilePath: storedPath,
videoSourceUrl: sourceUrl,
},
})
fixedCount += 1
console.log(`已修复: ${record.id} -> ${newName}`)
} catch (error) {
skippedMissing += 1
console.error(`修复失败 ${record.id}:`, error.message)
}
}
console.log(`修复完成:${fixedCount} 条成功,${skippedMissing} 条失败`)
}
main()
.catch((error) => {
console.error('修复过程出错:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

148
分析报告.md Normal file
View File

@ -0,0 +1,148 @@
# 任务平台接口调用分析报告
## 任务号
`TASK202601161454431928-02-01`
---
## 1. 素材平台接口状态 ✅ 正常
### API调用日志(最近20条)
| 时间 | 接口 | 方法 | 状态码 | 耗时(ms) |
|------|------|------|--------|----------|
| 2026-02-06 13:15:06 | /api/external/product-assets | GET | **200** | 39 |
| 2026-02-06 13:14:43 | /api/external/product-assets | GET | **200** | 39 |
| 2026-02-06 11:42:57 | /api/external/product-assets | GET | **200** | 30 |
| ... | ... | ... | ... | ... |
**结论**: 所有接口调用均返回 **200 OK**,接口层面正常。
---
## 2. 素材数据详情
### 素材基本信息
| 字段 | 值 |
|------|-----|
| 素材ID | 0bb7a2c3-db65-49a6-a709-bb4270e75983 |
| 素材类型 | 任务-详情 |
| 产品型号 | GBGJ-YMJ0113-640 |
| 品牌 | 顾家 |
| 场景 | 卧室 |
| 任务状态 | fetched |
### 资产分布
| 资产类型 | 数量 | 说明 |
|----------|------|------|
| **all** | 1 | 完整合并包 (14.4MB) |
| **detail** | 1 | 详情图zip包 (14.4MB) |
| **detail-preview** | 25 | 详情预览图切片 |
| **main** | 0 | ⚠️ 无主图 |
| **sku** | 0 | ⚠️ 无SKU图 |
| **psd** | 0 | 无PSD文件 |
---
## 3. 接口返回数据结构
### 实际返回的JSON结构
```json
{
"success": true,
"data": {
"taskNo": "TASK202601161454431928-02-01",
"productModel": "GBGJ-YMJ0113-640",
"categoryName": "顾家",
"sceneName": "卧室",
"status": "fetched",
"assets": {
"main": [], // ⚠️ 空数组
"sku": [], // ⚠️ 空数组
"detail": [...], // 有数据
"psd": [] // 空数组
},
"downloadUrls": {
"all": "https://3d.yydd.ai/api/external/download?token=...",
"main": "...",
"sku": "...",
"detail": "...",
"psd": null
}
}
}
```
---
## 4. 可能的问题分析
根据任务平台管理员的描述,子任务完结被拦截,可能的原因:
### 原因A:任务平台配置问题(推测最有可能)
```sql
-- 任务平台需要检查的配置
SELECT config_key, config_value
FROM sys_config
WHERE config_key IN (
'external_asset_api_enabled',
'external_asset_api_url',
'external_asset_api_key'
);
```
如果 `external_asset_api_enabled=false`,素材平台接口不会被调用。
### 原因B:任务平台校验逻辑问题
素材平台返回了 `main: []` 和 `sku: []`(空数组),如果任务平台的校验逻辑要求必须有主图或SKU图才能完结子任务,会导致拦截。
**注意**: 这个素材本身确实只有详情图,没有主图和SKU图,这是数据本身的特点。
### 原因C:下载链接无法访问
需要验证任务平台服务器能否访问素材平台生成的下载链接:
- 下载链接域名: `https://3d.yydd.ai`
- 需要验证网络连通性和证书问题
---
## 5. 需要任务平台提供的诊断信息
为了确定具体的拦截点,请提供以下任一项:
### 选项1(推荐):浏览器Network截图
浏览器开发者工具 -> Network -> 找到请求 `product-assets` -> Response Body 内容
### 选项2:任务平台后端日志
包含以下任一关键字的日志:
- `external_asset_api`
- `BusinessException`
- `子任务完结`
- 任务号 `TASK202601161454431928-02-01`
### 选项3:任务平台配置查询结果
```sql
SELECT config_key, config_value
FROM sys_config
WHERE config_key IN ('external_asset_api_enabled','external_asset_api_url','external_asset_api_key');
```
---
## 6. 素材平台自查结果
| 检查项 | 结果 |
|--------|------|
| 素材是否存在 | ✅ 存在 |
| 素材是否被删除 | ✅ 未删除 |
| 接口响应状态 | ✅ 200 OK |
| 下载链接可访问性 | ✅ 可下载 (14.4MB文件) |
| 返回数据完整性 | ✅ 完整 |
**素材平台结论**: 接口和数据均正常,问题可能出在任务平台侧的配置或校验逻辑。
---
## 7. 建议排查步骤
1. **检查任务平台配置**: 确认 `external_asset_api_enabled=true`
2. **检查网络连通性**: 任务平台服务器能否访问 `https://3d.yydd.ai`
3. **检查校验逻辑**: 任务平台是否要求必须有 main/sku 数据才允许完结
4. **查看详细日志**: 提供任务平台后端具体的错误信息或异常堆栈