feat(vote): remove all voting limits (no daily quota, no per-artist cap, unlimited votes)

This commit is contained in:
iye 2026-05-12 14:15:50 +08:00
parent 9fe9fa914f
commit bd5a361a18
11 changed files with 67 additions and 242 deletions

View File

@ -4,7 +4,8 @@ import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
/**
* GET /api/me
*
*
* "今日剩余票数"
*/
export async function GET() {
try {
@ -28,7 +29,7 @@ export async function GET() {
};
};
const [profile, quota, signIn, supports, config] = (await Promise.all([
const [profile, signIn, supports] = (await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
@ -39,9 +40,6 @@ export async function GET() {
createdAt: true,
},
}),
prisma.dailyQuota.findUnique({
where: { userId_date: { userId: user.id, date: today } },
}),
prisma.signIn.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
@ -64,21 +62,14 @@ export async function GET() {
},
orderBy: { votedTotal: "desc" },
}),
prisma.activityConfig.findUnique({ where: { id: 1 } }),
])) as [
Awaited<ReturnType<typeof prisma.user.findUnique>>,
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
SupportRow[],
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
];
if (!profile) return ERR.NOT_FOUND("用户不存在");
const dailyQuota = config?.dailyQuota ?? 12;
const totalQuota = quota?.totalQuota ?? dailyQuota;
const usedQuota = quota?.usedQuota ?? 0;
// 累计投票数
const totalVotes = await prisma.vote.aggregate({
where: { userId: user.id },
@ -88,11 +79,6 @@ export async function GET() {
return ok(
sanitizeBigInt({
profile,
quota: {
total: totalQuota,
used: usedQuota,
remaining: Math.max(0, totalQuota - usedQuota),
},
signIn: {
streak: signIn?.streak ?? 0,
lastDate: signIn?.date ?? null,

View File

@ -1,13 +1,11 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/current-user";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
type TxClient = Prisma.TransactionClient;
/**
* POST /api/me/signin
* · 7
* ·
*
*/
export async function POST() {
try {
@ -27,7 +25,6 @@ export async function POST() {
sanitizeBigInt({
alreadySigned: true,
streak: existing.streak,
bonusVotes: existing.bonusVotes,
}),
);
}
@ -37,43 +34,20 @@ export async function POST() {
where: { userId_date: { userId: user.id, date: yesterday } },
});
const streak = yest ? yest.streak + 1 : 1;
const bonusVotes = streak >= 7 ? 3 : streak >= 3 ? 2 : 1;
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
const dailyQuota = config?.dailyQuota ?? 12;
const result = await prisma.$transaction(async (tx: TxClient) => {
const signIn = await tx.signIn.create({
data: {
userId: user.id,
date: today,
streak,
bonusVotes,
},
});
// 把奖励票数追加到当日额度
await tx.dailyQuota.upsert({
where: { userId_date: { userId: user.id, date: today } },
create: {
userId: user.id,
date: today,
totalQuota: dailyQuota + bonusVotes,
usedQuota: 0,
},
update: {
totalQuota: { increment: bonusVotes },
},
});
return signIn;
const result = await prisma.signIn.create({
data: {
userId: user.id,
date: today,
streak,
bonusVotes: 0, // 保留字段,但不再发放
},
});
return ok(
sanitizeBigInt({
alreadySigned: false,
streak: result.streak,
bonusVotes: result.bonusVotes,
}),
);
} catch (e) {

View File

@ -1,5 +1,6 @@
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 {
@ -7,43 +8,41 @@ import {
getClientIp,
getUserAgent,
} from "@/lib/current-user";
import { Prisma } from "@prisma/client";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
type TxClient = Prisma.TransactionClient;
const VoteBody = z.object({
artistId: z.string().min(1).max(8),
count: z.number().int().min(1).max(12),
count: z.number().int().min(1).max(99_999),
});
/**
* POST /api/vote
*
* · /
*
*
* 1. + IP /
* 2.
* 3. + + +
* 4. /
* 1. + IP /
* 2.
* 3. + +
* 4.
*/
export async function POST(req: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return ERR.UNAUTHORIZED();
// 限流:单用户 1 秒最多 5 次投票请求
// 限流:单用户 1 秒最多 5 次(仅做防刷,非数量限制)
const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5);
if (!userRl.allowed) return ERR.RATE_LIMITED();
// 限流:单 IP 60 秒最多 60 次(更宽松)
// 限流:单 IP 60 秒最多 60 次
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) {
@ -58,41 +57,9 @@ export async function POST(req: NextRequest) {
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
const ua = await getUserAgent();
const today = startOfDay();
// 事务
const result = await prisma.$transaction(async (tx: TxClient) => {
// 1. 当日额度
const quota = await tx.dailyQuota.upsert({
where: { userId_date: { userId: user.id, date: today } },
create: {
userId: user.id,
date: today,
totalQuota: config.dailyQuota,
usedQuota: 0,
},
update: {},
});
const remaining = quota.totalQuota - quota.usedQuota;
if (remaining < count) {
throw new VoteBizError("QUOTA_EXHAUSTED", remaining);
}
// 2. 单艺人每日上限
const todayUsedForArtist = await tx.vote.aggregate({
where: {
userId: user.id,
artistId,
createdAt: { gte: today },
},
_sum: { count: true },
});
const usedForArtist = todayUsedForArtist._sum.count ?? 0;
if (usedForArtist + count > config.perArtistLimit) {
throw new VoteBizError("ARTIST_LIMIT", config.perArtistLimit);
}
// 3. 写入投票
// 1. 写入投票记录
const vote = await tx.vote.create({
data: {
userId: user.id,
@ -104,56 +71,32 @@ export async function POST(req: NextRequest) {
},
});
// 4. 扣减额度 + 累加艺人票数
await tx.dailyQuota.update({
where: { userId_date: { userId: user.id, date: today } },
data: { usedQuota: { increment: count } },
});
// 2. 累加艺人票数
const artist = await tx.artist.update({
where: { id: artistId },
data: { voteCount: { increment: count } },
select: { id: true, voteCount: true, name: true },
});
// 5. 更新 / 创建应援关系
// 3. 更新 / 创建应援关系
await tx.fanSupport.upsert({
where: { userId_artistId: { userId: user.id, artistId } },
create: { userId: user.id, artistId, votedTotal: count },
update: { votedTotal: { increment: count } },
});
return { vote, artist, remaining: remaining - count };
return { vote, artist };
});
return ok(
sanitizeBigInt({
artistId: result.artist.id,
artistVotes: result.artist.voteCount,
remainingQuota: result.remaining,
voteId: result.vote.id,
}),
);
} catch (e) {
if (e instanceof VoteBizError) {
if (e.code === "QUOTA_EXHAUSTED") return ERR.QUOTA_EXHAUSTED();
if (e.code === "ARTIST_LIMIT") return ERR.ARTIST_LIMIT(e.detail as number);
}
console.error("[POST /api/vote]", e);
return ERR.INTERNAL();
}
}
class VoteBizError extends Error {
constructor(
public code: "QUOTA_EXHAUSTED" | "ARTIST_LIMIT",
public detail?: unknown,
) {
super(code);
}
}
function startOfDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}

View File

@ -20,9 +20,7 @@ interface MeContentProps {
}
export default function MeContent({ session }: MeContentProps) {
const remaining = useVoteStore((s) => s.remainingVotes);
const used = useVoteStore((s) => s.usedVotes);
const dailyQuota = useVoteStore((s) => s.dailyQuota);
const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
const storeArtists = useVoteStore((s) => s.artists);
// 本地签到状态(数据库就绪后由 /api/me/signin 提供)
@ -33,12 +31,9 @@ export default function MeContent({ session }: MeContentProps) {
...MOCK_USER,
id: session.id,
nickname: session.nickname,
remainingVotes: remaining,
usedVotes: used,
dailyQuota,
todaySignedIn: signedInToday,
weeklySignIn,
totalVotes: MOCK_USER.totalVotes + used,
totalVotes: MOCK_USER.totalVotes + myTotalVotes,
};
// 用 store 里最新的艺人排名重算 "我的应援" 当前排名
@ -56,7 +51,7 @@ export default function MeContent({ session }: MeContentProps) {
try {
await navigator.share({
title: "CYBER STAR · 一起为偶像应援",
text: "邀请你加入虚拟偶像 Top12 出道企划,双方各得 +5 票",
text: "邀请你加入虚拟偶像 Top12 出道企划",
url,
});
return;
@ -66,7 +61,7 @@ export default function MeContent({ session }: MeContentProps) {
}
try {
await navigator.clipboard.writeText(url);
toast.success("邀请链接已复制 · 朋友注册后双方各 +5 票");
toast.success("邀请链接已复制 · 快去喊朋友一起来");
} catch {
toast.error("复制失败,请手动复制地址");
}
@ -77,7 +72,6 @@ export default function MeContent({ session }: MeContentProps) {
toast("今日已签到", { icon: "✓" });
return;
}
// 在 weeklySignIn 数组里找到今天的位置(第一个 false
const idx = weeklySignIn.findIndex((v) => !v);
if (idx === -1) {
toast("本周已全部签到");
@ -87,7 +81,7 @@ export default function MeContent({ session }: MeContentProps) {
next[idx] = true;
setWeeklySignIn(next);
setSignedInToday(true);
toast.success("签到成功 · 获得 +3 票");
toast.success("签到成功");
};
const handleLogout = () => {
@ -99,11 +93,7 @@ export default function MeContent({ session }: MeContentProps) {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} />
<QuotaCard
remaining={user.remainingVotes}
daily={user.dailyQuota}
onInvite={handleInvite}
/>
<QuotaCard total={user.totalVotes} onInvite={handleInvite} />
<StatsGrid user={user} />

View File

@ -3,37 +3,31 @@
import { useEffect, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { X, Heart, Gem } from "lucide-react";
import { X, Heart } from "lucide-react";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
import Button from "./ui/Button";
import ArtistPortrait from "./cards/ArtistPortrait";
import { useVoteStore } from "@/lib/store";
interface VoteModalProps {
/** 当前要投票的艺人,传 null 关闭弹窗 */
artist: Artist | null;
/** 单艺人每日上限 */
perArtistLimit?: number;
/** 关闭弹窗 */
onClose: () => void;
/** 确认投票 */
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
}
const VOTE_OPTIONS: Array<number | "ALL"> = [1, 3, 5, "ALL"];
const VOTE_OPTIONS: number[] = [1, 5, 10, 50];
const DEFAULT_COUNT = 5;
export default function VoteModal({
artist,
perArtistLimit = 3,
onClose,
onConfirm,
}: VoteModalProps) {
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const usedVotes = useVoteStore((s) => s.usedVotes);
const open = artist != null;
const [selected, setSelected] = useState<number | "ALL">(3);
const [selected, setSelected] = useState<number>(DEFAULT_COUNT);
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
@ -42,7 +36,7 @@ export default function VoteModal({
// 打开时重置默认选择
useEffect(() => {
if (open) {
setSelected(3);
setSelected(DEFAULT_COUNT);
setLoading(false);
}
}, [open]);
@ -62,19 +56,15 @@ export default function VoteModal({
};
}, [open, onClose]);
const maxVotes = Math.min(remainingVotes, perArtistLimit);
const actualCount = selected === "ALL" ? maxVotes : Math.min(selected, maxVotes);
const canVote = actualCount > 0 && !loading;
const handleConfirm = useCallback(async () => {
if (!artist || !canVote) return;
if (!artist || loading) return;
setLoading(true);
try {
await onConfirm(artist, actualCount);
await onConfirm(artist, selected);
} finally {
setLoading(false);
}
}, [artist, actualCount, canVote, onConfirm]);
}, [artist, selected, loading, onConfirm]);
if (!mounted) return null;
@ -145,25 +135,17 @@ export default function VoteModal({
{/* 票数选择 */}
<div className="text-xs text-white/55 mb-2.5"></div>
<div className="flex gap-2.5 justify-center mb-4">
<div className="flex gap-2.5 justify-center mb-5">
{VOTE_OPTIONS.map((opt) => {
const isAll = opt === "ALL";
const optNum = isAll ? maxVotes : (opt as number);
const disabled = optNum > maxVotes || maxVotes === 0;
const active = selected === opt;
return (
<button
type="button"
key={String(opt)}
disabled={disabled}
key={opt}
onClick={() => setSelected(opt)}
className={cn(
"rounded-lg font-display flex items-center justify-center transition-all",
isAll ? "w-16 text-[11px]" : "w-13 text-base",
"h-13 py-3.5 px-3",
disabled && "opacity-30 cursor-not-allowed",
"rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
!active &&
!disabled &&
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
active &&
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
@ -175,36 +157,16 @@ export default function VoteModal({
})}
</div>
{/* 票数余额 */}
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-purple-500/8 border border-purple-500/25 text-xs text-purple-300 mb-4">
<Gem size={14} />
<span>
<b className="text-white">{remainingVotes} </b>
</span>
<span className="text-white/30 mx-1">|</span>
<span>{usedVotes} </span>
</div>
{/* 确认按钮 */}
<Button
variant="primary"
className="w-full h-12 text-sm"
onClick={handleConfirm}
loading={loading}
disabled={!canVote}
leftIcon={<Heart size={14} />}
>
{canVote
? `确认投出 ${actualCount}`
: maxVotes === 0
? "今日已无可用票数"
: "请选择有效票数"}
{`确认投出 ${selected}`}
</Button>
{/* 提示 */}
<p className="text-[11px] text-white/40 mt-3 text-center">
12 · {perArtistLimit}
</p>
</motion.div>
</motion.div>
)}

View File

@ -1,22 +1,16 @@
"use client";
import { Gift, Users } from "lucide-react";
import { Heart, Users } from "lucide-react";
interface QuotaCardProps {
remaining: number;
daily: number;
/** 我累计投出的票数 */
total: number;
onInvite?: () => void;
}
export default function QuotaCard({
remaining,
daily,
onInvite,
}: QuotaCardProps) {
export default function QuotaCard({ total, onInvite }: QuotaCardProps) {
return (
<div
className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow"
>
<div className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow">
{/* 装饰星点 */}
<span className="absolute top-3 right-3 text-white/30 text-base"></span>
<span className="absolute bottom-4 right-12 text-white/15 text-xs">
@ -36,21 +30,21 @@ export default function QuotaCard({
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-1.5 text-white/85 text-xs font-label tracking-widest uppercase">
<Gift size={12} />
<Heart size={12} fill="currentColor" />
</div>
<div className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none mt-2 tracking-wider">
{remaining}{" "}
{total.toLocaleString()}{" "}
<span className="text-2xl font-body opacity-85"></span>
</div>
<div className="text-[11px] text-white/75 mt-2 tracking-wide">
00:00 {daily}
·
</div>
</div>
<div className="flex flex-col items-start sm:items-end gap-2">
<span className="font-label text-[10px] tracking-widest uppercase text-white/80">
</span>
<button
type="button"
@ -58,7 +52,7 @@ export default function QuotaCard({
className="inline-flex items-center gap-2 px-4 sm:px-5 h-10 rounded-full bg-white text-purple-700 font-display text-xs tracking-widest uppercase hover:bg-white/95 transition-colors"
>
<Users size={14} />
</button>
</div>
</div>

View File

@ -55,7 +55,7 @@ export default function SignInCalendar({
<Check size={14} strokeWidth={3} />
) : isToday ? (
<span className="inline-flex items-center gap-0.5 text-[10px] font-display">
<Gift size={11} /> +3
<Gift size={11} />
</span>
) : (
<span className="w-1 h-1 rounded-full bg-current opacity-30" />
@ -67,7 +67,7 @@ export default function SignInCalendar({
</div>
<p className="mt-3 text-[11px] text-white/40">
7 ·
· ·
</p>
</div>
);

View File

@ -24,34 +24,28 @@ interface UseVoteActionResult {
* -
* - store + API
* - toast
*
* /
*/
export function useVoteAction(): UseVoteActionResult {
const router = useRouter();
const pathname = usePathname();
const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote);
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const [target, setTarget] = useState<Artist | null>(null);
const openVote = useCallback(
(artist: Artist) => {
if (status === "loading") {
// 会话还在加载,等一下;用户可以再点
return;
}
if (status === "loading") return; // 会话还在加载,等一下
if (status === "unauthenticated") {
toast("请先登录后再为偶像投票", { icon: "🔐" });
const back = encodeURIComponent(pathname || "/");
setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
return;
}
if (remainingVotes <= 0) {
toast.error("今日票数已用完,明天再来吧~");
return;
}
setTarget(artist);
},
[status, pathname, router, remainingVotes],
[status, pathname, router],
);
const closeVote = useCallback(() => setTarget(null), []);

View File

@ -30,9 +30,6 @@ export const ERR = {
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
QUOTA_EXHAUSTED: () => err("QUOTA_EXHAUSTED", "今日票数已用完", 409),
ARTIST_LIMIT: (limit: number) =>
err("ARTIST_LIMIT", `每艺人每日最多 ${limit}`, 409),
};
/**

View File

@ -7,17 +7,11 @@ export interface MockUser {
/** 头像 URL无则使用首字母占位 */
avatar: string;
signInStreak: number;
/** 今日签到状态:每天 true/false按周一开始的一周 7 天) */
/** 本周签到状态:每天 true/false按周一开始的一周 7 天) */
weeklySignIn: boolean[];
/** 今日是否已签到 */
todaySignedIn: boolean;
/** 今日剩余票数 */
remainingVotes: number;
/** 今日已用票数 */
usedVotes: number;
/** 每日基础票数 */
dailyQuota: number;
/** 累计投票数 */
/** 累计投票数(无上限) */
totalVotes: number;
/** 应援的艺人 ID 列表 */
supportingIds: string[];
@ -37,9 +31,6 @@ export const MOCK_USER: MockUser = {
signInStreak: 7,
weeklySignIn: [true, true, true, true, true, true, false],
todaySignedIn: false,
remainingVotes: 9,
usedVotes: 3,
dailyQuota: 12,
totalVotes: 87,
supportingIds: ["001", "005", "014"],
invitedCount: 2,

View File

@ -5,10 +5,8 @@ import type { Artist } from "@/types/artist";
interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[];
/** 用户今日已用 / 剩余票数mock */
remainingVotes: number;
usedVotes: number;
dailyQuota: number;
/** 用户累计投出票数(无上限) */
myTotalVotes: number;
/** 给艺人投票(本地模拟,会重新排名) */
vote: (artistId: string, count: number) => void;
/** 重置(开发时用) */
@ -25,9 +23,7 @@ const INITIAL = rank(ARTISTS);
export const useVoteStore = create<VoteStore>((set) => ({
artists: INITIAL,
remainingVotes: 9,
usedVotes: 3,
dailyQuota: 12,
myTotalVotes: 0,
vote: (artistId, count) =>
set((state) => {
const updated = state.artists.map((a) =>
@ -35,15 +31,13 @@ export const useVoteStore = create<VoteStore>((set) => ({
);
return {
artists: rank(updated),
remainingVotes: Math.max(0, state.remainingVotes - count),
usedVotes: state.usedVotes + count,
myTotalVotes: state.myTotalVotes + count,
};
}),
reset: () =>
set({
artists: INITIAL,
remainingVotes: 9,
usedVotes: 3,
myTotalVotes: 0,
}),
}));