xk/backend/routes/videos.js
2026-02-02 20:51:52 +08:00

688 lines
21 KiB
JavaScript
Raw 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.

const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const db = require('../config/database');
const { requireAuth } = require('../middleware/auth');
// 引入腾讯云SDK
const tencentcloud = require('tencentcloud-sdk-nodejs');
const VodClient = tencentcloud.vod.v20180717.Client;
const COS = require('cos-nodejs-sdk-v5');
// 获取腾讯云VOD客户端
async function getVodClient() {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
throw new Error('VOD configuration not found');
}
const config = configs[0];
const clientConfig = {
credential: {
secretId: config.secret_id,
secretKey: config.secret_key,
},
region: config.region || 'ap-guangzhou',
profile: {
httpProfile: {
endpoint: 'vod.tencentcloudapi.com',
},
},
};
return new VodClient(clientConfig);
}
// 从腾讯云VOD获取视频封面URL
async function getVideoCoverFromVod(fileId) {
try {
const client = await getVodClient();
// 调用DescribeMediaInfos接口获取媒体信息
const params = {
FileIds: [fileId]
};
const response = await client.DescribeMediaInfos(params);
if (response.MediaInfoSet && response.MediaInfoSet.length > 0) {
const mediaInfo = response.MediaInfoSet[0];
return mediaInfo.BasicInfo?.CoverUrl || null;
}
return null;
} catch (error) {
console.error('Get video cover from VOD error:', error);
return null;
}
}
// 生成VOD防盗链签名URL
// 根据腾讯云文档https://cloud.tencent.com/document/product/266/14047
// 签名算法sign = MD5(KEY + Dir + t + us)
function generateSignedUrl(videoUrl, signKey, expirationTime = 3600) {
if (!signKey || !videoUrl) {
return videoUrl;
}
try {
// 解析URL
const url = new URL(videoUrl);
const pathname = url.pathname; // 例如:/xxx/xxx/video.mp4
// 提取目录路径(不含文件名)
// 例如:/xxx/xxx/video.mp4 -> /xxx/xxx/
const lastSlashIndex = pathname.lastIndexOf('/');
const dir = pathname.substring(0, lastSlashIndex + 1);
// 计算过期时间戳(十六进制小写)
const expirationTimestamp = Math.floor(Date.now() / 1000) + expirationTime;
const t = expirationTimestamp.toString(16).toLowerCase();
// 生成随机字符串10位
const us = Math.random().toString(36).substring(2, 12);
// 生成签名MD5(KEY + Dir + t + us)
const signString = signKey + dir + t + us;
const sign = crypto.createHash('md5').update(signString).digest('hex');
// 拼接最终URL参数顺序必须是t, us, sign
const signedUrl = `${videoUrl}?t=${t}&us=${us}&sign=${sign}`;
console.log('=== VOD签名调试信息 ===');
console.log('原始URL:', videoUrl);
console.log('Dir路径:', dir);
console.log('过期时间t:', t);
console.log('随机字符串us:', us);
console.log('签名字符串:', signString);
console.log('MD5签名:', sign);
console.log('最终URL:', signedUrl);
console.log('=====================');
return signedUrl;
} catch (error) {
console.error('Generate signed URL error:', error);
return videoUrl;
}
}
// 获取COS客户端
async function getCosClient() {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
throw new Error('COS configuration not found');
}
const config = configs[0];
return new COS({
SecretId: config.secret_id,
SecretKey: config.secret_key,
});
}
// 上传封面图到腾讯云COS
async function uploadCoverToCos(fileBuffer, fileName, mimeType) {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].cos_bucket) {
throw new Error('COS bucket not configured');
}
const config = configs[0];
const cos = await getCosClient();
// 生成唯一文件名
const ext = path.extname(fileName);
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 15);
const cosFileName = `${config.cos_path || 'video-covers/'}${timestamp}-${randomStr}${ext}`;
// 上传到COS
return new Promise((resolve, reject) => {
cos.putObject({
Bucket: config.cos_bucket,
Region: config.cos_region || 'ap-guangzhou',
Key: cosFileName,
Body: fileBuffer,
ContentType: mimeType,
}, (err, data) => {
if (err) {
console.error('COS upload error:', err);
reject(err);
} else {
// 构建访问URL
const url = `https://${config.cos_bucket}.cos.${config.cos_region || 'ap-guangzhou'}.myqcloud.com/${cosFileName}`;
console.log('Cover uploaded to COS:', url);
resolve(url);
}
});
});
} catch (error) {
console.error('Upload cover to COS failed:', error);
throw error;
}
}
// 从COS删除封面图
async function deleteCoverFromCos(coverUrl) {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].cos_bucket) {
console.log('COS bucket not configured');
return;
}
const config = configs[0];
// 检查是否是COS URL
if (!coverUrl.includes('.myqcloud.com/')) {
console.log('Not a COS URL, skipping deletion');
return;
}
// 从URL提取文件Key
const urlParts = coverUrl.split('.myqcloud.com/');
if (urlParts.length < 2) {
console.log('Invalid COS URL format');
return;
}
const fileKey = urlParts[1];
const cos = await getCosClient();
return new Promise((resolve, reject) => {
cos.deleteObject({
Bucket: config.cos_bucket,
Region: config.cos_region || 'ap-guangzhou',
Key: fileKey,
}, (err, data) => {
if (err) {
console.error('Failed to delete cover from COS:', err);
resolve(); // 即使删除失败也继续
} else {
console.log('Cover deleted from COS:', fileKey);
resolve();
}
});
});
} catch (error) {
console.error('Delete cover from COS error:', error);
// 不抛出错误,避免影响主流程
}
}
// 配置封面图上传(使用内存存储)
const storage = multer.memoryStorage();
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB for cover images
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpg, jpeg, png, webp)'));
}
}
});
// ==================== 公开接口 ====================
// 获取所有活跃视频(前台使用)
router.get('/public', async (req, res) => {
try {
// 获取VOD配置以获取签名密钥
const [configs] = await db.query('SELECT sign_key FROM vod_config LIMIT 1');
const signKey = configs.length > 0 ? configs[0].sign_key : null;
const [videos] = await db.query(
'SELECT file_id, title_cn, title_en, desc_cn, desc_en, cover_url, video_url, duration FROM videos WHERE status = 1 ORDER BY sort_order ASC, id DESC'
);
// 为每个视频URL生成签名
const videosWithSignedUrls = videos.map(video => {
if (video.video_url && signKey) {
video.video_url = generateSignedUrl(video.video_url, signKey);
}
return video;
});
res.json({
success: true,
data: videosWithSignedUrls
});
} catch (error) {
console.error('Get public videos error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch videos'
});
}
});
// ==================== VOD配置接口 ====================
// 获取VOD配置
router.get('/vod-config', requireAuth, async (req, res) => {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0) {
return res.json({
success: true,
data: {
secret_id: '',
secret_key: '',
region: 'ap-guangzhou',
sub_app_id: '',
procedure: '',
storage_region: ''
}
});
}
// 隐藏部分密钥
const config = configs[0];
if (config.secret_key && config.secret_key.length > 8) {
config.secret_key_masked = config.secret_key.substring(0, 4) + '****' + config.secret_key.substring(config.secret_key.length - 4);
}
res.json({
success: true,
data: config
});
} catch (error) {
console.error('Get VOD config error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch VOD configuration'
});
}
});
// 更新VOD配置
router.put('/vod-config', requireAuth, async (req, res) => {
try {
const { secret_id, secret_key, region, sub_app_id, procedure, storage_region, cos_bucket, cos_region, cos_path, sign_key } = req.body;
if (!secret_id || !secret_key) {
return res.status(400).json({
success: false,
message: 'SecretId and SecretKey are required'
});
}
if (!cos_bucket) {
return res.status(400).json({
success: false,
message: 'COS存储桶名称必填'
});
}
// 检查是否存在配置
const [existing] = await db.query('SELECT id FROM vod_config LIMIT 1');
if (existing.length > 0) {
// 更新配置
await db.query(
'UPDATE vod_config SET secret_id = ?, secret_key = ?, region = ?, sub_app_id = ?, `procedure` = ?, storage_region = ?, cos_bucket = ?, cos_region = ?, cos_path = ?, sign_key = ? WHERE id = ?',
[secret_id, secret_key, region || 'ap-guangzhou', sub_app_id || '', procedure || '', storage_region || '', cos_bucket, cos_region || 'ap-guangzhou', cos_path || 'video-covers/', sign_key || '', existing[0].id]
);
} else {
// 插入新配置
await db.query(
'INSERT INTO vod_config (secret_id, secret_key, region, sub_app_id, `procedure`, storage_region, cos_bucket, cos_region, cos_path, sign_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[secret_id, secret_key, region || 'ap-guangzhou', sub_app_id || '', procedure || '', storage_region || '', cos_bucket, cos_region || 'ap-guangzhou', cos_path || 'video-covers/', sign_key || '']
);
}
res.json({
success: true,
message: 'VOD configuration updated successfully'
});
} catch (error) {
console.error('Update VOD config error:', error);
res.status(500).json({
success: false,
message: 'Failed to update VOD configuration'
});
}
});
// ==================== 视频管理接口 ====================
// 获取所有视频列表
router.get('/', requireAuth, async (req, res) => {
try {
const [videos] = await db.query(
'SELECT * FROM videos ORDER BY sort_order ASC, id DESC'
);
res.json({
success: true,
data: videos
});
} catch (error) {
console.error('Get videos error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch videos'
});
}
});
// 获取单个视频
router.get('/:id', requireAuth, async (req, res) => {
try {
const [videos] = await db.query(
'SELECT * FROM videos WHERE id = ?',
[req.params.id]
);
if (videos.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
res.json({
success: true,
data: videos[0]
});
} catch (error) {
console.error('Get video error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch video'
});
}
});
// 创建视频记录(上传前)
router.post('/', requireAuth, upload.single('cover'), async (req, res) => {
try {
const { title_cn, title_en, desc_cn, desc_en, file_id, video_url, duration, file_size, status } = req.body;
if (!title_cn || !title_en) {
return res.status(400).json({
success: false,
message: 'Title (CN & EN) are required'
});
}
let coverUrl = '';
if (req.file) {
// 上传封面图到腾讯云COS
try {
coverUrl = await uploadCoverToCos(req.file.buffer, req.file.originalname, req.file.mimetype);
} catch (uploadError) {
console.error('Failed to upload cover to COS:', uploadError);
return res.status(500).json({
success: false,
message: '封面图上传失败请检查COS配置'
});
}
}
// 获取最大排序值
const [maxOrder] = await db.query('SELECT MAX(sort_order) as max_order FROM videos');
const sortOrder = (maxOrder[0].max_order || 0) + 1;
const [result] = await db.query(
'INSERT INTO videos (title_cn, title_en, desc_cn, desc_en, cover_url, file_id, video_url, duration, file_size, sort_order, status, upload_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[title_cn, title_en, desc_cn || '', desc_en || '', coverUrl, file_id || '', video_url || '', duration || 0, file_size || 0, sortOrder, status || 2, 'pending']
);
res.json({
success: true,
message: 'Video record created successfully',
data: {
id: result.insertId,
title_cn,
title_en,
cover_url: coverUrl
}
});
} catch (error) {
console.error('Create video error:', error);
res.status(500).json({
success: false,
message: 'Failed to create video record'
});
}
});
// 更新视频信息
router.put('/:id', requireAuth, upload.single('cover'), async (req, res) => {
try {
const { title_cn, title_en, desc_cn, desc_en, status, sort_order, file_id, video_url, duration, file_size, upload_status } = req.body;
const videoId = req.params.id;
// 检查视频是否存在
const [existingVideo] = await db.query('SELECT * FROM videos WHERE id = ?', [videoId]);
if (existingVideo.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
let coverUrl = existingVideo[0].cover_url;
// 如果上传了新封面
if (req.file) {
// 删除旧封面(如果是本地文件)
if (existingVideo[0].cover_url && existingVideo[0].cover_url.startsWith('img/')) {
const oldCoverPath = path.join(__dirname, '../..', existingVideo[0].cover_url);
try {
await fs.unlink(oldCoverPath);
} catch (err) {
console.log('Old cover file not found or cannot be deleted');
}
}
// 上传新封面到COS
try {
coverUrl = await uploadCoverToCos(req.file.buffer, req.file.originalname, req.file.mimetype);
} catch (uploadError) {
console.error('Failed to upload cover to COS:', uploadError);
return res.status(500).json({
success: false,
message: '封面图上传失败'
});
}
} else if (file_id && !existingVideo[0].cover_url) {
// 如果有file_id且之前没有封面尝试从腾讯云获取
try {
const vodCoverUrl = await getVideoCoverFromVod(file_id);
if (vodCoverUrl) {
coverUrl = vodCoverUrl;
console.log(`Auto-fetched cover from VOD: ${vodCoverUrl}`);
}
} catch (error) {
console.error('Failed to auto-fetch cover:', error);
// 继续执行,不影响主流程
}
}
await db.query(
'UPDATE videos SET title_cn = ?, title_en = ?, desc_cn = ?, desc_en = ?, cover_url = ?, status = ?, sort_order = ?, file_id = ?, video_url = ?, duration = ?, file_size = ?, upload_status = ? WHERE id = ?',
[
title_cn || existingVideo[0].title_cn,
title_en || existingVideo[0].title_en,
desc_cn !== undefined ? desc_cn : existingVideo[0].desc_cn,
desc_en !== undefined ? desc_en : existingVideo[0].desc_en,
coverUrl,
status !== undefined ? status : existingVideo[0].status,
sort_order !== undefined ? sort_order : existingVideo[0].sort_order,
file_id !== undefined ? file_id : existingVideo[0].file_id,
video_url !== undefined ? video_url : existingVideo[0].video_url,
duration !== undefined ? duration : existingVideo[0].duration,
file_size !== undefined ? file_size : existingVideo[0].file_size,
upload_status !== undefined ? upload_status : existingVideo[0].upload_status,
videoId
]
);
res.json({
success: true,
message: 'Video updated successfully'
});
} catch (error) {
console.error('Update video error:', error);
res.status(500).json({
success: false,
message: 'Failed to update video'
});
}
});
// 删除视频
router.delete('/:id', requireAuth, async (req, res) => {
try {
const videoId = req.params.id;
// 获取视频信息
const [video] = await db.query('SELECT * FROM videos WHERE id = ?', [videoId]);
if (video.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
const videoData = video[0];
// 如果有file_id从腾讯云VOD删除视频
if (videoData.file_id) {
try {
const client = await getVodClient();
// 调用删除媒体接口
const params = {
FileId: videoData.file_id
};
await client.DeleteMedia(params);
console.log(`Successfully deleted video from Tencent Cloud VOD: ${videoData.file_id}`);
} catch (vodError) {
console.error('Failed to delete video from VOD:', vodError);
// 继续删除数据库记录即使VOD删除失败
// 可能视频已经在VOD中被删除了
}
}
// 删除封面文件
if (videoData.cover_url) {
if (videoData.cover_url.includes('.myqcloud.com/')) {
// 如果是COS URL从COS删除
await deleteCoverFromCos(videoData.cover_url);
} else if (videoData.cover_url.startsWith('img/')) {
// 如果是本地文件,从本地删除
const coverPath = path.join(__dirname, '../..', videoData.cover_url);
try {
await fs.unlink(coverPath);
} catch (err) {
console.log('Cover file not found or cannot be deleted');
}
}
}
// 从数据库删除
await db.query('DELETE FROM videos WHERE id = ?', [videoId]);
res.json({
success: true,
message: 'Video deleted successfully from both database and Tencent Cloud VOD'
});
} catch (error) {
console.error('Delete video error:', error);
res.status(500).json({
success: false,
message: 'Failed to delete video: ' + error.message
});
}
});
// ==================== 腾讯云VOD上传接口 ====================
// 获取上传签名
router.post('/upload-signature', requireAuth, async (req, res) => {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
return res.status(400).json({
success: false,
message: 'VOD configuration not found. Please configure VOD settings first.'
});
}
const config = configs[0];
const currentTime = Math.floor(Date.now() / 1000);
const expireTime = currentTime + 86400; // 24小时有效期
// 构建签名原始字符串
const signatureParams = {
secretId: config.secret_id,
currentTimeStamp: currentTime,
expireTime: expireTime,
random: Math.floor(Math.random() * 1000000),
};
// 添加可选参数
if (config.procedure) {
signatureParams.procedure = config.procedure;
}
if (config.storage_region) {
signatureParams.storageRegion = config.storage_region;
}
// 构建原始字符串
const originalString = Object.keys(signatureParams)
.sort()
.map(key => `${key}=${signatureParams[key]}`)
.join('&');
// 使用 SecretKey 对原始字符串进行 HMAC-SHA1 加密
const hmac = crypto.createHmac('sha1', config.secret_key);
const signature = hmac.update(originalString).digest();
// 将加密后的字符串和原始字符串拼接
const signatureStr = Buffer.concat([signature, Buffer.from(originalString)]).toString('base64');
res.json({
success: true,
data: {
signature: signatureStr,
region: config.region,
sub_app_id: config.sub_app_id
}
});
} catch (error) {
console.error('Generate upload signature error:', error);
res.status(500).json({
success: false,
message: 'Failed to generate upload signature'
});
}
});
module.exports = router;