feat(api): add REST API routes (artists/ranking/me/vote/signin) + Redis rate limiting + Zod validation

This commit is contained in:
iye 2026-05-12 09:59:38 +08:00
parent 91a0dd0f05
commit 175276a085
15 changed files with 829 additions and 2 deletions

44
.env.example Normal file
View File

@ -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"

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@ -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",

70
pnpm-lock.yaml generated
View File

@ -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

View File

@ -5,7 +5,6 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {

View File

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

View File

@ -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<ReturnType<typeof prisma.artist.findMany>>[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();
}
}

126
src/app/api/me/route.ts Normal file
View File

@ -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<typeof prisma.fanSupport.findMany>
>[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<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 },
_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()
);
}

View File

@ -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;
}

View File

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

159
src/app/api/vote/route.ts Normal file
View File

@ -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;
}

46
src/lib/api-response.ts Normal file
View File

@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
/**
* API
*
* { ok: true, data: ... }
* { ok: false, error: { code, message } }
*/
export type ApiOk<T> = { ok: true; data: T };
export type ApiErr = { ok: false; error: { code: string; message: string } };
export type ApiResponse<T> = ApiOk<T> | ApiErr;
export function ok<T>(data: T, init?: ResponseInit) {
return NextResponse.json<ApiOk<T>>({ ok: true, data }, init);
}
export function err(code: string, message: string, status = 400) {
return NextResponse.json<ApiErr>(
{ 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<T>(value: T): T {
return JSON.parse(
JSON.stringify(value, (_, v) => (typeof v === "bigint" ? v.toString() : v)),
) as T;
}

45
src/lib/current-user.ts Normal file
View File

@ -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<CurrentUser | null> {
// 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<string | null> {
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<string | null> {
const h = await headers();
return h.get("user-agent");
}

66
src/lib/rate-limit.ts Normal file
View File

@ -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<string, { count: number; expiresAt: number }>();
export async function rateLimit(
key: string,
windowSec: number,
limit: number,
): Promise<RateLimitResult> {
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,
};
}

47
src/lib/redis.ts Normal file
View File

@ -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;
}