first commit

This commit is contained in:
2025-09-23 07:35:11 +00:00
commit a5dd3f1335
110 changed files with 46108 additions and 0 deletions

249
api/routes/auth.ts Executable file
View File

@@ -0,0 +1,249 @@
/**
* This is a user authentication API route.
* Handle user registration, login, token management, etc.
*/
import { Router, type Request, type Response } from 'express';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { query, queryOne, execute } from '../config/database.js';
const router = Router();
// JWT密钥
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* User Registration
* POST /api/auth/register
*/
router.post('/register', async (req: Request, res: Response): Promise<void> => {
try {
const { username, email, password } = req.body;
// 验证输入
if (!username || !email || !password) {
res.status(400).json({
success: false,
message: '用户名、邮箱和密码都是必填项'
});
return;
}
// 检查用户是否已存在
const existingUser = await queryOne(
'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email]
);
if (existingUser) {
res.status(400).json({
success: false,
message: '用户名或邮箱已存在'
});
return;
}
// 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 插入新用户
const result = await execute(
'INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, datetime("now"))',
[username, email, hashedPassword]
);
res.status(201).json({
success: true,
message: '用户注册成功',
data: {
id: result.lastID,
username,
email
}
});
} catch (error) {
console.error('注册失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* User Login
* POST /api/auth/login
*/
router.post('/login', async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
// 验证输入
if (!username || !password) {
res.status(400).json({
success: false,
message: '用户名和密码都是必填项'
});
return;
}
// 查找用户
const user = await queryOne(
'SELECT id, username, email, password, role FROM users WHERE username = ? OR email = ?',
[username, username]
);
if (!user) {
res.status(401).json({
success: false,
message: '用户名或密码错误'
});
return;
}
// 验证密码
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
res.status(401).json({
success: false,
message: '用户名或密码错误'
});
return;
}
// 生成JWT token
const token = jwt.sign(
{
id: user.id,
username: user.username,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
message: '登录成功',
data: {
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
}
});
} catch (error) {
console.error('登录失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* Token Refresh
* POST /api/auth/refresh
*/
router.post('/refresh', async (req: Request, res: Response): Promise<void> => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({
success: false,
message: '访问令牌缺失'
});
return;
}
// 验证当前token即使过期也要能解析出用户信息
let decoded: any;
try {
decoded = jwt.verify(token, JWT_SECRET);
} catch (error: any) {
// 如果是过期错误尝试解析过期的token
if (error.name === 'TokenExpiredError') {
decoded = jwt.decode(token);
if (!decoded) {
res.status(401).json({
success: false,
message: '无效的访问令牌'
});
return;
}
} else {
res.status(401).json({
success: false,
message: '无效的访问令牌'
});
return;
}
}
// 验证用户是否仍然存在
const user = await queryOne(
'SELECT id, username, email, role FROM users WHERE id = ?',
[decoded.id]
);
if (!user) {
res.status(401).json({
success: false,
message: '用户不存在'
});
return;
}
// 生成新的JWT token
const newToken = jwt.sign(
{
id: user.id,
username: user.username,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
success: true,
message: 'Token刷新成功',
data: {
token: newToken,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
}
});
} catch (error) {
console.error('Token刷新失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/**
* User Logout
* POST /api/auth/logout
*/
router.post('/logout', async (req: Request, res: Response): Promise<void> => {
// 由于使用JWTlogout主要在前端处理删除token
res.json({
success: true,
message: '退出登录成功'
});
});
export default router;

431
api/routes/categories.ts Normal file
View File

@@ -0,0 +1,431 @@
/**
* 分类管理API路由
* Handle category CRUD operations
*/
import { Router, type Request, type Response } from 'express';
import { query, queryOne, execute } from '../config/database.js';
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
const router = Router();
/**
* 获取所有分类列表
* GET /api/categories
*/
router.get('/', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const categories = await query(`
SELECT
c.id,
c.name,
c.description,
c.created_at as createdAt,
c.sort_order,
COUNT(v.id) as videoCount
FROM categories c
LEFT JOIN videos v ON c.id = v.category
GROUP BY c.id, c.name, c.description, c.created_at, c.sort_order
ORDER BY c.sort_order ASC, c.created_at DESC
`);
res.json({
code: 200,
message: '获取分类列表成功',
data: categories
});
} catch (error) {
// 获取分类列表失败
res.status(500).json({
code: 500,
message: '获取分类列表失败',
data: null
});
}
});
/**
* 获取分类详情
* GET /api/categories/:id
*/
router.get('/:id', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const category = await queryOne(`
SELECT
c.id,
c.name,
c.description,
c.created_at as createdAt,
COUNT(v.id) as videoCount
FROM categories c
LEFT JOIN videos v ON c.id = v.category
WHERE c.id = ?
GROUP BY c.id, c.name, c.description, c.created_at
`, [id]);
if (!category) {
res.status(404).json({
code: 404,
message: '分类不存在',
data: null
});
return;
}
res.json({
code: 200,
message: '获取分类详情成功',
data: category
});
} catch (error) {
// 获取分类详情失败
res.status(500).json({
code: 500,
message: '获取分类详情失败',
data: null
});
}
});
/**
* 创建分类
* POST /api/categories
*/
router.post('/', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { name, description = '' } = req.body;
if (!name || name.trim() === '') {
res.status(400).json({
code: 400,
message: '分类名称不能为空',
data: null
});
return;
}
// 检查分类名称是否已存在
const existingCategory = await queryOne(
'SELECT id FROM categories WHERE name = ?',
[name.trim()]
);
if (existingCategory) {
res.status(400).json({
code: 400,
message: '分类名称已存在',
data: null
});
return;
}
// 创建分类
const result = await execute(
'INSERT INTO categories (name, description, created_at) VALUES (?, ?, datetime("now"))',
[name.trim(), description.trim()]
);
// 获取创建的分类信息
const newCategory = await queryOne(
'SELECT id, name, description, created_at as createdAt FROM categories WHERE id = ?',
[result.lastID]
);
res.status(201).json({
code: 201,
message: '创建分类成功',
data: newCategory
});
} catch (error) {
// 创建分类失败
res.status(500).json({
code: 500,
message: '创建分类失败',
data: null
});
}
});
/**
* 更新分类
* PUT /api/categories/:id
*/
router.put('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { name, description = '' } = req.body;
if (!name || name.trim() === '') {
res.status(400).json({
code: 400,
message: '分类名称不能为空',
data: null
});
return;
}
// 检查分类是否存在
const existingCategory = await queryOne(
'SELECT id FROM categories WHERE id = ?',
[id]
);
if (!existingCategory) {
res.status(404).json({
code: 404,
message: '分类不存在',
data: null
});
return;
}
// 检查分类名称是否已被其他分类使用
const duplicateCategory = await queryOne(
'SELECT id FROM categories WHERE name = ? AND id != ?',
[name.trim(), id]
);
if (duplicateCategory) {
res.status(400).json({
code: 400,
message: '分类名称已存在',
data: null
});
return;
}
// 更新分类
await execute(
'UPDATE categories SET name = ?, description = ? WHERE id = ?',
[name.trim(), description.trim(), id]
);
// 获取更新后的分类信息
const updatedCategory = await queryOne(
'SELECT id, name, description, created_at as createdAt FROM categories WHERE id = ?',
[id]
);
res.json({
code: 200,
message: '更新分类成功',
data: updatedCategory
});
} catch (error) {
// 更新分类失败
res.status(500).json({
code: 500,
message: '更新分类失败',
data: null
});
}
});
/**
* 删除分类
* DELETE /api/categories/:id
*/
router.delete('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
// 检查分类是否存在
const existingCategory = await queryOne(
'SELECT id FROM categories WHERE id = ?',
[id]
);
if (!existingCategory) {
res.status(404).json({
code: 404,
message: '分类不存在',
data: null
});
return;
}
// 检查是否有视频使用该分类
const videosUsingCategory = await queryOne(
'SELECT COUNT(*) as count FROM videos WHERE category = ?',
[id]
);
if (videosUsingCategory.count > 0) {
res.status(400).json({
code: 400,
message: `无法删除分类,还有 ${videosUsingCategory.count} 个视频使用该分类`,
data: null
});
return;
}
// 删除分类
await execute('DELETE FROM categories WHERE id = ?', [id]);
res.json({
code: 200,
message: '删除分类成功',
data: null
});
} catch (error) {
// 删除分类失败
res.status(500).json({
code: 500,
message: '删除分类失败',
data: null
});
}
});
/**
* 批量删除分类
* DELETE /api/categories/batch
*/
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { categoryIds } = req.body;
if (!Array.isArray(categoryIds) || categoryIds.length === 0) {
res.status(400).json({
code: 400,
message: '请选择要删除的分类',
data: null
});
return;
}
// 检查是否有视频使用这些分类
const videosUsingCategories = await query(
`SELECT category, COUNT(*) as count FROM videos WHERE category IN (${categoryIds.map(() => '?').join(',')}) GROUP BY category`,
categoryIds
);
if (videosUsingCategories.length > 0) {
const categoryNames = await query(
`SELECT id, name FROM categories WHERE id IN (${categoryIds.map(() => '?').join(',')})`,
categoryIds
);
const usedCategories = videosUsingCategories.map(vc => {
const category = categoryNames.find(cn => cn.id === vc.category);
return `${category?.name || '未知分类'}(${vc.count}个视频)`;
});
res.status(400).json({
code: 400,
message: `无法删除分类,以下分类还有视频在使用:${usedCategories.join(', ')}`,
data: null
});
return;
}
// 批量删除分类
await execute(
`DELETE FROM categories WHERE id IN (${categoryIds.map(() => '?').join(',')})`,
categoryIds
);
res.json({
code: 200,
message: `成功删除 ${categoryIds.length} 个分类`,
data: null
});
} catch (error) {
// 批量删除分类失败
res.status(500).json({
code: 500,
message: '批量删除分类失败',
data: null
});
}
});
/**
* 更新分类顺序
* PUT /api/categories/:id/order
*/
router.put('/:id/order', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const categoryId = parseInt(req.params.id);
const { direction } = req.body; // 'up' 或 'down'
if (isNaN(categoryId)) {
res.status(400).json({
code: 400,
message: '分类ID必须是有效的数字',
data: null
});
return;
}
if (!direction || !['up', 'down'].includes(direction)) {
res.status(400).json({
code: 400,
message: '方向参数必须是 up 或 down',
data: null
});
return;
}
// 获取当前分类信息
const currentCategory = await queryOne('SELECT * FROM categories WHERE id = ?', [categoryId]);
if (!currentCategory) {
res.status(404).json({
code: 404,
message: '分类不存在',
data: null
});
return;
}
// 获取需要交换的分类
let targetCategory;
if (direction === 'up') {
// 获取排序值小于当前分类且最接近的分类
targetCategory = await queryOne(`
SELECT * FROM categories
WHERE sort_order < ?
ORDER BY sort_order DESC
LIMIT 1
`, [currentCategory.sort_order]);
} else {
// 获取排序值大于当前分类且最接近的分类
targetCategory = await queryOne(`
SELECT * FROM categories
WHERE sort_order > ?
ORDER BY sort_order ASC
LIMIT 1
`, [currentCategory.sort_order]);
}
if (!targetCategory) {
res.status(400).json({
code: 400,
message: direction === 'up' ? '已经是第一个分类' : '已经是最后一个分类',
data: null
});
return;
}
// 交换两个分类的sort_order值
const tempOrder = currentCategory.sort_order;
await execute('UPDATE categories SET sort_order = ? WHERE id = ?', [targetCategory.sort_order, categoryId]);
await execute('UPDATE categories SET sort_order = ? WHERE id = ?', [tempOrder, targetCategory.id]);
res.json({
code: 200,
message: '分类顺序更新成功',
data: null
});
} catch (error) {
// 更新分类顺序失败
res.status(500).json({
code: 500,
message: '更新分类顺序失败',
data: null
});
}
})
export default router;

196
api/routes/stats.ts Executable file
View File

@@ -0,0 +1,196 @@
/**
* Stats API routes
*/
import express, { type Request, type Response } from 'express';
import { getDatabase } from '../config/database.js';
const router = express.Router();
/**
* Get overall statistics
*/
router.get('/overall', async (req: Request, res: Response) => {
try {
const db = await getDatabase();
// Get total videos count
const videosResult = await db.get('SELECT COUNT(*) as total FROM videos');
const totalVideos = videosResult?.total || 0;
// Get total users count
const usersResult = await db.get('SELECT COUNT(*) as total FROM users');
const totalUsers = usersResult?.total || 0;
// Get total views (sum of all video views)
const viewsResult = await db.get('SELECT SUM(views) as total FROM videos');
const totalViews = viewsResult?.total || 0;
// Get videos uploaded today
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayResult = await db.get(
'SELECT COUNT(*) as total FROM videos WHERE created_at >= ?',
[todayStart.toISOString()]
);
const todayVideos = todayResult?.total || 0;
res.json({
success: true,
data: {
totalVideos,
totalUsers,
totalViews,
todayVideos
}
});
} catch (error) {
console.error('获取统计数据失败:', error);
res.status(500).json({
success: false,
error: '获取统计数据失败'
});
}
});
/**
* Get video statistics by category
*/
router.get('/videos/category', async (req: Request, res: Response) => {
try {
const db = await getDatabase();
const result = await db.all(`
SELECT
category,
COUNT(*) as count,
SUM(views) as totalViews
FROM videos
GROUP BY category
ORDER BY count DESC
`);
res.json({
success: true,
data: result || []
});
} catch (error) {
console.error('获取分类统计失败:', error);
res.status(500).json({
success: false,
error: '获取分类统计失败'
});
}
});
/**
* Get recent activity stats
*/
router.get('/activity', async (req: Request, res: Response) => {
try {
const db = await getDatabase();
// Get videos uploaded in last 7 days
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const result = await db.all(`
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM videos
WHERE created_at >= ?
GROUP BY DATE(created_at)
ORDER BY date DESC
`, [weekAgo.toISOString()]);
res.json({
success: true,
data: result || []
});
} catch (error) {
console.error('获取活动统计失败:', error);
res.status(500).json({
success: false,
error: '获取活动统计失败'
});
}
});
/**
* Get latest videos
*/
router.get('/latest', async (req: Request, res: Response) => {
try {
const db = await getDatabase();
const limit = parseInt(req.query.limit as string) || 10;
const result = await db.all(`
SELECT
id,
title,
description,
cover_url,
video_url,
duration,
views,
likes,
category,
status,
user_id,
created_at,
updated_at
FROM videos
WHERE status = 'published'
ORDER BY created_at DESC
LIMIT ?
`, [limit]);
res.json({
success: true,
data: result || []
});
} catch (error) {
console.error('获取最新视频失败:', error);
res.status(500).json({
success: false,
error: '获取最新视频失败'
});
}
});
/**
* Get recent users
*/
router.get('/recent-users', async (req: Request, res: Response) => {
try {
const db = await getDatabase();
const limit = parseInt(req.query.limit as string) || 10;
const result = await db.all(`
SELECT
id,
username,
email,
avatar,
role,
created_at,
updated_at
FROM users
ORDER BY created_at DESC
LIMIT ?
`, [limit]);
res.json({
success: true,
data: result || []
});
} catch (error) {
console.error('获取最近用户失败:', error);
res.status(500).json({
success: false,
error: '获取最近用户失败'
});
}
});
export default router;

356
api/routes/topics.ts Executable file
View File

@@ -0,0 +1,356 @@
/**
* 专题管理API路由
* Handle topic CRUD operations
*/
import { Router, type Request, type Response } from 'express';
import { query, queryOne, execute } from '../config/database.js';
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
const router = Router();
/**
* 获取所有专题列表
* GET /api/topics
*/
router.get('/', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const topics = await query(`
SELECT
t.id,
t.name,
t.description,
t.cover_image,
t.sort_order,
t.status,
t.created_at,
COUNT(vt.video_id) as video_count
FROM topics t
LEFT JOIN video_topics vt ON t.id = vt.topic_id
WHERE t.status = 'active'
GROUP BY t.id, t.name, t.description, t.cover_image, t.sort_order, t.status, t.created_at
ORDER BY t.sort_order ASC, t.created_at DESC
`);
res.json({
code: 200,
message: '获取成功',
data: topics
});
} catch (error) {
console.error('获取专题列表失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 获取专题详情及其视频列表
* GET /api/topics/:id
*/
router.get('/:id', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const topicId = parseInt(req.params.id);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 12;
const offset = (page - 1) * limit;
if (isNaN(topicId)) {
res.status(400).json({
code: 400,
message: '无效的专题ID'
});
return;
}
// 获取专题信息
const topic = await queryOne(`
SELECT
t.id,
t.name,
t.description,
t.cover_image,
t.sort_order,
t.status,
t.created_at,
COUNT(vt.video_id) as video_count
FROM topics t
LEFT JOIN video_topics vt ON t.id = vt.topic_id
WHERE t.id = ? AND t.status = 'active'
GROUP BY t.id, t.name, t.description, t.cover_image, t.sort_order, t.status, t.created_at
`, [topicId]);
if (!topic) {
res.status(404).json({
code: 404,
message: '专题不存在'
});
return;
}
// 获取专题下的视频列表
const videos = await query(`
SELECT
v.id,
v.title,
v.description,
v.file_path,
v.video_url,
v.cover_image,
v.duration,
v.file_size,
v.created_at,
u.username as uploader,
COALESCE(vs.views, 0) as views,
COALESCE(vs.likes, 0) as likes
FROM videos v
INNER JOIN video_topics vt ON v.id = vt.video_id
LEFT JOIN users u ON v.user_id = u.id
LEFT JOIN video_stats vs ON v.id = vs.video_id
WHERE vt.topic_id = ? AND v.status = 'active'
ORDER BY v.created_at DESC
LIMIT ? OFFSET ?
`, [topicId, limit, offset]);
// 获取总数
const totalResult = await queryOne(`
SELECT COUNT(*) as total
FROM videos v
INNER JOIN video_topics vt ON v.id = vt.video_id
WHERE vt.topic_id = ? AND v.status = 'active'
`, [topicId]);
const total = totalResult.total;
const totalPages = Math.ceil(total / limit);
res.json({
code: 200,
message: '获取成功',
data: {
topic,
videos: {
list: videos,
total,
page,
limit,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
}
});
} catch (error) {
console.error('获取专题详情失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 创建新专题(管理员功能)
* POST /api/topics
*/
router.post('/', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, cover_image, sort_order } = req.body;
if (!name) {
res.status(400).json({
code: 400,
message: '专题名称不能为空'
});
return;
}
// 检查专题名称是否已存在
const existingTopic = await queryOne('SELECT id FROM topics WHERE name = ?', [name]);
if (existingTopic) {
res.status(400).json({
code: 400,
message: '专题名称已存在'
});
return;
}
const result = await execute(`
INSERT INTO topics (name, description, cover_image, sort_order, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
`, [name, description || '', cover_image || '', sort_order || 0]);
res.status(201).json({
code: 201,
message: '专题创建成功',
data: {
id: result.lastID,
name,
description,
cover_image,
sort_order
}
});
} catch (error) {
console.error('创建专题失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 更新专题信息(管理员功能)
* PUT /api/topics/:id
*/
router.put('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const topicId = parseInt(req.params.id);
const { name, description, cover_image, sort_order, status } = req.body;
if (isNaN(topicId)) {
res.status(400).json({
code: 400,
message: '无效的专题ID'
});
return;
}
if (!name) {
res.status(400).json({
code: 400,
message: '专题名称不能为空'
});
return;
}
// 检查专题是否存在
const existingTopic = await queryOne('SELECT id FROM topics WHERE id = ?', [topicId]);
if (!existingTopic) {
res.status(404).json({
code: 404,
message: '专题不存在'
});
return;
}
// 检查名称是否与其他专题冲突
const nameConflict = await queryOne('SELECT id FROM topics WHERE name = ? AND id != ?', [name, topicId]);
if (nameConflict) {
res.status(400).json({
code: 400,
message: '专题名称已存在'
});
return;
}
await execute(`
UPDATE topics
SET name = ?, description = ?, cover_image = ?, sort_order = ?, status = ?, updated_at = datetime('now')
WHERE id = ?
`, [name, description || '', cover_image || '', sort_order || 0, status || 'active', topicId]);
res.json({
code: 200,
message: '专题更新成功'
});
} catch (error) {
console.error('更新专题失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 删除专题(管理员功能)
* DELETE /api/topics/:id
*/
router.delete('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const topicId = parseInt(req.params.id);
if (isNaN(topicId)) {
res.status(400).json({
code: 400,
message: '无效的专题ID'
});
return;
}
// 检查专题是否存在
const existingTopic = await queryOne('SELECT id FROM topics WHERE id = ?', [topicId]);
if (!existingTopic) {
res.status(404).json({
code: 404,
message: '专题不存在'
});
return;
}
// 软删除:将状态设置为 inactive
await execute(`
UPDATE topics
SET status = 'inactive', updated_at = datetime('now')
WHERE id = ?
`, [topicId]);
res.json({
code: 200,
message: '专题删除成功'
});
} catch (error) {
console.error('删除专题失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
/**
* 获取视频的专题列表
* GET /api/topics/video/:videoId
*/
router.get('/video/:videoId', optionalAuth, async (req: Request, res: Response): Promise<void> => {
try {
const videoId = parseInt(req.params.videoId);
if (isNaN(videoId)) {
res.status(400).json({
code: 400,
message: '无效的视频ID'
});
return;
}
const topics = await query(`
SELECT
t.id,
t.name,
t.description,
t.cover_image,
t.sort_order
FROM topics t
INNER JOIN video_topics vt ON t.id = vt.topic_id
WHERE vt.video_id = ? AND t.status = 'active'
ORDER BY t.sort_order ASC, t.name ASC
`, [videoId]);
res.json({
code: 200,
message: '获取成功',
data: topics
});
} catch (error) {
console.error('获取视频专题失败:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误'
});
}
});
export default router;

262
api/routes/upload.ts Executable file
View File

@@ -0,0 +1,262 @@
/**
* 文件上传API路由
* Handle file uploads for videos and cover images
*/
import { Router, type Request, type Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
// Multer错误处理中间件
const handleMulterError = (error: any, req: Request, res: Response, next: any) => {
console.error('Multer错误:', error);
if (error instanceof multer.MulterError) {
switch (error.code) {
case 'LIMIT_FILE_SIZE':
return res.status(400).json({
success: false,
message: '文件大小超出限制最大100MB'
});
case 'LIMIT_FILE_COUNT':
return res.status(400).json({
success: false,
message: '文件数量超出限制'
});
case 'LIMIT_UNEXPECTED_FILE':
return res.status(400).json({
success: false,
message: '意外的文件字段'
});
default:
return res.status(400).json({
success: false,
message: `文件上传错误: ${error.message}`
});
}
}
if (error.message) {
return res.status(400).json({
success: false,
message: error.message
});
}
return res.status(500).json({
success: false,
message: '文件上传失败'
});
};
// 确保上传目录存在
const ensureUploadDir = (dir: string) => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
};
// 配置multer用于视频文件上传
const videoStorage = multer.memoryStorage();
const videoUpload = multer({
storage: videoStorage,
limits: {
fileSize: 100 * 1024 * 1024 // 100MB限制
},
fileFilter: (req, file, cb) => {
console.log('视频文件过滤器检查,文件类型:', file.mimetype, '文件名:', file.originalname);
// 获取文件扩展名
const fileExtension = path.extname(file.originalname).toLowerCase();
const mimeType = file.mimetype ? file.mimetype.toLowerCase() : '';
// 支持的MIME类型与前端保持一致
const allowedTypes = [
// 标准视频MIME类型
'video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo',
'video/x-ms-wmv', 'video/x-flv', 'video/webm', 'video/ogg',
'video/3gpp', 'video/3gpp2', 'video/x-matroska', 'video/mp2t',
'video/x-ms-asf', 'video/x-m4v', 'video/divx', 'video/xvid',
// 应用程序类型(某些视频格式)
'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'
];
// 验证逻辑:
// 1. 扩展名匹配(优先级最高)
// 2. MIME类型完全匹配
// 3. MIME类型以video/开头且扩展名正确
// 4. MIME类型为空但扩展名正确
const isExtensionValid = allowedExtensions.includes(fileExtension);
const isMimeTypeValid = allowedTypes.includes(mimeType);
const isVideoMimeType = mimeType.startsWith('video/');
const isValid = isExtensionValid || isMimeTypeValid || (isVideoMimeType && isExtensionValid) || (!mimeType && isExtensionValid);
console.log('文件验证结果:', {
fileExtension,
mimeType,
isExtensionValid,
isMimeTypeValid,
isVideoMimeType,
isValid
});
if (isValid) {
console.log('视频文件类型检查通过');
cb(null, true);
} else {
console.log('视频文件类型检查失败,不支持的格式:', mimeType, '扩展名:', fileExtension);
cb(new Error(`不支持的视频格式。文件类型: ${mimeType}, 扩展名: ${fileExtension}`));
}
}
});
// 配置multer用于封面图片上传
const coverStorage = multer.memoryStorage();
const coverUpload = multer({
storage: coverStorage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB限制
},
fileFilter: (req, file, cb) => {
console.log('封面图片过滤器检查,文件类型:', file.mimetype);
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
console.log('封面图片类型检查通过');
cb(null, true);
} else {
console.log('封面图片类型检查失败,不支持的格式:', file.mimetype);
cb(new Error('不支持的图片格式'));
}
}
});
/**
* 上传视频文件
* POST /api/upload/video
*/
router.post('/video', authenticateToken, videoUpload.single('video'), handleMulterError, async (req: Request, res: Response): Promise<void> => {
try {
console.log('视频文件上传请求开始');
const file = req.file;
if (!file) {
console.log('错误: 没有视频文件');
res.status(400).json({
success: false,
message: '请选择要上传的视频文件'
});
return;
}
// 生成文件名
const timestamp = Date.now();
const originalName = file.originalname;
const ext = path.extname(originalName);
const filename = `video_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`;
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'uploads', 'videos');
ensureUploadDir(uploadDir);
// 保存文件
const filePath = path.join(uploadDir, filename);
const relativePath = path.join('uploads', 'videos', filename).replace(/\\/g, '/');
console.log('开始保存视频文件到:', filePath);
fs.writeFileSync(filePath, file.buffer);
console.log('视频文件保存成功');
// 返回文件URL
const fileUrl = `/${relativePath}`;
res.status(200).json({
success: true,
message: '视频文件上传成功',
data: {
url: fileUrl,
filename: filename,
size: file.size,
mimetype: file.mimetype
}
});
} catch (error) {
console.error('视频文件上传失败:', error);
res.status(500).json({
success: false,
message: '视频文件上传失败'
});
}
});
/**
* 上传封面图片
* POST /api/upload/cover
*/
router.post('/cover', authenticateToken, coverUpload.single('cover'), handleMulterError, async (req: Request, res: Response): Promise<void> => {
try {
console.log('封面图片上传请求开始');
const file = req.file;
if (!file) {
console.log('错误: 没有封面图片文件');
res.status(400).json({
success: false,
message: '请选择要上传的封面图片'
});
return;
}
// 生成文件名
const timestamp = Date.now();
const originalName = file.originalname;
const ext = path.extname(originalName);
const filename = `cover_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`;
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'uploads', 'covers');
ensureUploadDir(uploadDir);
// 保存文件
const filePath = path.join(uploadDir, filename);
const relativePath = path.join('uploads', 'covers', filename).replace(/\\/g, '/');
console.log('开始保存封面图片到:', filePath);
fs.writeFileSync(filePath, file.buffer);
console.log('封面图片保存成功');
// 返回文件URL
const fileUrl = `/${relativePath}`;
res.status(200).json({
success: true,
message: '封面图片上传成功',
data: {
url: fileUrl,
filename: filename,
size: file.size,
mimetype: file.mimetype
}
});
} catch (error) {
console.error('封面图片上传失败:', error);
res.status(500).json({
success: false,
message: '封面图片上传失败'
});
}
});
export default router;

1538
api/routes/videos.ts Executable file

File diff suppressed because it is too large Load Diff