diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44d5988..6d59fc1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/prisma/seed.ts b/prisma/seed.ts index 787cd26..c6dd49b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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 完成"); } diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index 1491b83..7e189e6 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -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 @@ -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() - ); -} diff --git a/src/app/api/me/signin/route.ts b/src/app/api/me/signin/route.ts index ade4ebc..d246851 100644 --- a/src/app/api/me/signin/route.ts +++ b/src/app/api/me/signin/route.ts @@ -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; -} diff --git a/src/app/api/vote/route.ts b/src/app/api/vote/route.ts index c0ed24b..8fd3030 100644 --- a/src/app/api/vote/route.ts +++ b/src/app/api/vote/route.ts @@ -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; -} diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts new file mode 100644 index 0000000..835eeac --- /dev/null +++ b/src/lib/date-utils.ts @@ -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() + ); +}