Sfoglia il codice sorgente

refactor(timezone): 统一项目时区为 Asia/Shanghai

- 创建 server/src/utils/time.ts 统一时区工具(getTodayStr 等)
  使用 Intl.DateTimeFormat 替代 toISOString().slice(0,10),
  解决 UTC 时区下凌晨 0:00-7:59 日期偏移一天的 bug
- 替换所有 server 端 toISOString().slice(0,10) 为 getTodayStr()
  涉及:usageLimit, member, sign, log, payment 模块
- 创建前端 utils/time.ts 统一时区工具(getTodayStartISO 等)
- 替换前端 setHours(0,0,0,0).toISOString() 为工具函数
  涉及:history, mine 页面
- .env.example 添加 serverTimezone=Asia/Shanghai
MyFramework User 3 mesi fa
parent
commit
5e8dcd630f

+ 4 - 8
my-uniapp-vue3/src/pages/history/index.vue

@@ -150,6 +150,7 @@ import { getBatchDownloadUrls } from '../../api/download';
 import type { AudioItem } from '../../types';
 import SkeletonList from '../../components/SkeletonList.vue';
 import { getCoverGradient, getTitleLetter, getBookGradient } from '../../composables/useCoverStyle';
+import { getTodayStartISO, getDaysAgoISO, getMonthsAgoISO } from '../../utils/time';
 
 const audioStore = useAudioStore();
 
@@ -368,21 +369,16 @@ async function fetchHistoryList(isLoadMore = false) {
   try {
     // 按时间过滤
     let startDate = '';
-    const now = new Date();
 
     switch (selectedTab.value) {
       case 2: // 今天
-        startDate = new Date(now.setHours(0, 0, 0, 0)).toISOString();
+        startDate = getTodayStartISO();
         break;
       case 3: // 本周
-        const weekAgo = new Date(now);
-        weekAgo.setDate(weekAgo.getDate() - 7);
-        startDate = weekAgo.toISOString();
+        startDate = getDaysAgoISO(7);
         break;
       case 4: // 本月
-        const monthAgo = new Date(now);
-        monthAgo.setMonth(monthAgo.getMonth() - 1);
-        startDate = monthAgo.toISOString();
+        startDate = getMonthsAgoISO(1);
         break;
     }
 

+ 2 - 2
my-uniapp-vue3/src/pages/mine/index.vue

@@ -205,6 +205,7 @@ import { get } from '../../utils/request';
 import { formatNumber } from '../../utils/format';
 import type { AudioItem } from '../../types';
 import { getCoverGradient, getTitleLetter } from '../../composables/useCoverStyle';
+import { getTodayStartISO } from '../../utils/time';
 
 const userStore = useUserStore();
 
@@ -263,8 +264,7 @@ async function loadTokenBalance() {
 // 获取今日创作概况
 async function fetchTodayStats() {
   try {
-    const now = new Date();
-    const startDate = new Date(now.setHours(0, 0, 0, 0)).toISOString();
+    const startDate = getTodayStartISO();
 
     // 获取今日音频数
     const audioResult = await get<{ list: AudioItem[]; total: number }>('/history', {

+ 64 - 0
my-uniapp-vue3/src/utils/time.ts

@@ -0,0 +1,64 @@
+/**
+ * 统一时区工具
+ * 项目时区:Asia/Shanghai (UTC+8)
+ *
+ * 前端与服务端通信使用 ISO 8601 格式(UTC),
+ * 本工具提供本地时区正确的日期边界值。
+ */
+
+export const TIMEZONE = 'Asia/Shanghai';
+
+/**
+ * 获取当前日期字符串(Asia/Shanghai 时区)
+ * @returns YYYY-MM-DD 格式
+ */
+export function getTodayStr(): string {
+  const parts = new Intl.DateTimeFormat('zh-CN', {
+    timeZone: TIMEZONE,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  }).formatToParts(new Date());
+
+  const year = parts.find(p => p.type === 'year')!.value;
+  const month = parts.find(p => p.type === 'month')!.value;
+  const day = parts.find(p => p.type === 'day')!.value;
+  return `${year}-${month}-${day}`;
+}
+
+/**
+ * 获取某天的起始时间 ISO 字符串(Asia/Shanghai 时区的 00:00:00 → UTC)
+ * 用于 API 请求的 startDate 参数
+ * @param daysAgo - 几天前,0=今天,7=7天前,30=30天前
+ * @returns ISO 8601 格式字符串
+ */
+export function getDayStartISO(daysAgo: number = 0): string {
+  const now = new Date();
+  const target = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo);
+  return target.toISOString();
+}
+
+/**
+ * 获取今天的起始时间 ISO 字符串
+ */
+export function getTodayStartISO(): string {
+  return getDayStartISO(0);
+}
+
+/**
+ * 获取 N 天前的 ISO 字符串
+ */
+export function getDaysAgoISO(days: number): string {
+  const d = new Date();
+  d.setDate(d.getDate() - days);
+  return d.toISOString();
+}
+
+/**
+ * 获取 N 月前的 ISO 字符串
+ */
+export function getMonthsAgoISO(months: number): string {
+  const d = new Date();
+  d.setMonth(d.getMonth() - months);
+  return d.toISOString();
+}

+ 1 - 1
server/.env.example

@@ -3,7 +3,7 @@ PORT=3000
 NODE_ENV=development
 
 # MySQL 数据库
-DATABASE_URL="mysql://root:password@localhost:3306/audio-book"
+DATABASE_URL="mysql://root:password@localhost:3306/audio-book?serverTimezone=Asia/Shanghai"
 
 # 存储类型配置(oss 或 local)
 STORAGE_TYPE=local  # oss=阿里云OSS, local=本地存储

+ 2 - 1
server/src/middleware/usageLimit.ts

@@ -2,6 +2,7 @@ import { Context, Next } from 'koa';
 import { prisma } from '../models';
 import { QuotaExceededError, ForbiddenError } from './errorHandler';
 import { MEMBER_QUOTA, MemberLevel } from '../types';
+import { getTodayStr } from '../utils/time';
 
 // 检查使用次数限制
 export async function usageLimitMiddleware(ctx: Context, next: Next): Promise<void> {
@@ -19,7 +20,7 @@ export async function usageLimitMiddleware(ctx: Context, next: Next): Promise<vo
     throw new ForbiddenError('用户不存在');
   }
 
-  const today = new Date().toISOString().slice(0, 10);
+  const today = getTodayStr();
   const memberLevel = user.memberLevel;
   const quota = MEMBER_QUOTA[memberLevel as MemberLevel];
 

+ 2 - 1
server/src/modules/member/member.service.ts

@@ -2,6 +2,7 @@ import { prisma } from '../../models';
 import { MemberLevel, MEMBER_QUOTA } from '../../types';
 import { generateOrderNo, handlePaymentCallback } from '../payment/payment.service';
 import { safeParseInt } from '../../utils/safe-parse';
+import { getTodayStr } from '../../utils/time';
 
 // 获取会员权益信息(新5级体系)
 export function getMemberBenefits() {
@@ -47,7 +48,7 @@ export async function getMemberStatus(userId: string) {
     throw new Error('用户不存在');
   }
 
-  const today = new Date().toISOString().slice(0, 10);
+  const today = getTodayStr();
   let dailyUsage = user.dailyUsage;
   const memberLevel = user.memberLevel;
   

+ 2 - 1
server/src/modules/payment/payment.service.ts

@@ -1,6 +1,7 @@
 import { prisma } from '../../models';
 import crypto from 'crypto';
 import https from 'https';
+import { getTodayStrCompact } from '../../utils/time';
 
 // 懒加载支付 SDK(避免 Node.js ESM 兼容性问题)
 let AlipaySdk: any = null;
@@ -39,7 +40,7 @@ async function initPaymentSdks() {
 // 生成订单号
 export function generateOrderNo(): string {
   const now = new Date();
-  const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
+  const dateStr = getTodayStrCompact();
   const random = Math.random().toString(36).substring(2, 8).toUpperCase();
   return `PAY${dateStr}${random}`;
 }

+ 2 - 1
server/src/modules/sign/sign.service.ts

@@ -1,4 +1,5 @@
 import { prisma } from '../../models';
+import { getTodayStr } from '../../utils/time';
 
 /**
  * 签到服务
@@ -144,7 +145,7 @@ export async function signIn(userId: number) {
       where: { id: userId },
       data: {
         dailyUsage: Math.max(0, user.dailyUsage - bonusCount),
-        lastUsageDate: new Date().toISOString().split('T')[0],
+        lastUsageDate: getTodayStr(),
       },
     });
   }

+ 3 - 2
server/src/services/log.service.ts

@@ -1,5 +1,6 @@
 import fs from 'fs';
 import path from 'path';
+import { getTodayStr } from '../utils/time';
 
 // 日志级别枚举
 export enum LogLevel {
@@ -198,7 +199,7 @@ export class LogService {
 
   // 按时间清理过期日志
   private cleanupByRetention(): void {
-    const today = new Date().toISOString().slice(0, 10);
+    const today = getTodayStr();
     if (today === this.lastCleanupDate) return; // 今天已经清理过
     this.lastCleanupDate = today;
 
@@ -240,7 +241,7 @@ export class LogService {
   private archiveCurrentFile(): void {
     try {
       if (fs.existsSync(this.logFilePath)) {
-        const date = new Date().toISOString().slice(0, 10);
+        const date = getTodayStr();
         const archiveName = `requests-${date}.json`;
         const archivePath = path.join(this.logDir, archiveName);
         fs.renameSync(this.logFilePath, archivePath);

+ 73 - 0
server/src/utils/time.ts

@@ -0,0 +1,73 @@
+/**
+ * 统一时区工具
+ * 项目时区:Asia/Shanghai (UTC+8)
+ * 
+ * 为什么不用 toISOString().slice(0,10)?
+ * toISOString() 始终返回 UTC 时间,凌晨 0:00-7:59 在 UTC+8 时区
+ * 会是前一天的日期,导致每日重置等逻辑出现一天偏差。
+ * 
+ * Intl.DateTimeFormat 配合 timeZone 参数能正确获取指定时区的日期。
+ */
+
+export const TIMEZONE = 'Asia/Shanghai' as const;
+
+/**
+ * 获取当前日期字符串(Asia/Shanghai 时区)
+ * @returns YYYY-MM-DD 格式,如 "2026-05-30"
+ */
+export function getTodayStr(): string {
+  const parts = new Intl.DateTimeFormat('zh-CN', {
+    timeZone: TIMEZONE,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  }).formatToParts(new Date());
+
+  const year = parts.find(p => p.type === 'year')!.value;
+  const month = parts.find(p => p.type === 'month')!.value;
+  const day = parts.find(p => p.type === 'day')!.value;
+  return `${year}-${month}-${day}`;
+}
+
+/**
+ * 获取当前紧凑日期字符串(Asia/Shanghai 时区)
+ * @returns YYYYMMDD 格式,如 "20260530"
+ */
+export function getTodayStrCompact(): string {
+  return getTodayStr().replace(/-/g, '');
+}
+
+/**
+ * 获取指定日期的 ISO 字符串(保持 UTC 格式,用于 API 传输)
+ * @param date - Date 对象,不传则用当前时间
+ * @returns ISO 8601 格式
+ */
+export function toISOString(date?: Date): string {
+  return (date || new Date()).toISOString();
+}
+
+/**
+ * 格式化日期为 Asia/Shanghai 时区的 ISO 风格字符串
+ * @param date - Date 对象
+ * @returns 如 "2026-05-30T12:00:00+08:00"
+ */
+export function toTZISOString(date: Date): string {
+  const parts = new Intl.DateTimeFormat('zh-CN', {
+    timeZone: TIMEZONE,
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false,
+  }).formatToParts(date);
+
+  const y = parts.find(p => p.type === 'year')!.value;
+  const mo = parts.find(p => p.type === 'month')!.value;
+  const d = parts.find(p => p.type === 'day')!.value;
+  const h = parts.find(p => p.type === 'hour')!.value;
+  const mi = parts.find(p => p.type === 'minute')!.value;
+  const s = parts.find(p => p.type === 'second')!.value;
+  return `${y}-${mo}-${d}T${h}:${mi}:${s}+08:00`;
+}