From bd5a361a1849d2163e2b9516d998430d5571fbb4 Mon Sep 17 00:00:00 2001 From: iye <1713042409@qq.com> Date: Tue, 12 May 2026 14:15:50 +0800 Subject: [PATCH] feat(vote): remove all voting limits (no daily quota, no per-artist cap, unlimited votes) --- src/app/api/me/route.ts | 20 +------ src/app/api/me/signin/route.ts | 44 +++------------ src/app/api/vote/route.ts | 83 +++++----------------------- src/app/me/MeContent.tsx | 22 ++------ src/components/VoteModal.tsx | 62 ++++----------------- src/components/me/QuotaCard.tsx | 28 ++++------ src/components/me/SignInCalendar.tsx | 4 +- src/hooks/useVoteAction.ts | 14 ++--- src/lib/api-response.ts | 3 - src/lib/mock-user.ts | 13 +---- src/lib/store.ts | 16 ++---- 11 files changed, 67 insertions(+), 242 deletions(-) diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index a5b7b09..f05a98a 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -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>, - Awaited>, Awaited>, SupportRow[], - Awaited>, ]; 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, diff --git a/src/app/api/me/signin/route.ts b/src/app/api/me/signin/route.ts index 9bac44d..ade4ebc 100644 --- a/src/app/api/me/signin/route.ts +++ b/src/app/api/me/signin/route.ts @@ -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) { diff --git a/src/app/api/vote/route.ts b/src/app/api/vote/route.ts index e0032bc..da1f291 100644 --- a/src/app/api/vote/route.ts +++ b/src/app/api/vote/route.ts @@ -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; -} diff --git a/src/app/me/MeContent.tsx b/src/app/me/MeContent.tsx index a27bc5c..44fad96 100644 --- a/src/app/me/MeContent.tsx +++ b/src/app/me/MeContent.tsx @@ -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) {
- + diff --git a/src/components/VoteModal.tsx b/src/components/VoteModal.tsx index 7e903a9..83ce8a1 100644 --- a/src/components/VoteModal.tsx +++ b/src/components/VoteModal.tsx @@ -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; } -const VOTE_OPTIONS: Array = [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(3); + const [selected, setSelected] = useState(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({ {/* 票数选择 */}
选择投票数:
-
+
{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 (
- {/* 票数余额 */} -
- - - 今日剩余:{remainingVotes} 票 - - | - 已用:{usedVotes} 票 -
- {/* 确认按钮 */} - - {/* 提示 */} -

- 每日 12 票 · 每艺人每日最多 {perArtistLimit} 票 -

)} diff --git a/src/components/me/QuotaCard.tsx b/src/components/me/QuotaCard.tsx index f961c8b..a2b88a8 100644 --- a/src/components/me/QuotaCard.tsx +++ b/src/components/me/QuotaCard.tsx @@ -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 ( -
+
{/* 装饰星点 */} @@ -36,21 +30,21 @@ export default function QuotaCard({
- - 今日剩余票数 + + 我累计投出
- {remaining}{" "} + {total.toLocaleString()}{" "}
- 明日 00:00 自动重置为 {daily} 票 + 为偶像应援无上限 · 越投越精彩
- 获取更多票数 + 喊好友一起来
diff --git a/src/components/me/SignInCalendar.tsx b/src/components/me/SignInCalendar.tsx index e97a5c9..75346dc 100644 --- a/src/components/me/SignInCalendar.tsx +++ b/src/components/me/SignInCalendar.tsx @@ -55,7 +55,7 @@ export default function SignInCalendar({ ) : isToday ? ( - +3 + ) : ( @@ -67,7 +67,7 @@ export default function SignInCalendar({

- 连续签到 7 天可获得额外票数奖励 · 中断后从头计算 + 每日签到 · 解锁专属应援徽章 · 中断后从头计算

); diff --git a/src/hooks/useVoteAction.ts b/src/hooks/useVoteAction.ts index cc4cbbf..9cb1c47 100644 --- a/src/hooks/useVoteAction.ts +++ b/src/hooks/useVoteAction.ts @@ -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(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), []); diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 1acac6f..10fa759 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -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), }; /** diff --git a/src/lib/mock-user.ts b/src/lib/mock-user.ts index 73a8daf..ea6c7a0 100644 --- a/src/lib/mock-user.ts +++ b/src/lib/mock-user.ts @@ -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, diff --git a/src/lib/store.ts b/src/lib/store.ts index 9497276..583e09b 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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((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((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, }), }));