diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b75a79b --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# ============================================================= +# CYBER STAR · 环境变量示例 +# 部署时复制此文件为 .env,填入真实值(.env 已被 .gitignore) +# ============================================================= + +# ── 数据库 ── +# MySQL 8 连接字符串(火山引擎 RDS / 自建均可) +# 格式:mysql://user:password@host:port/database +DATABASE_URL="mysql://cyberstar:CHANGE_ME@127.0.0.1:3306/cyberstar?charset=utf8mb4" + +# ── Redis(票数缓存 + 限流 + 风控) ── +# 火山引擎 Redis 实例 +REDIS_URL="redis://default:CHANGE_ME@127.0.0.1:6379" + +# ── 对象存储 · 火山引擎 TOS ── +# 用于存放艺人立绘、视频、用户头像等 +TOS_ENDPOINT="tos-cn-beijing.volces.com" +TOS_REGION="cn-beijing" +TOS_BUCKET="cyber-star" +TOS_ACCESS_KEY="CHANGE_ME" +TOS_SECRET_KEY="CHANGE_ME" +NEXT_PUBLIC_TOS_DOMAIN="https://cyber-star.tos-cn-beijing.volces.com" + +# ── Auth.js 鉴权 ── +# 用 `openssl rand -base64 32` 生成 +AUTH_SECRET="CHANGE_ME_RANDOM_32_BYTES" +AUTH_URL="https://cyber-star.airlabs.art" + +# 微信开放平台 +WECHAT_APP_ID="CHANGE_ME" +WECHAT_APP_SECRET="CHANGE_ME" + +# 短信服务(阿里云 / 火山引擎) +SMS_ACCESS_KEY="CHANGE_ME" +SMS_SECRET_KEY="CHANGE_ME" +SMS_SIGN_NAME="Cyber Star" +SMS_TEMPLATE_CODE="SMS_xxxxxxx" + +# ── 反作弊 ── +HCAPTCHA_SITE_KEY="CHANGE_ME" +HCAPTCHA_SECRET="CHANGE_ME" + +# ── 通用配置 ── +NODE_ENV="production" diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/package.json b/package.json index eeb644c..39da8c7 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ "@prisma/client": "^6.19.3", "clsx": "^2.1.1", "framer-motion": "^12.38.0", + "ioredis": "^5.10.1", "lucide-react": "^1.14.0", "next": "16.2.6", "prisma": "^6.19.3", "react": "19.2.4", "react-dom": "19.2.4", - "tailwind-merge": "^3.6.0" + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33eae0d..1995ed2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + ioredis: + specifier: ^5.10.1 + version: 5.10.1 lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.4) @@ -35,6 +38,9 @@ importers: tailwind-merge: specifier: ^3.6.0 version: 3.6.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -513,6 +519,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1096,6 +1105,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1173,6 +1186,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -1580,6 +1597,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} + engines: {node: '>=12.22.0'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1834,6 +1855,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -2099,6 +2126,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2206,6 +2241,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2770,6 +2808,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3329,6 +3369,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3397,6 +3439,8 @@ snapshots: defu@6.1.7: {} + denque@2.1.0: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -3971,6 +4015,20 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + ioredis@5.10.1: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4203,6 +4261,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} loose-envify@1.4.0: @@ -4458,6 +4520,12 @@ snapshots: readdirp@4.1.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -4620,6 +4688,8 @@ snapshots: stable-hash@0.0.5: {} + standard-as-callback@2.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd5fbe0..4cc5548 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,7 +5,6 @@ generator client { provider = "prisma-client-js" - output = "../node_modules/.prisma/client" } datasource db { diff --git a/src/app/api/artists/[id]/route.ts b/src/app/api/artists/[id]/route.ts new file mode 100644 index 0000000..3d8c341 --- /dev/null +++ b/src/app/api/artists/[id]/route.ts @@ -0,0 +1,27 @@ +import { prisma } from "@/lib/prisma"; +import { ok, ERR } from "@/lib/api-response"; + +interface RouteCtx { + params: Promise<{ id: string }>; +} + +/** + * GET /api/artists/:id + * 返回单个艺人详情(含表演图片列表)。 + */ +export async function GET(_req: Request, { params }: RouteCtx) { + try { + const { id } = await params; + const artist = await prisma.artist.findUnique({ + where: { id }, + include: { + images: { orderBy: { sortOrder: "asc" } }, + }, + }); + if (!artist) return ERR.NOT_FOUND("艺人不存在"); + return ok({ artist }); + } catch (e) { + console.error("[GET /api/artists/:id]", e); + return ERR.INTERNAL(); + } +} diff --git a/src/app/api/artists/route.ts b/src/app/api/artists/route.ts new file mode 100644 index 0000000..a60995c --- /dev/null +++ b/src/app/api/artists/route.ts @@ -0,0 +1,60 @@ +import type { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, ERR } from "@/lib/api-response"; + +type ArtistRow = Awaited>[number]; + +/** + * GET /api/artists + * 列出所有候选艺人(含当前票数 / 排名)。 + * + * Query: + * - sort: 'votes' (default) | 'no' | 'recent' + * - tag: ArtistTag · 单标签筛选 + * - q: 搜索关键词(按 name / enName / no 模糊) + */ +export async function GET(req: NextRequest) { + try { + const sp = req.nextUrl.searchParams; + const sort = (sp.get("sort") ?? "votes") as "votes" | "no" | "recent"; + const tag = sp.get("tag"); + const q = sp.get("q")?.trim(); + + const orderBy = + sort === "no" + ? { no: "asc" as const } + : sort === "recent" + ? { updatedAt: "desc" as const } + : [ + { voteCount: "desc" as const }, + { no: "asc" as const }, + ]; + + let artists: ArtistRow[] = await prisma.artist.findMany({ + where: { + status: "ACTIVE", + ...(q && { + OR: [ + { name: { contains: q } }, + { enName: { contains: q } }, + { no: { contains: q } }, + ], + }), + }, + orderBy, + }); + + // tag 过滤在内存做(tags 是 JSON 列) + if (tag) { + artists = artists.filter((a: ArtistRow) => { + const tags = Array.isArray(a.tags) ? (a.tags as string[]) : []; + return tags.includes(tag); + }); + } + + return ok({ artists, total: artists.length }); + } catch (e) { + console.error("[GET /api/artists]", e); + return ERR.INTERNAL(); + } +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..a5b7b09 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,126 @@ +import { prisma } from "@/lib/prisma"; +import { getCurrentUser } from "@/lib/current-user"; +import { ok, ERR, sanitizeBigInt } from "@/lib/api-response"; + +/** + * GET /api/me + * 当前用户信息:基础资料、今日票数余额、应援列表、连签天数。 + */ +export async function GET() { + try { + const user = await getCurrentUser(); + if (!user) return ERR.UNAUTHORIZED(); + + const today = startOfDay(); + + type SupportRow = Awaited< + ReturnType + >[number] & { + artist: { + id: string; + no: string; + name: string; + enName: string; + slogan: string; + themeColor: string; + voteCount: number; + currentRank: number | null; + }; + }; + + const [profile, quota, signIn, supports, config] = (await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + id: true, + nickname: true, + avatar: true, + phone: true, + createdAt: true, + }, + }), + prisma.dailyQuota.findUnique({ + where: { userId_date: { userId: user.id, date: today } }, + }), + prisma.signIn.findFirst({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + }), + prisma.fanSupport.findMany({ + where: { userId: user.id }, + include: { + artist: { + select: { + id: true, + no: true, + name: true, + enName: true, + slogan: true, + themeColor: true, + voteCount: true, + currentRank: true, + }, + }, + }, + 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 }, + _sum: { count: true }, + }); + + return ok( + sanitizeBigInt({ + profile, + quota: { + total: totalQuota, + used: usedQuota, + remaining: Math.max(0, totalQuota - usedQuota), + }, + signIn: { + streak: signIn?.streak ?? 0, + lastDate: signIn?.date ?? null, + todaySignedIn: signIn ? sameDay(signIn.date, today) : false, + }, + totalVotes: totalVotes._sum.count ?? 0, + supports: supports.map((s: SupportRow) => ({ + artist: s.artist, + votedTotal: s.votedTotal, + })), + }), + ); + } catch (e) { + console.error("[GET /api/me]", e); + return ERR.INTERNAL(); + } +} + +function startOfDay(d = new Date()): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} + +function sameDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} diff --git a/src/app/api/me/signin/route.ts b/src/app/api/me/signin/route.ts new file mode 100644 index 0000000..9bac44d --- /dev/null +++ b/src/app/api/me/signin/route.ts @@ -0,0 +1,89 @@ +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 { + const user = await getCurrentUser(); + if (!user) return ERR.UNAUTHORIZED(); + + const today = startOfDay(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // 已签到则直接返回 + const existing = await prisma.signIn.findUnique({ + where: { userId_date: { userId: user.id, date: today } }, + }); + if (existing) { + return ok( + sanitizeBigInt({ + alreadySigned: true, + streak: existing.streak, + bonusVotes: existing.bonusVotes, + }), + ); + } + + // 查询昨日签到判断连签 + const yest = await prisma.signIn.findUnique({ + 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; + }); + + return ok( + sanitizeBigInt({ + alreadySigned: false, + streak: result.streak, + bonusVotes: result.bonusVotes, + }), + ); + } catch (e) { + console.error("[POST /api/me/signin]", e); + return ERR.INTERNAL(); + } +} + +function startOfDay(d = new Date()): Date { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +} diff --git a/src/app/api/ranking/route.ts b/src/app/api/ranking/route.ts new file mode 100644 index 0000000..d096d46 --- /dev/null +++ b/src/app/api/ranking/route.ts @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/prisma"; +import { ok, ERR } from "@/lib/api-response"; + +/** + * GET /api/ranking + * 返回完整 35 人实时排名(按 voteCount 降序)。 + * + * 该接口适合每分钟轮询。生产环境会优先读 Redis 缓存(由后台聚合任务每分钟刷新)。 + */ +export async function GET() { + try { + const artists = await prisma.artist.findMany({ + where: { status: "ACTIVE" }, + orderBy: [{ voteCount: "desc" }, { no: "asc" }], + select: { + id: true, + no: true, + name: true, + enName: true, + slogan: true, + themeColor: true, + avatar: true, + portrait: true, + voteCount: true, + currentRank: true, + }, + }); + + type ArtistRanked = (typeof artists)[number] & { rank: number }; + // 计算实时排名(即使 currentRank 字段没及时更新) + const ranked: ArtistRanked[] = artists.map( + (a: (typeof artists)[number], i: number) => ({ ...a, rank: i + 1 }), + ); + + return ok({ + list: ranked, + top3: ranked.slice(0, 3), + top12: ranked.slice(0, 12), + candidates: ranked.slice(12), + generatedAt: new Date().toISOString(), + }); + } catch (e) { + console.error("[GET /api/ranking]", e); + return ERR.INTERNAL(); + } +} diff --git a/src/app/api/vote/route.ts b/src/app/api/vote/route.ts new file mode 100644 index 0000000..e0032bc --- /dev/null +++ b/src/app/api/vote/route.ts @@ -0,0 +1,159 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; +import { + getCurrentUser, + 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), +}); + +/** + * POST /api/vote + * 投票主接口(核心热路径)。 + * + * 流程: + * 1. 鉴权 + 风控限流(IP / 用户) + * 2. 校验活动状态 + * 3. 事务:检查每日额度 + 每艺人上限 → 扣减额度 + 写入投票 + 累加艺人票数 + * 4. 返回最新票数 / 排名 + */ +export async function POST(req: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) return ERR.UNAUTHORIZED(); + + // 限流:单用户 1 秒最多 5 次投票请求 + const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5); + if (!userRl.allowed) return ERR.RATE_LIMITED(); + + // 限流:单 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) { + return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误"); + } + const { artistId, count } = parsed.data; + + // 活动状态 + const config = await prisma.activityConfig.findUnique({ where: { id: 1 } }); + if (!config?.voteEnabled) return ERR.ACTIVITY_OFF(); + const now = new Date(); + 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. 写入投票 + const vote = await tx.vote.create({ + data: { + userId: user.id, + artistId, + count, + source: "QUOTA", + ip: ip ?? undefined, + ua: ua ?? undefined, + }, + }); + + // 4. 扣减额度 + 累加艺人票数 + await tx.dailyQuota.update({ + where: { userId_date: { userId: user.id, date: today } }, + data: { usedQuota: { increment: count } }, + }); + const artist = await tx.artist.update({ + where: { id: artistId }, + data: { voteCount: { increment: count } }, + select: { id: true, voteCount: true, name: true }, + }); + + // 5. 更新 / 创建应援关系 + 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 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/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..1acac6f --- /dev/null +++ b/src/lib/api-response.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; + +/** + * 统一 API 响应包装。 + * + * 成功:{ ok: true, data: ... } + * 失败:{ ok: false, error: { code, message } } + */ + +export type ApiOk = { ok: true; data: T }; +export type ApiErr = { ok: false; error: { code: string; message: string } }; +export type ApiResponse = ApiOk | ApiErr; + +export function ok(data: T, init?: ResponseInit) { + return NextResponse.json>({ ok: true, data }, init); +} + +export function err(code: string, message: string, status = 400) { + return NextResponse.json( + { ok: false, error: { code, message } }, + { status }, + ); +} + +export const ERR = { + UNAUTHORIZED: () => err("UNAUTHORIZED", "请先登录", 401), + FORBIDDEN: (msg = "无权限") => err("FORBIDDEN", msg, 403), + NOT_FOUND: (msg = "资源不存在") => err("NOT_FOUND", msg, 404), + RATE_LIMITED: () => err("RATE_LIMITED", "操作过于频繁,请稍后再试", 429), + 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), +}; + +/** + * 把 BigInt 转为字符串(JSON 不能直接序列化 BigInt)。 + * 用于 API 响应序列化前的清洗。 + */ +export function sanitizeBigInt(value: T): T { + return JSON.parse( + JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)), + ) as T; +} diff --git a/src/lib/current-user.ts b/src/lib/current-user.ts new file mode 100644 index 0000000..724dbc7 --- /dev/null +++ b/src/lib/current-user.ts @@ -0,0 +1,45 @@ +import { cookies, headers } from "next/headers"; + +/** + * 当前登录用户上下文(Phase 11 接入 Auth.js 后由 session 提供)。 + * + * 当前阶段使用 cookie `cs_user_id` 模拟用户身份,便于开发联调。 + */ + +export interface CurrentUser { + id: bigint; + nickname: string; + isAnonymous: boolean; +} + +export async function getCurrentUser(): Promise { + // TODO[Phase 11]:替换为 await auth() 从 Auth.js session 读取 + const cookieStore = await cookies(); + const idCookie = cookieStore.get("cs_user_id")?.value; + if (!idCookie) return null; + + try { + return { + id: BigInt(idCookie), + nickname: `dev-user-${idCookie}`, + isAnonymous: false, + }; + } catch { + return null; + } +} + +/** + * 从 headers 中安全获取真实 IP(火山 CDN/网关回源时透传 X-Forwarded-For)。 + */ +export async function getClientIp(): Promise { + const h = await headers(); + const xff = h.get("x-forwarded-for"); + if (xff) return xff.split(",")[0]!.trim(); + return h.get("x-real-ip") ?? null; +} + +export async function getUserAgent(): Promise { + const h = await headers(); + return h.get("user-agent"); +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..df3307f --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,66 @@ +import { getRedis } from "./redis"; + +/** + * 滑动窗口限流(基于 Redis INCR + EXPIRE)。 + * + * 用法: + * const ok = await rateLimit(`vote:${userId}`, 60, 30); // 每 60s 最多 30 次 + * if (!ok.allowed) return new Response('Too Many', { status: 429 }); + * + * 降级:未配置 Redis 时使用内存 Map(仅适合开发环境)。 + */ + +interface RateLimitResult { + allowed: boolean; + remaining: number; + resetAt: number; +} + +// 开发兜底 +const memoryStore = new Map(); + +export async function rateLimit( + key: string, + windowSec: number, + limit: number, +): Promise { + const now = Date.now(); + const redis = getRedis(); + + if (redis) { + const k = `rl:${key}`; + try { + const pipe = redis.pipeline(); + pipe.incr(k); + pipe.expire(k, windowSec, "NX"); // 仅在首次设置 TTL + pipe.pttl(k); + const [[, count], , [, pttl]] = (await pipe.exec()) as [ + [Error | null, number], + [Error | null, "OK" | 0], + [Error | null, number], + ]; + const remaining = Math.max(0, limit - count); + const resetAt = pttl > 0 ? now + pttl : now + windowSec * 1000; + return { allowed: count <= limit, remaining, resetAt }; + } catch (err) { + console.error("[rate-limit] redis error, falling back to memory:", err); + } + } + + // 内存兜底 + const cur = memoryStore.get(key); + if (!cur || cur.expiresAt < now) { + memoryStore.set(key, { count: 1, expiresAt: now + windowSec * 1000 }); + return { + allowed: 1 <= limit, + remaining: limit - 1, + resetAt: now + windowSec * 1000, + }; + } + cur.count += 1; + return { + allowed: cur.count <= limit, + remaining: Math.max(0, limit - cur.count), + resetAt: cur.expiresAt, + }; +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..6383be5 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,47 @@ +import Redis from "ioredis"; + +/** + * Redis 客户端单例 + * - 用于:票数缓存 / 限流 / 风控 / 实时排行 + * - 部署:火山引擎 Redis 实例(通过 REDIS_URL 连接) + * + * 开发环境若未配置 REDIS_URL,则降级为内存模式(不持久化、不分布式)。 + */ + +declare global { + // eslint-disable-next-line no-var + var __redis: Redis | undefined; +} + +let client: Redis | null = null; + +export function getRedis(): Redis | null { + if (client) return client; + + const url = process.env.REDIS_URL; + if (!url) { + if (process.env.NODE_ENV === "production") { + console.warn("[redis] REDIS_URL 未配置,限流和风控功能将无法使用"); + } + return null; + } + + if (process.env.NODE_ENV !== "production" && globalThis.__redis) { + return globalThis.__redis; + } + + client = new Redis(url, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + lazyConnect: true, + }); + + client.on("error", (err) => { + console.error("[redis] error:", err.message); + }); + + if (process.env.NODE_ENV !== "production") { + globalThis.__redis = client; + } + return client; +}