Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 42 additions & 42 deletions src/actions/my-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { db } from "@/drizzle/db";
import { keys as keysTable, messageRequest } from "@/drizzle/schema";
import { getSession } from "@/lib/auth";
import { getEnvConfig } from "@/lib/config";
import { logger } from "@/lib/logger";
import { RateLimitService } from "@/lib/rate-limit/service";
import type { DailyResetMode } from "@/lib/rate-limit/time-utils";
import { SessionTracker } from "@/lib/session-tracker";
import type { CurrencyCode } from "@/lib/utils";
import { sumUserCostToday } from "@/repository/statistics";
import { getSystemSettings } from "@/repository/system-config";
import {
findUsageLogsWithDetails,
Expand Down Expand Up @@ -121,44 +120,25 @@ export interface MyUsageLogsResult {
billingModelSource: BillingModelSource;
}

function getPeriodStart(period: "5h" | "weekly" | "monthly" | "total" | "today") {
const now = new Date();
if (period === "total") return null;
if (period === "today") {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
return start;
}
if (period === "5h") {
return new Date(now.getTime() - 5 * 60 * 60 * 1000);
}
if (period === "weekly") {
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
}
if (period === "monthly") {
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
return null;
}

/**
* 查询用户在指定周期内的消费
* 使用与 Key 层级和限额检查相同的时间范围计算逻辑
*
* @deprecated 此函数已被重构为使用统一的时间范围计算逻辑
*/
async function sumUserCost(userId: number, period: "5h" | "weekly" | "monthly" | "total") {
const conditions = [
eq(keysTable.userId, userId),
isNull(keysTable.deletedAt),
isNull(messageRequest.deletedAt),
];
const start = getPeriodStart(period);
if (start) {
conditions.push(gte(messageRequest.createdAt, start));
}
// 动态导入避免循环依赖
const { sumUserCostInTimeRange, sumUserTotalCost } = await import("@/repository/statistics");
const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils");

const [row] = await db
.select({ total: sql<string>`COALESCE(sum(${messageRequest.costUsd}), 0)` })
.from(messageRequest)
.innerJoin(keysTable, eq(messageRequest.key, keysTable.key))
.where(and(...conditions));
// 总消费:使用专用函数
if (period === "total") {
return await sumUserTotalCost(userId);
}

return Number(row?.total ?? 0);
// 其他周期:使用统一的时间范围计算
const { startTime, endTime } = getTimeRangeForPeriod(period);
return await sumUserCostInTimeRange(userId, startTime, endTime);
}

export async function getMyUsageMetadata(): Promise<ActionResult<MyUsageMetadata>> {
Expand Down Expand Up @@ -199,6 +179,18 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
const key = session.key;
const user = session.user;

// 获取用户每日消费时使用用户的 dailyResetTime 和 dailyResetMode 配置
// 导入时间工具函数
const { getTimeRangeForPeriodWithMode } = await import("@/lib/rate-limit/time-utils");
const { sumUserCostInTimeRange } = await import("@/repository/statistics");
Comment on lines +184 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getMyQuota (此处) 和 getMyTodayStats (第 306 行) 函数中都使用了动态导入。由于这是一个服务器模块 ("use server";),将这些依赖项转换为文件顶部的静态导入会更清晰,并可以避免在函数调用期间的轻微开销和代码重复。

建议在文件顶部添加:

import { getTimeRangeForPeriodWithMode } from "@/lib/rate-limit/time-utils";
import { sumUserCostInTimeRange } from "@/repository/statistics";

然后删除此处的动态导入以及 getMyTodayStats 中的动态导入。


// 计算用户每日消费的时间范围(使用用户的配置)
const userDailyTimeRange = getTimeRangeForPeriodWithMode(
"daily",
user.dailyResetTime ?? "00:00",
(user.dailyResetMode as DailyResetMode | undefined) ?? "fixed"
Comment on lines +190 to +191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

根据 src/drizzle/schema.ts 中的数据库结构,users.dailyResetTimeusers.dailyResetMode 字段都定义为 notNull 并有默认值。如果从数据库返回的 user 对象类型能正确反映这一点,这里的空值合并操作符 (??) 和类型断言 as 可能是多余的。

如果类型是准确的,代码可以简化为:

const userDailyTimeRange = getTimeRangeForPeriodWithMode(
  "daily",
  user.dailyResetTime,
  user.dailyResetMode
);

如果类型确实可能为 null,建议将 (user.dailyResetMode as DailyResetMode | undefined) ?? "fixed" 简化为 user.dailyResetMode ?? "fixed",因为当前的类型断言写法有些繁琐且容易引起困惑。

此建议同样适用于 getMyTodayStats 函数中对 session.key 的类似处理(第 309-310 行)。

);

const [
keyCost5h,
keyCostDaily,
Expand Down Expand Up @@ -226,7 +218,8 @@ export async function getMyQuota(): Promise<ActionResult<MyUsageQuota>> {
getTotalUsageForKey(key.key),
SessionTracker.getKeySessionCount(key.id),
sumUserCost(user.id, "5h"),
sumUserCostToday(user.id),
// 修复: 使用与 Key 层级相同的时间范围逻辑来计算用户每日消费
sumUserCostInTimeRange(user.id, userDailyTimeRange.startTime, userDailyTimeRange.endTime),
sumUserCost(user.id, "weekly"),
sumUserCost(user.id, "monthly"),
sumUserCost(user.id, "total"),
Expand Down Expand Up @@ -290,8 +283,13 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
const billingModelSource = settings.billingModelSource;
const currencyCode = settings.currencyDisplay;

const timezone = getEnvConfig().TZ || "UTC";
const startOfDay = sql`(CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date`;
// 修复: 使用 Key 的 dailyResetTime 和 dailyResetMode 来计算时间范围
const { getTimeRangeForPeriodWithMode } = await import("@/lib/rate-limit/time-utils");
const timeRange = getTimeRangeForPeriodWithMode(
"daily",
session.key.dailyResetTime ?? "00:00",
(session.key.dailyResetMode as DailyResetMode | undefined) ?? "fixed"
);

const [aggregate] = await db
.select({
Expand All @@ -305,7 +303,8 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = ${startOfDay}`
gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

为了保持代码风格统一和类型安全,建议使用 Drizzle ORM 的 lt 操作符来代替原生 SQL。这与前面使用的 gte 操作符风格一致。

请记得从 drizzle-orm 中导入 lt

import { and, eq, gte, isNull, lt, sql } from "drizzle-orm";
Suggested change
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
lt(messageRequest.createdAt, timeRange.endTime)

)
);

Expand All @@ -323,7 +322,8 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = ${startOfDay}`
gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

同上,为了保持代码风格统一和类型安全,建议使用 Drizzle ORM 的 lt 操作符来代替原生 SQL。

请记得从 drizzle-orm 中导入 lt

Suggested change
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
lt(messageRequest.createdAt, timeRange.endTime)

)
)
.groupBy(messageRequest.model, messageRequest.originalModel);
Expand Down
19 changes: 13 additions & 6 deletions src/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,7 @@ export async function removeUser(userId: number): Promise<ActionResult> {
export async function getUserLimitUsage(userId: number): Promise<
ActionResult<{
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt: Date };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
}>
> {
try {
Expand All @@ -1260,16 +1260,23 @@ export async function getUserLimitUsage(userId: number): Promise<
}

// 动态导入避免循环依赖
const { sumUserCostToday } = await import("@/repository/statistics");
const { getDailyResetTime } = await import("@/lib/rate-limit/time-utils");
const { sumUserCostInTimeRange } = await import("@/repository/statistics");
const { getResetInfoWithMode, getTimeRangeForPeriodWithMode } = await import(
"@/lib/rate-limit/time-utils"
);

// 获取当前 RPM 使用情况(从 Redis)
// 注意:RPM 是实时的滑动窗口,无法直接获取"当前值",这里返回 0
// 实际的 RPM 检查在请求时进行
const rpmCurrent = 0; // RPM 是动态滑动窗口,此处无法精确获取

// 获取每日消费(直接查询数据库)
const dailyCost = await sumUserCostToday(userId);
// 获取每日消费(使用用户的 dailyResetTime 和 dailyResetMode 配置)
const resetTime = user.dailyResetTime ?? "00:00";
const resetMode = user.dailyResetMode ?? "fixed";
const { startTime, endTime } = getTimeRangeForPeriodWithMode("daily", resetTime, resetMode);
const dailyCost = await sumUserCostInTimeRange(userId, startTime, endTime);
const resetInfo = getResetInfoWithMode("daily", resetTime, resetMode);
const resetAt = resetInfo.resetAt;

return {
ok: true,
Expand All @@ -1282,7 +1289,7 @@ export async function getUserLimitUsage(userId: number): Promise<
dailyCost: {
current: dailyCost,
limit: user.dailyQuota,
resetAt: getDailyResetTime(),
resetAt,
},
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import { Label } from "@/components/ui/label";
import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils/currency";

interface UserQuota {
rpm: { current: number; limit: number; window: "per_minute" };
dailyCost: { current: number; limit: number; resetAt: Date };
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
}

interface EditUserQuotaDialogProps {
Expand Down Expand Up @@ -116,7 +116,7 @@ export function EditUserQuotaDialog({
<p className="text-xs text-muted-foreground">
{t("rpm.current", {
current: currentQuota.rpm.current,
limit: currentQuota.rpm.limit,
limit: currentQuota.rpm.limit ?? t("unlimited"),
})}
</p>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ interface KeyQuota {
}

interface UserQuota {
rpm: { current: number; limit: number; window: "per_minute" };
dailyCost: { current: number; limit: number; resetAt: Date };
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
}

interface KeyWithQuota {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ interface KeyQuota {
}

interface UserQuota {
rpm: { current: number; limit: number; window: "per_minute" };
dailyCost: { current: number; limit: number; resetAt: Date };
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
}

interface KeyWithQuota {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CurrencyCode } from "@/lib/utils/currency";

export interface UserQuotaSnapshot {
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt: Date };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
}

export interface UserKeyWithUsage {
Expand Down
4 changes: 1 addition & 3 deletions src/app/[locale]/dashboard/users/users-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,9 +610,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) {
</div>
) : (
<div className="space-y-3">
<div className="h-4">
{isRefreshing ? <InlineLoading label={tCommon("loading")} /> : null}
</div>
<div>{isRefreshing ? <InlineLoading label={tCommon("loading")} /> : null}</div>
<UserManagementTable
users={visibleUsers}
hasNextPage={hasNextPage}
Expand Down
16 changes: 13 additions & 3 deletions src/lib/rate-limit/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import {
TRACK_COST_DAILY_ROLLING_WINDOW,
} from "@/lib/redis/lua-scripts";
import { SessionTracker } from "@/lib/session-tracker";
import { sumKeyTotalCost, sumUserCostToday, sumUserTotalCost } from "@/repository/statistics";
import { sumKeyTotalCost, sumUserCostInTimeRange, sumUserTotalCost } from "@/repository/statistics";
import {
type DailyResetMode,
getTimeRangeForPeriodWithMode,
Expand Down Expand Up @@ -1017,7 +1017,12 @@ export class RateLimitService {
} else {
// Cache Miss: 从数据库恢复
logger.info(`[RateLimit] Cache miss for ${key}, querying database`);
currentCost = await sumUserCostToday(userId);
const { startTime, endTime } = getTimeRangeForPeriodWithMode(
"daily",
normalizedResetTime,
mode
);
currentCost = await sumUserCostInTimeRange(userId, startTime, endTime);

// Cache Warming: 写回 Redis
const ttl = getTTLForPeriodWithMode("daily", normalizedResetTime, "fixed");
Expand All @@ -1027,7 +1032,12 @@ export class RateLimitService {
} else {
// Slow Path: 数据库查询(Redis 不可用)
logger.warn("[RateLimit] Redis unavailable, querying database for user daily cost");
currentCost = await sumUserCostToday(userId);
const { startTime, endTime } = getTimeRangeForPeriodWithMode(
"daily",
normalizedResetTime,
mode
);
currentCost = await sumUserCostInTimeRange(userId, startTime, endTime);
}

if (currentCost >= dailyLimitUsd) {
Expand Down
9 changes: 5 additions & 4 deletions src/lib/utils/quota-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export type KeyQuota = {
} | null;

export type UserQuota = {
rpm: { current: number; limit: number; window: "per_minute" };
dailyCost: { current: number; limit: number; resetAt: Date };
rpm: { current: number; limit: number | null; window: "per_minute" };
dailyCost: { current: number; limit: number | null; resetAt?: Date };
} | null;

/**
Expand Down Expand Up @@ -57,8 +57,9 @@ export function getUsageRate(current: number, limit: number | null): number {
export function isUserExceeded(userQuota: UserQuota): boolean {
if (!userQuota) return false;

const rpmExceeded = userQuota.rpm.current >= userQuota.rpm.limit;
const dailyExceeded = userQuota.dailyCost.current >= userQuota.dailyCost.limit;
const rpmExceeded = userQuota.rpm.limit !== null && userQuota.rpm.current >= userQuota.rpm.limit;
const dailyExceeded =
userQuota.dailyCost.limit !== null && userQuota.dailyCost.current >= userQuota.dailyCost.limit;

return rpmExceeded || dailyExceeded;
}
Expand Down
5 changes: 5 additions & 0 deletions src/repository/statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,11 @@ export async function getMixedStatisticsFromDB(
/**
* 查询用户今日总消费(所有 Key 的消费总和)
* 用于用户层每日限额检查(Redis 降级)
*
* DEPRECATED: 该函数使用简单的日期比较,不考虑用户的 dailyResetTime 配置。
* 请使用 sumUserCostInTimeRange() 配合 getTimeRangeForPeriodWithMode() 来获取正确的时间范围。
*
* @deprecated 使用 sumUserCostInTimeRange() 替代
*/
export async function sumUserCostToday(userId: number): Promise<number> {
const timezone = getEnvConfig().TZ;
Expand Down
Loading