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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ AUTO_MIGRATE=true
# 数据库连接字符串(仅用于本地开发或非 Docker Compose 部署)
DSN="postgres://user:password@host:port/db_name"

# API Key Vacuum Filter(真空过滤器)
# - true (默认):启用。用于在访问 DB 前“负向短路”无效 key,降低 DB 压力、抵御爆破
# - false:禁用(例如:需要排查问题或节省内存时)
ENABLE_API_KEY_VACUUM_FILTER="true"

# PostgreSQL 连接池配置(postgres.js)
# 说明:
# - 这些值是“每个应用进程”的连接池上限;k8s 多副本时需要按副本数分摊
Expand Down Expand Up @@ -58,6 +63,11 @@ REDIS_TLS_REJECT_UNAUTHORIZED=true # 是否验证 Redis TLS 证书(默认
# 设置为 false 可跳过证书验证,用于自签证书或共享证书场景
# 仅在 rediss:// 协议时生效

# API Key 鉴权缓存(Vacuum Filter -> Redis -> DB)
# 说明:需要 ENABLE_RATE_LIMIT=true 且配置 REDIS_URL 才会启用 Redis 缓存;否则自动回落到 DB。
API_KEY_AUTH_CACHE_TTL_SECONDS="60" # 鉴权缓存 TTL(秒,默认 60,最大 3600)
ENABLE_API_KEY_REDIS_CACHE="true" # 是否启用 API Key Redis 缓存(默认:true)

# Session 配置
SESSION_TTL=300 # Session 过期时间(秒,默认 300 = 5 分钟)
STORE_SESSION_MESSAGES=false # 会话消息存储模式(默认:false)
Expand Down
5 changes: 4 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,17 @@ Docker Compose is the **preferred deployment method** — it automatically provi
| `REDIS_URL` | `redis://localhost:6379` | Redis endpoint, supports `rediss://` for TLS providers. |
| `REDIS_TLS_REJECT_UNAUTHORIZED` | `true` | Validate Redis TLS certificates; set `false` to skip (for self-signed/shared certs). |
| `ENABLE_RATE_LIMIT` | `true` | Toggles multi-dimensional rate limiting; Fail-Open handles Redis outages gracefully. |
| `ENABLE_API_KEY_VACUUM_FILTER` | `true` | Enables API Key Vacuum Filter (negative short-circuit only; set to `false/0` to disable). |
| `ENABLE_API_KEY_REDIS_CACHE` | `true` | Enables API Key auth Redis cache (requires Redis; auto-fallback to DB on errors). |
| `API_KEY_AUTH_CACHE_TTL_SECONDS` | `60` | API Key auth cache TTL in seconds (default 60, max 3600). |
| `SESSION_TTL` | `300` | Session cache window (seconds) that drives vendor reuse. |
| `ENABLE_SECURE_COOKIES` | `true` | Browsers require HTTPS for Secure cookies; set to `false` when serving plain HTTP outside localhost. |
| `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false` | When `true`, network errors also trip the circuit breaker for quicker isolation. |
| `APP_PORT` | `23000` | Production port (override via container or process manager). |
| `APP_URL` | empty | Populate to expose correct `servers` entries in OpenAPI docs. |
| `API_TEST_TIMEOUT_MS` | `15000` | Timeout (ms) for provider API connectivity tests. Accepts 5000-120000 for regional tuning. |

> Boolean values should be `true/false` or `1/0` without quotes; otherwise Zod may coerce strings incorrectly. See `.env.example` for the full list.
> Boolean values support `true/false` or `1/0`. Quoting in `.env` is also fine (dotenv will strip quotes). See `.env.example` for the full list.

## ❓ FAQ

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,17 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应
| `REDIS_URL` | `redis://localhost:6379` | Redis 地址,支持 `rediss://` 用于 TLS。 |
| `REDIS_TLS_REJECT_UNAUTHORIZED` | `true` | 是否验证 Redis TLS 证书;设为 `false` 可跳过验证(用于自签/共享证书)。 |
| `ENABLE_RATE_LIMIT` | `true` | 控制多维限流开关;Fail-Open 策略在 Redis 不可用时自动降级。 |
| `ENABLE_API_KEY_VACUUM_FILTER` | `true` | 是否启用 API Key 真空过滤器(仅负向短路无效 key;可设为 `false/0` 关闭用于排查/节省内存)。 |
| `ENABLE_API_KEY_REDIS_CACHE` | `true` | 是否启用 API Key 鉴权 Redis 缓存(需 Redis 可用;异常自动回落到 DB)。 |
| `API_KEY_AUTH_CACHE_TTL_SECONDS` | `60` | API Key 鉴权缓存 TTL(秒,默认 60,最大 3600)。 |
| `SESSION_TTL` | `300` | Session 缓存时间(秒),影响供应商复用策略。 |
| `ENABLE_SECURE_COOKIES` | `true` | 仅 HTTPS 场景能设置 Secure Cookie;HTTP 访问(非 localhost)需改为 `false`。 |
| `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false` | 是否将网络错误计入熔断器;开启后能更激进地阻断异常线路。 |
| `APP_PORT` | `23000` | 生产端口,可被容器或进程管理器覆盖。 |
| `APP_URL` | 空 | 设置后 OpenAPI 文档 `servers` 将展示正确域名/端口。 |
| `API_TEST_TIMEOUT_MS` | `15000` | 供应商 API 测试超时时间(毫秒,范围 5000-120000),跨境网络可适当提高。 |

> 布尔变量请直接写 `true/false` 或 `1/0`,勿加引号,避免被 Zod 转换为真值。更多字段参考 `.env.example`。
> 布尔变量支持 `true/false` 或 `1/0`;在 `.env` 文件里写成带引号形式也没问题(dotenv 会解析并去掉引号)。更多字段参考 `.env.example`。

## ❓ FAQ

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --port 13500",
"build": "next build && cp VERSION .next/standalone/VERSION",
"build": "next build && (node scripts/copy-version-to-standalone.cjs || bun scripts/copy-version-to-standalone.cjs)",
"start": "next start",
"lint": "biome check .",
"lint:fix": "biome check --write .",
Expand Down
15 changes: 15 additions & 0 deletions scripts/copy-version-to-standalone.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const fs = require("node:fs");
const path = require("node:path");

Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

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

Build script may crash

This script uses require(...) (CommonJS), but this repo may be running Node in ESM mode depending on package.json ("type": "module"). If type=module, node scripts/copy-version-to-standalone.js will throw at runtime. Please confirm module type; if ESM, convert this file to ESM (import fs from "node:fs") or rename to .cjs and update the build script accordingly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/copy-version-to-standalone.js
Line: 1:3

Comment:
**Build script may crash**

This script uses `require(...)` (CommonJS), but this repo may be running Node in ESM mode depending on `package.json` (`"type": "module"`). If `type=module`, `node scripts/copy-version-to-standalone.js` will throw at runtime. Please confirm module type; if ESM, convert this file to ESM (`import fs from "node:fs"`) or rename to `.cjs` and update the build script accordingly.

How can I resolve this? If you propose a fix, please make it concise.

const src = path.resolve(process.cwd(), "VERSION");
const dstDir = path.resolve(process.cwd(), ".next", "standalone");
const dst = path.join(dstDir, "VERSION");

if (!fs.existsSync(src)) {
console.error(`[copy-version] VERSION not found at ${src}`);
process.exit(1);
}

fs.mkdirSync(dstDir, { recursive: true });
fs.copyFileSync(src, dst);
console.log(`[copy-version] Copied VERSION -> ${dst}`);
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ function createLazyFilterHook<T>(
};
}, []);

// biome-ignore lint/correctness/useExhaustiveDependencies: fetcher 是工厂函数的闭包参数,在 hook 生命周期内永不改变
const load = useCallback(async () => {
// 如果已加载或有进行中的请求,跳过
if (isLoaded || inFlightRef.current) return;
Expand Down
74 changes: 71 additions & 3 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
* 在服务器启动时自动执行数据库迁移
*/

// instrumentation 需要 Node.js runtime(依赖数据库与 Redis 等 Node 能力)
export const runtime = "nodejs";

import { startCacheCleanup, stopCacheCleanup } from "@/lib/cache/session-cache";
import { logger } from "@/lib/logger";
import { CHANNEL_API_KEYS_UPDATED, subscribeCacheInvalidation } from "@/lib/redis/pubsub";
import { apiKeyVacuumFilter } from "@/lib/security/api-key-vacuum-filter";

// instrumentation 需要 Node.js runtime(依赖数据库与 Redis 等 Node 能力)
export const runtime = "nodejs";

const instrumentationState = globalThis as unknown as {
__CCH_CACHE_CLEANUP_STARTED__?: boolean;
__CCH_SHUTDOWN_HOOKS_REGISTERED__?: boolean;
__CCH_SHUTDOWN_IN_PROGRESS__?: boolean;
__CCH_CLOUD_PRICE_SYNC_STARTED__?: boolean;
__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType<typeof setInterval>;
__CCH_API_KEY_VF_SYNC_STARTED__?: boolean;
__CCH_API_KEY_VF_SYNC_CLEANUP__?: (() => void) | null;
};

/**
Expand Down Expand Up @@ -82,6 +86,57 @@ async function startCloudPriceSyncScheduler(): Promise<void> {
}
}

/**
* 多实例:订阅 API Key 变更广播,触发本机 Vacuum Filter 失效并重建。
*
* 目标:
* - 避免“本机 filter 漏包含新 key”导致的误拒绝
* - 重建失败/Redis 未配置时自动降级(不阻塞启动)
*/
async function startApiKeyVacuumFilterSync(): Promise<void> {
if (instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__) {
return;
}

// 与 Redis client 的启用条件保持一致:未启用限流/未配置 Redis 时不尝试订阅,避免额外 warn 日志
const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim();
if (rateLimitRaw === "false" || rateLimitRaw === "0" || !process.env.REDIS_URL) {
return;
Comment on lines +101 to +104
Copy link

Choose a reason for hiding this comment

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

Missing VF enabled gate

startApiKeyVacuumFilterSync() returns early based on Redis/rate-limit envs, but it doesn’t check whether the Vacuum Filter is actually enabled. As written, setting ENABLE_RATE_LIMIT+REDIS_URL will subscribe and on every cch:cache:api_keys:updated message call apiKeyVacuumFilter.invalidateAndReload(...), which forces background reload attempts even when ENABLE_API_KEY_VACUUM_FILTER is off. This can trigger unexpected DB reads and log spam in deployments where VF is intentionally disabled.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/instrumentation.ts
Line: 101:104

Comment:
**Missing VF enabled gate**

`startApiKeyVacuumFilterSync()` returns early based on Redis/rate-limit envs, but it doesn’t check whether the Vacuum Filter is actually enabled. As written, setting `ENABLE_RATE_LIMIT`+`REDIS_URL` will subscribe and on every `cch:cache:api_keys:updated` message call `apiKeyVacuumFilter.invalidateAndReload(...)`, which forces background reload attempts even when `ENABLE_API_KEY_VACUUM_FILTER` is off. This can trigger unexpected DB reads and log spam in deployments where VF is intentionally disabled.

How can I resolve this? If you propose a fix, please make it concise.

}

try {
const cleanup = await subscribeCacheInvalidation(CHANNEL_API_KEYS_UPDATED, () => {
apiKeyVacuumFilter.invalidateAndReload({ reason: "api_keys_updated" });
Comment on lines +96 to +109
Copy link

Choose a reason for hiding this comment

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

subscribes and reloads VF even when VF is explicitly disabled

Currently checks rate limit and Redis config but not whether VF is enabled. If user sets the VF disable flag, subscription still fires and calls apiKeyVacuumFilter.invalidateAndReload on every broadcast, triggering unnecessary DB reads and log spam.

Check VF enabled status before subscribing:

Suggested change
async function startApiKeyVacuumFilterSync(): Promise<void> {
if (instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__) {
return;
}
// 与 Redis client 的启用条件保持一致:未启用限流/未配置 Redis 时不尝试订阅,避免额外 warn 日志
const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim();
if (rateLimitRaw === "false" || rateLimitRaw === "0" || !process.env.REDIS_URL) {
return;
}
try {
const cleanup = await subscribeCacheInvalidation(CHANNEL_API_KEYS_UPDATED, () => {
apiKeyVacuumFilter.invalidateAndReload({ reason: "api_keys_updated" });
async function startApiKeyVacuumFilterSync(): Promise<void> {
if (instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__) {
return;
}
// Check if VF is enabled (respect explicit disable)
const vfRaw = process.env.ENABLE_API_KEY_VACUUM_FILTER?.trim();
if (vfRaw === "false" || vfRaw === "0") {
return;
}
// 与 Redis client 的启用条件保持一致:未启用限流/未配置 Redis 时不尝试订阅,避免额外 warn 日志
const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim();
if (rateLimitRaw === "false" || rateLimitRaw === "0" || !process.env.REDIS_URL) {
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/instrumentation.ts
Line: 96:109

Comment:
subscribes and reloads VF even when VF is explicitly disabled

Currently checks rate limit and Redis config but not whether VF is enabled. If user sets the VF disable flag, subscription still fires and calls `apiKeyVacuumFilter.invalidateAndReload` on every broadcast, triggering unnecessary DB reads and log spam.

Check VF enabled status before subscribing:
```suggestion
async function startApiKeyVacuumFilterSync(): Promise<void> {
  if (instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__) {
    return;
  }

  // Check if VF is enabled (respect explicit disable)
  const vfRaw = process.env.ENABLE_API_KEY_VACUUM_FILTER?.trim();
  if (vfRaw === "false" || vfRaw === "0") {
    return;
  }

  // 与 Redis client 的启用条件保持一致:未启用限流/未配置 Redis 时不尝试订阅,避免额外 warn 日志
  const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim();
  if (rateLimitRaw === "false" || rateLimitRaw === "0" || !process.env.REDIS_URL) {
    return;
  }
```

How can I resolve this? If you propose a fix, please make it concise.

});

if (!cleanup) {
return;
}

instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__ = true;
instrumentationState.__CCH_API_KEY_VF_SYNC_CLEANUP__ = cleanup;
logger.info("[Instrumentation] API Key Vacuum Filter sync enabled");
} catch (error) {
logger.warn("[Instrumentation] API Key Vacuum Filter sync init failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}

function warmupApiKeyVacuumFilter(): void {
// 预热 API Key Vacuum Filter(减少无效 key 对 DB 的压力)
try {
apiKeyVacuumFilter.startBackgroundReload({ reason: "startup" });
} catch (error) {
logger.warn("[Instrumentation] Failed to start API key vacuum filter preload", {
error: error instanceof Error ? error.message : String(error),
});
}

// 多实例:订阅 key 变更广播以触发本机 filter 重建
void startApiKeyVacuumFilterSync();
}

export async function register() {
// 仅在服务器端执行
if (process.env.NEXT_RUNTIME === "nodejs") {
Expand Down Expand Up @@ -121,6 +176,15 @@ export async function register() {
});
}

try {
instrumentationState.__CCH_API_KEY_VF_SYNC_CLEANUP__?.();
instrumentationState.__CCH_API_KEY_VF_SYNC_STARTED__ = false;
} catch (error) {
logger.warn("[Instrumentation] Failed to cleanup API key vacuum filter sync", {
error: error instanceof Error ? error.message : String(error),
});
}

try {
const { stopEndpointProbeScheduler } = await import(
"@/lib/provider-endpoints/probe-scheduler"
Expand Down Expand Up @@ -206,6 +270,8 @@ export async function register() {
logger.info("[Instrumentation] AUTO_MIGRATE=false: skipping migrations");
}

warmupApiKeyVacuumFilter();

// 回填 provider_vendors(按域名自动聚合旧 providers)
try {
const { backfillProviderVendorsFromProviders } = await import(
Expand Down Expand Up @@ -306,6 +372,8 @@ export async function register() {
if (isConnected) {
await runMigrations();

warmupApiKeyVacuumFilter();

// 回填 provider_vendors(按域名自动聚合旧 providers)
try {
const { backfillProviderVendorsFromProviders } = await import(
Expand Down
21 changes: 13 additions & 8 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { cookies, headers } from "next/headers";
import { config } from "@/lib/config/config";
import { getEnvConfig } from "@/lib/config/env.schema";
import { findActiveKeyByKeyString } from "@/repository/key";
import { findUserById } from "@/repository/user";
import { validateApiKeyAndGetUser } from "@/repository/key";
import type { Key } from "@/types/key";
import type { User } from "@/types/user";

Expand Down Expand Up @@ -107,18 +106,24 @@ export async function validateKey(
return { user: adminUser, key: adminKey };
}

const key = await findActiveKeyByKeyString(keyString);
if (!key) {
// 默认鉴权链路:Vacuum Filter(仅负向短路) → Redis(key/user 缓存) → DB(权威校验)
const authResult = await validateApiKeyAndGetUser(keyString);
if (!authResult) {
return null;
}

// 检查 Web UI 登录权限
if (!allowReadOnlyAccess && !key.canLoginWebUi) {
const { user, key } = authResult;

// 用户状态校验:与 v1 proxy 侧保持一致,避免禁用/过期用户继续登录或持有会话
if (!user.isEnabled) {
return null;
}
if (user.expiresAt && user.expiresAt.getTime() <= Date.now()) {
return null;
}

const user = await findUserById(key.userId);
if (!user) {
// 检查 Web UI 登录权限
if (!allowReadOnlyAccess && !key.canLoginWebUi) {
return null;
}

Expand Down
9 changes: 6 additions & 3 deletions src/lib/redis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ function maskRedisUrl(redisUrl: string) {
* Includes servername for SNI (Server Name Indication) support.
*/
function buildTlsConfig(redisUrl: string): Record<string, unknown> {
const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false";
const raw = process.env.REDIS_TLS_REJECT_UNAUTHORIZED?.trim();
const rejectUnauthorized = raw !== "false" && raw !== "0";

try {
const url = new URL(redisUrl);
Expand Down Expand Up @@ -79,7 +80,8 @@ export function getRedisClient(): Redis | null {
}

const redisUrl = process.env.REDIS_URL;
const isEnabled = process.env.ENABLE_RATE_LIMIT === "true";
const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim();
const isEnabled = rateLimitRaw !== "false" && rateLimitRaw !== "0";

if (!isEnabled || !redisUrl) {
logger.warn("[Redis] Rate limiting disabled or REDIS_URL not configured");
Expand Down Expand Up @@ -112,7 +114,8 @@ export function getRedisClient(): Redis | null {

// 2. 如果使用 rediss://,则添加显式的 TLS 配置(支持跳过证书验证)
if (useTls) {
const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false";
const raw = process.env.REDIS_TLS_REJECT_UNAUTHORIZED?.trim();
const rejectUnauthorized = raw !== "false" && raw !== "0";
logger.info("[Redis] Using TLS connection (rediss://)", {
redisUrl: safeRedisUrl,
rejectUnauthorized,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/redis/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { getRedisClient } from "./client";
export const CHANNEL_ERROR_RULES_UPDATED = "cch:cache:error_rules:updated";
export const CHANNEL_REQUEST_FILTERS_UPDATED = "cch:cache:request_filters:updated";
export const CHANNEL_SENSITIVE_WORDS_UPDATED = "cch:cache:sensitive_words:updated";
// API Key 集合发生变化(典型:创建新 key)时,通知各实例重建 Vacuum Filter,避免误拒绝
export const CHANNEL_API_KEYS_UPDATED = "cch:cache:api_keys:updated";

type CacheInvalidationCallback = () => void;

Expand Down
Loading