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) // enableLog: 控制是否输出日志,默认从环境变量 VOD_SIGN_LOG 读取(值为'true'时启用),或直接传入 true/false function generateSignedUrl(videoUrl, signKey, expirationTime = 86400, requestDomain = '', enableLog = process.env.VOD_SIGN_LOG === 'true') { 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 now = Date.now(); const expirationTimestamp = Math.floor(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}`; // 如果启用日志输出,则输出详细信息 if (enableLog) { const formatTime = (timestamp) => { return new Date(timestamp).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); }; const videoDomain = `${url.protocol}//${url.host}`; console.log('=== VOD签名信息 ==='); console.log('当前请求域名:', requestDomain || '未提供'); console.log('视频URL域名:', videoDomain); console.log('当前时间:', formatTime(now)); console.log('当前时间戳:', Math.floor(now / 1000)); console.log('链接过期时间:', formatTime(expirationTimestamp * 1000)); console.log('有效时长:', expirationTime, '秒 (', Math.floor(expirationTime / 3600), '小时)'); console.log('原始URL:', videoUrl); console.log('签名后URL:', signedUrl); console.log('==================\n'); } 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 { // 获取当前请求的域名 const requestDomain = `${req.protocol}://${req.get('host')}`; // 获取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, 86400, requestDomain); } 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;