UI-UX/prisma/schema.prisma
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- 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>
2026-05-15 20:14:57 +08:00

294 lines
11 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// =============================================================
// 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 // 封号
}