fix(timezone): 修复数据写入时的时区错误(关键修复)

- 修复 redis.js 中所有时区相关的日期获取方法
  - 使用 getUTC* 方法替代 get* 方法获取正确的时区日期
  - 影响:incrementTokenUsage, incrementAccountUsage, incrementDailyCost 等
- 修复 admin.js 中查询数据时的日期键生成
- 确保所有 Redis 键格式一致:
  - 日期:YYYY-MM-DD
  - 月份:YYYY-MM
  - 小时:YYYY-MM-DD:HH
- 添加服务端时间标签,避免前端时区转换问题

这是核心修复,确保数据从源头就是正确的。
This commit is contained in:
shaw
2025-07-30 10:07:25 +08:00
parent 5503004b66
commit 4c64e6df4b
5 changed files with 56 additions and 27 deletions

View File

@@ -3,24 +3,30 @@ const config = require('../../config/config');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
// 时区辅助函数 // 时区辅助函数
// 注意:这个函数的目的是获取某个时间点在目标时区的"本地"表示
// 例如UTC时间 2025-07-30 01:00:00 在 UTC+8 时区表示为 2025-07-30 09:00:00
function getDateInTimezone(date = new Date()) { function getDateInTimezone(date = new Date()) {
const offset = config.system.timezoneOffset || 8; // 默认UTC+8 const offset = config.system.timezoneOffset || 8; // 默认UTC+8
// 直接基于UTC时间计算目标时区时间
// 不需要考虑本地时区因为我们总是基于UTC // 方法创建一个偏移后的Date对象使其getUTCXXX方法返回目标时区的值
const targetTime = new Date(date.getTime() + (offset * 3600000)); // 这样我们可以用getUTCFullYear()等方法获取目标时区的年月日时分秒
return targetTime; const offsetMs = offset * 3600000; // 时区偏移的毫秒数
const adjustedTime = new Date(date.getTime() + offsetMs);
return adjustedTime;
} }
// 获取配置时区的日期字符串 (YYYY-MM-DD) // 获取配置时区的日期字符串 (YYYY-MM-DD)
function getDateStringInTimezone(date = new Date()) { function getDateStringInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date); const tzDate = getDateInTimezone(date);
return `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; // 使用UTC方法获取偏移后的日期部分
return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`;
} }
// 获取配置时区的小时 (0-23) // 获取配置时区的小时 (0-23)
function getHourInTimezone(date = new Date()) { function getHourInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date); const tzDate = getDateInTimezone(date);
return tzDate.getHours(); return tzDate.getUTCHours();
} }
class RedisClient { class RedisClient {
@@ -163,7 +169,7 @@ class RedisClient {
const now = new Date(); const now = new Date();
const today = getDateStringInTimezone(now); const today = getDateStringInTimezone(now);
const tzDate = getDateInTimezone(now); const tzDate = getDateInTimezone(now);
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; // 新增小时级别 const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; // 新增小时级别
const daily = `usage:daily:${keyId}:${today}`; const daily = `usage:daily:${keyId}:${today}`;
@@ -288,7 +294,7 @@ class RedisClient {
const now = new Date(); const now = new Date();
const today = getDateStringInTimezone(now); const today = getDateStringInTimezone(now);
const tzDate = getDateInTimezone(now); const tzDate = getDateInTimezone(now);
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`;
// 账户级别统计的键 // 账户级别统计的键
@@ -386,7 +392,7 @@ class RedisClient {
const today = getDateStringInTimezone(); const today = getDateStringInTimezone();
const dailyKey = `usage:daily:${keyId}:${today}`; const dailyKey = `usage:daily:${keyId}:${today}`;
const tzDate = getDateInTimezone(); const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`; const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`;
const [total, daily, monthly] = await Promise.all([ const [total, daily, monthly] = await Promise.all([
@@ -482,8 +488,8 @@ class RedisClient {
async incrementDailyCost(keyId, amount) { async incrementDailyCost(keyId, amount) {
const today = getDateStringInTimezone(); const today = getDateStringInTimezone();
const tzDate = getDateInTimezone(); const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`;
const dailyKey = `usage:cost:daily:${keyId}:${today}`; const dailyKey = `usage:cost:daily:${keyId}:${today}`;
const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`; const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`;
@@ -510,8 +516,8 @@ class RedisClient {
async getCostStats(keyId) { async getCostStats(keyId) {
const today = getDateStringInTimezone(); const today = getDateStringInTimezone();
const tzDate = getDateInTimezone(); const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}`;
const [daily, monthly, hourly, total] = await Promise.all([ const [daily, monthly, hourly, total] = await Promise.all([
this.client.get(`usage:cost:daily:${keyId}:${today}`), this.client.get(`usage:cost:daily:${keyId}:${today}`),
@@ -534,7 +540,7 @@ class RedisClient {
const today = getDateStringInTimezone(); const today = getDateStringInTimezone();
const accountDailyKey = `account_usage:daily:${accountId}:${today}`; const accountDailyKey = `account_usage:daily:${accountId}:${today}`;
const tzDate = getDateInTimezone(); const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`; const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`;
const [total, daily, monthly] = await Promise.all([ const [total, daily, monthly] = await Promise.all([

View File

@@ -63,7 +63,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 今日 - 使用时区日期 // 今日 - 使用时区日期
const redis = require('../models/redis'); const redis = require('../models/redis');
const tzDate = redis.getDateInTimezone(now); const tzDate = redis.getDateInTimezone(now);
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`;
searchPatterns.push(`usage:daily:*:${dateStr}`); searchPatterns.push(`usage:daily:*:${dateStr}`);
} else if (timeRange === '7days') { } else if (timeRange === '7days') {
// 最近7天 // 最近7天
@@ -72,14 +72,14 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
const date = new Date(now); const date = new Date(now);
date.setDate(date.getDate() - i); date.setDate(date.getDate() - i);
const tzDate = redis.getDateInTimezone(date); const tzDate = redis.getDateInTimezone(date);
const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}`;
searchPatterns.push(`usage:daily:*:${dateStr}`); searchPatterns.push(`usage:daily:*:${dateStr}`);
} }
} else if (timeRange === 'monthly') { } else if (timeRange === 'monthly') {
// 本月 // 本月
const redis = require('../models/redis'); const redis = require('../models/redis');
const tzDate = redis.getDateInTimezone(now); const tzDate = redis.getDateInTimezone(now);
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
searchPatterns.push(`usage:monthly:*:${currentMonth}`); searchPatterns.push(`usage:monthly:*:${currentMonth}`);
} }
@@ -189,7 +189,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
const redis = require('../models/redis'); const redis = require('../models/redis');
const tzToday = redis.getDateStringInTimezone(now); const tzToday = redis.getDateStringInTimezone(now);
const tzDate = redis.getDateInTimezone(now); const tzDate = redis.getDateInTimezone(now);
const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
const modelKeys = timeRange === 'today' const modelKeys = timeRange === 'today'
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
@@ -1125,7 +1125,7 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
const { period = 'daily' } = req.query; // daily, monthly const { period = 'daily' } = req.query; // daily, monthly
const today = redis.getDateStringInTimezone(); const today = redis.getDateStringInTimezone();
const tzDate = redis.getDateInTimezone(); const tzDate = redis.getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`); logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`);
@@ -1269,7 +1269,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
// 使用时区转换后的时间来生成键 // 使用时区转换后的时间来生成键
const tzCurrentHour = redis.getDateInTimezone(currentHour); const tzCurrentHour = redis.getDateInTimezone(currentHour);
const dateStr = redis.getDateStringInTimezone(currentHour); const dateStr = redis.getDateStringInTimezone(currentHour);
const hour = String(tzCurrentHour.getHours()).padStart(2, '0'); const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`; const hourKey = `${dateStr}:${hour}`;
// 获取当前小时的模型统计数据 // 获取当前小时的模型统计数据
@@ -1340,9 +1340,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
hourCost = costResult.costs.total; hourCost = costResult.costs.total;
} }
// 格式化时间标签
const tzDate = redis.getDateInTimezone(currentHour);
const month = String(tzDate.getUTCMonth() + 1).padStart(2, '0');
const day = String(tzDate.getUTCDate()).padStart(2, '0');
const hourStr = String(tzDate.getUTCHours()).padStart(2, '0');
trendData.push({ trendData.push({
// 对于小时粒度只返回hour字段不返回date字段 // 对于小时粒度只返回hour字段不返回date字段
hour: tzCurrentHour.toISOString(), // 使用转换后的时区时间 hour: currentHour.toISOString(), // 保留原始ISO时间用于排序
label: `${month}/${day} ${hourStr}:00`, // 添加格式化的标签
inputTokens: hourInputTokens, inputTokens: hourInputTokens,
outputTokens: hourOutputTokens, outputTokens: hourOutputTokens,
requests: hourRequests, requests: hourRequests,
@@ -1483,7 +1490,7 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
const client = redis.getClientSafe(); const client = redis.getClientSafe();
const today = redis.getDateStringInTimezone(); const today = redis.getDateStringInTimezone();
const tzDate = redis.getDateInTimezone(); const tzDate = redis.getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
let searchPatterns = []; let searchPatterns = [];
@@ -1702,15 +1709,22 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
// 使用时区转换后的时间来生成键 // 使用时区转换后的时间来生成键
const tzCurrentHour = redis.getDateInTimezone(currentHour); const tzCurrentHour = redis.getDateInTimezone(currentHour);
const dateStr = redis.getDateStringInTimezone(currentHour); const dateStr = redis.getDateStringInTimezone(currentHour);
const hour = String(tzCurrentHour.getHours()).padStart(2, '0'); const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`; const hourKey = `${dateStr}:${hour}`;
// 获取这个小时所有API Key的数据 // 获取这个小时所有API Key的数据
const pattern = `usage:hourly:*:${hourKey}`; const pattern = `usage:hourly:*:${hourKey}`;
const keys = await client.keys(pattern); const keys = await client.keys(pattern);
// 格式化时间标签
const tzDateForLabel = redis.getDateInTimezone(currentHour);
const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0');
const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0');
const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0');
const hourData = { const hourData = {
hour: tzCurrentHour.toISOString(), // 使用转换后的时区时间 hour: currentHour.toISOString(), // 使用原始时间,不进行时区转换
label: `${monthLabel}/${dayLabel} ${hourLabel}:00`, // 添加格式化的标签
apiKeys: {} apiKeys: {}
}; };
@@ -1842,7 +1856,7 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
const client = redis.getClientSafe(); const client = redis.getClientSafe();
const today = redis.getDateStringInTimezone(); const today = redis.getDateStringInTimezone();
const tzDate = redis.getDateInTimezone(); const tzDate = redis.getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`;
let pattern; let pattern;
if (period === 'today') { if (period === 'today') {

View File

@@ -1 +0,0 @@
.custom-date-picker[data-v-5170898f] .el-input__inner{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.custom-date-picker[data-v-5170898f] .el-input__inner:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1));--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.custom-date-picker[data-v-5170898f] .el-input__inner{font-size:13px;padding:0 10px}.custom-date-picker[data-v-5170898f] .el-range-separator{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1));padding:0 2px}.custom-date-picker[data-v-5170898f] .el-range-input{font-size:13px}

View File

@@ -18,7 +18,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin> <link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net"> <link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com"> <link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<script type="module" crossorigin src="/admin-next/assets/index-Di89_tIg.js"></script> <script type="module" crossorigin src="/admin-next/assets/index--NCcj1IN.js"></script>
<link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js"> <link rel="modulepreload" crossorigin href="/admin-next/assets/vue-vendor-CKToUHZx.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js"> <link rel="modulepreload" crossorigin href="/admin-next/assets/vendor-BDiMbLwQ.js">
<link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js"> <link rel="modulepreload" crossorigin href="/admin-next/assets/element-plus-B8Fs_0jW.js">

View File

@@ -444,6 +444,11 @@ function createUsageTrendChart() {
// 根据数据类型确定标签字段和格式 // 根据数据类型确定标签字段和格式
const labelField = data[0]?.date ? 'date' : 'hour' const labelField = data[0]?.date ? 'date' : 'hour'
const labels = data.map(d => { const labels = data.map(d => {
// 优先使用后端提供的label字段
if (d.label) {
return d.label
}
if (labelField === 'hour') { if (labelField === 'hour') {
// 格式化小时显示 // 格式化小时显示
const date = new Date(d.hour) const date = new Date(d.hour)
@@ -655,6 +660,11 @@ function createApiKeysUsageTrendChart() {
const chartData = { const chartData = {
labels: data.map(d => { labels: data.map(d => {
// 优先使用后端提供的label字段
if (d.label) {
return d.label
}
if (labelField === 'hour') { if (labelField === 'hour') {
// 格式化小时显示 // 格式化小时显示
const date = new Date(d.hour) const date = new Date(d.hour)