Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - 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>
167 lines
4.9 KiB
TypeScript
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();
|
|
}
|
|
}
|