UI-UX/src/app/api/vote/route.ts
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- store 改为 votedArtists[] + zustand persist
- VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额)
- 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标
- Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度)
- /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport)

后端:
- votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS)
- /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底
- /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota
- 新增 ERR.ALREADY_VOTED 错误码

测试:
- DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh)
- 修复 P2003 FK 违反未识别的 bug

详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:14:57 +08:00

167 lines
4.9 KiB
TypeScript

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();
}
}