diff --git a/docs/token-expiration-check.md b/docs/token-expiration-check.md new file mode 100644 index 0000000..3598150 --- /dev/null +++ b/docs/token-expiration-check.md @@ -0,0 +1,277 @@ +# Token 过期检查实现方案 + +## 1. 概述 + +实现三个关键时机的 Token 过期检查: +- 插件激活时 +- 发起 API 请求前 +- 用户交互时(打开面板/侧边栏) + +## 2. 数据存储 + +### 2.1 存储位置 +使用 VS Code 的 `globalState` 存储: +```typescript +context.globalState.update('tokenExp', exp); +``` + +### 2.2 存储内容 +- `token`: 用户 token +- `tokenExp`: 过期时间戳(秒) +- `userInfo`: 用户信息 + +## 3. 核心函数设计 + +### 3.1 过期检查函数 +```typescript +/** + * 检查 token 是否过期 + * @param exp - 过期时间戳(秒) + * @param bufferSeconds - 提前判断过期的缓冲时间(默认 60 秒) + * @returns true 表示已过期或即将过期 + */ +function isTokenExpired(exp: number | undefined, bufferSeconds: number = 60): boolean { + if (!exp) { + return true; // 没有过期时间,视为已过期 + } + + const now = Math.floor(Date.now() / 1000); // 当前时间戳(秒) + return now >= (exp - bufferSeconds); // 提前 60 秒判断过期 +} +``` + +### 3.2 清除登录状态函数 +```typescript +/** + * 清除所有登录相关状态 + */ +async function clearAuthState(context: vscode.ExtensionContext): Promise { + await context.globalState.update('token', undefined); + await context.globalState.update('tokenExp', undefined); + await context.globalState.update('userInfo', undefined); +} +``` + +### 3.3 统一过期处理函数 +```typescript +/** + * 处理 token 过期情况 + * @param context - 扩展上下文 + * @param showMessage - 是否显示提示消息 + */ +async function handleTokenExpired( + context: vscode.ExtensionContext, + showMessage: boolean = true +): Promise { + await clearAuthState(context); + + if (showMessage) { + const action = await vscode.window.showWarningMessage( + '登录已过期,请重新登录', + '立即登录' + ); + + if (action === '立即登录') { + // 触发登录流程(打开登录面板) + vscode.commands.executeCommand('ic-coder.openPanel'); + } + } +} +``` + +## 4. 三个检查时机实现 + +### 4.1 插件激活时检查 + +**位置**: `src/extension.ts` 的 `activate` 函数 + +**实现**: +```typescript +export async function activate(context: vscode.ExtensionContext) { + console.log('IC Coder 插件正在激活...'); + + // 1. 检查 token 是否过期 + const tokenExp = context.globalState.get('tokenExp'); + if (isTokenExpired(tokenExp)) { + // 静默清除,不显示提示(避免启动时打扰用户) + await handleTokenExpired(context, false); + } + + // ... 其他激活逻辑 +} +``` + +**说明**: 启动时静默检查,如果过期则清除状态,但不弹窗提示 + +--- + +### 4.2 发起 API 请求前检查 + +**位置**: `src/utils/messageHandler.ts` 的 API 请求函数 + +**实现**: +```typescript +// 在发送消息到后端前检查 +async function sendMessageToBackend(message: string, context: vscode.ExtensionContext) { + // 1. 检查 token 是否过期 + const tokenExp = context.globalState.get('tokenExp'); + if (isTokenExpired(tokenExp)) { + await handleTokenExpired(context, true); // 显示提示 + return; // 中断请求 + } + + const token = context.globalState.get('token'); + if (!token) { + vscode.window.showWarningMessage('请先登录'); + return; + } + + // 2. 继续发送请求 + // ... 原有请求逻辑 +} +``` + +**说明**: 每次 API 请求前检查,如果过期则提示用户并中断请求 + +--- + +### 4.3 用户交互时检查 + +**位置**: +- `src/panels/ICHelperPanel.ts` - 打开聊天面板时 +- `src/views/ICViewProvider.ts` - 侧边栏视图加载时 + +**实现 - 聊天面板**: +```typescript +// ICHelperPanel.ts +public static render(extensionUri: vscode.Uri, context: vscode.ExtensionContext) { + // 1. 检查 token 是否过期 + const tokenExp = context.globalState.get('tokenExp'); + if (isTokenExpired(tokenExp)) { + handleTokenExpired(context, true); // 显示提示 + // 继续渲染面板,但会显示未登录状态 + } + + // 2. 创建或显示面板 + // ... 原有逻辑 +} +``` + +**实现 - 侧边栏视图**: +```typescript +// ICViewProvider.ts +public resolveWebviewView(webviewView: vscode.WebviewView) { + // 1. 检查 token 是否过期 + const tokenExp = this._context.globalState.get('tokenExp'); + if (isTokenExpired(tokenExp)) { + handleTokenExpired(this._context, false); // 静默清除 + // 继续渲染,显示未登录状态 + } + + // 2. 渲染视图 + // ... 原有逻辑 +} +``` + +**说明**: 打开面板时检查,聊天面板显示提示,侧边栏静默处理 + +## 5. 后端响应处理 + +### 5.1 保存 exp 字段 + +**位置**: `src/utils/messageHandler.ts` 处理登录响应的地方 + +**实现**: +```typescript +// 处理登录成功响应 +if (response.data.token) { + await context.globalState.update('token', response.data.token); + + // 保存过期时间 + if (response.data.exp) { + await context.globalState.update('tokenExp', response.data.exp); + } + + // 保存用户信息 + if (response.data.userInfo) { + await context.globalState.update('userInfo', response.data.userInfo); + } +} +``` + +### 5.2 处理 401 响应 + +**实现**: +```typescript +// API 请求错误处理 +if (error.response?.status === 401) { + // 后端返回 401,说明 token 无效或过期 + await handleTokenExpired(context, true); + return; +} +``` + +## 6. 工具函数位置 + +建议创建新文件 `src/utils/authHelper.ts`: + +```typescript +import * as vscode from 'vscode'; + +export function isTokenExpired(exp: number | undefined, bufferSeconds: number = 60): boolean { + if (!exp) { + return true; + } + const now = Math.floor(Date.now() / 1000); + return now >= (exp - bufferSeconds); +} + +export async function clearAuthState(context: vscode.ExtensionContext): Promise { + await context.globalState.update('token', undefined); + await context.globalState.update('tokenExp', undefined); + await context.globalState.update('userInfo', undefined); +} + +export async function handleTokenExpired( + context: vscode.ExtensionContext, + showMessage: boolean = true +): Promise { + await clearAuthState(context); + + if (showMessage) { + const action = await vscode.window.showWarningMessage( + '登录已过期,请重新登录', + '立即登录' + ); + + if (action === '立即登录') { + vscode.commands.executeCommand('ic-coder.openPanel'); + } + } +} +``` + +## 7. 测试场景 + +1. **启动测试**: 设置过期的 exp,重启插件,验证状态被清除 +2. **请求测试**: 设置即将过期的 exp,发送消息,验证被拦截 +3. **交互测试**: 设置过期的 exp,打开面板,验证提示显示 +4. **401 测试**: 模拟后端返回 401,验证状态清除 + +## 8. 注意事项 + +- 使用 60 秒缓冲时间,避免请求中途过期 +- 启动和侧边栏加载时静默处理,避免打扰用户 +- 主动操作(发消息、打开聊天面板)时显示提示 +- 所有时间戳使用秒为单位(与后端保持一致) +- 过期检查应该在所有需要 token 的操作前执行 + +## 9. 修改文件清单 + +需要修改的文件: +1. **新建**: `src/utils/authHelper.ts` - 认证辅助工具函数 +2. **修改**: `src/extension.ts` - 插件激活时检查 +3. **修改**: `src/utils/messageHandler.ts` - API 请求前检查 + 保存 exp + 处理 401 +4. **修改**: `src/panels/ICHelperPanel.ts` - 打开聊天面板时检查 +5. **修改**: `src/views/ICViewProvider.ts` - 侧边栏加载时检查 + diff --git a/src/extension.ts b/src/extension.ts index ec7ae25..64cf6c4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,10 +7,33 @@ import { ICCoderAuthenticationProvider } from "./services/icCoderAuthProvider"; import { VCDFileServer } from "./services/vcdFileServer"; import { initUserService } from "./services/userService"; import { initCreditsService } from "./services/creditsService"; +import { isTokenExpired } from "./utils/jwtUtils"; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { console.log("🎉 IC Coder 插件已激活!"); + // 【关键】在创建 AuthProvider 之前,先检查并清除过期的 session + const storedSessions = context.globalState.get('icCoderSessions', []); + console.log('[Extension] 检查 sessions 数量:', storedSessions.length); + + if (storedSessions.length > 0) { + const session = storedSessions[0]; + const token = session.accessToken; + console.log('[Extension] 检查 token 是否过期...'); + + if (token) { + const expired = isTokenExpired(token); + console.log('[Extension] token 过期检查结果:', expired); + + if (expired) { + // 必须等待清除完成后再创建 AuthProvider + await context.globalState.update('icCoderSessions', []); + await context.globalState.update('icCoderUserInfo', undefined); + console.log('[Extension] Token 已过期,已清除所有登录状态'); + } + } + } + // 初始化用户服务 initUserService(context); @@ -30,7 +53,7 @@ export function activate(context: vscode.ExtensionContext) { dispose: () => vcdFileServer.stop() }); - // 注册 Authentication Provider + // 注册 Authentication Provider(此时 icCoderSessions 已经被清除) const authProvider = new ICCoderAuthenticationProvider(context); context.subscriptions.push( vscode.authentication.registerAuthenticationProvider( diff --git a/src/panels/ICHelperPanel.ts b/src/panels/ICHelperPanel.ts index 3574b16..9fc998e 100644 --- a/src/panels/ICHelperPanel.ts +++ b/src/panels/ICHelperPanel.ts @@ -19,6 +19,7 @@ import { VCDViewerPanel } from "./VCDViewerPanel"; import { ChatHistoryManager } from "../utils/chatHistoryManager"; import { MessageType } from "../types/chatHistory"; import { getCachedUserInfo } from "../services/userService"; +import { isTokenExpired } from "../utils/jwtUtils"; /** * 获取会员等级图标 URI @@ -58,6 +59,30 @@ export async function showICHelperPanel( context: vscode.ExtensionContext, viewColumn?: vscode.ViewColumn ) { + // 检查 token 是否过期 + let token: string | undefined; + try { + const session = await vscode.authentication.getSession("iccoder", [], { createIfNone: false }); + token = session?.accessToken; + } catch (error) { + console.warn('[ICHelperPanel] 获取 session 失败:', error); + } + + if (token && isTokenExpired(token)) { + // 清除过期的 session + await context.globalState.update('icCoderSessions', []); + await context.globalState.update('icCoderUserInfo', undefined); + + const action = await vscode.window.showWarningMessage( + '登录已过期,请重新登录', + '立即登录' + ); + if (action === '立即登录') { + vscode.commands.executeCommand("ic-coder.login"); + } + return; + } + // 检查用户是否已登录 try { const session = await vscode.authentication.getSession("iccoder", [], { @@ -104,6 +129,7 @@ export async function showICHelperPanel( .toString(36) .substr(2, 9)}`; (panel as any).__uniqueId = panelId; + (panel as any).__context = context; // 设置标签页图标 panel.iconPath = vscode.Uri.joinPath( @@ -190,6 +216,25 @@ export async function showICHelperPanel( console.error('[ICHelperPanel] 获取用户信息失败:', error); } + // 检查是否有待发送的消息 + const pendingMessage = context.globalState.get('pendingMessage') as any; + if (pendingMessage) { + console.log('[ICHelperPanel] 检测到待发送消息,准备自动发送'); + + // 清除待发送消息 + await context.globalState.update('pendingMessage', undefined); + + // 延迟发送,确保面板已完全初始化 + setTimeout(() => { + panel.webview.postMessage({ + command: 'autoSendMessage', + text: pendingMessage.text, + mode: pendingMessage.mode, + serviceTier: pendingMessage.serviceTier + }); + }, 500); + } + // 处理消息 panel.webview.onDidReceiveMessage( async (message) => { @@ -524,7 +569,7 @@ export async function showICHelperPanel( break; case "logout": // 退出登录(前端已有确认对话框) - vscode.commands.executeCommand('iccoder.logout'); + vscode.commands.executeCommand('ic-coder.logout'); break; } }, diff --git a/src/utils/jwtUtils.ts b/src/utils/jwtUtils.ts index c65cda0..f3eb7b7 100644 --- a/src/utils/jwtUtils.ts +++ b/src/utils/jwtUtils.ts @@ -6,11 +6,11 @@ * JWT Payload 接口 */ export interface JwtPayload { - sub?: string; // subject (通常是 userId) - userId?: number; // 用户ID (驼峰命名) - user_id?: number; // 用户ID (下划线命名) - exp?: number; // 过期时间 - iat?: number; // 签发时间 + sub?: string; // subject (通常是 userId) + userId?: number; // 用户ID (驼峰命名) + user_id?: number; // 用户ID (下划线命名) + exp?: number; // 过期时间 + iat?: number; // 签发时间 [key: string]: unknown; } @@ -21,9 +21,9 @@ export interface JwtPayload { */ export function parseJwtPayload(token: string): JwtPayload | null { try { - const parts = token.split('.'); + const parts = token.split("."); if (parts.length !== 3) { - console.warn('[JWT] token 格式不正确,期望3部分,实际:', parts.length); + console.warn("[JWT] token 格式不正确,期望3部分,实际:", parts.length); return null; } @@ -31,17 +31,17 @@ export function parseJwtPayload(token: string): JwtPayload | null { const payload = parts[1]; // base64url 转 base64 - const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const base64 = payload.replace(/-/g, "+").replace(/_/g, "/"); // 解码 - const jsonStr = Buffer.from(base64, 'base64').toString('utf-8'); + const jsonStr = Buffer.from(base64, "base64").toString("utf-8"); const parsed = JSON.parse(jsonStr); - console.log('[JWT] 解析成功, payload 字段:', Object.keys(parsed)); - console.log('[JWT] payload 内容:', JSON.stringify(parsed)); + console.log("[JWT] 解析成功, payload 字段:", Object.keys(parsed)); + console.log("[JWT] payload 内容:", JSON.stringify(parsed)); return parsed; } catch (error) { - console.error('[JWT] 解析失败:', error); + console.error("[JWT] 解析失败:", error); return null; } } @@ -68,7 +68,7 @@ export function getUserIdFromToken(token: string): string | null { return String(payload.sub); } - console.warn('[JWT] payload 中没有 user_id, userId 或 sub 字段'); + console.warn("[JWT] payload 中没有 user_id, userId 或 sub 字段"); return null; } @@ -78,14 +78,17 @@ export function getUserIdFromToken(token: string): string | null { * @param bufferSeconds 提前多少秒判定为过期(默认60秒) * @returns true 表示已过期,false 表示未过期,null 表示无法判断 */ -export function isTokenExpired(token: string, bufferSeconds: number = 60): boolean | null { +export function isTokenExpired( + token: string, + bufferSeconds: number = 60, +): boolean | null { const payload = parseJwtPayload(token); if (!payload) { return null; } if (payload.exp === undefined) { - console.warn('[JWT] payload 中没有 exp 字段,无法判断过期'); + console.warn("[JWT] payload 中没有 exp 字段,无法判断过期"); return null; } @@ -94,7 +97,7 @@ export function isTokenExpired(token: string, bufferSeconds: number = 60): boole const isExpired = now >= expTime; if (isExpired) { - console.warn('[JWT] token 已过期,exp:', payload.exp, '当前:', now); + console.warn("[JWT] token 已过期,exp:", payload.exp, "当前:", now); } return isExpired; diff --git a/src/utils/messageHandler.ts b/src/utils/messageHandler.ts index 1438853..5fb0743 100644 --- a/src/utils/messageHandler.ts +++ b/src/utils/messageHandler.ts @@ -18,6 +18,7 @@ import { ChatHistoryManager } from "./chatHistoryManager"; import { dialogManager, DialogSession } from "../services/dialogService"; import { userInteractionManager } from "../services/userInteraction"; import { healthCheck } from "../services/apiClient"; +import { isTokenExpired } from "./jwtUtils"; import { checkBalanceBeforeSend, fetchBalance, @@ -47,6 +48,83 @@ export async function handleUserMessage( ) { console.log("收到用户消息:", text); + // 检查 token 是否过期 + const context = (panel as any).__context; + if (context) { + // 从 session 中获取 token + let token: string | undefined; + try { + const session = await vscode.authentication.getSession("iccoder", [], { createIfNone: false }); + token = session?.accessToken; + } catch (error) { + console.warn("[MessageHandler] 获取 session 失败:", error); + } + + if (!token) { + console.warn("[MessageHandler] 未登录,阻止发送"); + + // 保存待发送的消息 + await context.globalState.update('pendingMessage', { + text, + mode, + serviceTier, + timestamp: Date.now() + }); + + // 显示弹窗提示 + const action = await vscode.window.showWarningMessage( + '请先登录后再发送消息', + '立即登录' + ); + + if (action === '立即登录') { + vscode.commands.executeCommand("ic-coder.login"); + } + + // 恢复输入状态 + panel.webview.postMessage({ + command: "updateSegments", + segments: [], + isComplete: true, + }); + return; + } + + if (isTokenExpired(token)) { + console.warn("[MessageHandler] Token 已过期,阻止发送"); + + // 保存待发送的消息 + await context.globalState.update('pendingMessage', { + text, + mode, + serviceTier, + timestamp: Date.now() + }); + + // 清除过期的 session + await context.globalState.update('icCoderSessions', []); + await context.globalState.update('icCoderUserInfo', undefined); + + // 显示弹窗提示 + const action = await vscode.window.showWarningMessage( + '登录已过期,请重新登录', + '立即登录' + ); + + if (action === '立即登录') { + vscode.commands.executeCommand("ic-coder.login"); + } + + // 恢复输入状态 + panel.webview.postMessage({ + command: "updateSegments", + segments: [], + isComplete: true, + }); + return; + } + } + // 记录用户消息到历史(允许失败,不阻塞主流程) try { const historyManager = ChatHistoryManager.getInstance(); diff --git a/src/views/ICViewProvider.ts b/src/views/ICViewProvider.ts index a451677..db414e3 100644 --- a/src/views/ICViewProvider.ts +++ b/src/views/ICViewProvider.ts @@ -202,6 +202,20 @@ export class ICViewProvider implements vscode.WebviewViewProvider { localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "media")], }; + // 异步检查 token 是否过期并清除 + vscode.authentication.getSession("iccoder", [], { createIfNone: false }) + .then((session) => { + const token = session?.accessToken; + if (token && isTokenExpired(token)) { + // 静默清除过期的 session + this.context.globalState.update('icCoderSessions', []); + this.context.globalState.update('icCoderUserInfo', undefined); + console.log('[ICViewProvider] Token 已过期,已清除所有登录状态'); + } + }, () => { + // 忽略错误 + }); + // 检查是否已登录(使用 Authentication API) this.checkLoginStatus().then((isLoggedIn) => { webviewView.webview.html = this.getWebviewContent( diff --git a/src/views/webviewContent.ts b/src/views/webviewContent.ts index 78cf0ea..c26c470 100644 --- a/src/views/webviewContent.ts +++ b/src/views/webviewContent.ts @@ -624,6 +624,19 @@ export function getWebviewContent( } break; + case 'autoSendMessage': + // 自动发送待发送的消息(登录后) + console.log('[WebView] 自动发送待发送消息:', message.text); + const inputElement = document.getElementById('userInput'); + if (inputElement) { + inputElement.value = message.text; + // 触发发送 + if (typeof sendMessage === 'function') { + sendMessage(); + } + } + break; + case 'showFeedbackQRCode': // 显示用户反馈二维码弹窗 console.log('[WebView] 显示用户反馈二维码弹窗');