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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@ -17,12 +17,14 @@
|
|||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"tailwind-merge": "^3.6.0"
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@ -17,6 +17,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.14.0(react@19.2.4)
|
version: 1.14.0(react@19.2.4)
|
||||||
@ -35,6 +38,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
@ -513,6 +519,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1':
|
||||||
|
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@ -1096,6 +1105,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@ -1173,6 +1186,10 @@ packages:
|
|||||||
defu@6.1.7:
|
defu@6.1.7:
|
||||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
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:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
@ -1580,6 +1597,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
is-array-buffer@3.0.5:
|
||||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1834,6 +1855,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
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:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@ -2099,6 +2126,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2206,6 +2241,9 @@ packages:
|
|||||||
stable-hash@0.0.5:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2770,6 +2808,8 @@ snapshots:
|
|||||||
'@img/sharp-win32-x64@0.34.5':
|
'@img/sharp-win32-x64@0.34.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@ -3329,6 +3369,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@ -3397,6 +3439,8 @@ snapshots:
|
|||||||
|
|
||||||
defu@6.1.7: {}
|
defu@6.1.7: {}
|
||||||
|
|
||||||
|
denque@2.1.0: {}
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@ -3971,6 +4015,20 @@ snapshots:
|
|||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
side-channel: 1.1.0
|
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:
|
is-array-buffer@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@ -4203,6 +4261,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
@ -4458,6 +4520,12 @@ snapshots:
|
|||||||
|
|
||||||
readdirp@4.1.2: {}
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@ -4620,6 +4688,8 @@ snapshots:
|
|||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
|
standard-as-callback@2.1.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../node_modules/.prisma/client"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
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