|
|
@@ -0,0 +1,738 @@
|
|
|
+/**
|
|
|
+ * 用量配额服务 - 用量聚合、限额检查、预警/超限事件
|
|
|
+ * 每日窗口按 UTC 自然日;总量窗口终身累计
|
|
|
+ * 三维度(请求/token/费用)× 两窗口(每日/总量)× 两层级(账号/Key)
|
|
|
+ */
|
|
|
+import { Injectable, Logger } from '@nestjs/common';
|
|
|
+import { PrismaService } from '../prisma/prisma.service';
|
|
|
+import { RedisService } from '../redis/redis.service';
|
|
|
+import type { User, ApiKey } from '@prisma/client';
|
|
|
+
|
|
|
+// ---- 类型定义 ----
|
|
|
+
|
|
|
+/** 超限信息 */
|
|
|
+export interface BlockedInfo {
|
|
|
+ scope: 'account' | 'key';
|
|
|
+ window: 'daily' | 'total';
|
|
|
+ dimension: 'request' | 'token' | 'cost';
|
|
|
+ limit: number;
|
|
|
+ current: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** 预警信息 */
|
|
|
+export interface AlertInfo {
|
|
|
+ scope: 'account' | 'key';
|
|
|
+ window: 'daily' | 'total';
|
|
|
+ dimension: 'request' | 'token' | 'cost';
|
|
|
+ limit: number;
|
|
|
+ current: number;
|
|
|
+ pct: number; // 实际使用百分比
|
|
|
+}
|
|
|
+
|
|
|
+/** 配额状态(供前端进度条) */
|
|
|
+export interface QuotaUsageItem {
|
|
|
+ dimension: string;
|
|
|
+ window: string;
|
|
|
+ limit: number | null; // null = 不限
|
|
|
+ current: number;
|
|
|
+ pct: number; // 0-100,不限时 = 0
|
|
|
+}
|
|
|
+
|
|
|
+export interface QuotaStatusEntry {
|
|
|
+ scopeId: string;
|
|
|
+ scopeName: string;
|
|
|
+ limits: {
|
|
|
+ dailyRequestLimit: number | null;
|
|
|
+ dailyTokenLimit: number | null;
|
|
|
+ dailyCostCentsLimit: number | null;
|
|
|
+ totalRequestLimit: number | null;
|
|
|
+ totalTokenLimit: number | null;
|
|
|
+ totalCostCentsLimit: number | null;
|
|
|
+ alertPct: number;
|
|
|
+ };
|
|
|
+ usage: QuotaUsageItem[];
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 内部辅助 ----
|
|
|
+
|
|
|
+const DIMENSIONS = ['request', 'token', 'cost'] as const;
|
|
|
+const WINDOWS = ['daily', 'total'] as const;
|
|
|
+
|
|
|
+type Dimension = (typeof DIMENSIONS)[number];
|
|
|
+type WindowType = (typeof WINDOWS)[number];
|
|
|
+type ScopeType = 'account' | 'key';
|
|
|
+
|
|
|
+/** 提取实体中的限额字段值 */
|
|
|
+function getLimitValue(
|
|
|
+ entity: User | ApiKey,
|
|
|
+ dimension: Dimension,
|
|
|
+ windowType: WindowType,
|
|
|
+): number | null {
|
|
|
+ // 按维度+窗口直接映射,避免索引访问问题
|
|
|
+ if (dimension === 'request') {
|
|
|
+ if (windowType === 'daily') return entity.dailyRequestLimit;
|
|
|
+ return entity.totalRequestLimit;
|
|
|
+ }
|
|
|
+ if (dimension === 'token') {
|
|
|
+ if (windowType === 'daily') {
|
|
|
+ const v = entity.dailyTokenLimit;
|
|
|
+ return v !== null && v !== undefined ? Number(v) : null;
|
|
|
+ }
|
|
|
+ const v = entity.totalTokenLimit;
|
|
|
+ return v !== null && v !== undefined ? Number(v) : null;
|
|
|
+ }
|
|
|
+ if (dimension === 'cost') {
|
|
|
+ if (windowType === 'daily') return entity.dailyCostCentsLimit;
|
|
|
+ return entity.totalCostCentsLimit;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+/** 获取 alertPct,默认 80 */
|
|
|
+function getAlertPct(entity: User | ApiKey): number {
|
|
|
+ return entity.alertPct ?? 80;
|
|
|
+}
|
|
|
+
|
|
|
+/** 获取 UTC 当日 00:00:00 */
|
|
|
+function utcStartOfToday(): Date {
|
|
|
+ const d = new Date();
|
|
|
+ d.setUTCHours(0, 0, 0, 0);
|
|
|
+ return d;
|
|
|
+}
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class QuotaService {
|
|
|
+ private readonly logger = new Logger(QuotaService.name);
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private readonly prisma: PrismaService,
|
|
|
+ private readonly redis: RedisService,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ // ========== 用量聚合 ==========
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聚合指定维度和窗口的用量
|
|
|
+ * @returns BigInt 表示的用量(请求数、token 数、费用美分)
|
|
|
+ */
|
|
|
+ async computeUsage(
|
|
|
+ scopeType: ScopeType,
|
|
|
+ scopeId: string,
|
|
|
+ dimension: Dimension,
|
|
|
+ windowType: WindowType,
|
|
|
+ ): Promise<bigint> {
|
|
|
+ const scopeField = scopeType === 'account' ? 'user_id' : 'api_key_id';
|
|
|
+
|
|
|
+ const params: unknown[] = [scopeId];
|
|
|
+
|
|
|
+ let windowCondition = '';
|
|
|
+ if (windowType === 'daily') {
|
|
|
+ windowCondition = 'AND created_at >= $2';
|
|
|
+ params.push(utcStartOfToday());
|
|
|
+ }
|
|
|
+
|
|
|
+ let selectExpr: string;
|
|
|
+ switch (dimension) {
|
|
|
+ case 'request':
|
|
|
+ selectExpr = 'COUNT(*)';
|
|
|
+ break;
|
|
|
+ case 'token':
|
|
|
+ selectExpr = 'COALESCE(SUM(prompt_tokens + completion_tokens), 0)';
|
|
|
+ break;
|
|
|
+ case 'cost':
|
|
|
+ selectExpr = 'COALESCE(SUM(cost_cents), 0)';
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ const sql = `SELECT ${selectExpr}::bigint AS val FROM usage_records WHERE ${scopeField} = $1 ${windowCondition}`;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const rows = await this.prisma.$queryRawUnsafe<
|
|
|
+ Array<{ val: bigint }>
|
|
|
+ >(sql, ...params);
|
|
|
+ return rows[0]?.val ?? 0n;
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.warn(`computeUsage failed: ${String(err)}`);
|
|
|
+ return 0n;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 核心配额检查 ==========
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查请求是否被配额限制
|
|
|
+ * 同时检查账号级和 Key 级限额,任一维度超限即拒绝
|
|
|
+ */
|
|
|
+ async checkQuota(
|
|
|
+ userId: string,
|
|
|
+ apiKeyId: string,
|
|
|
+ ): Promise<{ blocked: BlockedInfo[]; alerts: AlertInfo[] }> {
|
|
|
+ const [user, apiKey] = await Promise.all([
|
|
|
+ this.prisma.user.findUnique({ where: { id: userId } }),
|
|
|
+ this.prisma.apiKey.findUnique({ where: { id: apiKeyId } }),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if (!user || !apiKey) {
|
|
|
+ return { blocked: [], alerts: [] };
|
|
|
+ }
|
|
|
+
|
|
|
+ const blocked: BlockedInfo[] = [];
|
|
|
+ const alerts: AlertInfo[] = [];
|
|
|
+
|
|
|
+ const scopes: {
|
|
|
+ type: ScopeType;
|
|
|
+ entity: User | ApiKey;
|
|
|
+ scopeId: string;
|
|
|
+ }[] = [
|
|
|
+ { type: 'account', entity: user, scopeId: userId },
|
|
|
+ { type: 'key', entity: apiKey, scopeId: apiKeyId },
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const scope of scopes) {
|
|
|
+ for (const dim of DIMENSIONS) {
|
|
|
+ for (const win of WINDOWS) {
|
|
|
+ const limitVal = getLimitValue(scope.entity, dim, win);
|
|
|
+ if (limitVal === null || limitVal === undefined) continue;
|
|
|
+ if (limitVal <= 0) continue; // 0 = 不限
|
|
|
+
|
|
|
+ const limit = BigInt(limitVal);
|
|
|
+ const usage = await this.computeUsage(scope.type, scope.scopeId, dim, win);
|
|
|
+
|
|
|
+ if (usage >= limit) {
|
|
|
+ // ---- 硬限:用量 >= 限额 ----
|
|
|
+ blocked.push({
|
|
|
+ scope: scope.type,
|
|
|
+ window: win,
|
|
|
+ dimension: dim,
|
|
|
+ limit: limitVal,
|
|
|
+ current: Number(usage),
|
|
|
+ });
|
|
|
+
|
|
|
+ // 记录超限事件(Redis 节流 5 分钟内不重复审计)
|
|
|
+ void this.recordBlockEvent(
|
|
|
+ userId,
|
|
|
+ apiKeyId,
|
|
|
+ dim,
|
|
|
+ win,
|
|
|
+ scope.type,
|
|
|
+ limitVal,
|
|
|
+ usage,
|
|
|
+ );
|
|
|
+ } else {
|
|
|
+ // ---- 预警检查 ----
|
|
|
+ const alertPct = getAlertPct(scope.entity);
|
|
|
+ const threshold = BigInt(Math.floor((limitVal * alertPct) / 100));
|
|
|
+
|
|
|
+ if (usage >= threshold) {
|
|
|
+ const alreadyAlerted = await this.isAlertedToday(
|
|
|
+ scope.type,
|
|
|
+ scope.scopeId,
|
|
|
+ dim,
|
|
|
+ win,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!alreadyAlerted) {
|
|
|
+ alerts.push({
|
|
|
+ scope: scope.type,
|
|
|
+ window: win,
|
|
|
+ dimension: dim,
|
|
|
+ limit: limitVal,
|
|
|
+ current: Number(usage),
|
|
|
+ pct: Math.min(
|
|
|
+ 99,
|
|
|
+ Math.floor((Number(usage) * 100) / limitVal),
|
|
|
+ ),
|
|
|
+ });
|
|
|
+
|
|
|
+ // 记录预警事件
|
|
|
+ void this.recordAlertEvent(
|
|
|
+ userId,
|
|
|
+ apiKeyId,
|
|
|
+ dim,
|
|
|
+ win,
|
|
|
+ scope.type,
|
|
|
+ Number(threshold),
|
|
|
+ usage,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { blocked, alerts };
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 事件记录 ==========
|
|
|
+
|
|
|
+ /** 检查当日是否已有同维度+窗口+层级的预警 */
|
|
|
+ private async isAlertedToday(
|
|
|
+ scopeType: ScopeType,
|
|
|
+ scopeId: string,
|
|
|
+ dimension: Dimension,
|
|
|
+ windowType: WindowType,
|
|
|
+ ): Promise<boolean> {
|
|
|
+ try {
|
|
|
+ const count = await this.prisma.quotaEvent.count({
|
|
|
+ where: {
|
|
|
+ action: 'alert',
|
|
|
+ dimension,
|
|
|
+ windowType,
|
|
|
+ scopeType,
|
|
|
+ createdAt: { gte: utcStartOfToday() },
|
|
|
+ ...(scopeType === 'account'
|
|
|
+ ? { userId: scopeId, apiKeyId: null }
|
|
|
+ : { apiKeyId: scopeId }),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ return count > 0;
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 记录预警事件(fire-and-forget) */
|
|
|
+ private async recordAlertEvent(
|
|
|
+ userId: string,
|
|
|
+ apiKeyId: string,
|
|
|
+ dimension: Dimension,
|
|
|
+ windowType: WindowType,
|
|
|
+ scopeType: ScopeType,
|
|
|
+ threshold: number,
|
|
|
+ current: bigint,
|
|
|
+ ): Promise<void> {
|
|
|
+ try {
|
|
|
+ await this.prisma.quotaEvent.create({
|
|
|
+ data: {
|
|
|
+ userId,
|
|
|
+ apiKeyId: scopeType === 'key' ? apiKeyId : null,
|
|
|
+ dimension,
|
|
|
+ windowType,
|
|
|
+ scopeType,
|
|
|
+ threshold,
|
|
|
+ current,
|
|
|
+ action: 'alert',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // [EMAIL] 预留 - 每日最多一封
|
|
|
+ this.logger.log(
|
|
|
+ `[EMAIL] Quota alert for user ${userId}: ${scopeType} ${dimension} ${windowType} at ${Number(current)}/${threshold}`,
|
|
|
+ );
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.warn(`Failed to record alert event: ${String(err)}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 记录超限事件(Redis 节流:同一 scope+window+dim 5 分钟内仅一条) */
|
|
|
+ private async recordBlockEvent(
|
|
|
+ userId: string,
|
|
|
+ apiKeyId: string,
|
|
|
+ dimension: Dimension,
|
|
|
+ windowType: WindowType,
|
|
|
+ scopeType: ScopeType,
|
|
|
+ limit: number,
|
|
|
+ current: bigint,
|
|
|
+ ): Promise<void> {
|
|
|
+ const scopeId = scopeType === 'account' ? userId : apiKeyId;
|
|
|
+ const throttleKey = `quota:block:${scopeType}:${scopeId}:${windowType}:${dimension}`;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // SET NX EX 300: 只有 key 不存在时才设置,5 分钟过期
|
|
|
+ const set = await this.redis.client.set(
|
|
|
+ throttleKey,
|
|
|
+ '1',
|
|
|
+ 'EX',
|
|
|
+ 300,
|
|
|
+ 'NX',
|
|
|
+ );
|
|
|
+ if (set !== 'OK') return; // 已存在,跳过本次记录
|
|
|
+ } catch {
|
|
|
+ // Redis 不可用时仍记录(不节流)
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await this.prisma.quotaEvent.create({
|
|
|
+ data: {
|
|
|
+ userId,
|
|
|
+ apiKeyId: scopeType === 'key' ? apiKeyId : null,
|
|
|
+ dimension,
|
|
|
+ windowType,
|
|
|
+ scopeType,
|
|
|
+ threshold: limit,
|
|
|
+ current,
|
|
|
+ action: 'block',
|
|
|
+ },
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ this.logger.warn(`Failed to record block event: ${String(err)}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 限额管理 ==========
|
|
|
+
|
|
|
+ /** 设置用户(账号级)限额 */
|
|
|
+ async setAccountLimits(
|
|
|
+ userId: string,
|
|
|
+ limits: Partial<{
|
|
|
+ dailyRequestLimit: number | null;
|
|
|
+ dailyTokenLimit: number | null;
|
|
|
+ dailyCostCentsLimit: number | null;
|
|
|
+ totalRequestLimit: number | null;
|
|
|
+ totalTokenLimit: number | null;
|
|
|
+ totalCostCentsLimit: number | null;
|
|
|
+ alertPct: number;
|
|
|
+ }>,
|
|
|
+ ): Promise<User> {
|
|
|
+ const data: Record<string, unknown> = {};
|
|
|
+ for (const [key, val] of Object.entries(limits)) {
|
|
|
+ if (val !== undefined) {
|
|
|
+ data[key] = val;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return this.prisma.user.update({
|
|
|
+ where: { id: userId },
|
|
|
+ data,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 设置 API Key 限额(验证所有权) */
|
|
|
+ async setKeyLimits(
|
|
|
+ userId: string,
|
|
|
+ keyId: string,
|
|
|
+ limits: Partial<{
|
|
|
+ dailyRequestLimit: number | null;
|
|
|
+ dailyTokenLimit: number | null;
|
|
|
+ dailyCostCentsLimit: number | null;
|
|
|
+ totalRequestLimit: number | null;
|
|
|
+ totalTokenLimit: number | null;
|
|
|
+ totalCostCentsLimit: number | null;
|
|
|
+ alertPct: number;
|
|
|
+ }>,
|
|
|
+ ): Promise<ApiKey> {
|
|
|
+ // 验证所有权
|
|
|
+ const key = await this.prisma.apiKey.findUnique({
|
|
|
+ where: { id: keyId },
|
|
|
+ });
|
|
|
+ if (!key || key.userId !== userId) {
|
|
|
+ throw new Error('API Key 不存在或无权操作');
|
|
|
+ }
|
|
|
+
|
|
|
+ const data: Record<string, unknown> = {};
|
|
|
+ for (const [keyName, val] of Object.entries(limits)) {
|
|
|
+ if (val !== undefined) {
|
|
|
+ data[keyName] = val;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return this.prisma.apiKey.update({
|
|
|
+ where: { id: keyId },
|
|
|
+ data,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 管理员设置用户限额(跳过所有权检查) */
|
|
|
+ async adminSetUserLimits(
|
|
|
+ userId: string,
|
|
|
+ limits: Record<string, unknown>,
|
|
|
+ ): Promise<User> {
|
|
|
+ return this.prisma.user.update({
|
|
|
+ where: { id: userId },
|
|
|
+ data: limits,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 管理员设置 Key 限额(跳过所有权检查) */
|
|
|
+ async adminSetKeyLimits(
|
|
|
+ keyId: string,
|
|
|
+ limits: Record<string, unknown>,
|
|
|
+ ): Promise<ApiKey> {
|
|
|
+ return this.prisma.apiKey.update({
|
|
|
+ where: { id: keyId },
|
|
|
+ data: limits,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ========== 查询 ==========
|
|
|
+
|
|
|
+ /** 获取用户配额状态(账号 + 所有 Key)- 供控制台进度条使用 */
|
|
|
+ async getQuotaStatus(userId: string): Promise<{
|
|
|
+ account: QuotaStatusEntry;
|
|
|
+ keys: QuotaStatusEntry[];
|
|
|
+ }> {
|
|
|
+ const user = await this.prisma.user.findUnique({
|
|
|
+ where: { id: userId },
|
|
|
+ });
|
|
|
+ if (!user) {
|
|
|
+ throw new Error('用户不存在');
|
|
|
+ }
|
|
|
+
|
|
|
+ const keys = await this.prisma.apiKey.findMany({
|
|
|
+ where: { userId, status: 'active' },
|
|
|
+ });
|
|
|
+
|
|
|
+ const accountStatus = await this.buildStatusEntry(
|
|
|
+ 'account',
|
|
|
+ userId,
|
|
|
+ user.email,
|
|
|
+ user,
|
|
|
+ );
|
|
|
+
|
|
|
+ const keyStatuses = await Promise.all(
|
|
|
+ keys.map((k) =>
|
|
|
+ this.buildStatusEntry('key', k.id, k.name, k),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ return { account: accountStatus, keys: keyStatuses };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 构建单个 scope 的状态条目 */
|
|
|
+ private async buildStatusEntry(
|
|
|
+ scopeType: ScopeType,
|
|
|
+ scopeId: string,
|
|
|
+ scopeName: string,
|
|
|
+ entity: User | ApiKey,
|
|
|
+ ): Promise<QuotaStatusEntry> {
|
|
|
+ const usageItems: QuotaUsageItem[] = [];
|
|
|
+
|
|
|
+ for (const dim of DIMENSIONS) {
|
|
|
+ for (const win of WINDOWS) {
|
|
|
+ const limit = getLimitValue(entity, dim, win);
|
|
|
+ const current = await this.computeUsage(scopeType, scopeId, dim, win);
|
|
|
+ const pct =
|
|
|
+ limit !== null && limit !== undefined && limit > 0
|
|
|
+ ? Math.min(100, Math.floor((Number(current) * 100) / limit))
|
|
|
+ : 0;
|
|
|
+
|
|
|
+ usageItems.push({
|
|
|
+ dimension: dim,
|
|
|
+ window: win,
|
|
|
+ limit,
|
|
|
+ current: Number(current),
|
|
|
+ pct,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ scopeId,
|
|
|
+ scopeName,
|
|
|
+ limits: {
|
|
|
+ dailyRequestLimit: entity.dailyRequestLimit ?? null,
|
|
|
+ dailyTokenLimit:
|
|
|
+ typeof entity.dailyTokenLimit === 'bigint'
|
|
|
+ ? Number(entity.dailyTokenLimit)
|
|
|
+ : entity.dailyTokenLimit ?? null,
|
|
|
+ dailyCostCentsLimit: entity.dailyCostCentsLimit ?? null,
|
|
|
+ totalRequestLimit: entity.totalRequestLimit ?? null,
|
|
|
+ totalTokenLimit:
|
|
|
+ typeof entity.totalTokenLimit === 'bigint'
|
|
|
+ ? Number(entity.totalTokenLimit)
|
|
|
+ : entity.totalTokenLimit ?? null,
|
|
|
+ totalCostCentsLimit: entity.totalCostCentsLimit ?? null,
|
|
|
+ alertPct: entity.alertPct ?? 80,
|
|
|
+ },
|
|
|
+ usage: usageItems,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 管理后台:获取全局限额列表 */
|
|
|
+ async listQuotas(params: {
|
|
|
+ q?: string;
|
|
|
+ scope?: string;
|
|
|
+ page: number;
|
|
|
+ pageSize: number;
|
|
|
+ }): Promise<{
|
|
|
+ total: number;
|
|
|
+ page: number;
|
|
|
+ pageSize: number;
|
|
|
+ data: Array<{
|
|
|
+ id: string;
|
|
|
+ email?: string;
|
|
|
+ name?: string;
|
|
|
+ scope: string;
|
|
|
+ limits: Record<string, number | null>;
|
|
|
+ status: 'over' | 'alert' | 'ok';
|
|
|
+ }>;
|
|
|
+ }> {
|
|
|
+ const { q, scope, page, pageSize } = params;
|
|
|
+
|
|
|
+ if (scope === 'key') {
|
|
|
+ // 列出所有 Key(含限额配置)
|
|
|
+ const where: Record<string, unknown> = {};
|
|
|
+ if (q) {
|
|
|
+ where['name'] = { contains: q, mode: 'insensitive' };
|
|
|
+ }
|
|
|
+
|
|
|
+ const [keys, total] = await Promise.all([
|
|
|
+ this.prisma.apiKey.findMany({
|
|
|
+ where,
|
|
|
+ skip: (page - 1) * pageSize,
|
|
|
+ take: pageSize,
|
|
|
+ orderBy: { createdAt: 'desc' },
|
|
|
+ select: {
|
|
|
+ id: true,
|
|
|
+ userId: true,
|
|
|
+ name: true,
|
|
|
+ prefix: true,
|
|
|
+ status: true,
|
|
|
+ dailyRequestLimit: true,
|
|
|
+ dailyTokenLimit: true,
|
|
|
+ dailyCostCentsLimit: true,
|
|
|
+ totalRequestLimit: true,
|
|
|
+ totalTokenLimit: true,
|
|
|
+ totalCostCentsLimit: true,
|
|
|
+ alertPct: true,
|
|
|
+ createdAt: true,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ this.prisma.apiKey.count({ where }),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const data = await Promise.all(
|
|
|
+ keys.map(async (k) => {
|
|
|
+ const status = await this.computeScopeStatus('key', k);
|
|
|
+ return {
|
|
|
+ id: k.id,
|
|
|
+ name: k.name,
|
|
|
+ scope: 'key',
|
|
|
+ limits: {
|
|
|
+ dailyRequestLimit: k.dailyRequestLimit,
|
|
|
+ dailyTokenLimit: typeof k.dailyTokenLimit === 'bigint' ? Number(k.dailyTokenLimit) : k.dailyTokenLimit,
|
|
|
+ dailyCostCentsLimit: k.dailyCostCentsLimit,
|
|
|
+ totalRequestLimit: k.totalRequestLimit,
|
|
|
+ totalTokenLimit: typeof k.totalTokenLimit === 'bigint' ? Number(k.totalTokenLimit) : k.totalTokenLimit,
|
|
|
+ totalCostCentsLimit: k.totalCostCentsLimit,
|
|
|
+ alertPct: k.alertPct,
|
|
|
+ },
|
|
|
+ status,
|
|
|
+ };
|
|
|
+ }),
|
|
|
+ );
|
|
|
+
|
|
|
+ return { total, page, pageSize, data };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 默认列出用户
|
|
|
+ const where: Record<string, unknown> = {};
|
|
|
+ if (q) {
|
|
|
+ where['email'] = { contains: q, mode: 'insensitive' };
|
|
|
+ }
|
|
|
+
|
|
|
+ const [users, total] = await Promise.all([
|
|
|
+ this.prisma.user.findMany({
|
|
|
+ where,
|
|
|
+ skip: (page - 1) * pageSize,
|
|
|
+ take: pageSize,
|
|
|
+ orderBy: { createdAt: 'desc' },
|
|
|
+ select: {
|
|
|
+ id: true,
|
|
|
+ email: true,
|
|
|
+ dailyRequestLimit: true,
|
|
|
+ dailyTokenLimit: true,
|
|
|
+ dailyCostCentsLimit: true,
|
|
|
+ totalRequestLimit: true,
|
|
|
+ totalTokenLimit: true,
|
|
|
+ totalCostCentsLimit: true,
|
|
|
+ alertPct: true,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ this.prisma.user.count({ where }),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const data = await Promise.all(
|
|
|
+ users.map(async (u) => {
|
|
|
+ const status = await this.computeScopeStatus('account', u);
|
|
|
+ return {
|
|
|
+ id: u.id,
|
|
|
+ email: u.email,
|
|
|
+ scope: 'user',
|
|
|
+ limits: {
|
|
|
+ dailyRequestLimit: u.dailyRequestLimit,
|
|
|
+ dailyTokenLimit: typeof u.dailyTokenLimit === 'bigint' ? Number(u.dailyTokenLimit) : u.dailyTokenLimit,
|
|
|
+ dailyCostCentsLimit: u.dailyCostCentsLimit,
|
|
|
+ totalRequestLimit: u.totalRequestLimit,
|
|
|
+ totalTokenLimit: typeof u.totalTokenLimit === 'bigint' ? Number(u.totalTokenLimit) : u.totalTokenLimit,
|
|
|
+ totalCostCentsLimit: u.totalCostCentsLimit,
|
|
|
+ alertPct: u.alertPct,
|
|
|
+ },
|
|
|
+ status,
|
|
|
+ };
|
|
|
+ }),
|
|
|
+ );
|
|
|
+
|
|
|
+ return { total, page, pageSize, data };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 判断单个 scope 的状态(over / alert / ok) */
|
|
|
+ private async computeScopeStatus(
|
|
|
+ scopeType: ScopeType,
|
|
|
+ entity: { id: string } & Record<string, unknown>,
|
|
|
+ ): Promise<'over' | 'alert' | 'ok'> {
|
|
|
+ const scopeId = entity.id as string;
|
|
|
+ let maxPct = 0;
|
|
|
+
|
|
|
+ for (const dim of DIMENSIONS) {
|
|
|
+ for (const win of WINDOWS) {
|
|
|
+ const limit = getLimitValue(
|
|
|
+ entity as unknown as User | ApiKey,
|
|
|
+ dim,
|
|
|
+ win,
|
|
|
+ );
|
|
|
+ if (!limit || limit <= 0) continue;
|
|
|
+
|
|
|
+ const usage = await this.computeUsage(scopeType, scopeId, dim, win);
|
|
|
+ const pct = Math.floor((Number(usage) * 100) / limit);
|
|
|
+
|
|
|
+ if (pct >= 100) return 'over';
|
|
|
+ if (pct > maxPct) maxPct = pct;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (maxPct >= 80) return 'alert';
|
|
|
+ return 'ok';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 管理后台:获取配额事件列表 */
|
|
|
+ async listQuotaEvents(params: {
|
|
|
+ action?: string;
|
|
|
+ limit: number;
|
|
|
+ }): Promise<Array<{
|
|
|
+ id: string;
|
|
|
+ userId: string;
|
|
|
+ apiKeyId: string | null;
|
|
|
+ dimension: string;
|
|
|
+ windowType: string;
|
|
|
+ scopeType: string;
|
|
|
+ threshold: number;
|
|
|
+ current: string;
|
|
|
+ action: string;
|
|
|
+ createdAt: string;
|
|
|
+ }>> {
|
|
|
+ const where: Record<string, unknown> = {};
|
|
|
+ if (params.action) {
|
|
|
+ where['action'] = params.action;
|
|
|
+ }
|
|
|
+
|
|
|
+ const events = await this.prisma.quotaEvent.findMany({
|
|
|
+ where,
|
|
|
+ orderBy: { createdAt: 'desc' },
|
|
|
+ take: params.limit,
|
|
|
+ });
|
|
|
+
|
|
|
+ return events.map((e) => ({
|
|
|
+ id: e.id,
|
|
|
+ userId: e.userId,
|
|
|
+ apiKeyId: e.apiKeyId,
|
|
|
+ dimension: e.dimension,
|
|
|
+ windowType: e.windowType,
|
|
|
+ scopeType: e.scopeType,
|
|
|
+ threshold: e.threshold,
|
|
|
+ current: String(e.current),
|
|
|
+ action: e.action,
|
|
|
+ createdAt: e.createdAt.toISOString(),
|
|
|
+ }));
|
|
|
+ }
|
|
|
+}
|