diff --git a/src/index.ts b/src/index.ts index c6e106e..d06f800 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,11 +13,15 @@ import schemeAobRoutes from './routes/schemesAob'; import filterRoutes from './routes/filters'; import vipRoutes from './routes/vip'; import favoriteRoutes from './routes/favorites'; +import likeRoutes from './routes/likes'; import { prisma } from './utils/prisma'; const app = express(); const PORT = process.env.PORT || 3001; +// 信任代理 (用于 express-rate-limit 正确识别客户端 IP) +app.set('trust proxy', 1); + // ============================================ // 中间件 // ============================================ @@ -112,6 +116,7 @@ app.get('/api/favorites/count', async (req: Request, res: Response) => { }); app.use('/api/favorites', favoriteRoutes); +app.use('/api/likes', likeRoutes); // ============================================ // 缺失的存根接口(防止前端 404) diff --git a/src/routes/favorites.ts b/src/routes/favorites.ts index 1319121..5931c31 100644 --- a/src/routes/favorites.ts +++ b/src/routes/favorites.ts @@ -15,7 +15,9 @@ const TARGET_TYPES = ['SCHEME', 'SCHEME_AOB', 'FILTER'] as const; router.get('/', async (req: Request, res: Response) => { try { const { type, page = 1, limit = 20 } = req.query; - const skip = (Number(page) - 1) * Number(limit); + const pageNum = Math.max(1, parseInt(String(page)) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit)) || 20)); + const skip = (pageNum - 1) * limitNum; const where: any = { userId: req.user!.userId }; if (type && TARGET_TYPES.includes(type as any)) { diff --git a/src/routes/filters.ts b/src/routes/filters.ts index 4ba36eb..8eedd9b 100644 --- a/src/routes/filters.ts +++ b/src/routes/filters.ts @@ -10,7 +10,9 @@ const router = Router(); router.get('/', optionalAuth, async (req: Request, res: Response) => { try { const { page = 1, limit = 20, category } = req.query; - const skip = (Number(page) - 1) * Number(limit); + const pageNum = Math.max(1, parseInt(String(page)) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit)) || 20)); + const skip = (pageNum - 1) * limitNum; const where: any = { status: 'PUBLISHED' }; if (category) where.category = String(category); diff --git a/src/routes/likes.ts b/src/routes/likes.ts new file mode 100644 index 0000000..7a98652 --- /dev/null +++ b/src/routes/likes.ts @@ -0,0 +1,148 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { authMiddleware } from '../middleware/auth'; +import { prisma } from '../utils/prisma'; + +const router = Router(); + +router.use(authMiddleware); + +const TARGET_TYPES = ['SCHEME', 'SCHEME_AOB', 'FILTER'] as const; + +router.post('/', async (req: Request, res: Response) => { + try { + const body = z.object({ + targetType: z.enum(TARGET_TYPES), + targetId: z.string().uuid(), + }).parse(req.body); + + const existing = await prisma.like.findFirst({ + where: { + userId: req.user!.userId, + targetType: body.targetType, + targetId: body.targetId, + }, + }); + + if (existing) { + return res.status(400).json({ success: false, message: '已点赞' }); + } + + let targetExists = false; + if (body.targetType === 'SCHEME') { + targetExists = !!(await prisma.scheme.findFirst({ + where: { id: body.targetId, status: 'PUBLISHED' }, + })); + } else if (body.targetType === 'SCHEME_AOB') { + targetExists = !!(await prisma.schemeAob.findFirst({ + where: { id: body.targetId, status: 'PUBLISHED' }, + })); + } else if (body.targetType === 'FILTER') { + targetExists = !!(await prisma.filterShare.findFirst({ + where: { id: body.targetId, status: 'PUBLISHED' }, + })); + } + + if (!targetExists) { + return res.status(404).json({ success: false, message: '目标不存在' }); + } + + await prisma.like.create({ + data: { + userId: req.user!.userId, + targetType: body.targetType, + targetId: body.targetId, + }, + }); + + if (body.targetType === 'SCHEME') { + await prisma.scheme.update({ + where: { id: body.targetId }, + data: { likesCount: { increment: 1 } }, + }); + } else if (body.targetType === 'SCHEME_AOB') { + await prisma.schemeAob.update({ + where: { id: body.targetId }, + data: { likesCount: { increment: 1 } }, + }); + } else if (body.targetType === 'FILTER') { + await prisma.filterShare.update({ + where: { id: body.targetId }, + data: { likesCount: { increment: 1 } }, + }); + } + + res.json({ success: true, message: '点赞成功' }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ success: false, message: '参数验证失败', errors: error.errors }); + } + console.error('Add like error:', error); + res.status(500).json({ success: false, message: '点赞失败' }); + } +}); + +router.delete('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const like = await prisma.like.findUnique({ where: { id } }); + + if (!like || like.userId !== req.user!.userId) { + return res.status(404).json({ success: false, message: '点赞记录不存在' }); + } + + await prisma.like.delete({ where: { id } }); + + if (like.targetType === 'SCHEME') { + await prisma.scheme.update({ + where: { id: like.targetId }, + data: { likesCount: { decrement: 1 } }, + }); + } else if (like.targetType === 'SCHEME_AOB') { + await prisma.schemeAob.update({ + where: { id: like.targetId }, + data: { likesCount: { decrement: 1 } }, + }); + } else if (like.targetType === 'FILTER') { + await prisma.filterShare.update({ + where: { id: like.targetId }, + data: { likesCount: { decrement: 1 } }, + }); + } + + res.json({ success: true, message: '已取消点赞' }); + } catch (error) { + console.error('Remove like error:', error); + res.status(500).json({ success: false, message: '取消点赞失败' }); + } +}); + +router.get('/check', async (req: Request, res: Response) => { + try { + const { targetType, targetId } = req.query; + + if (!targetType || !targetId) { + return res.status(400).json({ success: false, message: '参数缺失' }); + } + + const like = await prisma.like.findFirst({ + where: { + userId: req.user!.userId, + targetType: String(targetType), + targetId: String(targetId), + }, + }); + + res.json({ + success: true, + isLiked: !!like, + likeId: like?.id || null, + }); + } catch (error) { + console.error('Check like error:', error); + res.status(500).json({ success: false, message: '检查失败' }); + } +}); + +export default router; diff --git a/src/routes/schemes.ts b/src/routes/schemes.ts index eb51afc..59c9735 100644 --- a/src/routes/schemes.ts +++ b/src/routes/schemes.ts @@ -19,7 +19,9 @@ router.get('/', optionalAuth, async (req: Request, res: Response) => { sort = 'newest', } = req.query; - const skip = (Number(page) - 1) * Number(limit); + const pageNum = Math.max(1, parseInt(String(page)) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit)) || 20)); + const skip = (pageNum - 1) * limitNum; const where: any = { status: 'PUBLISHED' }; if (weapon) where.weaponName = { contains: String(weapon) }; @@ -219,6 +221,11 @@ router.delete('/:id', authMiddleware, async (req: Request, res: Response) => { data: { status: 'DELETED' }, }); + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { schemesCount: { decrement: 1 } }, + }); + res.json({ success: true, message: '方案已删除', diff --git a/src/routes/schemesAob.ts b/src/routes/schemesAob.ts index fd2955a..f0fec94 100644 --- a/src/routes/schemesAob.ts +++ b/src/routes/schemesAob.ts @@ -12,7 +12,9 @@ router.get('/', optionalAuth, async (req: Request, res: Response) => { try { const { page = 1, limit = 20, weapon, category, sort = 'newest' } = req.query; - const skip = (Number(page) - 1) * Number(limit); + const pageNum = Math.max(1, parseInt(String(page)) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit)) || 20)); + const skip = (pageNum - 1) * limitNum; const where: any = { status: 'PUBLISHED' }; if (weapon) where.weaponName = { contains: String(weapon) }; @@ -111,6 +113,11 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => { }, }); + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { schemesCount: { increment: 1 } }, + }); + res.json({ success: true, message: '方案创建成功', data: scheme }); } catch (error) { console.error('Create scheme_aob error:', error); @@ -118,4 +125,41 @@ router.post('/', authMiddleware, async (req: Request, res: Response) => { } }); +// ============================================ +// 删除方案 +// ============================================ +router.delete('/:id', authMiddleware, async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const scheme = await prisma.schemeAob.findUnique({ + where: { id }, + select: { userId: true }, + }); + + if (!scheme) { + return res.status(404).json({ success: false, message: '方案不存在' }); + } + + if (scheme.userId !== req.user!.userId) { + return res.status(403).json({ success: false, message: '无权删除此方案' }); + } + + await prisma.schemeAob.update({ + where: { id }, + data: { status: 'DELETED' }, + }); + + await prisma.user.update({ + where: { id: req.user!.userId }, + data: { schemesCount: { decrement: 1 } }, + }); + + res.json({ success: true, message: '方案已删除' }); + } catch (error) { + console.error('Delete scheme_aob error:', error); + res.status(500).json({ success: false, message: '删除失败' }); + } +}); + export default router; \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index 80a53c6..b132b31 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -54,7 +54,9 @@ router.get('/schemes/:userId', async (req: Request, res: Response) => { const { userId } = req.params; const { type = 'schemes', page = 1, limit = 20 } = req.query; - const skip = (Number(page) - 1) * Number(limit); + const pageNum = Math.max(1, parseInt(String(page)) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(String(limit)) || 20)); + const skip = (pageNum - 1) * limitNum; let schemes; if (type === 'aob') { @@ -231,6 +233,9 @@ router.put('/username', async (req: Request, res: Response) => { username: body.username, }); } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ success: false, message: '参数验证失败', errors: error.errors }); + } console.error('Update username error:', error); res.status(500).json({ success: false, message: '更新失败' }); } @@ -278,6 +283,9 @@ router.put('/email', async (req: Request, res: Response) => { email: body.email, }); } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ success: false, message: '参数验证失败', errors: error.errors }); + } console.error('Update email error:', error); res.status(500).json({ success: false, message: '更新失败' }); } @@ -313,179 +321,12 @@ router.put('/avatar', async (req: Request, res: Response) => { avatar: body.avatar, }); } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ success: false, message: '参数验证失败', errors: error.errors }); + } console.error('Update avatar error:', error); res.status(500).json({ success: false, message: '更新失败' }); } }); -// ============================================ -// 获取用户统计 -// ============================================ -router.get('/stats/:userId', async (req: Request, res: Response) => { - try { - const { userId } = req.params; - - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - schemesCount: true, - favoritesCount: true, - isVip: true, - vipExpireAt: true, - createdAt: true, - }, - }); - - if (!user) { - return res.status(404).json({ - success: false, - message: '用户不存在', - }); - } - - res.json({ - success: true, - data: { - schemesCount: user.schemesCount, - favoritesCount: user.favoritesCount, - isVip: user.isVip, - vipExpireAt: user.vipExpireAt, - memberSince: user.createdAt, - }, - }); - } catch (error) { - console.error('Get user stats error:', error); - res.status(500).json({ success: false, message: '获取失败' }); - } -}); - -// ============================================ -// 获取用户方案列表 -// ============================================ -router.get('/schemes/:userId', async (req: Request, res: Response) => { - try { - const { userId } = req.params; - const { type = 'schemes', page = 1, limit = 20 } = req.query; - - const skip = (Number(page) - 1) * Number(limit); - - let schemes; - if (type === 'aob') { - schemes = await prisma.schemeAob.findMany({ - where: { userId, status: 'PUBLISHED' }, - orderBy: { createdAt: 'desc' }, - skip, - take: Number(limit), - select: { - id: true, title: true, weaponName: true, category: true, - viewsCount: true, downloadsCount: true, likesCount: true, createdAt: true, - }, - }); - } else { - schemes = await prisma.scheme.findMany({ - where: { userId, status: 'PUBLISHED' }, - orderBy: { createdAt: 'desc' }, - skip, - take: Number(limit), - select: { - id: true, title: true, weaponName: true, category: true, - viewsCount: true, downloadsCount: true, likesCount: true, createdAt: true, - }, - }); - } - - res.json({ - success: true, - data: schemes, - }); - } catch (error) { - console.error('Get user schemes error:', error); - res.status(500).json({ success: false, message: '获取失败' }); - } -}); - -// ============================================ -// 获取用户被收藏次数 -// ============================================ -router.get('/favorited-count/:userId', async (req: Request, res: Response) => { - try { - const { userId } = req.params; - - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { favoritesCount: true }, - }); - - if (!user) { - return res.status(404).json({ - success: false, - message: '用户不存在', - }); - } - - res.json({ - success: true, - favoritedCount: user.favoritesCount, - }); - } catch (error) { - console.error('Get favorited count error:', error); - res.status(500).json({ success: false, message: '获取失败' }); - } -}); - -// ============================================ -// 获取用户功能限制信息 -// ============================================ -router.get('/limits/:userId', async (req: Request, res: Response) => { - try { - const { userId } = req.params; - - // 验证用户是否存在 - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - id: true, - isVip: true, - vipExpireAt: true, - }, - }); - - if (!user) { - return res.status(404).json({ - success: false, - message: '用户不存在', - }); - } - - // 检查 VIP 是否过期 - const now = new Date(); - const isVipActive = user.isVip && user.vipExpireAt && user.vipExpireAt > now; - - // 根据 VIP 状态返回不同的限制 - const limits = isVipActive - ? { - maxDailyUses: 500, // VIP: 每日最多使用500次 - dailyUsesLeft: 500, // 剩余次数(简化处理,实际需要统计当日使用) - maxFavorites: 1000, // VIP: 最多收藏1000个 - maxSchemes: 200, // VIP: 最多创建200个方案 - canUploadIcc: true, // VIP: 可以上传 ICC - } - : { - maxDailyUses: 50, // 非VIP: 每日最多使用50次 - dailyUsesLeft: 50, // 剩余次数 - maxFavorites: 100, // 非VIP: 最多收藏100个 - maxSchemes: 50, // 非VIP: 最多创建50个方案 - canUploadIcc: false, // 非VIP: 不能上传 ICC - }; - - res.json({ - success: true, - ...limits, - }); - } catch (error) { - console.error('Get user limits error:', error); - res.status(500).json({ success: false, message: '获取失败' }); - } -}); - export default router;