修复清理逻辑并同步项目文档
This commit is contained in:
parent
752b7bf999
commit
6aab2f305e
111
AGENTS.md
Normal file
111
AGENTS.md
Normal 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
298
README.md
@ -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` 说法
|
||||
- 项目已在线上运行,任何会影响上传、下载、权限、批量删除、资源链路的改动,都应先验证再发布
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
176
app/api/admin/data-stats/export/route.ts
Normal file
176
app/api/admin/data-stats/export/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
59
app/api/admin/data-stats/route.ts
Normal file
59
app/api/admin/data-stats/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
237
app/api/admin/material-operation-logs/route.ts
Normal file
237
app/api/admin/material-operation-logs/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
55
app/api/admin/video-transcode-queue/route.ts
Normal file
55
app/api/admin/video-transcode-queue/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
132
app/api/external/download/route.ts
vendored
132
app/api/external/download/route.ts
vendored
@ -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 的逻辑,但跳过权限检查
|
||||
|
||||
34
app/api/external/product-assets/route.ts
vendored
34
app/api/external/product-assets/route.ts
vendored
@ -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, // 任务平台只需要这个
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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: '删除成功',
|
||||
|
||||
@ -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: '排序更新成功',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
192
app/api/materials/[id]/white-background/route.ts
Normal file
192
app/api/materials/[id]/white-background/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
84
app/api/materials/upload/product-package/abort/route.ts
Normal file
84
app/api/materials/upload/product-package/abort/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
128
app/page.tsx
128
app/page.tsx
@ -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}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
1435
components/admin/DataStatisticsTab.tsx
Normal file
1435
components/admin/DataStatisticsTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
153
components/upload/StandardModelWhiteBackgroundUpload.tsx
Normal file
153
components/upload/StandardModelWhiteBackgroundUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
345
docs/CODEX_MULTI_AGENTS.md
Normal 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
222
docs/NETWORK_TOPOLOGY.md
Normal 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 |
|
||||
63
lib/__tests__/client-download.test.ts
Normal file
63
lib/__tests__/client-download.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
18
lib/__tests__/material-cleanup-display-images.test.ts
Normal file
18
lib/__tests__/material-cleanup-display-images.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
36
lib/__tests__/material-cleanup-standard-model.test.ts
Normal file
36
lib/__tests__/material-cleanup-standard-model.test.ts
Normal 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:' }")
|
||||
})
|
||||
})
|
||||
40
lib/__tests__/material-cleanup-task-page.test.ts
Normal file
40
lib/__tests__/material-cleanup-task-page.test.ts
Normal 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("'任务-页面'")
|
||||
})
|
||||
})
|
||||
62
lib/__tests__/standard-model-white-background.test.ts
Normal file
62
lib/__tests__/standard-model-white-background.test.ts
Normal 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
577
lib/admin-material-stats.ts
Normal 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
63
lib/client-download.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.')
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
45
lib/standard-model-white-background.ts
Normal file
45
lib/standard-model-white-background.ts
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
scripts/analyze-network-window.ps1
Normal file
152
scripts/analyze-network-window.ps1
Normal 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."
|
||||
17
scripts/cleanup-video-transcode-progress.js
Normal file
17
scripts/cleanup-video-transcode-progress.js
Normal 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()
|
||||
})
|
||||
125
scripts/fix-video-zip-ext.js
Normal file
125
scripts/fix-video-zip-ext.js
Normal 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
148
分析报告.md
Normal 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. **查看详细日志**: 提供任务平台后端具体的错误信息或异常堆栈
|
||||
Loading…
x
Reference in New Issue
Block a user