Files
ggl/api/routes/videos.ts
2025-10-05 13:34:28 +08:00

1439 lines
39 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 视频管理API路由
* Handle video upload, list, details, etc.
*/
import { Router, type Request, type Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { spawn } from 'child_process';
import { query, queryOne, execute } from '../config/database.js';
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
const router = Router();
/**
* 从视频文件直接提取封面帧
* @param videoPath 视频文件路径
* @param timestamp 提取时间点默认为1秒
* @returns Promise<Buffer | null> 返回图片Buffer或null
*/
const extractVideoFrame = (videoPath: string, timestamp: number = 1): Promise<Buffer | null> => {
return new Promise((resolve) => {
const ffmpeg = spawn('ffmpeg', [
'-i', videoPath,
'-ss', timestamp.toString(),
'-vframes', '1',
'-f', 'image2pipe',
'-vcodec', 'mjpeg',
'-'
]);
const chunks: Buffer[] = [];
ffmpeg.stdout.on('data', (chunk) => {
chunks.push(chunk);
});
ffmpeg.on('close', (code) => {
if (code === 0 && chunks.length > 0) {
resolve(Buffer.concat(chunks));
} else {
resolve(null);
}
});
ffmpeg.on('error', () => {
resolve(null);
});
});
};
/**
* 批量更新视频分类
* PUT /api/videos/batch/category
*/
router.put('/batch/category', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
console.log('=== 批量修改分类接口开始 ===');
console.log('请求方法:', req.method);
console.log('请求路径:', req.path);
console.log('请求数据:', req.body);
console.log('用户信息:', req.user);
const { videoIds, category } = req.body;
if (!videoIds || !Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供要修改的视频ID列表'
});
return;
}
if (category === undefined || category === null || category === '') {
res.status(400).json({
success: false,
message: '请提供有效的分类'
});
return;
}
// 将分类ID转换为字符串如果是数字
const categoryValue = typeof category === 'number' ? category.toString() : category;
// 验证所有ID都是有效数字
const validIds = videoIds.filter(id => typeof id === 'number' && !isNaN(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '无效的视频ID列表'
});
return;
}
// 检查用户权限(只能修改自己上传的视频,或管理员可以修改所有视频)
const placeholders = validIds.map(() => '?').join(',');
console.log('批量修改分类 - 查询视频:', validIds);
console.log('批量修改分类 - 用户信息:', req.user);
const videos = await query(`
SELECT id, user_id FROM videos
WHERE id IN (${placeholders})
`, validIds);
console.log('批量修改分类 - 查询到的视频:', videos);
if (videos.length === 0) {
console.log('批量修改分类 - 未找到视频');
res.status(404).json({
success: false,
message: '未找到要修改的视频'
});
return;
}
// 检查权限 - 管理员可以修改所有视频
if (req.user!.role !== 'admin') {
const unauthorizedVideos = videos.filter(video =>
video.user_id !== req.user!.id
);
if (unauthorizedVideos.length > 0) {
console.log('批量修改分类 - 权限不足:', unauthorizedVideos);
res.status(403).json({
success: false,
message: '没有权限修改部分视频'
});
return;
}
}
console.log('批量修改分类 - 权限检查通过');
// 更新视频分类
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
await execute(`
UPDATE videos
SET category = ?, updated_at = datetime('now')
WHERE id IN (${foundPlaceholders})
`, [categoryValue, ...foundIds]);
res.json({
success: true,
message: `成功修改 ${foundIds.length} 个视频的分类`,
data: {
updatedCount: foundIds.length,
updatedIds: foundIds,
category: categoryValue
}
});
} catch (error) {
// 批量修改视频分类失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 批量删除视频 (POST方式)
* POST /api/videos/batch/delete
*/
router.post('/batch/delete', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds } = req.body;
if (!videoIds || !Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供要删除的视频ID列表'
});
return;
}
// 验证所有ID都是有效数字
const validIds = videoIds.filter(id => typeof id === 'number' && !isNaN(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '无效的视频ID列表'
});
return;
}
// 检查用户权限(只能删除自己上传的视频,或管理员可以删除所有视频)
const placeholders = validIds.map(() => '?').join(',');
const videos = await query(`
SELECT id, file_path, cover_image, user_id FROM videos
WHERE id IN (${placeholders})
`, validIds);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '未找到要删除的视频'
});
return;
}
// 检查权限
const unauthorizedVideos = videos.filter(video =>
video.user_id !== req.user!.id && req.user!.role !== 'admin'
);
if (unauthorizedVideos.length > 0) {
res.status(403).json({
success: false,
message: '没有权限删除部分视频'
});
return;
}
// 删除相关文件
for (const video of videos) {
try {
// 删除视频文件
if (video.file_path) {
const videoPath = path.join(process.cwd(), video.file_path);
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
}
}
// 删除封面文件
if (video.cover_image) {
const coverPath = path.join(process.cwd(), video.cover_image.replace(/^\//, ''));
if (fs.existsSync(coverPath)) {
fs.unlinkSync(coverPath);
}
}
} catch (fileError) {
// 删除文件失败
}
}
// 删除数据库记录
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 删除相关统计数据
await execute(`DELETE FROM video_stats WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除专题关联
await execute(`DELETE FROM video_topics WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除视频记录
await execute(`DELETE FROM videos WHERE id IN (${foundPlaceholders})`, foundIds);
res.json({
success: true,
message: `成功删除 ${foundIds.length} 个视频`,
data: {
deletedCount: foundIds.length,
deletedIds: foundIds
}
});
} catch (error) {
// 批量删除视频失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 获取视频时长
* @param videoPath 视频文件路径
* @returns Promise<number> 返回视频时长失败返回0
*/
const getVideoDuration = (videoPath: string): Promise<number> => {
return new Promise((resolve) => {
const ffprobe = spawn('ffprobe', [
'-v', 'quiet',
'-show_entries', 'format=duration',
'-of', 'csv=p=0',
videoPath
]);
let output = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (code === 0) {
const videoDuration = parseFloat(output.trim());
if (!isNaN(videoDuration)) {
// 视频时长获取成功
resolve(Math.round(videoDuration));
} else {
// 视频时长解析失败
resolve(0);
}
} else {
// ffprobe 执行失败
resolve(0);
}
});
ffprobe.on('error', (error) => {
// ffprobe 执行错误
resolve(0);
});
});
};
/**
* 生成视频封面(保留原有功能用于兼容)
* @param videoPath 视频文件路径
* @param outputPath 输出封面路径
* @returns Promise<boolean> 是否生成成功
*/
const generateVideoCover = (videoPath: string, outputPath: string): Promise<boolean> => {
return new Promise((resolve) => {
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 使用 ffmpeg 从视频第1秒提取一帧作为封面
const ffmpeg = spawn('ffmpeg', [
'-i', videoPath,
'-ss', '00:00:01',
'-vframes', '1',
'-y',
outputPath
]);
ffmpeg.on('close', (code) => {
if (code === 0 && fs.existsSync(outputPath)) {
// 视频封面生成成功
resolve(true);
} else {
// 视频封面生成失败
resolve(false);
}
});
ffmpeg.on('error', (error) => {
// ffmpeg 执行错误
resolve(false);
});
});
};
// 配置multer用于文件上传
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: {
fileSize: 500 * 1024 * 1024 // 500MB限制
},
fileFilter: (req, file, cb) => {
// 检查文件类型
// 文件过滤器检查
// 支持的MIME类型更全面的列表
const allowedTypes = [
// 标准视频MIME类型
'video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo',
'video/webm', 'video/ogg', 'video/3gpp', 'video/3gpp2',
'video/x-ms-wmv', 'video/x-flv', 'video/x-matroska',
'video/mp2t', 'video/x-ms-asf', 'video/x-m4v',
// 应用程序类型(某些系统可能将视频文件识别为此类型)
'application/octet-stream', 'application/x-msvideo',
'application/vnd.rn-realmedia', 'application/x-shockwave-flash'
];
// 支持的文件扩展名(与前端一致)
const allowedExtensions = [
'.mp4', '.webm', '.ogg', '.avi', '.mov', '.3gp', '.3g2',
'.wmv', '.flv', '.mkv', '.m4v', '.ts', '.asf', '.mpg',
'.mpeg', '.m2v', '.rmvb', '.rm', '.divx', '.xvid',
'.f4v', '.m2ts', '.mts', '.vob', '.dat', '.amv'
];
const fileExtension = path.extname(file.originalname).toLowerCase();
const mimeType = file.mimetype.toLowerCase();
// 更宽松的验证逻辑:
// 1. 扩展名匹配(优先级最高,因为扩展名通常最可靠)
// 2. MIME类型完全匹配
// 3. MIME类型以video/开头且扩展名正确
// 4. MIME类型为空但扩展名正确某些浏览器可能不提供MIME类型
const isExtensionValid = allowedExtensions.includes(fileExtension);
const isMimeTypeValid = allowedTypes.includes(mimeType);
const isVideoMimeType = mimeType.startsWith('video/');
const isValid = isExtensionValid || isMimeTypeValid || (isVideoMimeType && isExtensionValid) || (!mimeType && isExtensionValid);
// 文件验证结果
if (isValid) {
// 文件类型和扩展名检查通过
cb(null, true);
} else {
const errorMsg = `不支持的视频格式。文件: ${file.originalname}, MIME类型: ${mimeType}, 扩展名: ${fileExtension}。请上传支持的视频格式MP4、AVI、MOV、WMV、WebM、MKV、FLV 等`;
// 文件类型检查失败
cb(new Error(errorMsg));
}
}
});
/**
* 获取视频列表(支持分页和搜索)
* GET /api/videos
*/
router.get('/', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 12;
const search = req.query.search as string || '';
const topicId = req.query.topicId as string || '';
const category = req.query.category as string || '';
const offset = (page - 1) * limit;
let whereClause = '';
let params: any[] = [];
let joins = '';
const conditions: string[] = [];
if (search) {
conditions.push('(v.title LIKE ? OR v.description LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (topicId) {
joins += ' INNER JOIN video_topics vt ON v.id = vt.video_id';
conditions.push('vt.topic_id = ?');
params.push(parseInt(topicId));
}
if (category) {
conditions.push('v.category = ?');
params.push(category);
}
// 添加状态过滤条件只显示active状态的视频
conditions.push('v.status = ?');
params.push('active');
if (conditions.length > 0) {
whereClause = 'WHERE ' + conditions.join(' AND ');
}
// 查询参数
// 获取视频列表
const videos = await query(`
SELECT DISTINCT
v.id,
v.title,
v.description,
v.file_path,
v.video_url,
v.cover_image,
v.duration,
v.file_size,
v.category as category_id,
c.name as category,
v.created_at,
u.username as uploader,
COALESCE(vs.views, 0) as views,
COALESCE(vs.likes, 0) as likes
FROM videos v
LEFT JOIN users u ON v.user_id = u.id
LEFT JOIN video_stats vs ON v.id = vs.video_id
LEFT JOIN categories c ON v.category = c.id
${joins}
${whereClause}
ORDER BY v.created_at DESC
LIMIT ? OFFSET ?
`, [...params, limit, offset]);
// 获取总数
const totalResult = await queryOne(`
SELECT COUNT(DISTINCT v.id) as total
FROM videos v
${joins}
${whereClause}
`, params);
const total = totalResult.total;
const totalPages = Math.ceil(total / limit);
res.json({
code: 200,
message: '获取成功',
data: {
list: videos,
total,
page,
limit,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
} catch (error) {
// 获取视频列表失败
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 获取视频详情
* GET /api/videos/:id
*/
router.get('/:id', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const videoId = parseInt(req.params.id);
if (isNaN(videoId)) {
res.status(400).json({
code: 400,
message: '无效的视频ID'
});
return;
}
// 获取视频详情
const video = await queryOne(`
SELECT
v.id,
v.title,
v.description,
v.file_path,
v.video_url,
v.cover_image,
v.duration,
v.file_size,
v.category as category_id,
c.name as category,
v.created_at,
u.username as uploader,
COALESCE(vs.views, 0) as views,
COALESCE(vs.likes, 0) as likes
FROM videos v
LEFT JOIN users u ON v.user_id = u.id
LEFT JOIN video_stats vs ON v.id = vs.video_id
LEFT JOIN categories c ON v.category = c.id
WHERE v.id = ?
`, [videoId]);
if (!video) {
res.status(404).json({
code: 404,
message: '视频不存在'
});
return;
}
// 增加观看次数
await execute(`
INSERT INTO video_stats (video_id, views, likes, created_at)
VALUES (?, 1, 0, datetime('now'))
ON CONFLICT(video_id) DO UPDATE SET
views = views + 1
`, [videoId]);
res.json({
code: 200,
message: '获取成功',
data: video
});
} catch (error) {
// 获取视频详情失败
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* Multer错误处理中间件
*/
const handleMulterError = (error: any, req: Request, res: Response, next: any) => {
if (error instanceof multer.MulterError) {
// Multer错误
if (error.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
code: 400,
message: '文件大小超过限制最大500MB'
});
}
return res.status(400).json({
code: 400,
message: '文件上传错误: ' + error.message
});
} else if (error) {
// 文件过滤器错误
return res.status(400).json({
code: 400,
message: error.message
});
}
next();
};
/**
* 上传视频
* POST /api/videos/upload
*/
router.post('/upload', authenticateToken, upload.single('video'), handleMulterError, async (req: Request, res: Response): Promise<void> => {
try {
// 视频上传请求开始
const {
title,
description,
category: rawCategory,
status = 'published',
tags = '[]',
topics = '[]',
allowComments = 'true',
allowDownload = 'false',
featured = 'false',
visibility = 'public',
thumbnailOption = 'auto',
duration: inputDuration = '0',
width = '0',
height = '0',
aspectRatio = '16:9'
} = req.body
// 确保category是数字类型
const category = parseInt(rawCategory, 10)
if (isNaN(category)) {
res.status(400).json({ error: '无效的分类ID' })
return
}
const file = req.file;
// 请求参数
// 解析专题ID列表
let topicIds: number[] = [];
if (topics) {
try {
topicIds = typeof topics === 'string' ? JSON.parse(topics) : topics;
if (!Array.isArray(topicIds)) {
topicIds = [];
}
// 确保所有ID都是数字
topicIds = topicIds.filter(id => typeof id === 'number' && !isNaN(id));
} catch (e) {
// 专题ID解析失败
topicIds = [];
}
}
if (!file) {
// 错误: 没有文件
res.status(400).json({
code: 400,
message: '请选择要上传的视频文件'
});
return;
}
if (!title) {
// 错误: 没有标题
res.status(400).json({
code: 400,
message: '视频标题不能为空'
});
return;
}
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'uploads', 'videos');
// 上传目录
if (!fs.existsSync(uploadDir)) {
// 创建上传目录
fs.mkdirSync(uploadDir, { recursive: true });
}
// 生成唯一文件名
const fileExtension = path.extname(file.originalname);
const fileName = `${Date.now()}_${Math.random().toString(36).substring(2)}${fileExtension}`;
const filePath = path.join(uploadDir, fileName);
const relativePath = path.join('uploads', 'videos', fileName).replace(/\\/g, '/');
// 文件信息
// 保存文件
// 开始保存文件
fs.writeFileSync(filePath, file.buffer);
// 文件保存成功
// 生成视频URL用于前端访问
const videoUrl = `/${relativePath}`;
// 获取视频时长
// 开始获取视频时长
const duration = await getVideoDuration(filePath);
// 视频时长获取完成
// 生成视频封面 - 修复封面文件命名,确保与视频文件关联
let coverImage = '';
try {
// 使用视频文件的基础名称(不含扩展名)作为封面文件名的基础
const videoBaseName = path.basename(fileName, fileExtension);
const coverFilename = `${videoBaseName}_cover.jpg`;
const coverPath = path.join(process.cwd(), 'uploads', 'covers', coverFilename);
// 确保封面目录存在
const coverDir = path.dirname(coverPath);
if (!fs.existsSync(coverDir)) {
fs.mkdirSync(coverDir, { recursive: true });
}
// 开始生成视频封面
const coverGenerated = await generateVideoCover(filePath, coverPath);
if (coverGenerated) {
coverImage = `/uploads/covers/${coverFilename}`;
// 视频封面生成成功
} else {
// 视频封面生成失败,使用默认封面
}
} catch (error) {
// 生成视频封面时出错
}
// 插入视频记录
// 开始插入数据库记录
const result = await execute(`
INSERT INTO videos (
title, description, video_url, file_path, file_size, cover_image, duration, category, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [title, description || '', videoUrl, relativePath, file.size, coverImage, duration, category || '', req.user!.id]);
// 数据库插入成功
const videoId = result.lastID;
// 关联专题
if (topicIds.length > 0) {
// 开始关联专题
for (const topicId of topicIds) {
try {
// 验证专题是否存在
const topicExists = await queryOne('SELECT id FROM topics WHERE id = ? AND status = "active"', [topicId]);
if (topicExists) {
await execute(`
INSERT INTO video_topics (video_id, topic_id, created_at)
VALUES (?, ?, datetime('now'))
`, [videoId, topicId]);
// 专题关联成功
} else {
// 专题不存在或已禁用
}
} catch (error) {
// 专题关联失败
}
}
}
res.status(201).json({
code: 201,
message: '视频上传成功',
data: {
id: videoId,
title,
description,
category,
file_path: relativePath,
file_size: file.size,
cover_image: coverImage,
topics: topicIds
}
});
} catch (error) {
// 视频上传失败
res.status(500).json({
success: false,
message: '服务器内部错误',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
/**
* 批量删除视频 (POST方法)
* POST /api/videos/batch/delete
*/
router.post('/batch/delete', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds } = req.body;
if (!videoIds || !Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供要删除的视频ID列表'
});
return;
}
// 验证所有ID都是有效数字
const validIds = videoIds.filter(id => typeof id === 'number' && !isNaN(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '无效的视频ID列表'
});
return;
}
// 检查用户权限(只能删除自己上传的视频,或管理员可以删除所有视频)
const placeholders = validIds.map(() => '?').join(',');
const videos = await query(`
SELECT id, file_path, cover_image, user_id FROM videos
WHERE id IN (${placeholders})
`, validIds);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '未找到要删除的视频'
});
return;
}
// 检查权限
const unauthorizedVideos = videos.filter(video =>
video.user_id !== req.user!.id && req.user!.role !== 'admin'
);
if (unauthorizedVideos.length > 0) {
res.status(403).json({
success: false,
message: '没有权限删除部分视频'
});
return;
}
// 删除相关文件
for (const video of videos) {
try {
// 删除视频文件
if (video.file_path) {
const videoPath = path.join(process.cwd(), video.file_path);
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
}
}
// 删除封面文件
if (video.cover_image) {
const coverPath = path.join(process.cwd(), video.cover_image.replace(/^\//, ''));
if (fs.existsSync(coverPath)) {
fs.unlinkSync(coverPath);
}
}
} catch (fileError) {
// 删除文件失败
}
}
// 删除数据库记录
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 删除相关统计数据
await execute(`DELETE FROM video_stats WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除专题关联
await execute(`DELETE FROM video_topics WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除视频记录
await execute(`DELETE FROM videos WHERE id IN (${foundPlaceholders})`, foundIds);
res.json({
success: true,
message: `成功删除 ${foundIds.length} 个视频`,
data: {
deletedCount: foundIds.length,
deletedIds: foundIds
}
});
} catch (error) {
console.error('批量删除视频失败:', error);
console.error('错误堆栈:', error.stack);
res.status(500).json({
success: false,
message: '服务器内部错误',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
/**
* 批量更新视频状态
* PUT /api/videos/batch/status
*/
router.put('/batch/status', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds, status } = req.body;
// 参数验证
if (!Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供有效的视频ID列表'
});
return;
}
if (!status || !['published', 'draft', 'blocked'].includes(status)) {
res.status(400).json({
success: false,
message: '请提供有效的状态值published/draft/blocked'
});
return;
}
// 权限检查 - 只有管理员可以批量操作
const user = (req as any).user;
if (!user || user.role !== 'admin') {
res.status(403).json({
success: false,
message: '权限不足,只有管理员可以批量操作视频'
});
return;
}
// 验证所有视频ID都是有效数字
const validIds = videoIds.filter(id => !isNaN(parseInt(id))).map(id => parseInt(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '没有有效的视频ID'
});
return;
}
// 检查视频是否存在
const placeholders = validIds.map(() => '?').join(',');
const existingVideos = await query(`SELECT id FROM videos WHERE id IN (${placeholders})`, validIds);
if (existingVideos.length === 0) {
res.status(404).json({
success: false,
message: '没有找到指定的视频'
});
return;
}
// 批量更新状态
const foundIds = existingVideos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
await execute(`
UPDATE videos
SET status = ?, updated_at = datetime('now')
WHERE id IN (${foundPlaceholders})
`, [status, ...foundIds]);
res.json({
success: true,
message: `成功更新 ${foundIds.length} 个视频的状态为 ${status}`,
data: {
updatedCount: foundIds.length,
updatedIds: foundIds,
status: status
}
});
} catch (error) {
// 批量更新视频状态失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 批量删除视频
* DELETE /api/videos/batch
*/
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
console.log('批量删除视频开始,请求体:', req.body);
console.log('用户信息:', req.user);
try {
const { videoIds } = req.body;
// 参数验证
if (!Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供有效的视频ID列表'
});
return;
}
// 权限检查 - 只有管理员可以批量删除
const user = (req as any).user;
if (!user || user.role !== 'admin') {
res.status(403).json({
success: false,
message: '权限不足,只有管理员可以批量删除视频'
});
return;
}
// 验证所有视频ID都是有效数字
const validIds = videoIds.filter(id => !isNaN(parseInt(id))).map(id => parseInt(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '没有有效的视频ID'
});
return;
}
// 获取要删除的视频信息
const placeholders = validIds.map(() => '?').join(',');
const videos = await query(`
SELECT id, file_path, cover_image
FROM videos
WHERE id IN (${placeholders})
`, validIds);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '没有找到指定的视频'
});
return;
}
// 删除物理文件
for (const video of videos) {
try {
// 删除视频文件
if (video.file_path && fs.existsSync(video.file_path)) {
fs.unlinkSync(video.file_path);
// 视频文件删除成功
}
// 删除缩略图文件
if (video.cover_image) {
const thumbnailPath = path.join(process.cwd(), 'public', video.cover_image);
if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath);
// 缩略图删除成功
}
}
} catch (fileError) {
console.error(`删除文件失败 (video_id: ${video.id}):`, fileError);
}
}
// 删除数据库记录
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 删除相关统计数据
await execute(`DELETE FROM video_stats WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除专题关联
await execute(`DELETE FROM video_topics WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除视频记录
await execute(`DELETE FROM videos WHERE id IN (${foundPlaceholders})`, foundIds);
res.json({
success: true,
message: `成功删除 ${foundIds.length} 个视频`,
data: {
deletedCount: foundIds.length,
deletedIds: foundIds
}
});
} catch (error) {
console.error('批量删除视频失败:', error);
console.error('错误堆栈:', error.stack);
res.status(500).json({
success: false,
message: '服务器内部错误',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
/**
* 批量更新视频分类
* PUT /api/videos/batch/category
*/
router.put('/batch/category', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds, category } = req.body;
// 参数验证
if (!Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请选择要修改的视频'
});
return;
}
if (!category || typeof category !== 'string') {
res.status(400).json({
success: false,
message: '请提供有效的分类名称'
});
return;
}
// 权限检查:确保用户有权限修改这些视频
const user = (req as any).user;
if (!user) {
res.status(401).json({
success: false,
message: '未授权访问'
});
return;
}
// 检查视频是否存在且用户有权限修改
const placeholders = videoIds.map(() => '?').join(',');
const videos = await query(`
SELECT id FROM videos
WHERE id IN (${placeholders}) AND user_id = ?
`, [...videoIds, user.id]);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '未找到可修改的视频或无权限修改'
});
return;
}
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 批量更新分类
await execute(`
UPDATE videos
SET category = ?, updated_at = datetime('now')
WHERE id IN (${foundPlaceholders})
`, [category, ...foundIds]);
res.json({
success: true,
message: `成功修改 ${foundIds.length} 个视频的分类`,
data: {
updatedCount: foundIds.length,
updatedIds: foundIds,
category
}
});
} catch (error) {
// 批量修改分类失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 批量更新视频状态
* PUT /api/videos/batch/status
*/
router.put('/batch/status', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds, status } = req.body;
// 参数验证
if (!Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请选择要修改的视频'
});
return;
}
if (!status || !['published', 'draft', 'blocked'].includes(status)) {
res.status(400).json({
success: false,
message: '请提供有效的状态值published/draft/blocked'
});
return;
}
// 权限检查
const user = (req as any).user;
if (!user) {
res.status(401).json({
success: false,
message: '未授权访问'
});
return;
}
// 检查视频是否存在且用户有权限修改
const placeholders = videoIds.map(() => '?').join(',');
const videos = await query(`
SELECT id FROM videos
WHERE id IN (${placeholders}) AND user_id = ?
`, [...videoIds, user.id]);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '未找到可修改的视频或无权限修改'
});
return;
}
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 批量更新状态
await execute(`
UPDATE videos
SET status = ?, updated_at = datetime('now')
WHERE id IN (${foundPlaceholders})
`, [status, ...foundIds]);
res.json({
success: true,
message: `成功修改 ${foundIds.length} 个视频的状态`,
data: {
updatedCount: foundIds.length,
updatedIds: foundIds,
status
}
});
} catch (error) {
// 批量修改状态失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 点赞视频
* POST /api/videos/:id/like
*/
router.post('/:id/like', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const videoId = parseInt(req.params.id);
if (isNaN(videoId)) {
res.status(400).json({
success: false,
message: '无效的视频ID'
});
return;
}
// 检查视频是否存在
const video = await queryOne('SELECT id FROM videos WHERE id = ?', [videoId]);
if (!video) {
res.status(404).json({
success: false,
message: '视频不存在'
});
return;
}
// 增加点赞数
await execute(`
INSERT INTO video_stats (video_id, views, likes, created_at)
VALUES (?, 0, 1, datetime('now'))
ON CONFLICT(video_id) DO UPDATE SET
likes = likes + 1
`, [videoId]);
res.json({
success: true,
message: '点赞成功'
});
} catch (error) {
// 点赞失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* 获取专题内其他视频
* GET /api/videos/:id/topic-videos
*/
router.get('/:id/topic-videos', async (req: Request, res: Response): Promise<void> => {
try {
const videoId = parseInt(req.params.id);
if (isNaN(videoId)) {
res.status(400).json({
code: 400,
message: '无效的视频ID'
});
return;
}
// 检查视频是否存在
const video = await queryOne('SELECT id FROM videos WHERE id = ?', [videoId]);
if (!video) {
res.status(404).json({
code: 404,
message: '视频不存在'
});
return;
}
// 获取该视频所属的专题
const videoTopics = await query(`
SELECT topic_id FROM video_topics WHERE video_id = ?
`, [videoId]);
if (videoTopics.length === 0) {
res.json({
code: 200,
message: '获取成功',
data: []
});
return;
}
// 获取同专题的其他视频
const topicIds = videoTopics.map(vt => vt.topic_id);
const placeholders = topicIds.map(() => '?').join(',');
const topicVideos = await query(`
SELECT DISTINCT v.id, v.title, v.description, v.video_url, v.thumbnail,
v.duration, v.created_at,
COALESCE(vs.views, 0) as views,
COALESCE(vs.likes, 0) as likes
FROM videos v
LEFT JOIN video_stats vs ON v.id = vs.video_id
INNER JOIN video_topics vt ON v.id = vt.video_id
WHERE vt.topic_id IN (${placeholders})
AND v.id != ?
AND v.status = 'active'
ORDER BY v.created_at DESC
LIMIT 10
`, [...topicIds, videoId]);
res.json({
code: 200,
message: '获取成功',
data: topicVideos
});
} catch (error) {
// 获取专题视频失败
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 获取视频封面帧
* GET /api/videos/:id/frame
*/
router.get('/:id/frame', async (req: Request, res: Response): Promise<void> => {
try {
const videoId = parseInt(req.params.id);
const timestamp = parseFloat(req.query.t as string) || 1; // 默认第1秒
if (isNaN(videoId)) {
res.status(400).json({
code: 400,
message: '无效的视频ID'
});
return;
}
// 获取视频信息
const video = await queryOne('SELECT file_path FROM videos WHERE id = ?', [videoId]);
if (!video) {
res.status(404).json({
code: 404,
message: '视频不存在'
});
return;
}
// 构建视频文件完整路径
const videoPath = path.join(process.cwd(), video.file_path);
// 检查视频文件是否存在
if (!fs.existsSync(videoPath)) {
res.status(404).json({
code: 404,
message: '视频文件不存在'
});
return;
}
// 提取视频帧
const frameBuffer = await extractVideoFrame(videoPath, timestamp);
if (!frameBuffer) {
res.status(500).json({
code: 500,
message: '视频帧提取失败'
});
return;
}
// 设置响应头并返回图片
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': frameBuffer.length.toString(),
'Cache-Control': 'public, max-age=86400' // 缓存1天
});
res.send(frameBuffer);
} catch (error) {
// 获取视频封面帧失败
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
export default router;