Skip to content

Commit 9f7ed90

Browse files
committed
feat(license): AI 网关付费授权体系
- 激活码池与生命周期管理(license_rules),支持多设备绑定与额度校验 - 支付回调 webhook(license_webhook)与 Supabase 适配层(services/) - JWT 注入 beta_tier/paid_tier,按 tier 分级预算与限流(budget/rate_limit/limits) - 开发者白名单 DEV_USER_IDS 完全豁免(.env.example 注释说明) - license-gen 支持 --duration 年卡时长,新增 license-admin 管理脚本 - 新增 5 个服务端测试(license_pool/payment_webhook/budget_tier/tier_injection/supabase_adapter) - 支付系统规格说明书 docs/product/payment-system-spec.md
1 parent 2e58083 commit 9f7ed90

20 files changed

Lines changed: 2198 additions & 69 deletions

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ SUPABASE_JWT_SECRET=
4747
# Supabase 项目 URL(用于后端验证 JWT issuer)
4848
SUPABASE_URL=https://your-project-id.supabase.co
4949

50+
# 开发者白名单(AI 网关):逗号分隔的 Supabase user_id 或邮箱,
51+
# 命中的账号完全豁免平台配额(不限流/不限费用)并赋予 lifetime 最高身份,
52+
# 仅供开发者自测,切勿在生产环境加入普通用户。
53+
# DEV_USER_IDS=dev@example.com,uuid-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
54+
5055
# 服务端口
5156
SYNC_SERVICE_PORT=8080
5257
AI_GATEWAY_PORT=8000

‎docs/product/payment-system-spec.md‎

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

‎scripts/license-admin.mjs‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env node
2+
/**
3+
* 激活码管理 CLI(对接 Supabase licenses 池)
4+
*
5+
* @ai-context: 单人运维工具:导入激活码 CSV → 池、按订单对账、撤销、状态查询。
6+
* 需要环境变量 SUPABASE_URL + SUPABASE_SERVICE_KEY(service key 仅服务端/本脚本持有)。
7+
*
8+
* 用法:
9+
* node scripts/license-admin.mjs import ./codes.csv # 导入 CSV(code,type,tier,duration_days,generated_at)
10+
* node scripts/license-admin.mjs status ENTROPY-PRO-XXXX-XXXX # 查询单个激活码状态
11+
* node scripts/license-admin.mjs revoke ENTROPY-PRO-XXXX-XXXX # 撤销激活码
12+
* node scripts/license-admin.mjs reconcile MB-1001 # 按订单号对账(标记 sold)
13+
*/
14+
15+
import { readFileSync } from 'node:fs';
16+
import { fileURLToPath } from 'node:url';
17+
import path from 'node:path';
18+
19+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
20+
21+
// 环境变量:脚本直接读进程环境(.env 由调用方注入)
22+
const SUPABASE_URL = (process.env.SUPABASE_URL || '').replace(/\/$/, '');
23+
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || '';
24+
25+
function requireEnv() {
26+
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
27+
console.error('❌ 缺少环境变量 SUPABASE_URL / SUPABASE_SERVICE_KEY');
28+
console.error(' powershell: $env:SUPABASE_URL=\"https://xxx.supabase.co\"; $env:SUPABASE_SERVICE_KEY=\"...\"');
29+
process.exit(1);
30+
}
31+
}
32+
33+
/** PostgREST 请求(service key) */
34+
async function pg(method, pathname, body) {
35+
const resp = await fetch(`${SUPABASE_URL}/rest/v1${pathname}`, {
36+
method,
37+
headers: {
38+
apikey: SUPABASE_SERVICE_KEY,
39+
Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`,
40+
'Content-Type': 'application/json',
41+
Prefer: 'return=representation',
42+
},
43+
body: body ? JSON.stringify(body) : undefined,
44+
});
45+
if (!resp.ok) {
46+
const text = await resp.text();
47+
throw new Error(`Supabase ${method} ${pathname} → ${resp.status}: ${text.slice(0, 200)}`);
48+
}
49+
const text = await resp.text();
50+
return text ? JSON.parse(text) : [];
51+
}
52+
53+
/** 解析 CSV 行(code,type,tier,duration_days,generated_at) */
54+
function parseCsv(content) {
55+
const rows = [];
56+
for (const line of content.split(/\r?\n/)) {
57+
if (!line.trim()) continue;
58+
const [code, type, , durationDays] = line.split(',');
59+
if (!code || !code.startsWith('ENTROPY-')) continue;
60+
rows.push({
61+
code: code.trim(),
62+
type: (type || 'PRO').trim().toLowerCase(),
63+
duration_days: parseInt(durationDays, 10) || 30,
64+
});
65+
}
66+
return rows;
67+
}
68+
69+
async function cmdImport(csvPath) {
70+
requireEnv();
71+
const content = readFileSync(csvPath, 'utf-8');
72+
const rows = parseCsv(content);
73+
if (rows.length === 0) {
74+
console.error('❌ 未解析到任何激活码(CSV 表头: code,type,tier,duration_days,generated_at)');
75+
process.exit(1);
76+
}
77+
let inserted = 0;
78+
for (const row of rows) {
79+
try {
80+
await pg('POST', '/licenses', row);
81+
inserted++;
82+
} catch (err) {
83+
// 唯一键冲突 = 已存在,跳过(幂等导入)
84+
if (String(err.message).includes('23505') || String(err.message).includes('duplicate')) {
85+
console.warn(`⏭ 已存在,跳过: ${row.code}`);
86+
} else {
87+
console.error(`✗ 导入失败 ${row.code}: ${err.message}`);
88+
}
89+
}
90+
}
91+
console.log(`✅ 导入完成: ${inserted}/${rows.length} 条(unsold 状态)`);
92+
}
93+
94+
async function cmdStatus(code) {
95+
requireEnv();
96+
const rows = await pg('GET', `/licenses?code=eq.${encodeURIComponent(code)}&select=*`);
97+
if (rows.length === 0) {
98+
console.log(`🔍 未找到激活码: ${code}`);
99+
return;
100+
}
101+
const r = rows[0];
102+
console.log(JSON.stringify({
103+
code: r.code,
104+
type: r.type,
105+
status: r.status,
106+
order_id: r.order_id,
107+
duration_days: r.duration_days,
108+
bound_user_id: r.bound_user_id,
109+
machine_id: r.machine_id,
110+
expires_at: r.expires_at,
111+
sold_at: r.sold_at,
112+
activated_at: r.activated_at,
113+
revoked_at: r.revoked_at,
114+
}, null, 2));
115+
}
116+
117+
async function cmdRevoke(code) {
118+
requireEnv();
119+
const now = new Date().toISOString();
120+
const rows = await pg('PATCH', `/licenses?code=eq.${encodeURIComponent(code)}`, {
121+
status: 'revoked',
122+
revoked_at: now,
123+
});
124+
if (rows.length === 0) {
125+
console.error(`✗ 激活码不存在: ${code}`);
126+
process.exit(1);
127+
}
128+
console.log(`✅ 已撤销: ${code}(此前状态: ${rows[0].status})`);
129+
}
130+
131+
async function cmdReconcile(orderId) {
132+
requireEnv();
133+
// 按订单号查池记录;不存在 → 提示先确认订单在面包多已支付
134+
const rows = await pg('GET', `/licenses?order_id=eq.${encodeURIComponent(orderId)}&select=*`);
135+
if (rows.length === 0) {
136+
console.error(`✗ 未找到订单 ${orderId} 关联的激活码——请先在面包多后台确认订单状态,再手动补标。`);
137+
console.error(' 可先执行 status 查询确认激活码,然后 revoke 或重发。');
138+
process.exit(1);
139+
}
140+
for (const r of rows) {
141+
if (r.status === 'sold' || r.status === 'bound') {
142+
console.log(`⏭ 激活码 ${r.code} 已处于 ${r.status} 状态,无需处理`);
143+
continue;
144+
}
145+
await pg('PATCH', `/licenses?code=eq.${encodeURIComponent(r.code)}`, { status: 'sold', sold_at: new Date().toISOString() });
146+
console.log(`✅ 对账完成: 订单 ${orderId} → ${r.code} 已标记 sold`);
147+
}
148+
}
149+
150+
const [subcommand, arg] = process.argv.slice(2);
151+
152+
switch (subcommand) {
153+
case 'import':
154+
if (!arg) { console.error('用法: license-admin.mjs import <codes.csv>'); process.exit(1); }
155+
cmdImport(arg);
156+
break;
157+
case 'status':
158+
if (!arg) { console.error('用法: license-admin.mjs status <ENTROPY-CODE>'); process.exit(1); }
159+
cmdStatus(arg);
160+
break;
161+
case 'revoke':
162+
if (!arg) { console.error('用法: license-admin.mjs revoke <ENTROPY-CODE>'); process.exit(1); }
163+
cmdRevoke(arg);
164+
break;
165+
case 'reconcile':
166+
if (!arg) { console.error('用法: license-admin.mjs reconcile <ORDER_ID>'); process.exit(1); }
167+
cmdReconcile(arg);
168+
break;
169+
default:
170+
console.log('用法: license-admin.mjs <import|status|revoke|reconcile> <参数>');
171+
process.exit(1);
172+
}

‎scripts/license-gen.mjs‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
* 用法:
1010
* node scripts/license-gen.mjs # 生成 1 个 Pro 激活码
1111
* node scripts/license-gen.mjs --type LIFE --count 5 # 生成 5 个终身激活码
12+
* node scripts/license-gen.mjs --type PRO --duration 365 # 年卡(时长由池记录决定)
1213
* node scripts/license-gen.mjs --type SND1 # 生成 1 个音效包激活码
13-
* node scripts/license-gen.mjs --csv # 输出 CSV 格式
14+
* node scripts/license-gen.mjs --csv # 输出 CSV 格式(含 duration 列)
1415
*/
1516

1617
import crypto from 'node:crypto';
1718

1819
const TYPES = {
19-
PRO: { name: 'Pro 订阅', tier: 'pro', duration: '30天' },
20-
LIFE: { name: '终身 Pro', tier: 'lifetime', duration: '永久' },
21-
SND1: { name: '音效包 Vol.1', tier: 'free', duration: '永久' },
22-
THM1: { name: '主题皮肤包', tier: 'free', duration: '永久' },
20+
PRO: { name: 'Pro 订阅', tier: 'pro', defaultDuration: 30 },
21+
LIFE: { name: '终身 Pro', tier: 'lifetime', defaultDuration: 36500 },
22+
SND1: { name: '音效包 Vol.1', tier: 'free', defaultDuration: 36500 },
23+
THM1: { name: '主题皮肤包', tier: 'free', defaultDuration: 36500 },
2324
};
2425

2526
function generateCode(type) {
@@ -35,6 +36,7 @@ function parseArgs() {
3536
let type = 'PRO';
3637
let count = 1;
3738
let csv = false;
39+
let duration = null;
3840

3941
for (let i = 0; i < args.length; i++) {
4042
if (args[i] === '--type' && args[i + 1]) {
@@ -44,27 +46,32 @@ function parseArgs() {
4446
} else if (args[i] === '--count' && args[i + 1]) {
4547
count = parseInt(args[++i], 10);
4648
if (count < 1 || count > 100) { console.error('count 范围: 1-100'); process.exit(1); }
49+
} else if (args[i] === '--duration' && args[i + 1]) {
50+
duration = parseInt(args[++i], 10);
51+
if (!Number.isInteger(duration) || duration < 1) { console.error('duration 必须为正整数(天)'); process.exit(1); }
4752
} else if (args[i] === '--csv') {
4853
csv = true;
4954
}
5055
}
5156

52-
return { type, count, csv };
57+
return { type, count, csv, duration };
5358
}
5459

5560
function main() {
56-
const { type, count, csv } = parseArgs();
61+
const { type, count, csv, duration } = parseArgs();
5762
const typeInfo = TYPES[type];
63+
const durationDays = duration ?? typeInfo.defaultDuration;
5864

5965
if (csv) {
60-
console.log('code,type,tier,duration,generated_at');
66+
console.log('code,type,tier,duration_days,generated_at');
6167
for (let i = 0; i < count; i++) {
6268
const code = generateCode(type);
6369
const now = new Date().toISOString();
64-
console.log(`${code},${type},${typeInfo.tier},${typeInfo.duration},${now}`);
70+
console.log(`${code},${type},${typeInfo.tier},${durationDays},${now}`);
6571
}
6672
} else {
67-
console.log(`\n📋 生成 ${count} 个 ${typeInfo.name} 激活码(${typeInfo.duration})\n`);
73+
const durationLabel = durationDays >= 36500 ? '永久' : `${durationDays} 天`;
74+
console.log(`\n📋 生成 ${count} 个 ${typeInfo.name} 激活码(${durationLabel})\n`);
6875
for (let i = 0; i < count; i++) {
6976
const code = generateCode(type);
7077
console.log(` ${code}`);

‎server/ai-gateway/config/limits.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
"podcast": 40, # AI 播客生成器(多段对话脚本,生成量较大)
7474
"learning_coach": 40, # AI 学习教练(周计划+每日任务,生成量较大)
7575
"infographic": 30, # 知识信息图生成器(多节+关系)
76+
# 付费/内测系统:激活码验证(5s 足够,防暴力枚举限流为主)
77+
"license_activate": 5,
7678
}
7779

7880
# ============================================================
@@ -145,4 +147,6 @@
145147
"podcast": 5, # AI 播客生成器(生成成本高,严格限频)
146148
"learning_coach": 5, # AI 学习教练(每周计划节奏,低频)
147149
"infographic": 10, # 知识信息图生成器(生成成本高,严格限频)
150+
# 付费/内测系统:激活码验证(防暴力枚举,10 次/天/用户,豁免全局 AI 总量)
151+
"license_activate": 10,
148152
}

‎server/ai-gateway/cost/budget.py‎

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
熵减 AI 网关 — 预算控制中间件
33
44
@ai-context: 在请求到达路由前检查用户日预算是否耗尽。
5-
预算分两级:token 上限(防滥用)和费用上限(控成本)。
6-
Redis 不可用时降级放行。超限返回 HTTP 429 + 友好提示。
5+
@ai-context: 预算分两级:token 上限(防滥用,全局统一)和费用上限(控成本,按 tier 分级)。
6+
@ai-context: Redis 不可用时降级放行。超限返回 HTTP 429 + 友好提示。
77
@ai-context: 预算阈值通过环境变量配置,支持运行时调整。
88
"""
99

@@ -21,13 +21,36 @@
2121
# 预算配置(环境变量可覆盖)
2222
# 每用户每日 token 上限(默认 200K,约等于 50 次中等调用)
2323
DAILY_TOKEN_LIMIT = int(os.getenv("BUDGET_DAILY_TOKEN_LIMIT", "200000"))
24-
# 每用户每日费用上限(元,默认 2 元)
25-
DAILY_COST_LIMIT_YUAN = float(os.getenv("BUDGET_DAILY_COST_LIMIT", "2.0"))
24+
# 每用户每日费用上限(元)——作为 TIER_LIMITS 缺失时的全局兜底
25+
DEFAULT_COST_LIMIT_YUAN = float(os.getenv("BUDGET_DAILY_COST_LIMIT", "0.5"))
2626

2727
# 需要预算检查的路径前缀
2828
BUDGET_CHECKED_PATHS = ("/api/v1/ai/", "/api/v1/asr/", "/api/v1/multimodal/", "/api/v1/vision/")
2929

3030

31+
def resolve_cost_limit(beta_tier: str | None = None, paid_tier: str | None = None) -> float:
32+
"""按有效 tier 解析每日费用上限(元)。
33+
34+
@ai-context: 纯函数。tier 由 JWT claims 注入(auth.py),此处只做查表;
35+
TIER_LIMITS 缺失 cost 键时回退环境变量默认值。
36+
@ai-context: 延迟导入 get_tier_limits——cost 包位于 config 初始化链上
37+
(config → fallback → cost.tracker → cost/__init__),顶层导入 middleware
38+
会触发 middleware → auth → config 循环导入(ImportError: APP_CONFIG)。
39+
40+
Args:
41+
beta_tier: 内测 tier(observer/active/core)
42+
paid_tier: 付费 tier(pro/lifetime)
43+
44+
Returns:
45+
float: 每日费用上限(元)
46+
"""
47+
# 延迟导入:打破 config → cost → middleware → config 的循环依赖
48+
from middleware.rate_limit import get_tier_limits
49+
50+
limits = get_tier_limits(beta_tier, paid_tier)
51+
return float(limits.get("cost", DEFAULT_COST_LIMIT_YUAN))
52+
53+
3154
class BudgetMiddleware(BaseHTTPMiddleware):
3255
"""预算控制中间件 — 超限时拒绝新请求"""
3356

@@ -36,11 +59,20 @@ async def dispatch(self, request: Request, call_next):
3659
if not any(request.url.path.startswith(p) for p in BUDGET_CHECKED_PATHS):
3760
return await call_next(request)
3861

62+
# 开发者白名单完全豁免(auth.py 注入 is_dev,DEV_USER_IDS 配置)
63+
if getattr(request.state, "is_dev", False):
64+
return await call_next(request)
65+
3966
# 获取 user_id
4067
user_id = getattr(request.state, "user_id", None)
4168
if not user_id or user_id == "anonymous":
4269
return await call_next(request)
4370

71+
# 按 tier 解析当日费用上限(由 auth.py 注入,解析失败回落 free)
72+
beta_tier = getattr(request.state, "beta_tier", None)
73+
paid_tier = getattr(request.state, "paid_tier", None)
74+
daily_cost_limit = resolve_cost_limit(beta_tier, paid_tier)
75+
4476
# 检查预算
4577
tracker = get_cost_tracker()
4678
usage = await tracker.get_user_daily_usage(user_id)
@@ -62,16 +94,16 @@ async def dispatch(self, request: Request, call_next):
6294
},
6395
)
6496

65-
if usage["yuan"] >= DAILY_COST_LIMIT_YUAN:
97+
if usage["yuan"] >= daily_cost_limit:
6698
logger.warning(
6799
"预算拦截(cost): user=%s, yuan=%.2f/%.2f",
68-
user_id, usage["yuan"], DAILY_COST_LIMIT_YUAN,
100+
user_id, usage["yuan"], daily_cost_limit,
69101
)
70102
return JSONResponse(
71103
status_code=429,
72104
content={
73105
"detail": (
74-
f"今日 AI 费用已达上限(¥{DAILY_COST_LIMIT_YUAN:.0f}),"
106+
f"今日 AI 费用已达上限(¥{daily_cost_limit:.1f}),"
75107
"请明天再试,或使用自己的 API Key 解除限制。"
76108
),
77109
"code": "budget_cost_exceeded",

‎server/ai-gateway/main.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
concept_precheck_router,
5555
import_concept_router,
5656
license_router,
57+
license_webhook_router,
5758
beta_router,
5859
learning_plan_router,
5960
session_qa_router,
@@ -283,6 +284,7 @@ async def ai_error_handler(request: Request, exc: AIError) -> JSONResponse:
283284
app.include_router(concept_precheck_router) # E1: 概念预检——同样注册于 streaming_router 之前
284285
app.include_router(import_concept_router) # 阶段 A: 知识入籍概念化——同样注册于 streaming_router 之前
285286
app.include_router(license_router) # 激活码验证(POST /api/v1/license/activate)
287+
app.include_router(license_webhook_router) # 面包多订单通知(POST /api/v1/license/webhook)
286288
app.include_router(beta_router) # 内测邀请 API(POST /api/v1/beta/use-invite)
287289
app.include_router(streaming_router) # 流式输出(SSE,全量 AI 功能)
288290
app.include_router(balance_router) # API 余额查询

0 commit comments

Comments
 (0)