Skip to content

Commit 9e625ca

Browse files
committed
fix(audit3): resolve deferred findings X1-X6
- X1: CryptoManager.init now wired to auth lifecycle (login derives device key via safeStorage, logout clears; init failure surfaces explicit warning toast instead of silent plaintext); mcpManager gains shuttingDown flag - no retry scheduling after shutdown and retryAttempts reset for dev hot-reload - X2: electron.d.ts duplicate Window.electronAPI declaration removed (env.d.ts is the single authoritative superset; conflicting signatures were masked by skipLibCheck) - X4: Gemini vision/multi/video return real input/output token split for cost tracking; DeepSeek input tokens use cache-miss portion (cache-hit billed at 1/4 price, previously overestimated) - X5: error_pattern cache key uses only fields the chain consumes (first 20 correctAnswer/userAnswer, no flashcardId) - improves hit rate without correctness impact - X6: feature-config startup validation extracted to shared function and applied to streaming registry (was middleware-only, streaming misconfigs silently fell back to 300s timeout / default limits)
1 parent beed8b4 commit 9e625ca

8 files changed

Lines changed: 83 additions & 63 deletions

File tree

‎client/electron/mcpManager.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class McpManager {
5151
/** ELEC2-M1: init 重试计数与定时器(指数退避,环境故障自愈) */
5252
private retryAttempts = 0;
5353
private retryTimer: ReturnType<typeof setTimeout> | null = null;
54+
/** GW-3(X1): 退出标志——shutdown 发起后禁止再调度 init 重试 */
55+
private shuttingDown = false;
5456
/** 待响应的请求回调 */
5557
private pending = new Map<string, {
5658
resolve: (value: unknown) => void;
@@ -135,6 +137,12 @@ class McpManager {
135137
// ELEC2-M1: 重置 initialized 并指数退避重试——原实现保留 true 导致
136138
// MCP 功能本次运行永久静默禁用(bridge 故障/环境变量缺失后无自愈机会)
137139
this.initialized = false;
140+
// GW-3(X1): shutdown 已发起时不再调度重试——否则退出窗口期(timer
141+
// 回调已触发、init 的 sendRequest in-flight)会 fork 孤儿子进程
142+
if (this.shuttingDown) {
143+
logger.warn('[MCP] Init failed during shutdown, skip retry scheduling');
144+
return;
145+
}
138146
this.scheduleRetryInit();
139147
}
140148
}
@@ -205,6 +213,11 @@ class McpManager {
205213
async shutdown(): Promise<void> {
206214
logger.info('[MCP] Shutting down MCP Manager...');
207215

216+
// GW-3(X1): 置位退出标志 + 复位重试计数——in-flight init 失败后不再
217+
// 调度重试;重试计数复位防止 dev 热重载重启后直接耗尽
218+
this.shuttingDown = true;
219+
this.retryAttempts = 0;
220+
208221
// ELEC2-M1: 取消未执行的重试定时器(退出时不再自愈)
209222
if (this.retryTimer) {
210223
clearTimeout(this.retryTimer);

‎client/src/lib/auth/AuthContext.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, use
22
import type { User, Session, AuthError } from '@supabase/supabase-js';
33
import { supabase, isPlaceholder } from './supabaseClient';
44
import { useToast } from '@/components/ui';
5+
import { cryptoManager } from '@/lib/crypto';
56

67
interface AuthState {
78
user: User | null;
@@ -37,6 +38,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
3738
/** 标记用户主动登出,避免将主动登出误判为 session 过期 */
3839
const intentionalSignOutRef = useRef(false);
3940

41+
// GW-3(X1): 加密初始化接入登录生命周期——init 原无调用方,safeStorage 加密
42+
// 路径从未激活(敏感字段恒明文写入)。登录成功后派生设备级 AES 密钥
43+
//(Electron 环境经 safeStorage 加密落盘),登出/未登录时清除密钥。
44+
// init 失败时显式提示(已置 initFailed 标记),不静默降级为明文
45+
useEffect(() => {
46+
const userId = state.user?.id;
47+
if (userId) {
48+
cryptoManager.init(userId).catch((err) => {
49+
console.warn('[Auth] CryptoManager init failed, sensitive fields will be stored in plaintext:', err);
50+
toast({ type: 'warning', message: '本地加密初始化失败,敏感字段将以明文存储' });
51+
});
52+
} else {
53+
cryptoManager.clear();
54+
}
55+
// eslint-disable-next-line react-hooks/exhaustive-deps
56+
}, [state.user?.id]);
57+
4058
useEffect(() => {
4159
supabase.auth.getSession().then(({ data: { session } }) => {
4260
setState({

‎client/src/types/electron.d.ts‎

Lines changed: 5 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,66 +7,11 @@
77
* 绕过类型(根治优于局部断言)。
88
* @ai-context: invoke/on/send 为通配逃生通道,新增 IPC 能力应优先
99
* 定义精确的具名方法而非走通配 invoke。
10+
*
11+
* GW-3(X2): Window.electronAPI 的唯一权威声明已并入 src/env.d.ts
12+
*(该文件为超集:含 recording/ollama/audio_capture/ai_progress_narrate 等
13+
* 全部新 API)。本文件保留空壳仅维持旧导入路径兼容——双份声明曾多处
14+
* 类型冲突(必选/可选、返回类型不一致),被 skipLibCheck 掩盖。
1015
*/
1116

1217
export {};
13-
14-
declare global {
15-
interface Window {
16-
electronAPI?: {
17-
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
18-
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
19-
send: (channel: string, ...args: unknown[]) => void;
20-
/** 拖拽文件 → 绝对路径(知识入籍 PDF 拖拽,Electron 35 webUtils) */
21-
getPathForFile: (file: File) => string;
22-
/** 监听主进程发出的窗口关闭事件,返回取消监听函数 */
23-
onWindowClosing: (callback: () => void) => () => void;
24-
/** 向主进程发送关闭行为选择 */
25-
closeAction: (action: 'quit' | 'minimize' | 'cancel', remember: boolean) => Promise<void>;
26-
/** 最小化窗口 */
27-
windowMinimize: () => Promise<{ success: boolean }>;
28-
/** 切换最大化/还原 */
29-
windowMaximize: () => Promise<{ success: boolean }>;
30-
/** 关闭窗口(触发确认流程) */
31-
windowClose: () => Promise<{ success: boolean }>;
32-
/** 查询当前是否最大化 */
33-
windowIsMaximized: () => Promise<boolean>;
34-
/** 监听最大化状态变化,返回取消监听函数 */
35-
onMaximizedChanged: (callback: (isMaximized: boolean) => void) => () => void;
36-
/** 监听退出前同步事件,返回取消监听函数 */
37-
onSyncBeforeQuit: (callback: () => void) => () => void;
38-
/** 通知主进程同步完成 */
39-
notifySyncComplete: () => void;
40-
/** 设置是否自动检查更新 */
41-
setAutoUpdate: (enabled: boolean) => Promise<{ success: boolean }>;
42-
/** FRONT2-M5: safeStorage 系统级加密(密钥材料落盘保护) */
43-
safeStorageEncrypt: (plain: string) => Promise<string>;
44-
safeStorageDecrypt: (encoded: string) => Promise<string>;
45-
/** v1.0.0: 数据访问 API */
46-
db: {
47-
query: <T = unknown>(table: string, method: string, args?: unknown[]) => Promise<T>;
48-
insert: (table: string, item: unknown) => Promise<string>;
49-
update: (table: string, id: string, changes: unknown) => Promise<void>;
50-
delete: (table: string, id: string) => Promise<void>;
51-
search: <T = unknown>(table: string, query: string) => Promise<T[]>;
52-
batch: (operations: unknown[]) => Promise<{ success: boolean }>;
53-
};
54-
/** v1.0.0: 数据迁移 API */
55-
migration: {
56-
check: () => Promise<{ needed: boolean; tableMapping: Array<{ dexie: string; sqlite: string }> }>;
57-
importTable: (table: string, rows: unknown[]) => Promise<{ success: boolean; rowsImported: number; error?: string }>;
58-
complete: () => Promise<{ success: boolean; integrity: string; error?: string }>;
59-
};
60-
/** v1.1.0: 存储路径管理 API */
61-
storage: {
62-
changePath: (newPath: string) => Promise<{
63-
success: boolean;
64-
previousPath?: string;
65-
newPath?: string;
66-
error?: string;
67-
}>;
68-
getActivePath: () => Promise<string>;
69-
};
70-
};
71-
}
72-
}

‎server/ai-gateway/middleware/rate_limit.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@
111111
# GW-2#9: 启动校验——PATH_TO_FEATURE 登记的 feature 必须同时在
112112
# TIMEOUT_CONFIG 与 RATE_LIMITS 登记,否则静默兜底(300s 超时/默认 10 次限流)
113113
# 会掩盖配置缺失(import_concept 曾因此落入 300s 超时预算)
114+
# GW-3(X6): 校验提取为公共函数——流式路由(/{feature}/stream)的
115+
# _FEATURE_TO_CONFIG_KEY 注册表同样调用,覆盖此前校验盲区
114116
_MISSING_CONFIG = sorted(
115117
f for f in set(PATH_TO_FEATURE.values())
116118
if f not in RATE_LIMITS or f not in TIMEOUT_CONFIG
@@ -122,6 +124,20 @@
122124
)
123125

124126

127+
def warn_missing_feature_config(features: set[str], source: str) -> None:
128+
"""GW-3(X6): 校验一组 feature 是否同时登记 TIMEOUT_CONFIG 与 RATE_LIMITS。
129+
130+
中间件(PATH_TO_FEATURE)与流式路由(_FEATURE_TO_CONFIG_KEY)共用本函数,
131+
新增功能漏配时启动即告警,避免静默落入 300s 兜底超时/默认 10 次限流。
132+
"""
133+
missing = sorted(f for f in features if f not in RATE_LIMITS or f not in TIMEOUT_CONFIG)
134+
if missing:
135+
logger.warning(
136+
"[%s] 以下 feature 缺少 TIMEOUT_CONFIG/RATE_LIMITS 登记(将使用兜底值): %s",
137+
source, missing,
138+
)
139+
140+
125141
class RateLimitMiddleware(BaseHTTPMiddleware):
126142
"""频率限制中间件 — 基于 Redis 滑动窗口"""
127143

‎server/ai-gateway/providers/deepseek_provider.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ async def generate(
126126
cache_miss_tokens = prompt_tokens - cache_hit_tokens
127127
# DeepSeek 缓存命中按 1/4 价格计费
128128
effective_tokens = int(cache_hit_tokens * 0.25) + cache_miss_tokens + completion_tokens
129+
# GW-3(X4): input 记账改用缓存未命中部分(命中部分按 1/4
130+
# 计费)——原实现用原始 prompt_tokens,缓存命中场景成本高估
131+
input_tokens = cache_miss_tokens
129132
logger.info(
130133
"DeepSeek 缓存命中: hit=%d, miss=%d, 实际计费 token=%d",
131134
cache_hit_tokens, cache_miss_tokens, effective_tokens,

‎server/ai-gateway/providers/gemini_provider.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,17 @@ async def generate_vision(
143143
latency_ms = int((time.monotonic() - start_time) * 1000)
144144
content = response.text or ""
145145
tokens_used = 0
146+
input_tokens = 0
147+
output_tokens = 0
146148
if response.usage_metadata:
147149
tokens_used = response.usage_metadata.total_token_count or 0
150+
# GW-3(X4): 视觉路径同样返回 input/output 拆分供成本记账
151+
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0) or 0
152+
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0) or 0
148153
logger.info("GeminiProvider.generate_vision 成功: model=%s, tokens=%d, latency=%dms",
149154
model, tokens_used, latency_ms)
150155
return {"content": content, "tokens_used": tokens_used,
156+
"input_tokens": input_tokens, "output_tokens": output_tokens,
151157
"model": model, "latency_ms": latency_ms}
152158
except Exception as e:
153159
logger.error("GeminiProvider.generate_vision 失败: %s", str(e))
@@ -194,11 +200,17 @@ async def generate_vision_multi(
194200
latency_ms = int((time.monotonic() - start_time) * 1000)
195201
content = response.text or ""
196202
tokens_used = 0
203+
input_tokens = 0
204+
output_tokens = 0
197205
if response.usage_metadata:
198206
tokens_used = response.usage_metadata.total_token_count or 0
207+
# GW-3(X4): 多图路径同样返回 input/output 拆分供成本记账
208+
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0) or 0
209+
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0) or 0
199210
logger.info("GeminiProvider.generate_vision_multi 成功: model=%s, images=%d, tokens=%d, latency=%dms",
200211
model, len(images_base64), tokens_used, latency_ms)
201212
return {"content": content, "tokens_used": tokens_used,
213+
"input_tokens": input_tokens, "output_tokens": output_tokens,
202214
"model": model, "latency_ms": latency_ms}
203215
except Exception as e:
204216
logger.error("GeminiProvider.generate_vision_multi 失败: %s", str(e))
@@ -261,11 +273,17 @@ async def generate_video(
261273
latency_ms = int((time.monotonic() - start_time) * 1000)
262274
content = response.text or ""
263275
tokens_used = 0
276+
input_tokens = 0
277+
output_tokens = 0
264278
if response.usage_metadata:
265279
tokens_used = response.usage_metadata.total_token_count or 0
280+
# GW-3(X4): 视频路径同样返回 input/output 拆分供成本记账
281+
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0) or 0
282+
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0) or 0
266283
logger.info("GeminiProvider.generate_video 成功: model=%s, tokens=%d, latency=%dms",
267284
model, tokens_used, latency_ms)
268285
return {"content": content, "tokens_used": tokens_used,
286+
"input_tokens": input_tokens, "output_tokens": output_tokens,
269287
"model": model, "latency_ms": latency_ms}
270288
except Exception as e:
271289
logger.error("GeminiProvider.generate_video 失败: %s", str(e))

‎server/ai-gateway/routers/error_pattern.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,12 @@ async def error_pattern(request: Request, body: ErrorPatternRequest) -> ErrorPat
137137
# GW-2#10: 缓存键加入 user_id 隔离——错误模式分析结果基于用户私有
138138
# 学习数据,原键不含用户维度,内容相同的不同用户会串用彼此的分析结论
139139
#(隐私泄露 + 正确性错误);同时不再截断 50 字符(sha256 本身压缩长度)
140+
# GW-3(X5): 键仅含 chain 实际消费的字段(前 20 条 correctAnswer/userAnswer,
141+
# flashcardId 不参与分析)——原键含 flashcardId 与全量文本,同内容不同
142+
# flashcardId 不命中缓存,命中率低(多付 AI 调用成本)
140143
errors_str = "|".join([
141-
f"{e.flashcardId}:{e.correctAnswer}:{e.userAnswer}"
142-
for e in body.goldenErrors
144+
f"{e.correctAnswer}:{e.userAnswer}"
145+
for e in body.goldenErrors[:20]
143146
])
144147
cache_key = hashlib.sha256(f"{user_id}:{errors_str}".encode()).hexdigest()
145148

‎server/ai-gateway/routers/streaming.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pydantic import BaseModel, Field
2525

2626
from config import call_with_fallback_stream
27-
from middleware.rate_limit import check_rate_limit
27+
from middleware.rate_limit import check_rate_limit, warn_missing_feature_config
2828

2929
logger = logging.getLogger(__name__)
3030
router = APIRouter(prefix="/api/v1/ai", tags=["流式输出"])
@@ -165,6 +165,10 @@
165165
"inspiration-draft": "inspiration_draft",
166166
}
167167

168+
# GW-3(X6): 启动校验——流式 feature 同样必须登记 TIMEOUT_CONFIG/RATE_LIMITS
169+
#(原校验只覆盖中间件 PATH_TO_FEATURE,流式注册表漏配不告警)
170+
warn_missing_feature_config(set(_FEATURE_TO_CONFIG_KEY.values()), "streaming")
171+
168172

169173
# ============================================================
170174
# 通用请求体(流式请求统一使用)

0 commit comments

Comments
 (0)