UI-UX/prisma/schema.prisma
iye a9f4799f71
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m26s
feat(db): wire real persistence for votes / users / quota / supports
数据正式落库, 不再仅靠浏览器内存:

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>
2026-05-13 17:32:38 +08:00

293 lines
11 KiB
Plaintext
Raw 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")
// 关键索引:用户每日单艺人查询、艺人聚合
@@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 // 封号
}