import type { NextRequest } from "next/server"; import { z } from "zod"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { rateLimit } from "@/lib/rate-limit"; import { getCurrentUser, getClientIp, getUserAgent, } from "@/lib/current-user"; import { ok, ERR, sanitizeBigInt } from "@/lib/api-response"; type TxClient = Prisma.TransactionClient; /** * 终身投票额度:每个用户共 12 票,每位艺人最多 1 票。 * 用 const 而非读 DB,避免每次请求多一次查询。前端 store 同名常量保持一致。 */ const TOTAL_VOTE_QUOTA = 12; const VoteBody = z.object({ artistId: z.string().min(1).max(8), // 旧前端仍可能传 count 字段(>=1),后端一律视为 1 票 count: z.number().int().min(1).max(99_999).optional(), }); /** 内部抛错用,事务捕获后转业务错误响应 */ class QuotaExhaustedError extends Error { constructor() { super("QUOTA_EXHAUSTED"); } } class AlreadyVotedError extends Error { constructor() { super("ALREADY_VOTED"); } } /** * POST /api/vote * * 新规则: * - 每用户终身 12 票 * - 每艺人 1 票(DB 层 @@unique([userId, artistId]) 兜底) * - 不可撤销,不限时 * - 单用户限流:1 秒 5 次;单 IP 限流:60 秒 60 次 * * 流程: * 1. 鉴权 + 反作弊限流 * 2. 校验活动开关(voteEnabled) * 3. 事务:已投艺人计数 >= 12 → QUOTA_EXHAUSTED;否则写票 * - DB unique 冲突 (P2002) → ALREADY_VOTED * 4. 累加 artist.voteCount;upsert FanSupport(votedTotal=1) * 5. 返回最新票数 + 剩余票数 */ export async function POST(req: NextRequest) { try { const user = await getCurrentUser(); if (!user) return ERR.UNAUTHORIZED(); const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5); if (!userRl.allowed) return ERR.RATE_LIMITED(); const ip = await getClientIp(); if (ip) { const ipRl = await rateLimit(`vote:ip:${ip}`, 60, 60); if (!ipRl.allowed) return ERR.RATE_LIMITED(); } const raw = await req.json(); const parsed = VoteBody.safeParse(raw); if (!parsed.success) { return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误"); } const { artistId } = parsed.data; const config = await prisma.activityConfig.findUnique({ where: { id: 1 } }); if (!config?.voteEnabled) return ERR.ACTIVITY_OFF(); // 新规则不限时,移除 startAt/endAt 校验 const ua = await getUserAgent(); try { const result = await prisma.$transaction(async (tx: TxClient) => { // 1. 终身额度校验:已投艺人数 >= 12 → 拒 const votedSoFar = await tx.vote.count({ where: { userId: user.id } }); if (votedSoFar >= TOTAL_VOTE_QUOTA) { throw new QuotaExhaustedError(); } // 2. 写入投票 // DB unique (userId, artistId) 在 catch 里转 ALREADY_VOTED const vote = await tx.vote.create({ data: { userId: user.id, artistId, count: 1, source: "QUOTA", ip: ip ?? undefined, ua: ua ?? undefined, }, }); // 3. 累加艺人票数 const artist = await tx.artist.update({ where: { id: artistId }, data: { voteCount: { increment: 1 } }, select: { id: true, voteCount: true, name: true }, }); // 4. 应援关系 —— 每艺人 1 票,votedTotal 固定 1 await tx.fanSupport.upsert({ where: { userId_artistId: { userId: user.id, artistId } }, create: { userId: user.id, artistId, votedTotal: 1 }, update: { votedTotal: 1 }, }); const votedAfter = votedSoFar + 1; return { vote, artist, votedCount: votedAfter, remaining: TOTAL_VOTE_QUOTA - votedAfter, }; }); return ok( sanitizeBigInt({ artistId: result.artist.id, artistVotes: result.artist.voteCount, voteId: result.vote.id, votedCount: result.votedCount, remaining: result.remaining, totalQuota: TOTAL_VOTE_QUOTA, }), ); } catch (e) { if (e instanceof QuotaExhaustedError) { return ERR.QUOTA_EXHAUSTED(); } if (e instanceof AlreadyVotedError) { return ERR.ALREADY_VOTED(); } // Prisma unique 冲突 → ALREADY_VOTED if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002" ) { return ERR.ALREADY_VOTED(); } // 艺人不存在: // - P2003: FK 违反(vote.create 时 artistId 外键约束失败) // - P2025: 记录不存在(artist.update 找不到目标) if ( e instanceof Prisma.PrismaClientKnownRequestError && (e.code === "P2003" || e.code === "P2025") ) { return ERR.NOT_FOUND("艺人不存在"); } throw e; } } catch (e) { console.error("[POST /api/vote]", e); return ERR.INTERNAL(); } }