All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m26s
数据正式落库, 不再仅靠浏览器内存: prisma/schema.prisma: - Artist 模型对齐当前前端数据形态: * 旧字段 slogan / birthday / cv / themeColor 改为可选 (前端早不用, 但保留兼容历史 seed) * 新增 age / gender / motto / personality / catchphrase / skills / track (来自人物小传) - 注释从 "001 ~ 035" 改 "001 ~ 036" prisma/seed.ts: - 整体重写: 从 src/lib/artist-bios.ts 的 ARTIST_SEEDS 灌真实 36 人 - 不再写假数据 (AURORA / LUMI / NEBULA...) - portrait / videoUrl 不入库 (前端 NEXT_PUBLIC_TOS_DOMAIN 拼接, 换桶不用 reseed) - ActivityConfig 默认 dailyQuota=10, perArtistLimit=0, voteEnabled=true, 活动期 30 天 src/lib/date-utils.ts (新增): - startOfUtcDay(): 修复"今日"在 MySQL @db.Date 列与 JS Date 之间的 TZ 漂移 - isSameUtcDay(): 共享给签到判断 修复 P2025 bug (vote / me / signin): - 用 startOfUtcDay 替代 startOfDay (后者用 setHours 取本地午夜, 对 @db.Date 列会因 TZ 漂移导致 upsert 后再用 userId_date 复合键查找失败) - /api/vote 的扣额度从 userId_date 改用 dq.id 主键 update, 双保险 - 三个路由的 startOfDay 重复实现合并到 lib/date-utils E2E 验证 (curl): 登录 → 投 5 给 002 → 余 5 ✓ 投 3 给 003 → 余 2 / totalVotes 8 ✓ /api/me supports 反映 002+003 真实 voteTotal ✓ 超额 (5 票 余 2) → 409 QUOTA_EXHAUSTED ✓ /api/ranking 票数实时反映 DB ✓ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
11 KiB
Plaintext
293 lines
11 KiB
Plaintext
// =============================================================
|
||
// CYBER STAR · Prisma Schema
|
||
// 数据库:MySQL 8 · 部署:火山引擎 RDS
|
||
// =============================================================
|
||
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
// native:本地开发(macOS / linux-glibc)
|
||
// linux-musl-openssl-3.0.x:容器运行时(node:22-alpine)
|
||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||
}
|
||
|
||
datasource db {
|
||
provider = "mysql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
// =============================================================
|
||
// 艺人 · 候选偶像
|
||
// =============================================================
|
||
model Artist {
|
||
id String @id @db.VarChar(8) // 编号 001 ~ 036
|
||
no String @unique @db.VarChar(8) // 展示用编号(带前置零)
|
||
name String @db.VarChar(50) // 中文名
|
||
enName String @map("en_name") @db.VarChar(50) // 英文名
|
||
bio String @db.Text // 详细简介 / 人物小传
|
||
height Int @db.SmallInt // cm
|
||
|
||
// 人物小传字段(从《36 位虚拟艺人人物小传.docx》提取)
|
||
age Int? @db.SmallInt // 年龄
|
||
gender String? @db.VarChar(1) // 'M' / 'F' / 'N'
|
||
motto String? @db.VarChar(200) // 座右铭
|
||
personality String? @db.Text // 性格描述
|
||
catchphrase String? @db.VarChar(200) // 口头禅
|
||
skills String? @db.VarChar(200) // 核心技能(顿号分隔)
|
||
track String? @db.VarChar(200) // 核心赛道(顿号分隔)
|
||
|
||
// 媒体资源(URL 走 TOS 桶 + CDN)
|
||
portrait String? @db.VarChar(500)
|
||
avatar String? @db.VarChar(500)
|
||
videoUrl String? @map("video_url") @db.VarChar(500)
|
||
videoPoster String? @map("video_poster") @db.VarChar(500)
|
||
|
||
// 历史遗留字段(前端已不再使用,保留为可选避免破坏既有数据)
|
||
slogan String? @db.VarChar(120)
|
||
birthday String? @db.VarChar(8) // MM-DD
|
||
cv String? @db.VarChar(80)
|
||
themeColor String? @map("theme_color") @db.VarChar(10)
|
||
|
||
tags Json @db.Json // ArtistTag[] · 现在是音乐流派 (rock/pop/chinese/hiphop/folk/jazz)
|
||
status ArtistStatus @default(ACTIVE)
|
||
/// 缓存字段:当前票数。定期由后台聚合任务更新,避免实时 SUM(votes)。
|
||
voteCount Int @default(0) @map("vote_count")
|
||
/// 缓存字段:当前排名。同上由后台计算。
|
||
currentRank Int? @map("current_rank")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
images ArtistImage[]
|
||
votes Vote[]
|
||
supports FanSupport[]
|
||
snapshots RankingSnapshot[]
|
||
|
||
@@map("artists")
|
||
@@index([voteCount(sort: Desc)])
|
||
}
|
||
|
||
enum ArtistStatus {
|
||
ACTIVE // 正常参赛
|
||
WITHDRAWN // 退赛
|
||
DISQUALIFIED // 取消资格
|
||
}
|
||
|
||
// 艺人多张展示图(定妆/表演/幕后等)
|
||
model ArtistImage {
|
||
id BigInt @id @default(autoincrement())
|
||
artistId String @map("artist_id") @db.VarChar(8)
|
||
url String @db.VarChar(500)
|
||
type String @db.VarChar(20) // 'portrait' | 'performance' | 'backstage' | 'stage'
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("artist_images")
|
||
@@index([artistId, sortOrder])
|
||
}
|
||
|
||
// =============================================================
|
||
// 用户
|
||
// =============================================================
|
||
model User {
|
||
id BigInt @id @default(autoincrement())
|
||
phone String? @unique @db.VarChar(20) // E.164 格式
|
||
email String? @unique @db.VarChar(120)
|
||
openId String? @unique @map("open_id") @db.VarChar(120) // 微信 openid
|
||
unionId String? @unique @map("union_id") @db.VarChar(120) // 微信 unionid
|
||
nickname String @db.VarChar(80)
|
||
avatar String? @db.VarChar(500)
|
||
loginType LoginType @default(PHONE) @map("login_type")
|
||
registerIp String? @map("register_ip") @db.VarChar(45) // IPv4/IPv6
|
||
deviceFingerprint String? @map("device_fingerprint") @db.VarChar(120)
|
||
/// 风控等级 · 数值越大风险越高,由风控系统更新
|
||
riskLevel Int @default(0) @map("risk_level") @db.TinyInt
|
||
status UserStatus @default(NORMAL)
|
||
lastLoginAt DateTime? @map("last_login_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
votes Vote[]
|
||
quotas DailyQuota[]
|
||
signIns SignIn[]
|
||
supports FanSupport[]
|
||
invitedBy Invitation[] @relation("invitee")
|
||
invitations Invitation[] @relation("inviter")
|
||
|
||
@@map("users")
|
||
@@index([phone])
|
||
@@index([deviceFingerprint])
|
||
}
|
||
|
||
enum LoginType {
|
||
PHONE // 手机号 + OTP
|
||
WECHAT // 微信扫码
|
||
EMAIL // 邮箱(境外)
|
||
}
|
||
|
||
enum UserStatus {
|
||
NORMAL // 正常
|
||
WARNED // 警告
|
||
BANNED // 封禁
|
||
}
|
||
|
||
// =============================================================
|
||
// 投票记录(核心热表)
|
||
// =============================================================
|
||
model Vote {
|
||
id BigInt @id @default(autoincrement())
|
||
userId BigInt @map("user_id")
|
||
artistId String @map("artist_id") @db.VarChar(8)
|
||
count Int @default(1) // 单次投票数
|
||
source VoteSource @default(QUOTA)
|
||
ip String? @db.VarChar(45)
|
||
ua String? @db.VarChar(500) // user agent
|
||
fingerprint String? @db.VarChar(120) // 设备指纹快照
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("votes")
|
||
// 关键索引:用户每日单艺人查询、艺人聚合
|
||
@@index([userId, artistId, createdAt])
|
||
@@index([artistId, createdAt])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
enum VoteSource {
|
||
QUOTA // 每日基础票
|
||
SIGNIN // 签到额外票
|
||
SHARE // 分享得票
|
||
INVITE // 邀请奖励
|
||
PAID // 付费购票(若开放)
|
||
}
|
||
|
||
// =============================================================
|
||
// 每日票数余额
|
||
// =============================================================
|
||
model DailyQuota {
|
||
id BigInt @id @default(autoincrement())
|
||
userId BigInt @map("user_id")
|
||
date DateTime @db.Date
|
||
totalQuota Int @default(12) @map("total_quota") // 当日总票数(基础 + 奖励)
|
||
usedQuota Int @default(0) @map("used_quota") // 已用
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("daily_quota")
|
||
@@unique([userId, date])
|
||
}
|
||
|
||
// =============================================================
|
||
// 签到记录
|
||
// =============================================================
|
||
model SignIn {
|
||
id BigInt @id @default(autoincrement())
|
||
userId BigInt @map("user_id")
|
||
date DateTime @db.Date
|
||
streak Int @default(1) // 连续签到天数
|
||
bonusVotes Int @default(1) @map("bonus_votes") // 奖励票数
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("sign_ins")
|
||
@@unique([userId, date])
|
||
}
|
||
|
||
// =============================================================
|
||
// 应援关系(用户 ❤ 艺人)· 用于"我的应援"区
|
||
// =============================================================
|
||
model FanSupport {
|
||
id BigInt @id @default(autoincrement())
|
||
userId BigInt @map("user_id")
|
||
artistId String @map("artist_id") @db.VarChar(8)
|
||
votedTotal Int @default(0) @map("voted_total") // 累计已投给该艺人的票数
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("fan_supports")
|
||
@@unique([userId, artistId])
|
||
}
|
||
|
||
// =============================================================
|
||
// 邀请记录(病毒传播)
|
||
// =============================================================
|
||
model Invitation {
|
||
id BigInt @id @default(autoincrement())
|
||
inviterId BigInt @map("inviter_id")
|
||
inviteeId BigInt @unique @map("invitee_id") // 被邀请人只能被邀请一次
|
||
bonusGiven Boolean @default(false) @map("bonus_given") // 是否已发放奖励
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
inviter User @relation("inviter", fields: [inviterId], references: [id], onDelete: Cascade)
|
||
invitee User @relation("invitee", fields: [inviteeId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("invitations")
|
||
@@index([inviterId])
|
||
}
|
||
|
||
// =============================================================
|
||
// 排名快照(用于历史趋势 / 大屏展示)
|
||
// =============================================================
|
||
model RankingSnapshot {
|
||
id BigInt @id @default(autoincrement())
|
||
artistId String @map("artist_id") @db.VarChar(8)
|
||
date DateTime @db.DateTime(0) // 精确到小时的快照
|
||
voteCount Int @map("vote_count")
|
||
rank Int
|
||
|
||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("ranking_snapshots")
|
||
@@unique([artistId, date])
|
||
@@index([date])
|
||
}
|
||
|
||
// =============================================================
|
||
// 活动配置(开关 / 时间 / 规则)
|
||
// =============================================================
|
||
model ActivityConfig {
|
||
id Int @id @default(1) // 单行配置
|
||
startAt DateTime @map("start_at")
|
||
endAt DateTime @map("end_at")
|
||
voteEnabled Boolean @default(true) @map("vote_enabled") // 紧急停止开关
|
||
dailyQuota Int @default(12) @map("daily_quota")
|
||
perArtistLimit Int @default(3) @map("per_artist_limit") // 每艺人每日上限
|
||
paidVoteEnabled Boolean @default(false) @map("paid_vote_enabled")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@map("activity_config")
|
||
}
|
||
|
||
// =============================================================
|
||
// 风控日志(异常投票审计)
|
||
// =============================================================
|
||
model RiskLog {
|
||
id BigInt @id @default(autoincrement())
|
||
userId BigInt? @map("user_id")
|
||
ip String? @db.VarChar(45)
|
||
fingerprint String? @db.VarChar(120)
|
||
rule String @db.VarChar(80) // 命中的风控规则
|
||
detail Json? @db.Json
|
||
action RiskAction @default(WARN)
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@map("risk_logs")
|
||
@@index([userId, createdAt])
|
||
@@index([ip, createdAt])
|
||
}
|
||
|
||
enum RiskAction {
|
||
WARN // 仅记录
|
||
CAPTCHA // 触发验证码
|
||
BLOCK // 拦截本次操作
|
||
BAN // 封号
|
||
}
|