20 KiB
插件试用用户功能实现方案
1. 方案概述
核心思路:
- Web 登录成功后只返回 token(保持现状)
- 插件调用
getUserInfo(token)时,后端返回的数据里包含标识字段 - 前端根据该字段判断是否是插件试用用户
- 插件试用用户:显示欢迎弹窗,不显示邀请码弹窗
- 正式用户:显示邀请码弹窗(现有逻辑)
2. 后端需要做什么
2.1 在用户信息接口中添加字段
接口: GET /system/user/getInfo
现有响应:
{
"userId": "xxx",
"username": "testuser",
"nickname": "测试用户",
"email": "test@example.com"
}
新增字段:
方案 :添加 isPluginTrial 字段
{
"userId": "xxx",
"username": "testuser",
"nickname": "测试用户",
"email": "test@example.com",
"isPluginTrial": true, // ← 新增:是否是插件试用用户
"pluginTrialExpiresAt": 1709654400000 // ← 新增:试用到期时间(毫秒时间戳)
}
2.2 后端逻辑说明
判断逻辑:
// 伪代码
function getUserInfo(userId) {
const user = db.users.findById(userId);
// 判断是否是插件试用用户(后端自己的逻辑)
const isPluginTrial = checkIfPluginTrialUser(user);
return {
userId: user.id,
username: user.username,
nickname: user.nickname,
email: user.email,
isPluginTrial: isPluginTrial,
pluginTrialExpiresAt: isPluginTrial ? user.trial_expires_at : null
};
}
Token 过期时间:
- 插件试用用户:JWT Token 设置 15 天过期
- 正式用户:JWT Token 设置 30 天过期(或现有逻辑)
3. 前端需要修改的地方
3.1 修改 UserInfo 接口
文件: src/services/userService.ts
现有接口:
interface UserInfo {
userId: string;
username: string;
nickname: string;
email?: string;
phonenumber?: string;
avatar?: string;
roles?: string[];
permissions?: string[];
createTime?: string;
loginDate?: string;
membership?: {
tierCode: string;
tierName: string;
tierLevel: number;
remainingDays?: number;
monthlyCredits?: number;
};
credits?: number;
}
新增字段:
interface UserInfo {
// ... 现有字段
isPluginTrial?: boolean; // ← 新增:是否是插件试用用户
pluginTrialExpiresAt?: number; // ← 新增:试用到期时间(毫秒时间戳)
membership?: {
tierCode: string;
tierName: string;
tierLevel: number;
remainingDays?: number;
monthlyCredits?: number;
};
credits?: number;
}
3.2 修改 onTokenReceived() 方法
文件: src/services/userService.ts
现有代码位置: 约 200 行左右
修改内容:
async onTokenReceived(token: string) {
// 现有逻辑:并行获取三类信息
const [userInfo, membershipInfo, credits] = await Promise.all([
getUserInfo(token),
getMembershipInfo(token),
fetchBalanceWithToken(token)
]);
// 合并数据
const fullUserInfo = {
...userInfo,
membership: membershipInfo,
credits: credits
};
// 保存到 globalState
await this.context.globalState.update('icCoderUserInfo', fullUserInfo);
// ========== 新增逻辑 ==========
// 判断是否是插件试用用户
if (fullUserInfo.isPluginTrial === true) {
// 插件试用用户:显示欢迎弹窗,不显示邀请码弹窗
await this.showWelcomePanel();
// 标记为已显示欢迎弹窗(避免重复显示)
await this.context.globalState.update('pluginTrialWelcomed', true);
} else {
// 正式用户:显示邀请码弹窗(现有逻辑)
await this.checkAndShowInvitationModal();
}
// ========== 新增逻辑结束 ==========
return fullUserInfo;
}
3.3 新增欢迎弹窗面板
新建文件: src/panels/WelcomePanel.ts
/**
* 欢迎引导面板
* 功能:插件试用用户首次登录显示使用教程
*/
import * as vscode from 'vscode';
export class WelcomePanel {
public static currentPanel: WelcomePanel | undefined;
private readonly _panel: vscode.WebviewPanel;
private _disposables: vscode.Disposable[] = [];
private constructor(panel: vscode.WebviewPanel) {
this._panel = panel;
this._panel.webview.html = this.getHtmlContent();
// 监听关闭事件
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
}
public static render(context: vscode.ExtensionContext) {
// 避免重复显示
if (WelcomePanel.currentPanel) {
WelcomePanel.currentPanel._panel.reveal(vscode.ViewColumn.One);
return;
}
const panel = vscode.window.createWebviewPanel(
'icCoderWelcome',
'欢迎使用 IC Coder',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true
}
);
WelcomePanel.currentPanel = new WelcomePanel(panel);
}
private getHtmlContent(): string {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>欢迎使用 IC Coder</title>
<style>
body {
padding: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
color: var(--vscode-foreground);
background-color: var(--vscode-editor-background);
}
h1 {
color: var(--vscode-textLink-foreground);
margin-bottom: 20px;
}
.welcome-message {
font-size: 16px;
margin-bottom: 30px;
color: var(--vscode-descriptionForeground);
}
.step {
margin: 20px 0;
padding: 20px;
background: var(--vscode-editor-inactiveSelectionBackground);
border-radius: 8px;
border-left: 4px solid var(--vscode-textLink-foreground);
}
.step h3 {
margin-top: 0;
color: var(--vscode-textLink-foreground);
}
.step p {
margin: 10px 0;
}
.button {
padding: 12px 24px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 20px;
}
.button:hover {
background: var(--vscode-button-hoverBackground);
}
</style>
</head>
<body>
<h1>🎉 欢迎使用 IC Coder!</h1>
<p class="welcome-message">
您已成功激活 15 天试用期,让我们开始探索 IC Coder 的强大功能吧!
</p>
<div class="step">
<h3>📝 步骤 1:打开聊天面板</h3>
<p>点击侧边栏的 IC Coder 图标,或使用命令面板搜索 "IC Coder: Open Chat"</p>
</div>
<div class="step">
<h3>💬 步骤 2:输入您的需求</h3>
<p>描述您想要生成的 Verilog 代码或需要帮助的问题,AI 将为您提供专业的解决方案</p>
</div>
<div class="step">
<h3>🔬 步骤 3:运行仿真</h3>
<p>使用 "生成 VCD" 命令运行 iverilog 仿真,并通过波形查看器查看仿真结果</p>
</div>
<button class="button" onclick="close()">开始使用</button>
<script>
function close() {
// 通知 VS Code 关闭面板
window.close();
}
</script>
</body>
</html>
`;
}
public dispose() {
WelcomePanel.currentPanel = undefined;
this._panel.dispose();
while (this._disposables.length) {
const disposable = this._disposables.pop();
if (disposable) {
disposable.dispose();
}
}
}
}
3.4 新增过期提醒面板
新建文件: src/panels/ExpiredPanel.ts
/**
* 试用期到期提醒面板
* 功能:试用期到期时显示续费提示
*/
import * as vscode from 'vscode';
export class ExpiredPanel {
public static render() {
const panel = vscode.window.createWebviewPanel(
'icCoderExpired',
'试用期已到期',
vscode.ViewColumn.One,
{ enableScripts: true }
);
panel.webview.html = this.getHtmlContent();
}
private static getHtmlContent(): string {
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
body {
padding: 60px 40px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--vscode-foreground);
background-color: var(--vscode-editor-background);
}
h1 {
color: var(--vscode-errorForeground);
font-size: 28px;
margin-bottom: 20px;
}
p {
font-size: 16px;
line-height: 1.6;
margin: 15px 0;
color: var(--vscode-descriptionForeground);
}
.button {
padding: 12px 30px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin: 10px;
}
.button:hover {
background: var(--vscode-button-hoverBackground);
}
</style>
</head>
<body>
<h1>⏰ 您的试用期已到期</h1>
<p>感谢您使用 IC Coder!您的 15 天试用期已结束。</p>
<p>如需继续使用,请联系我们获取正式版本。</p>
<button class="button" onclick="contact()">联系我们</button>
<script>
function contact() {
// 可以打开联系页面或发送邮件
window.open('https://iccoder.com/contact', '_blank');
}
</script>
</body>
</html>
`;
}
}
3.5 新增过期检测服务
新建文件: src/services/trialExpirationService.ts
/**
* 试用期过期检测服务
* 功能:检查插件试用用户是否过期
*/
import * as vscode from 'vscode';
import { getUserInfo } from './userService';
import { ExpiredPanel } from '../panels/ExpiredPanel';
export class TrialExpirationService {
private context: vscode.ExtensionContext;
constructor(context: vscode.ExtensionContext) {
this.context = context;
}
/**
* 检查是否过期
* @returns true=已过期,false=未过期
*/
public async checkExpiration(): Promise<boolean> {
const userInfo = await getUserInfo();
// 不是插件试用用户,不需要检查
if (!userInfo?.isPluginTrial) {
return false;
}
// 没有过期时间,不检查
if (!userInfo.pluginTrialExpiresAt) {
return false;
}
// 检查是否过期
const now = Date.now();
if (now >= userInfo.pluginTrialExpiresAt) {
// 已过期
await this.handleExpired();
return true;
}
return false;
}
/**
* 处理过期逻辑
*/
private async handleExpired(): Promise<void> {
// 显示过期弹窗
ExpiredPanel.render();
// 清除本地数据(可选)
// await this.context.globalState.update('icCoderUserInfo', undefined);
// await this.context.globalState.update('icCoderSessions', undefined);
}
}
3.6 在消息发送前检查过期
文件: src/utils/messageHandler.ts
修改位置: 在发送消息给后端之前添加过期检查
import { TrialExpirationService } from '../services/trialExpirationService';
// 在 handleUserMessage 或类似的消息处理函数中添加
async function handleUserMessage(message: string, context: vscode.ExtensionContext) {
// ========== 新增:检查试用期是否过期 ==========
const trialService = new TrialExpirationService(context);
const isExpired = await trialService.checkExpiration();
if (isExpired) {
// 已过期,禁止使用
return {
success: false,
message: '您的试用期已到期,请联系我们获取正式版本'
};
}
// ========== 新增结束 ==========
// 现有的消息处理逻辑
// ...
}
4. 完整的实现流程
4.1 登录流程(带过期检查)
1. 用户点击登录
↓
2. 打开浏览器,Web 端登录
↓
3. 重定向回插件:http://localhost:{port}/callback?token={token}
↓
4. 插件调用 onTokenReceived(token)
↓
5. 并行获取:getUserInfo + getMembershipInfo + Credits
↓
6. 后端返回 userInfo(包含 isPluginTrial 和 pluginTrialExpiresAt)
↓
7. 判断 isPluginTrial === true?
├─ 是:显示欢迎弹窗,不显示邀请码弹窗
└─ 否:显示邀请码弹窗(现有逻辑)
↓
8. 保存用户信息到 globalState
4.2 使用功能时的过期检查
1. 用户发送消息/使用功能
↓
2. 调用 trialService.checkExpiration()
↓
3. 获取 userInfo,检查 isPluginTrial
↓
4. 如果是插件试用用户,检查 Date.now() >= pluginTrialExpiresAt?
├─ 是:显示过期弹窗,禁止使用,返回 true
└─ 否:允许使用,返回 false
↓
5. 继续正常的消息处理流程
5. ⚠️ 潜在 Bug 和注意事项
5.1 时间同步问题
问题: 前端使用 Date.now() 判断过期,如果用户本地时间不准确会导致误判
场景:
- 用户本地时间快了 1 天 → 提前显示过期弹窗
- 用户本地时间慢了 1 天 → 过期后仍可使用
解决方案:
// 方案 1:每次使用前调用后端验证(推荐)
async checkExpiration(): Promise<boolean> {
try {
// 调用后端接口验证 Token 是否过期
const response = await fetch(`${API_BASE}/auth/verify`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.status === 401) {
// Token 过期
await this.handleExpired();
return true;
}
return false;
} catch (error) {
// 网络错误,使用本地时间判断
const userInfo = await getUserInfo();
return Date.now() >= (userInfo?.pluginTrialExpiresAt || 0);
}
}
5.2 欢迎弹窗重复显示
问题: 每次登录都显示欢迎弹窗,用户体验不好
场景:
- 试用用户第一次登录显示欢迎弹窗 ✅
- 用户关闭插件后重新打开,又显示欢迎弹窗 ❌
解决方案:
async onTokenReceived(token: string) {
// ... 获取用户信息
if (fullUserInfo.isPluginTrial === true) {
// 检查是否已经显示过欢迎弹窗
const hasWelcomed = this.context.globalState.get('pluginTrialWelcomed');
if (!hasWelcomed) {
await this.showWelcomePanel();
await this.context.globalState.update('pluginTrialWelcomed', true);
}
} else {
await this.checkAndShowInvitationModal();
}
}
5.3 isPluginTrial 字段类型不一致
问题: 后端可能返回 true、false、null、undefined,前端判断时需要严格处理
场景:
// ❌ 错误写法
if (userInfo.isPluginTrial) {
// undefined 会被判断为 false
}
// ✅ 正确写法
if (userInfo.isPluginTrial === true) {
// 插件试用用户
}
建议:
- 后端统一返回
true或false,不要返回null - 前端使用严格相等
===判断
5.4 Token 过期但前端未清除
问题: Token 在后端已过期,但前端仍保存着过期的 Token
场景:
- 用户 15 天后打开插件
- 前端尝试调用 API,后端返回 401
- 前端没有处理 401,导致功能异常
解决方案:
// 在 apiClient.ts 中统一处理 401
async function apiCall(url: string, options: any) {
const response = await fetch(url, options);
if (response.status === 401) {
// Token 过期,清除本地数据
await clearAllData();
ExpiredPanel.render();
throw new Error('Token expired');
}
return response.json();
}
6. 前后端对接清单
6.1 后端需要提供
1. 修改 GET /system/user/getInfo 接口
- 新增字段:
isPluginTrial(boolean) - 新增字段:
pluginTrialExpiresAt(number, 毫秒时间戳)
2. Token 过期时间设置
- 插件试用用户:JWT Token 设置 15 天过期
- 正式用户:保持现有逻辑
3. 测试账号
- 提供 1-2 个插件试用用户账号用于测试
6.2 前端需要修改
1. 修改文件:
src/services/userService.ts- 修改 UserInfo 接口和 onTokenReceived()src/utils/messageHandler.ts- 添加过期检查
2. 新增文件:
src/panels/WelcomePanel.ts- 欢迎弹窗src/panels/ExpiredPanel.ts- 过期提醒弹窗src/services/trialExpirationService.ts- 过期检测服务
3. globalState 新增存储键:
pluginTrialWelcomed(boolean) - 是否已显示欢迎弹窗
7. 测试计划
7.1 登录流程测试
测试用例 1:插件试用用户登录
- 使用插件试用用户账号登录
- 验证是否显示欢迎弹窗
- 验证是否不显示邀请码弹窗
- 验证 userInfo 中 isPluginTrial === true
测试用例 2:正式用户登录
- 使用正式用户账号登录
- 验证是否显示邀请码弹窗
- 验证是否不显示欢迎弹窗
- 验证 userInfo 中 isPluginTrial !== true
测试用例 3:欢迎弹窗不重复显示
- 插件试用用户登录后显示欢迎弹窗
- 关闭插件重新打开
- 验证不再显示欢迎弹窗
7.2 过期检测测试
测试用例 4:未过期用户正常使用
- 插件试用用户登录(未过期)
- 发送消息使用功能
- 验证功能正常使用
测试用例 5:已过期用户禁止使用
- 修改本地时间到 15 天后(或修改 pluginTrialExpiresAt)
- 尝试发送消息
- 验证显示过期弹窗
- 验证功能被禁用
测试用例 6:Token 过期处理
- 使用过期的 Token 调用 API
- 验证后端返回 401
- 验证前端显示过期弹窗
8. 总结
8.1 核心改动点
后端(最小改动):
GET /system/user/getInfo接口新增 2 个字段- JWT Token 根据用户类型设置不同过期时间
前端(主要改动):
- 修改 UserInfo 接口定义
- 修改 onTokenReceived() 添加判断逻辑
- 新增 3 个文件(欢迎面板、过期面板、过期检测服务)
- 在消息发送前添加过期检查
8.2 关键判断逻辑
// 登录后判断
if (userInfo.isPluginTrial === true) {
showWelcomePanel(); // 显示欢迎弹窗
} else {
showInvitationModal(); // 显示邀请码弹窗
}
// 使用前判断
if (Date.now() >= userInfo.pluginTrialExpiresAt) {
showExpiredPanel(); // 显示过期弹窗
return; // 禁止使用
}
8.3 必须注意的问题
- 时间同步问题 - 用户本地时间不准确导致误判,建议调用后端验证
- isPluginTrial 严格判断 - 必须使用
=== true判断 - 欢迎弹窗重复显示 - 使用 globalState 标记避免重复
- Token 过期处理 - 在 apiClient 中统一处理 401 响应
8.4 实现优先级
P0(必须实现):
- 后端接口新增字段
- 前端 UserInfo 接口修改
- 前端 onTokenReceived() 判断逻辑
- 过期检测逻辑
P1(重要):
- 欢迎弹窗
- 过期提醒弹窗
P2(优化):
- 后端验证过期(避免时间同步问题)
- 更友好的过期提示
文档完成! 基于你现有的代码架构,这个方案改动最小,实现最简单。