feat(db): wire real persistence for votes / users / quota / supports
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>
This commit is contained in:
iye 2026-05-13 17:32:38 +08:00
parent 58da508e7d
commit a9f4799f71
6 changed files with 130 additions and 125 deletions

View File

@ -19,25 +19,39 @@ datasource db {
// 艺人 · 候选偶像
// =============================================================
model Artist {
id String @id @db.VarChar(8) // 编号 001 ~ 035
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) // 英文名
slogan String @db.VarChar(120) // 短宣传语
bio String @db.Text // 详细简介
birthday String @db.VarChar(8) // MM-DD
bio String @db.Text // 详细简介 / 人物小传
height Int @db.SmallInt // cm
cv String? @db.VarChar(80) // CV 配音
themeColor String @map("theme_color") @db.VarChar(10) // 应援色 hex
portrait String? @db.VarChar(500) // 立绘主图 URL
avatar String? @db.VarChar(500) // 圆形头像 URL
videoUrl String? @map("video_url") @db.VarChar(500) // 15s 表演视频
videoPoster String? @map("video_poster") @db.VarChar(500) // 视频封面
tags Json @db.Json // string[] 标签数组
// 人物小传字段从《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")
/// 缓存字段当前排名1 ~ 35。同上由后台计算。
/// 缓存字段:当前排名。同上由后台计算。
currentRank Int? @map("current_rank")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@ -1,70 +1,28 @@
/**
* Prisma · 35 +
* Prisma · 36 + DB
*
* src/lib/artist-bios.ts (36 .docx)
* SSG + DB seed ,,
* artist-bios.ts, seed
*
* pnpm db:seed
*
* portrait / videoUrl TOS URL DB NEXT_PUBLIC_TOS_DOMAIN
* ,DB NULL (, seed)
*/
import { PrismaClient } from "@prisma/client";
import { PrismaClient, Prisma } from "@prisma/client";
import { ARTIST_SEEDS } from "../src/lib/artist-bios";
const prisma = new PrismaClient();
interface SeedArtist {
no: string;
name: string;
enName: string;
slogan: string;
themeColor: string;
birthday: string;
height: number;
tags: string[];
hasCV: boolean;
}
const STAGE_NAMES: SeedArtist[] = [
{ no: "001", name: "艺奈", enName: "AURORA", slogan: "破晓极光", themeColor: "#8b5cf6", birthday: "01-15", height: 165, tags: ["vocal", "visual"], hasCV: true },
{ no: "002", name: "路米", enName: "LUMI", slogan: "暖光治愈", themeColor: "#ec4899", birthday: "02-22", height: 163, tags: ["dance", "all-rounder"], hasCV: true },
{ no: "003", name: "星澪", enName: "NEBULA", slogan: "星云吟唱", themeColor: "#06b6d4", birthday: "03-08", height: 167, tags: ["rap", "leader"], hasCV: true },
{ no: "004", name: "凯", enName: "KAI", slogan: "海岸少年", themeColor: "#f59e0b", birthday: "04-12", height: 178, tags: ["all-rounder"], hasCV: true },
{ no: "005", name: "回音", enName: "ECHO", slogan: "声波女王", themeColor: "#10b981", birthday: "05-30", height: 164, tags: ["vocal", "leader"], hasCV: true },
{ no: "006", name: "薇尔", enName: "VEIL", slogan: "薄雾低语", themeColor: "#ef4444", birthday: "06-18", height: 162, tags: ["dance", "visual"], hasCV: true },
{ no: "007", name: "艾莉雅", enName: "ARIA", slogan: "咏叹之声", themeColor: "#a78bfa", birthday: "07-25", height: 168, tags: ["vocal"], hasCV: true },
{ no: "008", name: "怜", enName: "REN", slogan: "莲华少女", themeColor: "#f472b6", birthday: "08-09", height: 161, tags: ["rap", "all-rounder"], hasCV: true },
{ no: "009", name: "米拉", enName: "MIRA", slogan: "镜面舞者", themeColor: "#38bdf8", birthday: "09-14", height: 166, tags: ["dance"], hasCV: true },
{ no: "010", name: "诺娃", enName: "NOVA", slogan: "超新星", themeColor: "#fbbf24", birthday: "10-31", height: 165, tags: ["visual", "all-rounder"], hasCV: true },
{ no: "011", name: "纪罗", enName: "KIRO", slogan: "Rap 制造机", themeColor: "#34d399", birthday: "11-11", height: 175, tags: ["rap"], hasCV: true },
{ no: "012", name: "瑞", enName: "ZUI", slogan: "醉月夜", themeColor: "#fb7185", birthday: "12-24", height: 169, tags: ["vocal", "dance"], hasCV: true },
{ no: "013", name: "阳", enName: "SOL", slogan: "阳光少年", themeColor: "#fcd34d", birthday: "01-08", height: 172, tags: ["all-rounder"], hasCV: false },
{ no: "014", name: "凛", enName: "LIN", slogan: "学院偶像", themeColor: "#8b5cf6", birthday: "02-14", height: 168, tags: ["vocal"], hasCV: false },
{ no: "015", name: "律", enName: "LYRA", slogan: "竖琴公主", themeColor: "#a78bfa", birthday: "03-22", height: 164, tags: ["vocal", "visual"], hasCV: false },
{ no: "016", name: "昕", enName: "DAWN", slogan: "晨曦少女", themeColor: "#f472b6", birthday: "04-05", height: 166, tags: ["dance"], hasCV: false },
{ no: "017", name: "天", enName: "SKY", slogan: "天空之翼", themeColor: "#38bdf8", birthday: "05-19", height: 170, tags: ["all-rounder"], hasCV: false },
{ no: "018", name: "语", enName: "ARIE", slogan: "诗与远方", themeColor: "#10b981", birthday: "06-30", height: 163, tags: ["vocal"], hasCV: false },
{ no: "019", name: "翼", enName: "WING", slogan: "飞翔之翼", themeColor: "#ef4444", birthday: "07-15", height: 174, tags: ["dance", "all-rounder"], hasCV: false },
{ no: "020", name: "铃", enName: "CHIME", slogan: "风铃声", themeColor: "#fbbf24", birthday: "08-21", height: 162, tags: ["vocal"], hasCV: false },
{ no: "021", name: "夜", enName: "NYX", slogan: "暗夜女神", themeColor: "#7c3aed", birthday: "09-28", height: 167, tags: ["visual", "rap"], hasCV: false },
{ no: "022", name: "晴", enName: "SUNNY", slogan: "晴空万里", themeColor: "#facc15", birthday: "10-06", height: 165, tags: ["all-rounder"], hasCV: false },
{ no: "023", name: "月", enName: "LUNA", slogan: "月光女神", themeColor: "#c4b5fd", birthday: "11-25", height: 168, tags: ["vocal", "visual"], hasCV: false },
{ no: "024", name: "岚", enName: "STORM", slogan: "暴风之子", themeColor: "#0ea5e9", birthday: "12-13", height: 176, tags: ["rap"], hasCV: false },
{ no: "025", name: "雷", enName: "BOLT", slogan: "雷霆速度", themeColor: "#eab308", birthday: "01-29", height: 173, tags: ["dance"], hasCV: false },
{ no: "026", name: "焰", enName: "FLARE", slogan: "火焰之心", themeColor: "#dc2626", birthday: "02-08", height: 169, tags: ["all-rounder"], hasCV: false },
{ no: "027", name: "雪", enName: "FROST", slogan: "霜花少女", themeColor: "#e0e7ff", birthday: "03-15", height: 161, tags: ["vocal"], hasCV: false },
{ no: "028", name: "林", enName: "LEAF", slogan: "森林精灵", themeColor: "#22c55e", birthday: "04-22", height: 164, tags: ["dance", "all-rounder"], hasCV: false },
{ no: "029", name: "渊", enName: "ABYSS", slogan: "深渊之声", themeColor: "#1e293b", birthday: "05-11", height: 171, tags: ["rap"], hasCV: false },
{ no: "030", name: "瑶", enName: "JADE", slogan: "翡翠少女", themeColor: "#14b8a6", birthday: "06-27", height: 163, tags: ["visual"], hasCV: false },
{ no: "031", name: "晨", enName: "AURIA", slogan: "金色晨光", themeColor: "#f59e0b", birthday: "07-04", height: 166, tags: ["all-rounder"], hasCV: false },
{ no: "032", name: "岩", enName: "ROCK", slogan: "硬核摇滚", themeColor: "#78716c", birthday: "08-16", height: 177, tags: ["rap"], hasCV: false },
{ no: "033", name: "翔", enName: "SOAR", slogan: "翱翔天际", themeColor: "#0284c7", birthday: "09-02", height: 175, tags: ["dance"], hasCV: false },
{ no: "034", name: "茉", enName: "MOLLY", slogan: "茉莉芬芳", themeColor: "#fef3c7", birthday: "10-19", height: 162, tags: ["visual", "vocal"], hasCV: false },
{ no: "035", name: "梓", enName: "AZUR", slogan: "蓝调诗人", themeColor: "#6366f1", birthday: "11-07", height: 165, tags: ["all-rounder"], hasCV: false },
];
async function main() {
console.log("🌱 开始 seed 数据库...");
// 1. 创建活动配置
// 1. 活动配置 (upsert: 第一次创建, 后续仅延长 endAt)
const now = new Date();
const endAt = new Date(now);
endAt.setDate(endAt.getDate() + 12);
endAt.setDate(endAt.getDate() + 30); // 默认活动期 30 天
await prisma.activityConfig.upsert({
where: { id: 1 },
@ -74,47 +32,71 @@ async function main() {
endAt,
voteEnabled: true,
dailyQuota: 10,
perArtistLimit: 0,
perArtistLimit: 0, // 不限单艺人
paidVoteEnabled: false,
},
update: {
endAt,
voteEnabled: true,
dailyQuota: 10,
perArtistLimit: 0,
},
});
console.log(" ✓ 活动配置已写入");
console.log(" ✓ 活动配置已写入 (dailyQuota=10, voteEnabled=true)");
// 2. 创建 35 位艺人
for (const a of STAGE_NAMES) {
await prisma.artist.upsert({
where: { id: a.no },
create: {
id: a.no,
no: a.no,
name: a.name,
enName: a.enName,
slogan: a.slogan,
bio: `来自虚拟星域的偶像候选人 ${a.enName}${a.name}),从小热爱音乐与舞蹈。代表作《${a.enName} - ${a.slogan}》深受粉丝喜爱。立志成为 Top12 出道阵容的一员,用音乐传递梦想与力量。`,
birthday: a.birthday,
height: a.height,
cv: a.hasCV ? `CV 配音 #${a.no}` : null,
themeColor: a.themeColor,
tags: a.tags,
status: "ACTIVE",
voteCount: 0,
currentRank: parseInt(a.no, 10),
},
update: {
name: a.name,
enName: a.enName,
slogan: a.slogan,
themeColor: a.themeColor,
tags: a.tags,
},
});
// 2. 36 位艺人 (upsert: 若 DB 里有旧 seed 的假数据, 覆盖为真实姓名/简介)
let created = 0;
let updated = 0;
for (const seed of ARTIST_SEEDS) {
const existing = await prisma.artist.findUnique({ where: { id: seed.no } });
const data = {
no: seed.no,
name: seed.name,
enName: seed.enName,
bio: seed.bio,
height: seed.height,
age: seed.age,
gender: seed.gender,
motto: seed.motto ?? null,
personality: seed.personality ?? null,
catchphrase: seed.catchphrase ?? null,
skills: seed.skills ?? null,
track: seed.track ?? null,
tags: seed.tags as unknown as Prisma.InputJsonValue,
};
if (existing) {
await prisma.artist.update({
where: { id: seed.no },
data,
});
updated++;
} else {
await prisma.artist.create({
data: {
id: seed.no,
...data,
status: "ACTIVE",
voteCount: 0,
currentRank: parseInt(seed.no, 10),
},
});
created++;
}
}
console.log(` ✓ 已写入 ${STAGE_NAMES.length} 位艺人`);
console.log(
` ✓ 36 人 seed 完成 (新建 ${created}, 更新 ${updated})`,
);
// 3. 报告 DB 当前状态
const stats = {
artists: await prisma.artist.count({ where: { status: "ACTIVE" } }),
users: await prisma.user.count(),
votes: await prisma.vote.count(),
config: await prisma.activityConfig.count(),
};
console.log("📊 DB 当前状态:", stats);
console.log("✅ Seed 完成");
}

View File

@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/current-user";
import { startOfUtcDay, isSameUtcDay } from "@/lib/date-utils";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
/**
@ -11,7 +12,7 @@ export async function GET() {
const user = await getCurrentUser();
if (!user) return ERR.UNAUTHORIZED();
const today = startOfDay();
const today = startOfUtcDay();
type SupportRow = Awaited<
ReturnType<typeof prisma.fanSupport.findMany>
@ -86,7 +87,7 @@ export async function GET() {
signIn: {
streak: signIn?.streak ?? 0,
lastDate: signIn?.date ?? null,
todaySignedIn: signIn ? sameDay(signIn.date, today) : false,
todaySignedIn: signIn ? isSameUtcDay(signIn.date, today) : false,
},
totalVotes: totalVotes._sum.count ?? 0,
dailyQuota: {
@ -106,16 +107,3 @@ export async function GET() {
}
}
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()
);
}

View File

@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/current-user";
import { startOfUtcDay } from "@/lib/date-utils";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
/**
@ -12,7 +13,7 @@ export async function POST() {
const user = await getCurrentUser();
if (!user) return ERR.UNAUTHORIZED();
const today = startOfDay();
const today = startOfUtcDay();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
@ -56,8 +57,3 @@ export async function POST() {
}
}
function startOfDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}

View File

@ -8,6 +8,7 @@ import {
getClientIp,
getUserAgent,
} from "@/lib/current-user";
import { startOfUtcDay } from "@/lib/date-utils";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
type TxClient = Prisma.TransactionClient;
@ -66,7 +67,7 @@ export async function POST(req: NextRequest) {
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
const ua = await getUserAgent();
const today = startOfDay();
const today = startOfUtcDay();
const dailyQuota = config.dailyQuota;
try {
@ -114,8 +115,10 @@ export async function POST(req: NextRequest) {
});
// 5. 扣减当日额度
// 用上一步 upsert 返回的 dq.id 做主键更新, 避免 MySQL @db.Date 字段
// 经时区转换后 userId_date 复合键查不到行 (P2025)
const updatedDq = await tx.dailyQuota.update({
where: { userId_date: { userId: user.id, date: today } },
where: { id: dq.id },
data: { usedQuota: { increment: count } },
});
@ -149,8 +152,3 @@ export async function POST(req: NextRequest) {
}
}
function startOfDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}

27
src/lib/date-utils.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* · API , / bug
*
* UTC
* MySQL @db.Date setHours(0,0,0,0) ,
* JS Date UTC +8 16:00 UTC, Prisma + MySQL
* TZ , "日期""日期", dailyQuota (userId, date)
* upsert (P2025)
*
* setUTCHours(0,0,0,0) "今天的 UTC 0 点", / TZ,
* 代价: 中国用户在 08:00 (UTC 0 )"昨天"
* (, "运营日")
*/
export function startOfUtcDay(d = new Date()): Date {
const x = new Date(d);
x.setUTCHours(0, 0, 0, 0);
return x;
}
/** 判断两个 Date 是否同一个 UTC 日期 */
export function isSameUtcDay(a: Date, b: Date): boolean {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
);
}