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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose",
"test:coverage": "vitest run --coverage",
"test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage",
"test:coverage:my-usage": "vitest run --config vitest.my-usage.config.ts --coverage",
"test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml",
"cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e",
"db:generate": "drizzle-kit generate && node scripts/validate-migrations.js",
Expand Down
11 changes: 8 additions & 3 deletions src/actions/my-usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use server";

import { and, eq, gte, isNull, sql } from "drizzle-orm";
import { and, eq, gte, isNull, lt, sql } from "drizzle-orm";
import { db } from "@/drizzle/db";
import { keys as keysTable, messageRequest } from "@/drizzle/schema";
import { getSession } from "@/lib/auth";
Expand All @@ -20,6 +20,9 @@ import {
import type { BillingModelSource } from "@/types/system-config";
import type { ActionResult } from "./types";

// Warmup 抢答请求只用于探测/预热:日志可见,但不计入任何聚合统计
const EXCLUDE_WARMUP_CONDITION = sql`(${messageRequest.blockedBy} IS NULL OR ${messageRequest.blockedBy} <> 'warmup')`;

export interface MyUsageMetadata {
keyName: string;
keyProviderGroup: string | null;
Expand Down Expand Up @@ -303,8 +306,9 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
EXCLUDE_WARMUP_CONDITION,
gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
lt(messageRequest.createdAt, timeRange.endTime)
)
);

Expand All @@ -322,8 +326,9 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
and(
eq(messageRequest.key, session.key.key),
isNull(messageRequest.deletedAt),
EXCLUDE_WARMUP_CONDITION,
gte(messageRequest.createdAt, timeRange.startTime),
sql`${messageRequest.createdAt} < ${timeRange.endTime}`
lt(messageRequest.createdAt, timeRange.endTime)
)
)
.groupBy(messageRequest.model, messageRequest.originalModel);
Expand Down
197 changes: 197 additions & 0 deletions src/app/api/actions/[...route]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { z } from "zod";
import * as activeSessionActions from "@/actions/active-sessions";
import * as keyActions from "@/actions/keys";
import * as modelPriceActions from "@/actions/model-prices";
import * as myUsageActions from "@/actions/my-usage";
import * as notificationBindingActions from "@/actions/notification-bindings";
import * as notificationActions from "@/actions/notifications";
import * as overviewActions from "@/actions/overview";
Expand Down Expand Up @@ -55,6 +56,14 @@ app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
"HTTP Cookie 认证。请先通过 Web UI 登录获取 auth-token Cookie,或从浏览器开发者工具中复制 Cookie 值用于 API 调用。详见上方「认证方式」章节。",
});

app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
bearerFormat: "API Key",
description:
"Authorization: Bearer <token> 方式认证(适合脚本/CLI 调用)。注意:token 与 Cookie 中 auth-token 值一致。",
});

// ==================== 用户管理 ====================

const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute(
Expand Down Expand Up @@ -607,6 +616,194 @@ const { route: getStatusCodeListRoute, handler: getStatusCodeListHandler } = cre
);
app.openapi(getStatusCodeListRoute, getStatusCodeListHandler);

// ==================== 我的用量(只读 Key 可访问) ====================

const { route: getMyUsageMetadataRoute, handler: getMyUsageMetadataHandler } = createActionRoute(
"my-usage",
"getMyUsageMetadata",
myUsageActions.getMyUsageMetadata,
{
requestSchema: z.object({}).describe("无需请求参数"),
responseSchema: z.object({
keyName: z.string().describe("当前 Key 名称"),
keyProviderGroup: z.string().nullable().describe("Key 供应商分组(可为空)"),
keyExpiresAt: z.string().nullable().describe("Key 过期时间(ISO 字符串,可为空)"),
keyIsEnabled: z.boolean().describe("Key 是否启用"),
userName: z.string().describe("当前用户名称"),
userProviderGroup: z.string().nullable().describe("用户供应商分组(可为空)"),
userExpiresAt: z.string().nullable().describe("用户过期时间(ISO 字符串,可为空)"),
userIsEnabled: z.boolean().describe("用户是否启用"),
dailyResetMode: z.enum(["fixed", "rolling"]).describe("日限额重置模式"),
dailyResetTime: z.string().describe("日限额重置时间(HH:mm)"),
currencyCode: z.string().describe("货币显示(如 USD)"),
}),
description: "获取当前会话的基础信息(仅返回自己的数据)",
summary: "获取我的用量元信息",
tags: ["概览"],
allowReadOnlyAccess: true,
}
);
app.openapi(getMyUsageMetadataRoute, getMyUsageMetadataHandler);

const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute(
"my-usage",
"getMyQuota",
myUsageActions.getMyQuota,
{
requestSchema: z.object({}).describe("无需请求参数"),
responseSchema: z.object({
keyLimit5hUsd: z.number().nullable(),
keyLimitDailyUsd: z.number().nullable(),
keyLimitWeeklyUsd: z.number().nullable(),
keyLimitMonthlyUsd: z.number().nullable(),
keyLimitTotalUsd: z.number().nullable(),
keyLimitConcurrentSessions: z.number().nullable(),
keyCurrent5hUsd: z.number(),
keyCurrentDailyUsd: z.number(),
keyCurrentWeeklyUsd: z.number(),
keyCurrentMonthlyUsd: z.number(),
keyCurrentTotalUsd: z.number(),
keyCurrentConcurrentSessions: z.number(),

userLimit5hUsd: z.number().nullable(),
userLimitWeeklyUsd: z.number().nullable(),
userLimitMonthlyUsd: z.number().nullable(),
userLimitTotalUsd: z.number().nullable(),
userLimitConcurrentSessions: z.number().nullable(),
userCurrent5hUsd: z.number(),
userCurrentDailyUsd: z.number(),
userCurrentWeeklyUsd: z.number(),
userCurrentMonthlyUsd: z.number(),
userCurrentTotalUsd: z.number(),
userCurrentConcurrentSessions: z.number(),

userLimitDailyUsd: z.number().nullable(),
userExpiresAt: z.string().nullable(),
userProviderGroup: z.string().nullable(),
userName: z.string(),
userIsEnabled: z.boolean(),

keyProviderGroup: z.string().nullable(),
keyName: z.string(),
keyIsEnabled: z.boolean(),

expiresAt: z.string().nullable(),
dailyResetMode: z.enum(["fixed", "rolling"]),
dailyResetTime: z.string(),
}),
description: "获取当前会话的限额与当前使用量(仅返回自己的数据)",
summary: "获取我的限额与用量",
tags: ["密钥管理"],
allowReadOnlyAccess: true,
}
);
app.openapi(getMyQuotaRoute, getMyQuotaHandler);

const { route: getMyTodayStatsRoute, handler: getMyTodayStatsHandler } = createActionRoute(
"my-usage",
"getMyTodayStats",
myUsageActions.getMyTodayStats,
{
requestSchema: z.object({}).describe("无需请求参数"),
responseSchema: z.object({
calls: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
costUsd: z.number(),
modelBreakdown: z.array(
z.object({
model: z.string().nullable(),
billingModel: z.string().nullable(),
calls: z.number(),
costUsd: z.number(),
inputTokens: z.number(),
outputTokens: z.number(),
})
),
currencyCode: z.string(),
billingModelSource: z.enum(["original", "redirected"]),
}),
description: "获取当前会话的“今日”使用统计(按 Key 的日重置配置计算)",
summary: "获取我的今日使用统计",
tags: ["统计分析"],
allowReadOnlyAccess: true,
}
);
app.openapi(getMyTodayStatsRoute, getMyTodayStatsHandler);

const { route: getMyUsageLogsRoute, handler: getMyUsageLogsHandler } = createActionRoute(
"my-usage",
"getMyUsageLogs",
myUsageActions.getMyUsageLogs,
{
requestSchema: z.object({
startDate: z.string().optional().describe("开始日期(YYYY-MM-DD,可为空)"),
endDate: z.string().optional().describe("结束日期(YYYY-MM-DD,可为空)"),
model: z.string().optional(),
endpoint: z.string().optional(),
statusCode: z.number().optional(),
excludeStatusCode200: z.boolean().optional(),
minRetryCount: z.number().int().nonnegative().optional(),
pageSize: z.number().int().positive().max(100).default(20).optional(),
page: z.number().int().positive().default(1).optional(),
}),
responseSchema: z.object({
logs: z.array(
z.object({
id: z.number(),
createdAt: z.string().nullable(),
model: z.string().nullable(),
billingModel: z.string().nullable(),
modelRedirect: z.string().nullable(),
inputTokens: z.number(),
outputTokens: z.number(),
cost: z.number(),
statusCode: z.number().nullable(),
duration: z.number().nullable(),
endpoint: z.string().nullable(),
cacheCreationInputTokens: z.number().nullable(),
cacheReadInputTokens: z.number().nullable(),
cacheCreation5mInputTokens: z.number().nullable(),
cacheCreation1hInputTokens: z.number().nullable(),
cacheTtlApplied: z.string().nullable(),
})
),
total: z.number(),
page: z.number(),
pageSize: z.number(),
currencyCode: z.string(),
billingModelSource: z.enum(["original", "redirected"]),
}),
description: "获取当前会话的使用日志(仅返回自己的数据)",
summary: "获取我的使用日志",
tags: ["使用日志"],
allowReadOnlyAccess: true,
}
);
app.openapi(getMyUsageLogsRoute, getMyUsageLogsHandler);

const { route: getMyAvailableModelsRoute, handler: getMyAvailableModelsHandler } =
createActionRoute("my-usage", "getMyAvailableModels", myUsageActions.getMyAvailableModels, {
requestSchema: z.object({}).describe("无需请求参数"),
responseSchema: z.array(z.string()),
description: "获取当前会话日志中出现过的模型列表(仅返回自己的数据)",
summary: "获取我的模型筛选项",
tags: ["使用日志"],
allowReadOnlyAccess: true,
});
app.openapi(getMyAvailableModelsRoute, getMyAvailableModelsHandler);

const { route: getMyAvailableEndpointsRoute, handler: getMyAvailableEndpointsHandler } =
createActionRoute("my-usage", "getMyAvailableEndpoints", myUsageActions.getMyAvailableEndpoints, {
requestSchema: z.object({}).describe("无需请求参数"),
responseSchema: z.array(z.string()),
description: "获取当前会话日志中出现过的 endpoint 列表(仅返回自己的数据)",
summary: "获取我的 endpoint 筛选项",
tags: ["使用日志"],
allowReadOnlyAccess: true,
});
app.openapi(getMyAvailableEndpointsRoute, getMyAvailableEndpointsHandler);

// ==================== 概览数据 ====================

const { route: getOverviewDataRoute, handler: getOverviewDataHandler } = createActionRoute(
Expand Down
28 changes: 25 additions & 3 deletions src/lib/api/action-adapter-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ import type { ActionResult } from "@/actions/types";
import { validateKey } from "@/lib/auth";
import { logger } from "@/lib/logger";

function getBearerTokenFromAuthHeader(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (!trimmed) return null;

const match = /^Bearer\s+(.+)$/i.exec(trimmed);
const token = match?.[1]?.trim();
return token ? token : null;
}

// Server Action 函数签名 (支持两种格式)
type ServerAction =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -57,6 +66,17 @@ export interface ActionRouteOptions {
*/
requiresAuth?: boolean;

/**
* 允许仅访问只读页面/接口(如 my-usage),跳过 canLoginWebUi 校验
*
* 注意:
* - 这是一个“白名单开关”,仅应对“只读且强制绑定当前会话”的端点开启
* - 绝不能用于允许传入 userId/keyId 等可导致越权的管理型接口
*
* @default false
*/
allowReadOnlyAccess?: boolean;

/**
* 权限要求
*/
Expand Down Expand Up @@ -243,6 +263,7 @@ export function createActionRoute(
summary,
tags = [module],
requiresAuth = true,
allowReadOnlyAccess = false,
requiredRole,
requestExamples,
argsMapper, // 新增:参数映射函数
Expand All @@ -269,7 +290,7 @@ export function createActionRoute(
responses: createResponseSchemas(responseSchema),
// 安全定义 (可选,需要在 OpenAPI 文档中配置)
...(requiresAuth && {
security: [{ cookieAuth: [] }],
security: [{ cookieAuth: [] }, { bearerAuth: [] }],
}),
});

Expand All @@ -281,13 +302,14 @@ export function createActionRoute(
try {
// 0. 认证检查 (如果需要)
if (requiresAuth) {
const authToken = getCookie(c, "auth-token");
const authToken =
getCookie(c, "auth-token") ?? getBearerTokenFromAuthHeader(c.req.header("authorization"));
if (!authToken) {
Copy link
Contributor

Choose a reason for hiding this comment

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

[HIGH] [LOGIC-BUG] bearerAuth 在 adapter 通过,但多数 action 仍依赖 Cookie 的 getSession(),导致 Bearer-only 调用失败

Why this is a problem: createActionRoute 现在允许从 Authorization: Bearer <token> 读取 token 并通过 validateKey() 放行,但多数 server action(包括本 PR 新增暴露的 my-usage)仍在 action 内部调用 getSession(),而 getSession() 只读取 auth-token Cookie。结果是:Bearer-only(不带 Cookie)的脚本/CLI 请求会在 adapter 通过后,在 action 内部变成 Unauthorized,最终返回 400。

Evidence:

  • src/lib/api/action-adapter-openapi.ts:305-316:
    • getCookie(c, "auth-token") ?? getBearerTokenFromAuthHeader(c.req.header("authorization"))
  • src/lib/auth.ts:122-127:
    • const keyString = await getAuthCookie();(没有从 Authorization header 取值)
  • src/actions/my-usage.ts:149-150:
    • const session = await getSession({ allowReadOnlyAccess: true }); if (!session) return { ok: false, error: "Unauthorized" };
  • OpenAPI 也声明 Bearer 适合脚本/CLI:src/app/api/actions/[...route]/route.ts:59-65

Suggested fix: 让 getSession()(或 getAuthCookie())在 Cookie 缺失时回退读取 Authorization: Bearer,并补充 Bearer-only 的 API 测试。

// src/lib/auth.ts
import { cookies, headers } from "next/headers";

function parseBearer(raw: string | null): string | undefined {
  const match = /^Bearer\s+(.+)$/i.exec(raw?.trim() ?? "");
  const token = match?.[1]?.trim();
  return token || undefined;
}

async function getAuthToken(): Promise<string | undefined> {
  const cookieStore = await cookies();
  const cookieToken = cookieStore.get("auth-token")?.value;
  if (cookieToken) return cookieToken;

  const headerStore = await headers();
  return parseBearer(headerStore.get("authorization"));
}

export async function getSession(options?: { allowReadOnlyAccess?: boolean }) {
  const token = await getAuthToken();
  if (!token) return null;
  return validateKey(token, options);
}
// tests/api/my-usage-readonly.test.ts
// 增加用例:仅设置 Authorization(不设置 Cookie)时,my-usage 端点应 200。

logger.warn(`[ActionAPI] ${fullPath} 认证失败: 缺少 auth-token`);
return c.json({ ok: false, error: "未认证" }, 401);
}

const session = await validateKey(authToken);
const session = await validateKey(authToken, { allowReadOnlyAccess });
if (!session) {
logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 auth-token`);
return c.json({ ok: false, error: "认证无效或已过期" }, 401);
Expand Down
23 changes: 21 additions & 2 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { config } from "@/lib/config/config";
import { getEnvConfig } from "@/lib/config/env.schema";
import { findActiveKeyByKeyString } from "@/repository/key";
Expand Down Expand Up @@ -119,10 +119,29 @@ export async function getSession(options?: {
*/
allowReadOnlyAccess?: boolean;
}): Promise<AuthSession | null> {
const keyString = await getAuthCookie();
const keyString = await getAuthToken();
if (!keyString) {
return null;
}

return validateKey(keyString, options);
}

function parseBearerToken(raw: string | null | undefined): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;

const match = /^Bearer\s+(.+)$/i.exec(trimmed);
const token = match?.[1]?.trim();
return token || undefined;
}

async function getAuthToken(): Promise<string | undefined> {
// 优先使用 Cookie(兼容现有 Web UI 的登录态)
const cookieToken = await getAuthCookie();
if (cookieToken) return cookieToken;

// Cookie 缺失时,允许通过 Authorization: Bearer <token> 自助调用只读接口
const headersStore = await headers();
return parseBearerToken(headersStore.get("authorization"));
}
Loading
Loading