first commit
This commit is contained in:
249
api/routes/auth.ts
Executable file
249
api/routes/auth.ts
Executable 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> => {
|
||||
// 由于使用JWT,logout主要在前端处理(删除token)
|
||||
res.json({
|
||||
success: true,
|
||||
message: '退出登录成功'
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
431
api/routes/categories.ts
Normal file
431
api/routes/categories.ts
Normal 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
196
api/routes/stats.ts
Executable 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
356
api/routes/topics.ts
Executable 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
262
api/routes/upload.ts
Executable 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
1538
api/routes/videos.ts
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user