fix: 三端联调 P1 修复 — API密钥页崩溃 + 桌面端401恢复 + 用量统计全零
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

P1-03: vite.config.ts proxy '/api' → '/api/' 加尾部斜杠,
  防止前缀匹配 /api-keys 导致 SPA 路由崩溃

P1-01: kernel_init 增加 api_key 变更检测(token 刷新后自动重连),
  streamStore 增加 401 自动恢复(refresh token → kernel reconnect),
  KernelClient 新增 getConfig() 方法

P1-02: /api/v1/usage 总计改从 billing_usage_quotas 读取
  (authoritative source,SSE 和 JSON 均写入),
  by_model/by_day 仍从 usage_records 读取
This commit is contained in:
iven
2026-04-14 22:02:02 +08:00
parent 6721a1cc6e
commit e0eb7173c5
5 changed files with 85 additions and 24 deletions

View File

@@ -164,6 +164,11 @@ export class KernelClient {
this.config = config;
}
/** Get current kernel configuration (for auth token refresh) */
getConfig(): KernelConfig | undefined {
return this.config;
}
getState(): ConnectionState {
return this.state;
}

View File

@@ -38,6 +38,47 @@ import { useArtifactStore } from './artifactStore';
const log = createLogger('StreamStore');
// ---------------------------------------------------------------------------
// 401 Auth Error Recovery
// ---------------------------------------------------------------------------
/**
* Detect and handle 401 auth errors during chat streaming.
* Attempts token refresh → kernel reconnect → auto-retry.
* Returns a user-friendly error message if recovery fails.
*/
async function tryRecoverFromAuthError(error: string): Promise<string | null> {
const is401 = /401|Unauthorized|UNAUTHORIZED|未认证|认证已过期/.test(error);
if (!is401) return null;
log.info('Detected 401 auth error, attempting token refresh...');
try {
const { saasClient } = await import('../../lib/saas-client');
const newToken = await saasClient.refreshMutex();
if (newToken) {
// Update kernel config with refreshed token → triggers kernel re-init via changed api_key detection
const { useConnectionStore } = await import('../connectionStore');
const { getKernelClient } = await import('../../lib/kernel-client');
const kernelClient = getKernelClient();
const currentConfig = kernelClient.getConfig();
if (currentConfig) {
kernelClient.setConfig({ ...currentConfig, apiKey: newToken });
await kernelClient.connect();
log.info('Kernel reconnected with refreshed token');
}
return '认证已刷新,请重新发送消息';
}
} catch (refreshErr) {
log.warn('Token refresh failed, triggering logout:', refreshErr);
try {
const { useSaaSStore } = await import('../saasStore');
useSaaSStore.getState().logout();
} catch { /* non-critical */ }
return 'SaaS 会话已过期,请重新登录';
}
return '认证失败,请重新登录';
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -517,7 +558,7 @@ export const useStreamStore = create<StreamState>()(
}
}
},
onError: (error: string) => {
onError: async (error: string) => {
// Flush any remaining buffered deltas before erroring
if (flushTimer !== null) {
clearTimeout(flushTimer);
@@ -525,10 +566,14 @@ export const useStreamStore = create<StreamState>()(
}
flushBuffers();
// Attempt 401 auth recovery (token refresh + kernel reconnect)
const recoveryMsg = await tryRecoverFromAuthError(error);
const displayError = recoveryMsg || error;
_chat?.updateMessages(msgs =>
msgs.map(m =>
m.id === assistantId
? { ...m, content: error, streaming: false, error }
? { ...m, content: displayError, streaming: false, error: displayError }
: m.role === 'user' && m.optimistic && m.timestamp.getTime() >= streamStartTime
? { ...m, optimistic: false }
: m
@@ -573,13 +618,18 @@ export const useStreamStore = create<StreamState>()(
textBuffer = '';
thinkBuffer = '';
const errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
let errorMessage = err instanceof Error ? err.message : '无法连接 Gateway';
// Attempt 401 auth recovery
const recoveryMsg = await tryRecoverFromAuthError(errorMessage);
if (recoveryMsg) errorMessage = recoveryMsg;
_chat?.updateMessages(msgs =>
msgs.map(m =>
m.id === assistantId
? {
...m,
content: `⚠️ ${errorMessage}`,
content: errorMessage,
streaming: false,
error: errorMessage,
}