Skip to content

Commit acf72e3

Browse files
committed
feat(payment): 临时收入方案全栈实现(数据库+UI+AI网关tier分级)
- 收入方案设计:docs/product/temporary-revenue-implementation.md - 数据库层:schema.ts 新增 beta_profile/licenses/invite_codes 三表,SCHEMA_VERSION=10 - 类型系统:client/src/types/beta.ts — UserTier/TIER_RANK/TIER_PERKS 权益矩阵 - 状态管理:betaStore.ts — Zustand persist 持久化 tier/激活码/邀请码 - UI组件:BetaProfile(身份卡片)、InviteCodeSection(邀请码管理)、 LicenseActivation(激活码输入+服务端验证)、UpgradePrompt(非阻断升级引导) - 权限Hook:useTierAccess(tier-based 功能访问控制)、useBetaProfile(metadata加载) - SettingsPage 集成:ProfileSettings 后插入 BetaProfile 区域 - AI网关集成:rate_limit.py TIER_LIMITS 分级配额 + providers.py TIER_MODEL_ACCESS 分级路由 - 新增路由:routers/license.py(激活码验证) + routers/beta.py(邀请码API) - 管理工具:scripts/license-gen.mjs(激活码离线生成,支持 PRO/LIFE/SND1/THM1) - 代码审查修复:修复 recalcEffectiveTier 排序、激活码大小写、tier 参数传递、 服务端验证缺失、v10迁移、邀请码数量逻辑、cohort 类型转换、import re 延迟导入等30个问题 - 路由注册:main.py 和 __init__.py 注册 license_router 和 beta_router
1 parent 60d72a3 commit acf72e3

18 files changed

Lines changed: 1872 additions & 13 deletions

File tree

‎client/electron/db/schema.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,9 @@ export function initializeSchema(db: Database.Database): void {
354354
// v9 迁移:SOP 模板/步骤/执行记录 + 统一收件箱(sop_templates/sop_steps/sop_runs/inbox_items)
355355
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
356356

357+
// v10 迁移:内测身份/激活码/邀请码表(beta_profile/licenses/invite_codes)
358+
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
359+
357360
db.pragma(`user_version = ${SCHEMA_VERSION}`);
358361
}
359362

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
/**
2+
* 内测身份卡片 — 设置页嵌入
3+
*
4+
* @ai-context: 展示用户的内测层级、加入时间、徽章、权益摘要。
5+
* 核心层用户可在此查看/复制邀请码。
6+
* 所有用户可在此输入激活码升级 Pro。
7+
*/
8+
import { useState } from 'react';
9+
import { Shield, Check, Gift, Key, Users, ChevronDown, ChevronUp } from 'lucide-react';
10+
import { Card, Button, Input, useToast } from '@/components/ui';
11+
import { cn } from '@/lib/utils';
12+
import { useAuth } from '@/lib/auth/AuthContext';
13+
import { useBetaStore } from '@/features/beta/betaStore';
14+
import { TIER_LABELS, TIER_COLORS, type UserTier } from '@/types/beta';
15+
import { InviteCodeSection } from './InviteCodeSection';
16+
import { LicenseActivation } from './LicenseActivation';
17+
18+
export function BetaProfile() {
19+
const { user } = useAuth();
20+
const { toast } = useToast();
21+
const {
22+
betaProfile,
23+
effectiveTier,
24+
activeLicenses,
25+
} = useBetaStore();
26+
27+
const [expanded, setExpanded] = useState(false);
28+
29+
// 是否显示内测面板:有内测身份 或 有付费许可
30+
const hasBeta = !!betaProfile;
31+
const hasLicense = activeLicenses.length > 0;
32+
33+
if (!user) return null;
34+
35+
// 既不是内测用户也没有付费许可 → 显示邀请码输入(轻量版)
36+
if (!hasBeta && !hasLicense) {
37+
return <BetaInvitePrompt />;
38+
}
39+
40+
return (
41+
<Card padding="md" className="flex flex-col gap-kb-md">
42+
{/* 标题行 */}
43+
<div className="flex items-center justify-between">
44+
<div className="flex items-center gap-2">
45+
<Shield className="w-icon-sm h-icon-sm text-brand-500" strokeWidth={1.5} />
46+
<h2 className="text-b1 font-semibold text-text-primary">我的身份</h2>
47+
</div>
48+
<button
49+
type="button"
50+
onClick={() => setExpanded(!expanded)}
51+
className="flex items-center gap-1 text-b3 text-text-tertiary hover:text-text-secondary transition-colors"
52+
>
53+
{expanded ? '收起' : '展开'}
54+
{expanded ? (
55+
<ChevronUp className="w-3.5 h-3.5" strokeWidth={1.5} />
56+
) : (
57+
<ChevronDown className="w-3.5 h-3.5" strokeWidth={1.5} />
58+
)}
59+
</button>
60+
</div>
61+
62+
{/* 身份摘要 */}
63+
<div className="flex items-center gap-3 p-3 rounded-kb-md bg-bg-elevated border border-border-default">
64+
{/* Tier 层级标识 */}
65+
<div className={cn(
66+
'w-10 h-10 rounded-kb-full flex items-center justify-center flex-shrink-0',
67+
effectiveTier === 'lifetime' || effectiveTier === 'core'
68+
? 'bg-semantic-warning/10'
69+
: effectiveTier === 'pro'
70+
? 'bg-brand-50 dark:bg-brand-900/20'
71+
: 'bg-bg-tertiary',
72+
)}>
73+
<Shield className={cn(
74+
'w-5 h-5',
75+
effectiveTier === 'lifetime' || effectiveTier === 'core'
76+
? 'text-semantic-warning'
77+
: effectiveTier === 'pro'
78+
? 'text-brand-500'
79+
: 'text-text-tertiary',
80+
)} strokeWidth={1.5} />
81+
</div>
82+
<div className="flex-1 min-w-0">
83+
<p className={cn('text-b1 font-semibold', TIER_COLORS[effectiveTier])}>
84+
{TIER_LABELS[effectiveTier]}
85+
</p>
86+
<p className="text-c1 text-text-tertiary">
87+
{betaProfile?.joinedAt
88+
? `加入于 ${new Date(betaProfile.joinedAt).toLocaleDateString('zh-CN')}`
89+
: activeLicenses.length > 0
90+
? '付费用户'
91+
: ''}
92+
{betaProfile?.cohort && ` · 第 ${betaProfile.cohort} 批`}
93+
</p>
94+
</div>
95+
{/* 徽章 */}
96+
{betaProfile && betaProfile.badges.length > 0 && (
97+
<div className="flex items-center gap-1">
98+
{betaProfile.badges.slice(0, 3).map((badge) => (
99+
<span
100+
key={badge}
101+
className="w-6 h-6 rounded-kb-full bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center text-c1"
102+
title={badge}
103+
>
104+
{badge === 'founder' ? '🪼' : badge === 'bug-hunter' ? '🐛' : badge === 'idea-lighthouse' ? '💡' : '🌟'}
105+
</span>
106+
))}
107+
{betaProfile.badges.length > 3 && (
108+
<span className="text-c1 text-text-tertiary">+{betaProfile.badges.length - 3}</span>
109+
)}
110+
</div>
111+
)}
112+
</div>
113+
114+
{/* 展开面板 */}
115+
{expanded && (
116+
<div className="space-y-4">
117+
{/* 权益摘要 */}
118+
<div className="grid grid-cols-2 gap-2">
119+
<PerkBadge
120+
label="每日 AI 配额"
121+
value={effectiveTier === 'lifetime' || effectiveTier === 'core' ? '120次' : effectiveTier === 'pro' || effectiveTier === 'active' ? '80次' : '50次'}
122+
/>
123+
<PerkBadge
124+
label="可用模型"
125+
value={effectiveTier === 'lifetime' || effectiveTier === 'core' ? '全部模型' : effectiveTier === 'pro' || effectiveTier === 'active' ? '高级模型' : '基础模型'}
126+
/>
127+
<PerkBadge
128+
label="多模态"
129+
value={effectiveTier === 'lifetime' || effectiveTier === 'core' || effectiveTier === 'active' ? '✅ 已开启' : '❌ 未开启'}
130+
/>
131+
<PerkBadge
132+
label="抢先体验"
133+
value={effectiveTier === 'lifetime' || effectiveTier === 'core' ? '提前5天' : effectiveTier === 'active' ? '提前3天' : '同步发布'}
134+
/>
135+
</div>
136+
137+
{/* 内测用户专属:邀请码管理 */}
138+
{betaProfile && (betaProfile.tier === 'core' || betaProfile.tier === 'active') && (
139+
<InviteCodeSection />
140+
)}
141+
142+
{/* 激活码输入(所有用户可见) */}
143+
<LicenseActivation />
144+
145+
{/* 已有激活码列表 */}
146+
{activeLicenses.length > 0 && (
147+
<div className="space-y-1">
148+
<p className="text-b3 text-text-tertiary">已激活的许可</p>
149+
{activeLicenses.map((license) => (
150+
<div
151+
key={license.id}
152+
className="flex items-center justify-between p-2 rounded-kb-sm bg-bg-tertiary/50"
153+
>
154+
<div className="flex items-center gap-2">
155+
<Key className="w-3.5 h-3.5 text-text-tertiary" strokeWidth={1.5} />
156+
<span className="text-c1 text-text-secondary font-mono text-xs">
157+
{license.code.slice(0, 12)}...
158+
</span>
159+
<span className={cn(
160+
'text-c2 px-1.5 py-0.5 rounded-kb-sm',
161+
license.status === 'active' ? 'bg-semantic-success/10 text-semantic-success' : 'bg-semantic-error/10 text-semantic-error',
162+
)}>
163+
{license.status === 'active' ? '有效' : '已失效'}
164+
</span>
165+
</div>
166+
<span className="text-c1 text-text-tertiary">{TIER_LABELS[license.tier]}</span>
167+
</div>
168+
))}
169+
</div>
170+
)}
171+
</div>
172+
)}
173+
</Card>
174+
);
175+
}
176+
177+
/** 权益小标签 */
178+
function PerkBadge({ label, value }: { label: string; value: string }) {
179+
return (
180+
<div className="flex flex-col gap-0.5 p-2 rounded-kb-sm bg-bg-tertiary/50">
181+
<span className="text-c1 text-text-tertiary">{label}</span>
182+
<span className="text-b3 font-medium text-text-primary">{value}</span>
183+
</div>
184+
);
185+
}
186+
187+
/** 非内测用户的邀请码输入提示 */
188+
function BetaInvitePrompt() {
189+
const { toast } = useToast();
190+
const { getAccessToken } = useAuth();
191+
const [inviteCode, setInviteCode] = useState('');
192+
const [activating, setActivating] = useState(false);
193+
194+
async function handleActivate() {
195+
if (!inviteCode.trim()) {
196+
toast({ type: 'error', message: '请输入邀请码' });
197+
return;
198+
}
199+
setActivating(true);
200+
try {
201+
const code = inviteCode.trim().toUpperCase();
202+
203+
// 调服务端验证邀请码
204+
const token = await getAccessToken();
205+
const resp = await fetch('/api/v1/beta/use-invite', {
206+
method: 'POST',
207+
headers: {
208+
'Content-Type': 'application/json',
209+
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
210+
},
211+
body: JSON.stringify({ code }),
212+
});
213+
214+
if (!resp.ok) {
215+
const errBody = await resp.json().catch(() => ({ detail: '验证失败' }));
216+
throw new Error(errBody.detail || '邀请码无效');
217+
}
218+
219+
const data = await resp.json();
220+
221+
// 服务端验证成功 → 更新本地身份
222+
const { addInviteCode, setBetaProfile } = useBetaStore.getState();
223+
addInviteCode({
224+
id: crypto.randomUUID(),
225+
code,
226+
issuerUserId: 'pending',
227+
status: 'pending',
228+
createdAt: new Date().toISOString(),
229+
});
230+
setBetaProfile({
231+
id: crypto.randomUUID(),
232+
userId: 'local',
233+
tier: (data.tier || 'observer') as UserTier,
234+
cohort: data.cohort || 1,
235+
joinedAt: new Date().toISOString(),
236+
lifetimePro: false,
237+
badges: [],
238+
perksConfig: '{}',
239+
});
240+
toast({ type: 'success', message: data.message || '欢迎加入内测!' });
241+
setInviteCode('');
242+
} catch (err) {
243+
const msg = err instanceof Error ? err.message : '邀请码验证失败';
244+
toast({ type: 'error', message: msg });
245+
} finally {
246+
setActivating(false);
247+
}
248+
}
249+
250+
return (
251+
<Card padding="md" className="flex flex-col gap-kb-md">
252+
<div className="flex items-center gap-2">
253+
<Gift className="w-icon-sm h-icon-sm text-brand-500" strokeWidth={1.5} />
254+
<h2 className="text-b1 font-semibold text-text-primary">内测邀请</h2>
255+
</div>
256+
<p className="text-b3 text-text-tertiary">
257+
你已获得内测邀请码?输入后即可加入内测,享受专属权益
258+
</p>
259+
<div className="flex items-center gap-2">
260+
<Input
261+
placeholder="输入邀请码,如 INVITE-XXXX-XXXX"
262+
value={inviteCode}
263+
onChange={(e) => setInviteCode(e.target.value)}
264+
className="flex-1"
265+
/>
266+
<Button
267+
variant="primary"
268+
size="sm"
269+
loading={activating}
270+
onClick={handleActivate}
271+
icon={<Gift className="w-3.5 h-3.5" strokeWidth={1.5} />}
272+
>
273+
激活
274+
</Button>
275+
</div>
276+
</Card>
277+
);
278+
}

0 commit comments

Comments
 (0)