xk/backend/routes/videos.js

688 lines
21 KiB
JavaScript
Raw Normal View History

2026-02-02 20:51:52 +08:00
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}`;
2026-02-04 14:14:28 +08:00
// 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('=====================');
2026-02-02 20:51:52 +08:00
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;