Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - store 改为 votedArtists[] + zustand persist - VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额) - 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标 - Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度) - /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport) 后端: - votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS) - /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底 - /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota - 新增 ERR.ALREADY_VOTED 错误码 测试: - DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh) - 修复 P2003 FK 违反未识别的 bug 详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
11 KiB
Plaintext
294 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")
|
||
// 投票规则:每用户对每艺人仅可投 1 票 —— DB 硬约束,防止并发绕过前端
|
||
@@unique([userId, artistId])
|
||
// 关键索引:艺人聚合 / 时序查询
|
||
@@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 // 封号
|
||
}
|