feat(api): add REST API routes (artists/ranking/me/vote/signin) + Redis rate limiting + Zod validation
This commit is contained in:
parent
91a0dd0f05
commit
175276a085
44
.env.example
Normal file
44
.env.example
Normal 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
1
.gitignore
vendored
@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@ -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
70
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
||||
27
src/app/api/artists/[id]/route.ts
Normal file
27
src/app/api/artists/[id]/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
60
src/app/api/artists/route.ts
Normal file
60
src/app/api/artists/route.ts
Normal 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
126
src/app/api/me/route.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
89
src/app/api/me/signin/route.ts
Normal file
89
src/app/api/me/signin/route.ts
Normal 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;
|
||||
}
|
||||
46
src/app/api/ranking/route.ts
Normal file
46
src/app/api/ranking/route.ts
Normal 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
159
src/app/api/vote/route.ts
Normal 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
46
src/lib/api-response.ts
Normal 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
45
src/lib/current-user.ts
Normal 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
66
src/lib/rate-limit.ts
Normal 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
47
src/lib/redis.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user