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 @@ -149,6 +149,16 @@ FETCH_HEADERS_TIMEOUT=600000
FETCH_BODY_TIMEOUT=600000
MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2

# 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节)
# 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。
# - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。
# 作用:内存受限部署可下调来收紧鉴权前的解压开销;代理刻意支持大请求体,默认值已较宽松。
# - MAX_COMPRESSED_REQUEST_BYTES:压缩输入(线上字节)上限,解压前即校验,超过按 413 拒绝。
# 默认与 MAX_DECOMPRESSED_REQUEST_BYTES 一致(真实压缩比下合法请求的压缩体一定不超过其解压体,
# 故默认不会误拒大上下文/图片压缩请求);下调解压上限时本上限随之收紧,也可单独覆盖。
# MAX_DECOMPRESSED_REQUEST_BYTES=104857600
# MAX_COMPRESSED_REQUEST_BYTES=104857600

# Langfuse Observability (optional, auto-enabled when keys are set)
# 功能说明:企业级 LLM 可观测性集成,自动追踪所有代理请求的完整生命周期
# - 配置 PUBLIC_KEY 和 SECRET_KEY 后自动启用
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: 🟢 Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

在 Node.js 官方文档中确认:node:zlib的 zstd 同步解压(如zstdDecompressSync)从哪个 Node 版本开始可用?Node 22.0.0 是否保证可用?

💡 Result:

在 Node.js 中,node:zlib 模块提供的 Zstd 同步解压方法 zstdDecompressSync 是从 Node.js v22.15.0 版本开始可用的 [1][2]。 关于您询问的 Node 22.0.0 版本:该功能在 Node 22.0.0 中不可用 [2]。zstdDecompressSync 直到后续的补丁版本 v22.15.0 才被正式加入到 API 中 [1][2]。如果您需要在 Node 22.x 系列中使用此功能,请确保您的 Node.js 版本至少升级到 v22.15.0 或更高版本。

Citations:


CI Node 版本应固定到明确下限(22.15.x)
当前 .github/workflows/pr-check.ymlnode-version 固定为 "22",可能解析到 22.15.0 之前的 22.x;而 node:zlibzstdDecompressSync 需要至少 Node.js v22.15.0 才提供(22.0.0 不包含该能力)。为避免 CI 运行时漂移,建议固定到 22.15.x(或更高)。

建议修改
-          node-version: '22'
+          node-version: "22.15.x"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/pr-check.yml at line 34, 将 CI 的 Node 版本从宽泛的 "22"
锁定到具体下限以避免运行时漂移:在 `.github/workflows/pr-check.yml` 中把 node-version 字段的值改为
"22.15.x"(或更高的明确版本),确保使用的 Node API(如 zstdDecompressSync)在 CI 环境中可用并且不会因 22.x
的不同次版本而失败;更新后运行一次 CI 以验证兼容性。


- name: Cache Bun package cache
uses: actions/cache@v5
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV CI=true
RUN --mount=type=cache,target=/app/.next/cache bun run build

FROM node:20-slim AS runner
FROM node:22-slim AS runner

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 node:22-slim is a rolling tag that can resolve to any 22.x patch release. The zstdDecompressSync API (used in request-body-codec.ts) was first shipped in Node 22.15.0. If this image is built against a stale Docker cache that points to an earlier 22.x build, the decompression call will fail at runtime with TypeError: zstdDecompressSync is not a function. Pinning to node:22.15-slim (or later) makes the minimum requirement explicit and matches what package.json engines already declares.

Suggested change
FROM node:22-slim AS runner
FROM node:22.15-slim AS runner
Prompt To Fix With AI
This is a comment left during a code review.
Path: Dockerfile
Line: 15

Comment:
`node:22-slim` is a rolling tag that can resolve to any 22.x patch release. The `zstdDecompressSync` API (used in `request-body-codec.ts`) was first shipped in Node 22.15.0. If this image is built against a stale Docker cache that points to an earlier 22.x build, the decompression call will fail at runtime with `TypeError: zstdDecompressSync is not a function`. Pinning to `node:22.15-slim` (or later) makes the minimum requirement explicit and matches what `package.json` `engines` already declares.

```suggestion
FROM node:22.15-slim AS runner
```

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Node 官方 Docker 镜像是否提供 node:22.15-slim(或 22.15.x-slim)标签?推荐的生产固定标签策略是什么?

💡 Result:

是的,Node 官方 Docker 镜像提供 node:22.15-slim 这一类“具体版本+slim”的标签;但从官方文档当前维护的标签列表来看,更常见/推荐使用的精确固定形式是 node:22.15.0-slim(或 node:22.15.0-*),因为 22.15.x-slim/22.15-slim 这类“模糊 x”并不是官方文档中最标准的命名方式。 证据:Docker Hub 对 node:22.15-slim 有对应镜像条目(可拉取的标签存在)[1],同时官方镜像文档列出了 node:<version>-slim 体系及具体支持标签形如 22.15-slim22.15.0-slim(以及不同 Debian 发行版变体)[2]。 推荐的生产固定标签策略: 1) 固定到“完整版本号 + 变体” - 直接使用类似 node:22.15.0-slim(或你需要的 -bullseye-slim/-bookworm-slim/-trixie-slim 等后缀),避免使用 node:22-slimnode:lts-slimnode:current-slim 这类会随时间移动的浮动标签。[3][2] 2) 记录并更新策略(自动更新但可回滚) - 用固定精确标签保证可复现;当需要升级时再显式把镜像版本从旧的 22.15.0-slim 变为新版本(例如下一次补丁/次版本),由 CI/CD 触发验证后再发布。 3) 如需“最小体积”,才用 -slim - 官方对 -slim 的描述是:它不包含默认标签里的常见包,只包含运行 node 所需的最少组件;除非你明确在空间敏感场景,否则建议优先使用默认镜像(但这不改变“生产固定到精确版本”的策略)[4][2]。 如果你告诉我你希望基于哪个 Debian 发行版(bookworm/bullseye/trixie)以及是否需要 openssl/构建工具等,我也可以给出你可以直接写进 Dockerfile/compose 的具体推荐标签组合(如 node:22.15.0-bookworm-slim vs node:22.15.0-slim)。

Citations:


建议将运行时基础镜像固定到精确补丁版本,避免使用漂移标签(Dockerfile 第15行:node:22-slim

  • node:22-slim/类似“宽泛标签”会随时间移动,影响可重复性;而 engines >=22.15.0 更适合用精确匹配的 node:22.15.0-slim(或按需选择 -bookworm-slim / -bullseye-slim 等发行版变体)来固定运行时。
  • 目前虽然存在 node:22.15-slim,但生产实践上仍更建议直接 pin 到完整版本(到 .0)以减少补丁层面的漂移风险。
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` at line 15, The Dockerfile uses a floating runtime image tag
("FROM node:22-slim"); change this to a fully pinned patch-level image (for
example "node:22.15.0-slim" or a specific distro variant like
"node:22.15.0-bookworm-slim") to ensure reproducible builds—update the FROM
instruction that currently references node:22-slim to the chosen exact patch tag
across build stages.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [STANDARD-VIOLATION] Rolling Docker tag doesn't guarantee the minimum Node version required by engines

package.json declares "engines": { "node": ">=22.15.0" } and request-body-codec.ts relies on zstdDecompressSync (available since 22.15). The node:22-slim tag is rolling and can resolve to any 22.x patch. If Docker build cache is stale and resolves to a pre-22.15 image, zstdDecompressSync will throw TypeError at runtime for any zstd-encoded request body.

The deploy/Dockerfile already uses node:trixie-slim (Node 24+) with a comment explaining the 22.15 requirement. The main Dockerfile should be similarly explicit.

Suggested fix:

FROM node:22.15-slim AS runner

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
Expand Down
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Register now via <a href="https://pateway.ai/?ch=1ycdoum&aff=T8FV5H42">this link
### Requirements

- Docker and Docker Compose (latest version recommended)
- Optional (for local development): Node.js ≥ 20, Bun ≥ 1.3
- Optional (for local development): Node.js ≥ 22.15 (inbound zstd request-body decompression uses native `node:zlib` zstd), Bun ≥ 1.3

### 🚀 One-Click Deployment Script (✨ Recommended - Fully Automated)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Claude Code / Codex / Gemini 官方渠道价格低至原价的 38% / 6% / 9%,
### 环境要求

- Docker 与 Docker Compose(推荐使用最新版本)
- 可选(本地开发):Node.js ≥ 20,Bun ≥ 1.3
- 可选(本地开发):Node.js ≥ 22.15(入站请求体 zstd 解压依赖原生 `node:zlib` zstd),Bun ≥ 1.3

### 🚀 一键部署脚本(✨ 推荐方式,全自动安装)

Expand Down
3 changes: 3 additions & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ ENV CI=true
RUN bun run build

# 运行阶段:使用 Node.js(避免 Bun 流式响应内存泄漏 Issue #18488)
# 要求 Node >= 22.15(入站请求体 zstd 解压依赖 node:zlib 原生 zstd,见
# src/app/v1/_lib/proxy/request-body-codec.ts 与 package.json engines)。
# node:trixie-slim 基于 Debian Trixie,提供 Node 24+,满足该要求。
FROM node:trixie-slim AS runner
ENV NODE_ENV=production
ENV PORT=3000
Expand Down
5 changes: 4 additions & 1 deletion messages/en/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"exportError": "Export failed",
"exportPreparing": "Preparing export...",
"exportProgress": "Exported {current} / {total}",
"exportAsCsv": "Export as CSV",
"exportAsXlsx": "Export as XLSX (with summary)",
"quickFilters": {
"today": "Today",
"thisWeek": "This Week",
Expand Down Expand Up @@ -359,7 +361,8 @@
},
"effort": {
"label": "Effort",
"overridden": "Overridden by provider"
"overridden": "Overridden by provider",
"tooltip": "Thinking effort requested by the client (output_config.effort), shown verbatim. The proxy does not rename or convert levels."
},
"logicTrace": {
"title": "Decision Chain",
Expand Down
5 changes: 4 additions & 1 deletion messages/ja/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"exportError": "エクスポートに失敗しました",
"exportPreparing": "エクスポートを準備中...",
"exportProgress": "{current} / {total} 件をエクスポート済み",
"exportAsCsv": "CSV としてエクスポート",
"exportAsXlsx": "XLSX としてエクスポート (集計付き)",
"quickFilters": {
"today": "今日",
"thisWeek": "今週",
Expand Down Expand Up @@ -359,7 +361,8 @@
},
"effort": {
"label": "Effort",
"overridden": "プロバイダーにより上書き"
"overridden": "プロバイダーにより上書き",
"tooltip": "クライアントがリクエストボディで指定した思考強度 (output_config.effort)。プロキシは値をそのまま表示し、レベル名を変換しません。"
},
"logicTrace": {
"title": "決定チェーン",
Expand Down
5 changes: 4 additions & 1 deletion messages/ru/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"exportError": "Ошибка экспорта",
"exportPreparing": "Подготовка экспорта...",
"exportProgress": "Экспортировано {current} / {total}",
"exportAsCsv": "Экспорт в CSV",
"exportAsXlsx": "Экспорт в XLSX (со сводкой)",
"quickFilters": {
"today": "Сегодня",
"thisWeek": "Эта неделя",
Expand Down Expand Up @@ -359,7 +361,8 @@
},
"effort": {
"label": "Effort",
"overridden": "Переопределено провайдером"
"overridden": "Переопределено провайдером",
"tooltip": "Уровень усилий на размышления, запрошенный клиентом (output_config.effort); показан как есть — прокси не переименовывает и не преобразует уровни."
},
"logicTrace": {
"title": "Цепочка решений",
Expand Down
5 changes: 4 additions & 1 deletion messages/zh-CN/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"exportError": "导出失败",
"exportPreparing": "正在准备导出...",
"exportProgress": "已导出 {current} / {total} 条",
"exportAsCsv": "导出为 CSV",
"exportAsXlsx": "导出为 XLSX(含汇总表)",
"quickFilters": {
"today": "今天",
"thisWeek": "本周",
Expand Down Expand Up @@ -359,7 +361,8 @@
},
"effort": {
"label": "Effort",
"overridden": "已被供应商覆写"
"overridden": "已被供应商覆写",
"tooltip": "客户端在请求体中声明的思考强度(output_config.effort),按原值显示,代理不会重命名或转换等级。"
},
"logicTrace": {
"title": "决策链",
Expand Down
5 changes: 4 additions & 1 deletion messages/zh-TW/dashboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"exportError": "匯出失敗",
"exportPreparing": "正在準備匯出...",
"exportProgress": "已匯出 {current} / {total} 筆",
"exportAsCsv": "匯出為 CSV",
"exportAsXlsx": "匯出為 XLSX(含彙總表)",
"quickFilters": {
"today": "今天",
"thisWeek": "本週",
Expand Down Expand Up @@ -359,7 +361,8 @@
},
"effort": {
"label": "Effort",
"overridden": "已被供應商覆寫"
"overridden": "已被供應商覆寫",
"tooltip": "用戶端在請求體中宣告的思考強度(output_config.effort),按原值顯示,代理不會重新命名或轉換等級。"
},
"logicTrace": {
"title": "決策鏈",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "claude-code-hub",
"version": "0.8.0",
"private": true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Version not bumped for this release

The PR title is "release v0.8.4" but package.json still declares "version": "0.8.0". Any build artifact, API response, or UI element that reads the package version will report 0.8.0, making it impossible to distinguish this release from prior ones by version string alone.

Prompt To Fix With AI
This is a comment left during a code review.
Path: package.json
Line: 4

Comment:
**Version not bumped for this release**

The PR title is "release v0.8.4" but `package.json` still declares `"version": "0.8.0"`. Any build artifact, API response, or UI element that reads the package version will report 0.8.0, making it impossible to distinguish this release from prior ones by version string alone.

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

"engines": {
"node": ">=22.15.0"
},
"scripts": {
"dev": "tsgo -p tsconfig.json --noEmit && next dev --port 13500",
"dev:server": "NODE_ENV=development node server.js",
Expand Down Expand Up @@ -89,6 +92,7 @@
"dotenv": "^17",
"drizzle-orm": "^0.45",
"fetch-socks": "^1",
"fflate": "^0.8.2",
"framer-motion": "^12",
"hono": "^4",
"html2canvas": "^1",
Expand Down
26 changes: 24 additions & 2 deletions src/actions/error-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ export async function createErrorRuleAction(data: {

// 刷新缓存(事件广播,支持多 worker 同步)
await emitErrorRulesUpdated();
// 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。
// reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。
try {
await errorRuleDetector.reload();
} catch (reloadError) {
logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError });
}
Comment on lines 179 to +187

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

emitErrorRulesUpdated() 也改成 best-effort。

现在只有 reload() 失败会被降级处理;如果数据库写入已经成功,但 emitErrorRulesUpdated() 因 Redis/pubsub 短暂故障抛错,这三个 action 仍会走外层 catch 返回 ok: false。创建路径下这会诱导用户重试并重复创建,更新/删除也会把已提交的变更误报成失败。

可选修正
-    await emitErrorRulesUpdated();
+    try {
+      await emitErrorRulesUpdated();
+    } catch (emitError) {
+      logger.warn("[ErrorRulesAction] Failed to broadcast error-rule update", { emitError });
+    }
     try {
       await errorRuleDetector.reload();
     } catch (reloadError) {
       logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError });
     }

Also applies to: 321-327, 382-388

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/actions/error-rules.ts` around lines 179 - 187, The call to
emitErrorRulesUpdated() should be made best-effort so transient pub/sub/Redis
failures don't cause the whole action to return ok: false; wrap the
emitErrorRulesUpdated() call in its own try/catch (similar to how
errorRuleDetector.reload() is handled) and on error log a warning with context
(e.g., "[ErrorRulesAction] Failed to emit rules update") but do not rethrow;
apply the same change to the other occurrences referenced (the other
emitErrorRulesUpdated() calls around the 321-327 and 382-388 regions) so emits
never bubble errors back to the caller.


revalidatePath("/settings/error-rules");

Expand Down Expand Up @@ -311,6 +318,13 @@ export async function updateErrorRuleAction(

// 刷新缓存(事件广播,支持多 worker 同步)
await emitErrorRulesUpdated();
// 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。
// reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。
try {
await errorRuleDetector.reload();
} catch (reloadError) {
logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError });
}

revalidatePath("/settings/error-rules");

Expand Down Expand Up @@ -365,6 +379,13 @@ export async function deleteErrorRuleAction(id: number): Promise<ActionResult> {

// 刷新缓存(事件广播,支持多 worker 同步)
await emitErrorRulesUpdated();
// 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。
// reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。
try {
await errorRuleDetector.reload();
} catch (reloadError) {
logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError });
}

revalidatePath("/settings/error-rules");

Expand Down Expand Up @@ -412,8 +433,9 @@ export async function refreshCacheAction(): Promise<
// 1. 同步默认规则到数据库
const syncResult = await repo.syncDefaultErrorRules();

// 2. 重新加载缓存
await errorRuleDetector.reload();
// 2. 重新加载缓存:手动刷新必须读到刚同步的默认规则,
// 若已有在途 reload 则排队补跑一轮(queueIfRunning),确保拿到同步后的最新快照。
await errorRuleDetector.reload({ queueIfRunning: true });

const stats = errorRuleDetector.getStats();

Expand Down
40 changes: 26 additions & 14 deletions src/actions/model-prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import type {
ModelPrice,
ModelPriceData,
ModelPriceSource,
PriceTableJson,
PriceUpdateResult,
SyncConflict,
Expand Down Expand Up @@ -98,13 +99,17 @@ function buildManualPriceDataFromProviderPricing(

/**
* 价格表处理核心逻辑(内部函数,无权限检查)
* 用于系统初始化和 Web UI 上传
* 用于系统初始化、云端自动同步和 Web UI 上传
* @param jsonContent - 价格表 JSON 内容
* @param overwriteManual - 可选,要覆盖的手动添加模型名称列表
* @param source - 写入记录的来源。云端/自动同步为 'litellm'(默认);
* 用户在本地显式上传的价格表为 'manual',使其遵循“本地优先”原则、
* 不被后续云端自动同步覆盖。
*/
export async function processPriceTableInternal(
jsonContent: string,
overwriteManual?: string[]
overwriteManual?: string[],
source: ModelPriceSource = "litellm"
): Promise<ActionResult<PriceUpdateResult>> {
try {
// 解析JSON内容
Expand Down Expand Up @@ -156,7 +161,10 @@ export async function processPriceTableInternal(
};

// 处理每个模型的价格
for (const [modelName, priceData] of entries) {
for (const [rawModelName, priceData] of entries) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Performing individual database transactions (await createModelPrice / await upsertModelPrice) inside a loop for each model entry can lead to severe performance bottlenecks (N+1 transactions) when processing large price tables. Consider wrapping the entire loop inside a single database transaction (db.transaction) to commit all changes atomically and significantly reduce database I/O overhead.

// 与 manual 记录入库时(upsertModelPrice 使用 trim 后的名称)保持一致地归一化,
// 避免云端表里带空白的同名键绕过本地手动模型的保护检查。
const modelName = typeof rawModelName === "string" ? rawModelName.trim() : rawModelName;
try {
// 验证价格数据基本类型
if (typeof priceData !== "object" || priceData === null) {
Expand All @@ -172,9 +180,11 @@ export async function processPriceTableInternal(
continue;
}

// 检查是否存在手动添加的价格且不在覆盖列表中
// 本地优先:仅当本次写入来自云端/自动同步(source='litellm')时,
// 才跳过用户手动维护的模型,除非显式列入覆盖列表。
// 用户显式上传(source='manual')属于权威导入,不受此保护跳过,可正常覆盖。
const isManualPrice = manualPrices.has(modelName);
if (isManualPrice && !overwriteSet.has(modelName)) {
if (source === "litellm" && isManualPrice && !overwriteSet.has(modelName)) {
// 跳过手动添加的模型,记录到 skippedConflicts
result.skippedConflicts?.push(modelName);
result.unchanged.push(modelName);
Expand All @@ -186,15 +196,15 @@ export async function processPriceTableInternal(

if (!existingPrice) {
// 模型不存在,新增记录
await createModelPrice(modelName, priceData, "litellm");
await createModelPrice(modelName, priceData, source);
result.added.push(modelName);
} else if (!isPriceDataEqual(existingPrice.priceData, priceData)) {
// 模型存在但价格发生变化
// 如果是手动模型且在覆盖列表中,先删除旧记录
if (isManualPrice && overwriteSet.has(modelName)) {
await deleteModelPriceByName(modelName);
}
await createModelPrice(modelName, priceData, "litellm");
} else if (
existingPrice.source !== source ||
!isPriceDataEqual(existingPrice.priceData, priceData)
) {
// 价格或来源发生变化:用事务原子地“删旧 + 插新”替换该模型的所有记录,
// 既保证不会在崩溃时丢失价格,又避免同名记录堆积(litellm 孤儿行,或 manual + litellm 并存)。
await upsertModelPrice(modelName, priceData, source);
result.updated.push(modelName);
} else {
// 价格未发生变化,不需要更新
Expand Down Expand Up @@ -261,7 +271,9 @@ export async function uploadPriceTable(
jsonContent = JSON.stringify(parseResult.data.models);
}

const result = await processPriceTableInternal(jsonContent, overwriteManual);
// 用户显式上传的价格表视为“本地优先”的权威来源,标记为 manual,
// 使其不会被后续云端自动同步静默覆盖。
const result = await processPriceTableInternal(jsonContent, overwriteManual, "manual");

if (result.ok) {
emitActionAudit({
Expand Down
17 changes: 4 additions & 13 deletions src/actions/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { emitActionAudit } from "@/lib/audit/emit";
import { getSession } from "@/lib/auth";
import type { NotificationJobType } from "@/lib/constants/notification.constants";
import { logger } from "@/lib/logger";
import { resolveSystemTimezone } from "@/lib/utils/timezone";
import { WebhookNotifier } from "@/lib/webhook";
import { buildTestMessage } from "@/lib/webhook/templates/test-messages";
Expand Down Expand Up @@ -41,18 +40,10 @@ export async function updateNotificationSettingsAction(
const before = await getNotificationSettings();
const updated = await updateNotificationSettings(payload);

// 重新调度通知任务(仅生产环境)
if (process.env.NODE_ENV === "production") {
// 动态导入避免 Turbopack 编译 Bull 模块
const { scheduleNotifications } = await import("@/lib/notification/notification-queue");
await scheduleNotifications();
} else {
logger.warn({
action: "schedule_notifications_skipped",
reason: "development_mode",
message: "Notification scheduling is disabled in development mode",
});
}
// 重新调度通知任务,使总开关、子开关、时间/间隔等变更立即生效(添加/移除 repeatable 作业)。
// 动态导入避免静态加载 Bull;scheduleNotifications 内部已 fail-open,缺少 REDIS_URL 时不会影响设置保存。
const { scheduleNotifications } = await import("@/lib/notification/notification-queue");
await scheduleNotifications();
Comment on lines +43 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

将调度调用改为真正的 fail-open,避免“设置已保存但返回失败”

这里如果 import()scheduleNotifications() 抛错,会进入外层 catch,但 updateNotificationSettings(payload) 已经成功提交,接口会返回失败并可能诱发重复提交。

建议修改
-    const { scheduleNotifications } = await import("`@/lib/notification/notification-queue`");
-    await scheduleNotifications();
+    try {
+      const { scheduleNotifications } = await import("`@/lib/notification/notification-queue`");
+      await scheduleNotifications();
+    } catch {
+      // fail-open: 调度失败不影响设置保存结果
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 重新调度通知任务,使总开关、子开关、时间/间隔等变更立即生效(添加/移除 repeatable 作业)。
// 动态导入避免静态加载 Bull;scheduleNotifications 内部已 fail-open,缺少 REDIS_URL 时不会影响设置保存。
const { scheduleNotifications } = await import("@/lib/notification/notification-queue");
await scheduleNotifications();
// 重新调度通知任务,使总开关、子开关、时间/间隔等变更立即生效(添加/移除 repeatable 作业)。
// 动态导入避免静态加载 Bull;scheduleNotifications 内部已 fail-open,缺少 REDIS_URL 时不会影响设置保存。
try {
const { scheduleNotifications } = await import("`@/lib/notification/notification-queue`");
await scheduleNotifications();
} catch {
// fail-open: 调度失败不影响设置保存结果
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/actions/notifications.ts` around lines 43 - 46, The scheduleNotifications
import/call can throw and currently bubbles to the outer catch after
updateNotificationSettings(payload) succeeded; wrap the dynamic import and await
scheduleNotifications() in their own try/catch so any errors are caught, logged
(or processLogger.warn/error) and not rethrown—i.e., perform a fail-open: use
try { const { scheduleNotifications } = await
import("`@/lib/notification/notification-queue`"); await scheduleNotifications();
} catch (err) { /* log err but do not throw */ } to ensure
updateNotificationSettings completes successfully even if scheduling fails.


emitActionAudit({
category: "notification",
Expand Down
24 changes: 24 additions & 0 deletions src/actions/request-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ export async function createRequestFilterAction(data: {
operations: data.operations ?? null,
});

// 立即同步内存缓存,确保新规则对代理请求即时生效,无需用户手动点"刷新缓存"。
// 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮);
// reload 仅为缓存同步,失败不应把已成功的写入误报为失败。
try {
await requestFilterEngine.reload(false);
} catch (reloadError) {
logger.warn("[RequestFiltersAction] Failed to reload engine after create", { reloadError });
}
revalidatePath(SETTINGS_PATH);
return { ok: true, data: created };
} catch (error) {
Expand Down Expand Up @@ -378,6 +386,14 @@ export async function updateRequestFilterAction(
return { ok: false, error: "记录不存在" };
}

// 立即同步内存缓存,确保规则改动对代理请求即时生效,无需用户手动点"刷新缓存"。
// 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮);
// reload 仅为缓存同步,失败不应把已成功的写入误报为失败。
try {
await requestFilterEngine.reload(false);
} catch (reloadError) {
logger.warn("[RequestFiltersAction] Failed to reload engine after update", { reloadError });
}
revalidatePath(SETTINGS_PATH);
return { ok: true, data: updated };
} catch (error) {
Expand All @@ -393,6 +409,14 @@ export async function deleteRequestFilterAction(id: number): Promise<ActionResul
try {
const ok = await deleteRequestFilter(id);
if (!ok) return { ok: false, error: "记录不存在" };
// 立即同步内存缓存,确保删除对代理请求即时生效,无需用户手动点"刷新缓存"。
// 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮);
// reload 仅为缓存同步,失败不应把已成功的写入误报为失败。
try {
await requestFilterEngine.reload(false);
} catch (reloadError) {
logger.warn("[RequestFiltersAction] Failed to reload engine after delete", { reloadError });
}
revalidatePath(SETTINGS_PATH);
return { ok: true };
} catch (error) {
Expand Down
Loading
Loading