Skip to content

Commit 3b4d6c3

Browse files
committed
feat(beta): 客户端付费体系——设备指纹/付费状态/AI 用量卡
- 主进程 machineId 设备指纹(激活码一码多设备绑定,首次调用后恒定) - LicenseActivation 接入设备指纹,到期前 3 天续费提醒 - betaStore 新增 paidStatus(服务端 user_metadata.paid 快照跨设备同步) - 新增 AiQuotaCard 与 useQuota:服务端权威配额展示(次数/费用双进度) - 设置页 BetaProfile 展示服务端确认的付费状态与到期日
1 parent 9f7ed90 commit 3b4d6c3

11 files changed

Lines changed: 426 additions & 24 deletions

File tree

‎client/electron/machineId.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* 设备指纹(machineId)— 主进程生成与持久化
3+
*
4+
* @ai-context: 激活码一码多设备绑定依赖稳定且不可被渲染进程改写的设备标识。
5+
* 生成规则:sha256(hostname + platform + arch + 随机盐),持久化到 userData/machine-id;
6+
* 首次调用生成后固定不变(重装系统/换机 = 新指纹,触发服务端设备上限判定)。
7+
* @ai-context: 仅主进程可读写(渲染进程经 IPC 白名单只读获取),防止伪造/篡改。
8+
*/
9+
10+
import { createHash, randomBytes } from 'node:crypto';
11+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12+
import { join } from 'node:path';
13+
import { arch, hostname, platform } from 'node:os';
14+
import { app } from 'electron';
15+
16+
/** machine-id 持久化文件名(userData 目录) */
17+
const MACHINE_ID_FILE = 'machine-id';
18+
19+
/**
20+
* 获取设备指纹(首次调用生成并持久化,此后恒定)。
21+
*
22+
* @returns 32 位十六进制字符串(sha256 截断前 32 字符)
23+
*/
24+
export function getMachineId(): string {
25+
const filePath = join(app.getPath('userData'), MACHINE_ID_FILE);
26+
27+
// 已存在则直接返回(稳定优先)
28+
if (existsSync(filePath)) {
29+
try {
30+
const cached = readFileSync(filePath, 'utf-8').trim();
31+
if (cached) return cached;
32+
} catch (err) {
33+
console.warn('[machineId] 读取失败,重新生成:', err);
34+
}
35+
}
36+
37+
// 生成:hostname+platform+arch 混合 + 128 位随机盐(防同配置机器指纹碰撞)
38+
const salt = randomBytes(16).toString('hex');
39+
const raw = `${hostname()}|${platform()}|${arch()}|${salt}`;
40+
const machineId = createHash('sha256').update(raw).digest('hex').slice(0, 32);
41+
42+
try {
43+
writeFileSync(filePath, machineId, 'utf-8');
44+
} catch (err) {
45+
console.warn('[machineId] 持久化失败(本次会话内仍可用):', err);
46+
}
47+
return machineId;
48+
}

‎client/src/env.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ declare global {
5858
safeStorageDecrypt: (encoded: string) => Promise<string>;
5959
backupSave: (data: string, defaultName?: string) => Promise<unknown>;
6060
backupOpen: () => Promise<unknown>;
61+
/** 设备指纹(激活码一码多设备绑定;主进程生成,首次调用后恒定) */
62+
getMachineId?: () => Promise<string>;
6163
db: {
6264
query: <T = unknown>(table: string, method: string, args?: unknown[]) => Promise<T>;
6365
insert: (table: string, item: unknown) => Promise<string>;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* AI 用量卡 — 设置页展示当日配额使用情况
3+
*
4+
* @ai-context: 数据来自服务端权威计数(GET /api/v1/license/quota),
5+
* 展示今日调用次数与费用双进度条;网关不可达时静默隐藏(增强信息不阻塞)。
6+
*/
7+
import { Gauge, Coins } from 'lucide-react';
8+
import { useQuota } from '@/features/beta/hooks/useQuota';
9+
import { TIER_LABELS } from '@/types/beta';
10+
import { cn } from '@/lib/utils';
11+
12+
/** 用量条(百分比钳制 0-100) */
13+
function UsageBar({ used, total, tone }: { used: number; total: number; tone: 'brand' | 'warning' }) {
14+
const pct = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
15+
return (
16+
<div className="h-1.5 rounded-kb-full bg-bg-tertiary overflow-hidden">
17+
<div
18+
className={cn(
19+
'h-full rounded-kb-full transition-all duration-kb-fast',
20+
tone === 'brand' ? 'bg-brand-500' : 'bg-semantic-warning',
21+
)}
22+
style={{ width: `${pct}%` }}
23+
/>
24+
</div>
25+
);
26+
}
27+
28+
/** AI 用量卡(设置页内嵌) */
29+
export function AiQuotaCard() {
30+
const { quota } = useQuota();
31+
32+
// 网关不可达或未登录时不展示(增强信息)
33+
if (!quota || quota.totalCalls <= 0) return null;
34+
35+
const callPct = quota.totalCalls > 0 ? quota.usedCalls / quota.totalCalls : 0;
36+
const costPct = quota.costLimit > 0 ? quota.usedCost / quota.costLimit : 0;
37+
const callsNearlyDone = callPct >= 0.8;
38+
39+
return (
40+
<div className="flex flex-col gap-3 p-3 rounded-kb-md bg-bg-elevated border border-border-default">
41+
{/* 标题行 */}
42+
<div className="flex items-center justify-between">
43+
<div className="flex items-center gap-2">
44+
<Gauge className="w-4 h-4 text-brand-500" strokeWidth={1.5} />
45+
<span className="text-b3 font-medium text-text-primary">今日 AI 用量</span>
46+
</div>
47+
<span className="text-c1 text-text-tertiary">{TIER_LABELS[quota.tier]}</span>
48+
</div>
49+
50+
{/* 调用次数 */}
51+
<div className="space-y-1">
52+
<div className="flex items-center justify-between text-c1">
53+
<span className="text-text-tertiary">AI 调用</span>
54+
<span className={cn('font-medium', callsNearlyDone ? 'text-semantic-warning' : 'text-text-secondary')}>
55+
{quota.usedCalls}/{quota.totalCalls} 次
56+
</span>
57+
</div>
58+
<UsageBar used={quota.usedCalls} total={quota.totalCalls} tone={callsNearlyDone ? 'warning' : 'brand'} />
59+
</div>
60+
61+
{/* 费用 */}
62+
<div className="space-y-1">
63+
<div className="flex items-center justify-between text-c1">
64+
<span className="inline-flex items-center gap-1 text-text-tertiary">
65+
<Coins className="w-3 h-3" strokeWidth={1.5} />
66+
今日费用
67+
</span>
68+
<span className="font-medium text-text-secondary">
69+
¥{quota.usedCost.toFixed(2)}/¥{quota.costLimit.toFixed(1)}
70+
</span>
71+
</div>
72+
<UsageBar used={quota.usedCost} total={quota.costLimit} tone={costPct >= 0.8 ? 'warning' : 'brand'} />
73+
</div>
74+
75+
{/* 到期时间(订阅用户) */}
76+
{quota.expiresAt && quota.tier !== 'free' && (
77+
<p className="text-c1 text-text-tertiary">
78+
订阅至 {new Date(quota.expiresAt).toLocaleDateString('zh-CN')}
79+
</p>
80+
)}
81+
</div>
82+
);
83+
}

‎client/src/features/beta/BetaProfile.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* 所有用户可在此输入激活码升级 Pro。
77
*/
88
import { useState } from 'react';
9-
import { Shield, Check, Gift, Key, Users, ChevronDown, ChevronUp } from 'lucide-react';
9+
import { Shield, Gift, Key, ChevronDown, ChevronUp } from 'lucide-react';
1010
import { Card, Button, Input, useToast } from '@/components/ui';
1111
import { cn } from '@/lib/utils';
1212
import { useAuth } from '@/lib/auth/AuthContext';
@@ -17,11 +17,11 @@ import { LicenseActivation } from './LicenseActivation';
1717

1818
export function BetaProfile() {
1919
const { user } = useAuth();
20-
const { toast } = useToast();
2120
const {
2221
betaProfile,
2322
effectiveTier,
2423
activeLicenses,
24+
paidStatus,
2525
} = useBetaStore();
2626

2727
const [expanded, setExpanded] = useState(false);
@@ -114,6 +114,20 @@ export function BetaProfile() {
114114
{/* 展开面板 */}
115115
{expanded && (
116116
<div className="space-y-4">
117+
{/* 付费状态(服务端 user_metadata.paid 快照,跨设备同步) */}
118+
{paidStatus && (
119+
<div className="flex items-center justify-between p-2 rounded-kb-sm bg-brand-50/50 dark:bg-brand-900/10">
120+
<span className="text-b3 font-medium text-brand-500">
121+
{paidStatus.tier === 'lifetime' ? '✨ 终身 Pro(服务端确认)' : '✨ Pro 订阅(服务端确认)'}
122+
</span>
123+
{paidStatus.expiresAt && (
124+
<span className="text-c1 text-text-tertiary">
125+
至 {new Date(paidStatus.expiresAt).toLocaleDateString('zh-CN')}
126+
</span>
127+
)}
128+
</div>
129+
)}
130+
117131
{/* 权益摘要 */}
118132
<div className="grid grid-cols-2 gap-2">
119133
<PerkBadge

‎client/src/features/beta/LicenseActivation.tsx‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* @ai-context: 用户输入激活码(ENTROPY-{TYPE}-{XXXX}-{XXXX})升级付费 tier。
55
* 本地优先:首次验证需联网,成功后本地缓存 7 天宽限期。
66
* 激活码格式:ENTROPY-PRO-XXXX-XXXX(订阅)/ ENTROPY-LIFE-XXXX-XXXX(终身)
7+
* @ai-context: machine_id 取主进程设备指纹(electronAPI.getMachineId),
8+
* 服务端据此做一码多设备绑定(防分享);到期前 3 天展示续费提醒。
79
*/
810
import { useState } from 'react';
911
import { Key, Check, AlertTriangle, Sparkles, ExternalLink } from 'lucide-react';
@@ -24,6 +26,22 @@ const LICENSE_TYPE_TO_TIER: Record<string, UserTier> = {
2426
THM1: 'free', // 主题包不升级 tier
2527
};
2628

29+
/** 剩余天数(不足 1 天按 0 处理) */
30+
function daysRemaining(expiresAt: string): number {
31+
return Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86_400_000);
32+
}
33+
34+
/** 获取设备指纹(Electron 主进程;Web 环境降级为固定占位符) */
35+
async function getDeviceMachineId(): Promise<string> {
36+
try {
37+
const mid = await window.electronAPI?.getMachineId?.();
38+
if (mid) return mid;
39+
} catch {
40+
// 主进程不可用(Web 预览等)时降级,服务端按单设备兼容处理
41+
}
42+
return 'web';
43+
}
44+
2745
export function LicenseActivation() {
2846
const { toast } = useToast();
2947
const { getAccessToken } = useAuth();
@@ -62,6 +80,9 @@ export function LicenseActivation() {
6280
// 提取类型(trimmed 已 toUpperCase,type 为大写字符串如 'PRO'/'LIFE')
6381
const type = trimmed.split('-')[1];
6482

83+
// 设备指纹(一码多设备绑定;Web 环境降级 'web')
84+
const machineId = await getDeviceMachineId();
85+
6586
// 调服务端验证激活码
6687
const token = await getAccessToken();
6788
const resp = await fetch('/api/v1/license/activate', {
@@ -72,7 +93,7 @@ export function LicenseActivation() {
7293
},
7394
body: JSON.stringify({
7495
code: trimmed,
75-
machine_id: 'local',
96+
machine_id: machineId,
7697
}),
7798
});
7899

@@ -84,18 +105,19 @@ export function LicenseActivation() {
84105
const data = await resp.json();
85106
const tier = LICENSE_TYPE_TO_TIER[type] ?? 'free';
86107

108+
// 服务端返回的到期时间优先(池记录 duration_days 为准,客户端不再自行 30 天)
109+
const serverExpiresAt = typeof data.expires_at === 'string' ? data.expires_at : undefined;
110+
87111
// 创建本地激活码记录
88112
const license: License = {
89113
id: crypto.randomUUID(),
90114
code: trimmed,
91115
type: type.toLowerCase() as LicenseType,
92116
tier,
93117
status: 'active',
94-
machineId: 'local',
118+
machineId,
95119
activatedAt: new Date().toISOString(),
96-
expiresAt: type === 'PRO' // 使用大写常量比较
97-
? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
98-
: data.expires_at || undefined,
120+
expiresAt: serverExpiresAt,
99121
syncedAt: new Date().toISOString(),
100122
};
101123

@@ -121,20 +143,34 @@ export function LicenseActivation() {
121143
const activeLicense = activeLicenses.find(
122144
(l) => (l.tier === 'pro' || l.tier === 'lifetime') && l.status === 'active',
123145
);
146+
const expiresAt = activeLicense?.expiresAt;
147+
const remainingDays = expiresAt ? daysRemaining(expiresAt) : 0;
148+
const isAnnual = !!expiresAt && remainingDays >= 300; // 年付方案识别(服务端时长为准)
149+
const expiringSoon = !!expiresAt && remainingDays > 0 && remainingDays <= 3;
124150
return (
125-
<div className="flex items-center gap-2 p-3 rounded-kb-md bg-semantic-success/5 border border-semantic-success/20">
126-
<Sparkles className="w-4 h-4 text-semantic-success flex-shrink-0" strokeWidth={1.5} />
127-
<div className="flex-1 min-w-0">
128-
<p className="text-b3 font-medium text-semantic-success">
129-
{activeLicense?.tier === 'lifetime' ? '终身 Pro 已激活' : 'Pro 已激活'}
130-
</p>
131-
{activeLicense?.expiresAt && (
132-
<p className="text-c1 text-text-tertiary">
133-
有效期至 {new Date(activeLicense.expiresAt).toLocaleDateString('zh-CN')}
151+
<div className="flex flex-col gap-2 p-3 rounded-kb-md bg-semantic-success/5 border border-semantic-success/20">
152+
<div className="flex items-center gap-2">
153+
<Sparkles className="w-4 h-4 text-semantic-success flex-shrink-0" strokeWidth={1.5} />
154+
<div className="flex-1 min-w-0">
155+
<p className="text-b3 font-medium text-semantic-success">
156+
{activeLicense?.tier === 'lifetime' ? '终身 Pro 已激活' : `Pro 已激活${isAnnual ? '(年付方案)' : ''}`}
134157
</p>
135-
)}
158+
{expiresAt && (
159+
<p className="text-c1 text-text-tertiary">
160+
有效期至 {new Date(expiresAt).toLocaleDateString('zh-CN')}
161+
{activeLicense?.tier !== 'lifetime' && `(剩余 ${remainingDays} 天)`}
162+
</p>
163+
)}
164+
</div>
165+
<span className="text-c1 text-semantic-success">已激活</span>
136166
</div>
137-
<span className="text-c1 text-semantic-success">已激活</span>
167+
{/* 到期前 3 天温和提醒(非阻断) */}
168+
{expiringSoon && (
169+
<div className="flex items-center gap-1.5 text-c1 text-semantic-warning">
170+
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" strokeWidth={1.5} />
171+
订阅将于 {remainingDays} 天后到期,续费保持 Pro 权益
172+
</div>
173+
)}
138174
</div>
139175
);
140176
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* betaStore — effectiveTier 计算测试
3+
*
4+
* @ai-context: 覆盖 paidStatus(服务端 user_metadata.paid 快照)纳入
5+
* effectiveTier 计算的行为:跨设备订阅同步、过期降级、与本地激活码/内测身份取最高者。
6+
*/
7+
import { describe, expect, it, beforeEach } from 'vitest';
8+
import { useBetaStore } from './betaStore';
9+
import type { License, PaidStatus } from '@/types/beta';
10+
11+
/** 构造一条本地激活码记录 */
12+
function makeLicense(tier: License['tier'], status: License['status'] = 'active'): License {
13+
return {
14+
id: `lic-${tier}-${status}`,
15+
code: `ENTROPY-${tier.toUpperCase()}-AAAA-BBBB`,
16+
type: tier === 'lifetime' ? 'lifetime' : tier === 'pro' ? 'pro' : 'snd1',
17+
tier,
18+
status,
19+
machineId: 'm1',
20+
activatedAt: new Date().toISOString(),
21+
expiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString(),
22+
syncedAt: new Date().toISOString(),
23+
};
24+
}
25+
26+
/** 构造服务端付费快照 */
27+
function makePaidStatus(tier: 'pro' | 'lifetime', expiresInDays?: number): PaidStatus {
28+
return {
29+
tier,
30+
...(expiresInDays !== undefined
31+
? { expiresAt: new Date(Date.now() + expiresInDays * 86_400_000).toISOString() }
32+
: {}),
33+
updatedAt: new Date().toISOString(),
34+
};
35+
}
36+
37+
beforeEach(() => {
38+
useBetaStore.getState().reset();
39+
});
40+
41+
describe('betaStore.recalcEffectiveTier', () => {
42+
it('服务端 paid 快照(lifetime)无本地激活码时提升 tier', () => {
43+
// Arrange:仅登录快照(跨设备场景,本机无激活记录)
44+
useBetaStore.getState().setPaidStatus(makePaidStatus('lifetime'));
45+
46+
// Act
47+
const tier = useBetaStore.getState().effectiveTier;
48+
49+
// Assert
50+
expect(tier).toBe('lifetime');
51+
});
52+
53+
it('服务端 paid 快照(pro 未过期)生效', () => {
54+
useBetaStore.getState().setPaidStatus(makePaidStatus('pro', 30));
55+
expect(useBetaStore.getState().effectiveTier).toBe('pro');
56+
});
57+
58+
it('服务端 paid 快照过期后不提升 tier', () => {
59+
// Arrange:已过期的 pro 快照
60+
useBetaStore.getState().setPaidStatus(makePaidStatus('pro', -1));
61+
expect(useBetaStore.getState().effectiveTier).toBe('free');
62+
});
63+
64+
it('本地激活码 pro 优先于 paid 快照(两者同等级取本地)', () => {
65+
useBetaStore.getState().addLicense(makeLicense('pro'));
66+
expect(useBetaStore.getState().effectiveTier).toBe('pro');
67+
});
68+
69+
it('内测 core + 本地 pro → 取 core(rank 最高者)', () => {
70+
useBetaStore.getState().setBetaProfile({
71+
id: 'p1', userId: 'u1', tier: 'core', cohort: 1,
72+
joinedAt: new Date().toISOString(), lifetimePro: false,
73+
badges: [], perksConfig: '{}',
74+
});
75+
useBetaStore.getState().addLicense(makeLicense('pro'));
76+
expect(useBetaStore.getState().effectiveTier).toBe('core');
77+
});
78+
79+
it('内测 observer + 服务端 lifetime → 取 lifetime', () => {
80+
useBetaStore.getState().setBetaProfile({
81+
id: 'p2', userId: 'u1', tier: 'observer', cohort: 1,
82+
joinedAt: new Date().toISOString(), lifetimePro: false,
83+
badges: [], perksConfig: '{}',
84+
});
85+
useBetaStore.getState().setPaidStatus(makePaidStatus('lifetime'));
86+
expect(useBetaStore.getState().effectiveTier).toBe('lifetime');
87+
});
88+
89+
it('失效激活码(expired)不计入付费 tier', () => {
90+
useBetaStore.getState().addLicense(makeLicense('pro', 'expired'));
91+
expect(useBetaStore.getState().effectiveTier).toBe('free');
92+
});
93+
});

0 commit comments

Comments
 (0)