feat(db): wire real persistence for votes / users / quota / supports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m26s
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:
parent
58da508e7d
commit
a9f4799f71
@ -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")
|
||||
|
||||
152
prisma/seed.ts
152
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 完成");
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
27
src/lib/date-utils.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user