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;
|