fogact 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bin/web-server.js +829 -230
  2. package/frontend/admin/admin-panel-v2.js +8 -5
  3. package/frontend/user/assets/AnnouncementDetail-Dvxmwz0A-fogact-live.js +12 -0
  4. package/frontend/user/assets/Announcements-CS1tF2mx-fogact-live.js +11 -0
  5. package/frontend/user/assets/CardBind-CsCxihhP-fogact-live.js +21 -0
  6. package/frontend/user/assets/CardContent.vue_vue_type_script_setup_true_lang-D2L-uqSl-fogact-live.js +1 -0
  7. package/frontend/user/assets/CardDescription.vue_vue_type_script_setup_true_lang-D-v5Pl7F-fogact-live.js +1 -0
  8. package/frontend/user/assets/CardTitle.vue_vue_type_script_setup_true_lang-a0CCN6D5-fogact-live.js +1 -0
  9. package/frontend/user/assets/Dashboard-rPsmltm5-fogact-live.js +51 -0
  10. package/frontend/user/assets/Dashboard-rPsmltm5.js +1 -1
  11. package/frontend/user/assets/DashboardLayout-DDkxHYFj-fogact-live.js +80 -0
  12. package/frontend/user/assets/DashboardLayout-DDkxHYFj.js +2 -2
  13. package/frontend/user/assets/Input.vue_vue_type_script_setup_true_lang-B0SyPmYb-fogact-live.js +6 -0
  14. package/frontend/user/assets/Label.vue_vue_type_script_setup_true_lang-CxYORSgN-fogact-live.js +1 -0
  15. package/frontend/user/assets/Progress.vue_vue_type_script_setup_true_lang-2_QbPsEQ-fogact-live.js +1 -0
  16. package/frontend/user/assets/QuotaPack-B_tJ7Psm-fogact-live.js +6 -0
  17. package/frontend/user/assets/Renewal-BSDhDmwv-fogact-live.js +6 -0
  18. package/frontend/user/assets/ScrollArea.vue_vue_type_script_setup_true_lang-DMYwcfpz-fogact-live.js +1 -0
  19. package/frontend/user/assets/Separator.vue_vue_type_script_setup_true_lang-Ckg8EXj_-fogact-live.js +1 -0
  20. package/frontend/user/assets/Settings-CBdAa3lw-fogact-live.js +11 -0
  21. package/frontend/user/assets/TooltipTrigger.vue_vue_type_script_setup_true_lang-DtSBjzGo-fogact-live.js +16 -0
  22. package/frontend/user/assets/Welcome-Dtfp6oER-fogact-live.js +1 -0
  23. package/frontend/user/assets/Welcome-Dtfp6oER.js +1 -1
  24. package/frontend/user/assets/_plugin-vue_export-helper-5cjT4u0R-fogact-live.js +16 -0
  25. package/frontend/user/assets/activity-wYWtyqTJ-fogact-live.js +6 -0
  26. package/frontend/user/assets/announcement-35mOnjRL-fogact-live.js +16 -0
  27. package/frontend/user/assets/announcement-35mOnjRL.js +1 -1
  28. package/frontend/user/assets/calendar-BFNuCata-fogact-live.js +6 -0
  29. package/frontend/user/assets/chevron-down-kDbuU1Py-fogact-live.js +6 -0
  30. package/frontend/user/assets/chevron-right-BayASIm0-fogact-live.js +6 -0
  31. package/frontend/user/assets/eye-CY62vip0-fogact-live.js +6 -0
  32. package/frontend/user/assets/gauge-C5NQ-mV8-fogact-live.js +6 -0
  33. package/frontend/user/assets/index-Da98HOxL.js +3 -3
  34. package/frontend/user/assets/index-fogact-live.js +91 -0
  35. package/frontend/user/assets/link-2-DT5R5nGO-fogact-live.js +6 -0
  36. package/frontend/user/assets/package-rUbExUEn-fogact-live.js +6 -0
  37. package/frontend/user/assets/plus-CQc6C8wG-fogact-live.js +11 -0
  38. package/frontend/user/assets/refresh-cw-Y9hCloPL-fogact-live.js +6 -0
  39. package/frontend/user/assets/useUserPageRefresh-BYZvpNR9-fogact-live.js +1 -0
  40. package/frontend/user/assets/zap-l5zbZqrM-fogact-live.js +11 -0
  41. package/frontend/user/index.html +5 -5
  42. package/lib/commands/activate.js +1 -8
  43. package/lib/config/claude.js +1 -0
  44. package/lib/config/codex.js +1 -1
  45. package/lib/config/upstream.js +3 -0
  46. package/lib/index.js +109 -29
  47. package/lib/platforms/opencode.js +1 -1
  48. package/lib/services/activation-orchestrator.js +30 -166
  49. package/lib/services/database.js +47 -0
  50. package/lib/services/fogact-api.js +12 -9
  51. package/package.json +1 -1
package/bin/web-server.js CHANGED
@@ -5,7 +5,7 @@ const https = require("https");
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
7
  const os = require("os");
8
- const { userDb, codeDb, initializeSampleData } = require("../lib/services/database");
8
+ const { userDb, codeDb, usageDb, initializeSampleData } = require("../lib/services/database");
9
9
  const { DEFAULT_CONFIG_PATH, getServiceBaseUrl, loadUpstreamConfig } = require("../lib/config/upstream");
10
10
  const { readJsonFile, writeJsonFile } = require("../lib/utils/json-file");
11
11
  const { maskKey, verifyNewApiKey } = require("../lib/services/newapi");
@@ -36,18 +36,41 @@ const USER_STATUS_KEY_MAP = {
36
36
  const CODE_STATUS_MAP = {
37
37
  unused: "unused",
38
38
  "未使用": "unused",
39
- used: "used",
40
- "已使用": "used",
39
+ "未激活": "unused",
40
+ active: "active",
41
+ "活跃": "active",
42
+ "已激活": "active",
43
+ used: "active",
44
+ "已使用": "active",
45
+ disabled: "disabled",
46
+ blocked: "disabled",
47
+ banned: "disabled",
48
+ "已禁用": "disabled",
49
+ "禁用": "disabled",
41
50
  expired: "expired",
42
51
  "已过期": "expired",
43
52
  };
44
53
 
45
54
  const CODE_STATUS_LABEL_MAP = {
46
- unused: "未使用",
47
- used: "已使用",
55
+ unused: "未激活",
56
+ active: "活跃",
57
+ disabled: "已禁用",
48
58
  expired: "已过期",
49
59
  };
50
60
 
61
+ const HOP_BY_HOP_HEADERS = new Set([
62
+ "connection",
63
+ "keep-alive",
64
+ "proxy-authenticate",
65
+ "proxy-authorization",
66
+ "te",
67
+ "trailer",
68
+ "transfer-encoding",
69
+ "upgrade",
70
+ "host",
71
+ "content-length",
72
+ ]);
73
+
51
74
  // 初始化示例数据
52
75
  initializeSampleData();
53
76
 
@@ -181,7 +204,7 @@ function normalizeCodeStatus(codeOrStatus, expiresAt) {
181
204
  }
182
205
 
183
206
  function getCodeStatusLabel(status) {
184
- return CODE_STATUS_LABEL_MAP[normalizeCodeStatus(status)] || "未使用";
207
+ return CODE_STATUS_LABEL_MAP[normalizeCodeStatus(status)] || "未激活";
185
208
  }
186
209
 
187
210
  function serializeUser(user) {
@@ -219,30 +242,27 @@ function getActivationPlatforms(serviceKey) {
219
242
  return [];
220
243
  }
221
244
 
222
- function buildActivationData(serializedCode) {
245
+ function getPublicBaseUrl(req) {
246
+ const forwardedHost = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
247
+ const publicHost = forwardedHost && !forwardedHost.includes('fogact.fogact.com')
248
+ ? forwardedHost
249
+ : 'cliproxy.fogidc.com';
250
+ const isLocalHost = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)(:\d+)?$/i.test(publicHost);
251
+ const defaultProtocol = isLocalHost ? 'http' : 'https';
252
+ const publicProtocol = String(req?.headers?.['x-forwarded-proto'] || defaultProtocol).split(',')[0].trim() || defaultProtocol;
253
+ return trimTrailingSlash(process.env.FOGACT_PUBLIC_BASE_URL || `${publicProtocol}://${publicHost}`);
254
+ }
255
+
256
+ function getProxyBaseUrl(req, serviceKey) {
257
+ const publicBaseUrl = getPublicBaseUrl(req);
258
+ return serviceKey === "claude" ? publicBaseUrl : `${publicBaseUrl}/v1`;
259
+ }
260
+
261
+ function buildActivationData(serializedCode, req) {
223
262
  const serviceKey = serializedCode.serviceKey;
224
- const upstream = loadUpstreamConfig({ configPath: getUpstreamConfigPath() });
225
- const serviceConfig = serializedCode.serviceConfig || {};
226
- const baseUrl = trimTrailingSlash(
227
- serializedCode.baseUrl ||
228
- serializedCode.baseURL ||
229
- serializedCode.url ||
230
- serviceConfig.baseUrl ||
231
- serviceConfig.baseURL ||
232
- serviceConfig.url ||
233
- getServiceBaseUrl(upstream, serviceKey) ||
234
- upstream.baseUrl
235
- );
236
- const apiKey = String(
237
- serializedCode.apiKey ||
238
- serializedCode.key ||
239
- serializedCode.token ||
240
- serviceConfig.apiKey ||
241
- serviceConfig.key ||
242
- serviceConfig.token ||
243
- upstream.apiKey ||
244
- ""
245
- ).trim();
263
+ const publicBaseUrl = getPublicBaseUrl(req);
264
+ const baseUrl = getProxyBaseUrl(req, serviceKey);
265
+ const apiKey = String(serializedCode.code || "").trim();
246
266
 
247
267
  return {
248
268
  code: serializedCode.code,
@@ -253,13 +273,17 @@ function buildActivationData(serializedCode) {
253
273
  allowedModels: serializedCode.allowedModels,
254
274
  quota: serializedCode.quota,
255
275
  expiresAt: serializedCode.expiresAt,
276
+ proxy: true,
277
+ publicBaseUrl,
256
278
  baseUrl,
257
279
  apiKey,
258
280
  };
259
281
  }
260
282
 
261
- function ensureActivationDataReady(res, activationData) {
262
- if (activationData.baseUrl && activationData.apiKey) {
283
+ function ensureProxyReady(res, serviceKey) {
284
+ const upstream = loadUpstreamConfig({ configPath: getUpstreamConfigPath() });
285
+ const upstreamUrl = getServiceBaseUrl(upstream, serviceKey) || upstream.baseUrl;
286
+ if (upstreamUrl && upstream.apiKey) {
263
287
  return true;
264
288
  }
265
289
 
@@ -272,6 +296,626 @@ function ensureActivationDataReady(res, activationData) {
272
296
  return false;
273
297
  }
274
298
 
299
+ function getBearerToken(req) {
300
+ const auth = String(req.headers.authorization || "");
301
+ const match = auth.match(/^Bearer\s+(.+)$/i);
302
+ if (match) return match[1].trim();
303
+ const apiKey = req.headers["x-api-key"] || req.headers["api-key"];
304
+ return apiKey ? String(apiKey).trim() : "";
305
+ }
306
+
307
+ function getProxyCode(token, body) {
308
+ return String(
309
+ token ||
310
+ body?.fogact_code ||
311
+ body?.activation_code ||
312
+ ""
313
+ ).trim();
314
+ }
315
+
316
+ function getRequestCode(req) {
317
+ return String(getBearerToken(req) || "").trim();
318
+ }
319
+
320
+ function findCodeByRequest(req) {
321
+ const token = getRequestCode(req);
322
+ return token ? codeDb.getByCode(token) : null;
323
+ }
324
+
325
+ function getActiveProxyCode(codeValue, serviceKey) {
326
+ const code = codeDb.getByCode(String(codeValue || "").trim());
327
+ if (!code) return { ok: false, status: 401, message: "激活码不存在或无效" };
328
+
329
+ const serializedCode = serializeCode(code);
330
+ if (!serviceMatches(serializedCode, serviceKey)) {
331
+ return { ok: false, status: 403, message: `此激活码不支持 ${normalizeService(serviceKey)}` };
332
+ }
333
+ if (serializedCode.status === "disabled") {
334
+ return { ok: false, status: 403, message: "此激活码已被禁用,无法访问中转" };
335
+ }
336
+ if (serializedCode.status === "expired") {
337
+ return { ok: false, status: 403, message: "激活码已过期" };
338
+ }
339
+
340
+ return { ok: true, code, serializedCode };
341
+ }
342
+
343
+ function getRemainingQuota(code) {
344
+ const quota = code?.quota || {};
345
+ const total = Number(quota.total || 0);
346
+ const loggedUsed = aggregateUsageItems(getUsageItemsForCode(code)).tokens;
347
+ const used = Math.max(loggedUsed, Number(quota.used || 0));
348
+ if (!Number.isFinite(total) || total <= 0) return Infinity;
349
+ return Math.max(total - (Number.isFinite(used) ? used : 0), 0);
350
+ }
351
+
352
+ function getRemainingDailyQuota(code) {
353
+ const quota = code?.quota || {};
354
+ const dailyLimit = Number(quota.dailyLimit || quota.daily || 0);
355
+ const todayItems = getUsageItemsForCode(code).filter((item) => String(item.createdAt || '').startsWith(getTodayKey()));
356
+ const todayStats = code?.usage?.dailyStats?.[getTodayKey()];
357
+ const dailyUsed = aggregateUsageItems(todayItems).tokens || Number(todayStats?.tokens || 0);
358
+ if (!Number.isFinite(dailyLimit) || dailyLimit <= 0) return Infinity;
359
+ return Math.max(dailyLimit - (Number.isFinite(dailyUsed) ? dailyUsed : 0), 0);
360
+ }
361
+
362
+ function ensureQuotaAvailable(res, code) {
363
+ if (getRemainingQuota(code) <= 0) {
364
+ res.writeHead(402, { "Content-Type": "application/json" });
365
+ res.end(JSON.stringify({ error: { message: "额度已用尽,请续费或更换可用激活码" } }));
366
+ return false;
367
+ }
368
+
369
+ if (getRemainingDailyQuota(code) <= 0) {
370
+ res.writeHead(429, { "Content-Type": "application/json" });
371
+ res.end(JSON.stringify({ error: { message: "今日额度已用尽,请明日再试或联系管理员调整额度" } }));
372
+ return false;
373
+ }
374
+
375
+ return true;
376
+ }
377
+
378
+ function getPeriodBuckets(period = "7d") {
379
+ const normalized = String(period || "7d").toLowerCase();
380
+ const hourly = normalized === "24h";
381
+ const bucketCount = hourly ? 24 : Math.max(1, Math.min(90, parseInt(normalized, 10) || 7));
382
+ const now = new Date();
383
+ const buckets = [];
384
+
385
+ for (let index = bucketCount - 1; index >= 0; index -= 1) {
386
+ const date = new Date(now);
387
+ if (hourly) {
388
+ date.setMinutes(0, 0, 0);
389
+ date.setHours(date.getHours() - index);
390
+ buckets.push(date.toISOString().slice(0, 13));
391
+ } else {
392
+ date.setDate(date.getDate() - index);
393
+ buckets.push(date.toISOString().slice(0, 10));
394
+ }
395
+ }
396
+
397
+ return { period: normalized, hourly, buckets };
398
+ }
399
+
400
+ function getUsageBucketKey(createdAt, hourly) {
401
+ const value = String(createdAt || "");
402
+ return hourly ? value.slice(0, 13) : value.slice(0, 10);
403
+ }
404
+
405
+ function parseJsonBody(body) {
406
+ if (!body) return null;
407
+ try {
408
+ return JSON.parse(body);
409
+ } catch (_error) {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ function extractRequestModel(rawBody) {
415
+ const body = parseJsonBody(rawBody);
416
+ return String(body?.model || body?.model_name || "unknown");
417
+ }
418
+
419
+ function extractUsageFromResponse(data) {
420
+ const usage = data?.usage || data?.message?.usage || data?.response?.usage || data?.data?.usage || null;
421
+ if (!usage || typeof usage !== "object") {
422
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0 };
423
+ }
424
+
425
+ const inputTokens = Number(
426
+ usage.prompt_tokens ??
427
+ usage.input_tokens ??
428
+ usage.inputTokens ??
429
+ usage.promptTokens ??
430
+ 0
431
+ );
432
+ const outputTokens = Number(
433
+ usage.completion_tokens ??
434
+ usage.output_tokens ??
435
+ usage.outputTokens ??
436
+ usage.completionTokens ??
437
+ 0
438
+ );
439
+ const explicitTotal = Number(
440
+ usage.total_tokens ??
441
+ usage.totalTokens ??
442
+ usage.tokens ??
443
+ 0
444
+ );
445
+ const totalTokens = explicitTotal > 0
446
+ ? explicitTotal
447
+ : Math.max(inputTokens, 0) + Math.max(outputTokens, 0);
448
+ const cost = Number(usage.cost ?? usage.total_cost ?? usage.quota ?? usage.used_quota ?? 0);
449
+
450
+ return {
451
+ inputTokens: Number.isFinite(inputTokens) ? Math.max(inputTokens, 0) : 0,
452
+ outputTokens: Number.isFinite(outputTokens) ? Math.max(outputTokens, 0) : 0,
453
+ totalTokens: Number.isFinite(totalTokens) ? Math.max(totalTokens, 0) : 0,
454
+ cost: Number.isFinite(cost) ? Math.max(cost, 0) : 0,
455
+ };
456
+ }
457
+
458
+ function extractModelFromResponse(data, fallbackModel) {
459
+ return String(data?.model || data?.message?.model || data?.response?.model || data?.data?.model || fallbackModel || "unknown");
460
+ }
461
+
462
+ function parseResponseUsagePayloads(responseBody) {
463
+ const json = parseJsonBody(responseBody);
464
+ if (json) return [json];
465
+
466
+ return String(responseBody || "")
467
+ .split(/\r?\n/)
468
+ .map((line) => line.trim())
469
+ .filter((line) => line.startsWith("data:"))
470
+ .map((line) => line.slice(5).trim())
471
+ .filter((line) => line && line !== "[DONE]")
472
+ .map((line) => parseJsonBody(line))
473
+ .filter(Boolean);
474
+ }
475
+
476
+ function summarizeResponseUsage(responseBody, fallbackModel) {
477
+ const payloads = parseResponseUsagePayloads(responseBody);
478
+ let model = fallbackModel || "unknown";
479
+ const totals = { inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0 };
480
+
481
+ for (const payload of payloads) {
482
+ model = extractModelFromResponse(payload, model);
483
+ const usage = extractUsageFromResponse(payload);
484
+ totals.inputTokens = Math.max(totals.inputTokens, usage.inputTokens);
485
+ totals.outputTokens = Math.max(totals.outputTokens, usage.outputTokens);
486
+ totals.totalTokens = Math.max(totals.totalTokens, usage.totalTokens);
487
+ totals.cost = Math.max(totals.cost, usage.cost);
488
+ }
489
+
490
+ if (totals.totalTokens <= 0) {
491
+ totals.totalTokens = totals.inputTokens + totals.outputTokens;
492
+ }
493
+
494
+ return { model, usage: totals };
495
+ }
496
+
497
+ function settleProxyUsage(code, serializedCode, serviceKey, requestPath, statusCode, rawBody, responseBody) {
498
+ const requestModel = extractRequestModel(rawBody);
499
+ const summary = summarizeResponseUsage(responseBody, requestModel);
500
+ const model = summary.model;
501
+ const usageTokens = summary.usage;
502
+ const success = statusCode >= 200 && statusCode < 300;
503
+ const now = new Date().toISOString();
504
+
505
+ usageDb.create({
506
+ codeId: code.id,
507
+ code: code.code,
508
+ service: serviceKey,
509
+ model,
510
+ inputTokens: usageTokens.inputTokens,
511
+ outputTokens: usageTokens.outputTokens,
512
+ totalTokens: usageTokens.totalTokens,
513
+ cost: usageTokens.cost,
514
+ statusCode,
515
+ success,
516
+ path: requestPath,
517
+ createdAt: now,
518
+ });
519
+
520
+ const current = codeDb.getById(code.id) || code;
521
+ const nextQuota = { ...(current.quota || {}) };
522
+ const currentUsage = current.usage || {};
523
+ const dailyStats = { ...(currentUsage.dailyStats || {}) };
524
+ const modelStats = { ...(currentUsage.modelStats || {}) };
525
+ const today = getTodayKey();
526
+ const quotaDelta = success ? usageTokens.totalTokens : 0;
527
+
528
+ if (quotaDelta > 0) {
529
+ const totalLoggedUsage = aggregateUsageItems(getUsageItemsForCode(current)).tokens;
530
+ nextQuota.used = Math.max(Number(nextQuota.used || 0) + quotaDelta, totalLoggedUsage);
531
+ nextQuota.dailyUsed = Number(nextQuota.dailyUsed || 0) + quotaDelta;
532
+
533
+ const day = dailyStats[today] || { requests: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
534
+ dailyStats[today] = {
535
+ requests: Number(day.requests || 0) + 1,
536
+ tokens: Number(day.tokens || 0) + quotaDelta,
537
+ inputTokens: Number(day.inputTokens || 0) + usageTokens.inputTokens,
538
+ outputTokens: Number(day.outputTokens || 0) + usageTokens.outputTokens,
539
+ };
540
+
541
+ const modelEntry = modelStats[model] || { requests: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
542
+ modelStats[model] = {
543
+ requests: Number(modelEntry.requests || 0) + 1,
544
+ tokens: Number(modelEntry.tokens || 0) + quotaDelta,
545
+ inputTokens: Number(modelEntry.inputTokens || 0) + usageTokens.inputTokens,
546
+ outputTokens: Number(modelEntry.outputTokens || 0) + usageTokens.outputTokens,
547
+ };
548
+ }
549
+
550
+ codeDb.update(code.id, {
551
+ status: "active",
552
+ usedBy: current.usedBy || "proxy-user",
553
+ lastUsedAt: now,
554
+ activatedService: serializedCode.service,
555
+ activatedServiceKey: serializedCode.serviceKey,
556
+ quota: nextQuota,
557
+ usage: {
558
+ totalRequests: Number(currentUsage.totalRequests || 0) + (quotaDelta > 0 ? 1 : 0),
559
+ totalTokens: Number(currentUsage.totalTokens || 0) + quotaDelta,
560
+ inputTokens: Number(currentUsage.inputTokens || 0) + (quotaDelta > 0 ? usageTokens.inputTokens : 0),
561
+ outputTokens: Number(currentUsage.outputTokens || 0) + (quotaDelta > 0 ? usageTokens.outputTokens : 0),
562
+ lastModel: quotaDelta > 0 ? model : currentUsage.lastModel,
563
+ lastUsedAt: now,
564
+ dailyStats: trimUsageHistory(dailyStats),
565
+ modelStats,
566
+ },
567
+ });
568
+ }
569
+
570
+ function buildProxyHeaders(req, upstreamApiKey, bodyLength) {
571
+ const headers = {};
572
+ for (const [name, value] of Object.entries(req.headers)) {
573
+ const key = name.toLowerCase();
574
+ if (HOP_BY_HOP_HEADERS.has(key)) continue;
575
+ if (key === "authorization" || key === "x-api-key" || key === "api-key") continue;
576
+ if (key === "accept-encoding") continue;
577
+ headers[name] = value;
578
+ }
579
+ headers.authorization = `Bearer ${upstreamApiKey}`;
580
+ headers["accept-encoding"] = "identity";
581
+ if (bodyLength !== undefined) headers["content-length"] = Buffer.byteLength(bodyLength);
582
+ return headers;
583
+ }
584
+
585
+ function sanitizeProxyBody(req, rawBody) {
586
+ if (!rawBody || !String(req.headers["content-type"] || "").includes("application/json")) {
587
+ return rawBody;
588
+ }
589
+
590
+ try {
591
+ const body = JSON.parse(rawBody);
592
+ if (body && typeof body === "object" && !Array.isArray(body)) {
593
+ delete body.fogact_code;
594
+ delete body.activation_code;
595
+ delete body.api_key;
596
+ return JSON.stringify(body);
597
+ }
598
+ } catch (_error) {
599
+ return rawBody;
600
+ }
601
+
602
+ return rawBody;
603
+ }
604
+
605
+ function getTodayKey() {
606
+ return new Date().toISOString().slice(0, 10);
607
+ }
608
+
609
+ function trimUsageHistory(history, limit = 90) {
610
+ const entries = Object.entries(history || {}).sort(([a], [b]) => a.localeCompare(b));
611
+ return Object.fromEntries(entries.slice(Math.max(0, entries.length - limit)));
612
+ }
613
+
614
+ function getUsageItemsForCode(code) {
615
+ if (!code?.code) return [];
616
+ return usageDb.getByCode(code.code).filter((item) => item.success !== false && Number(item.totalTokens || 0) > 0);
617
+ }
618
+
619
+ function aggregateUsageItems(items) {
620
+ return items.reduce((totals, item) => {
621
+ totals.requests += Number(item.requests || 1);
622
+ totals.tokens += Number(item.totalTokens || 0);
623
+ totals.inputTokens += Number(item.inputTokens || 0);
624
+ totals.outputTokens += Number(item.outputTokens || 0);
625
+ totals.cost += Number(item.cost || 0);
626
+ return totals;
627
+ }, { requests: 0, tokens: 0, inputTokens: 0, outputTokens: 0, cost: 0 });
628
+ }
629
+
630
+ function emptyUsageBucket(date) {
631
+ return {
632
+ date,
633
+ requests: 0,
634
+ total_tokens: 0,
635
+ input_tokens: 0,
636
+ output_tokens: 0,
637
+ cache_read_tokens: 0,
638
+ cache_create_tokens: 0,
639
+ cost: 0,
640
+ };
641
+ }
642
+
643
+ function getUsageHistoryForPeriod(code, period = "7d") {
644
+ const { period: normalizedPeriod, hourly, buckets } = getPeriodBuckets(period);
645
+ const items = getUsageItemsForCode(code);
646
+ const byBucket = new Map(buckets.map((bucket) => [bucket, emptyUsageBucket(bucket)]));
647
+ const bucketSet = new Set(buckets);
648
+
649
+ for (const item of items) {
650
+ const bucket = getUsageBucketKey(item.createdAt, hourly);
651
+ if (!bucketSet.has(bucket)) continue;
652
+ const current = byBucket.get(bucket) || emptyUsageBucket(bucket);
653
+ current.requests += Number(item.requests || 1);
654
+ current.total_tokens += Number(item.totalTokens || 0);
655
+ current.input_tokens += Number(item.inputTokens || 0);
656
+ current.output_tokens += Number(item.outputTokens || 0);
657
+ current.cost += Number(item.cost || 0);
658
+ byBucket.set(bucket, current);
659
+ }
660
+
661
+ return { period: normalizedPeriod, data: buckets.map((bucket) => byBucket.get(bucket) || emptyUsageBucket(bucket)) };
662
+ }
663
+
664
+ function getCodeUsagePayload(code, period = "7d") {
665
+ const quota = code?.quota || {};
666
+ const items = getUsageItemsForCode(code);
667
+ const totals = aggregateUsageItems(items);
668
+ const today = getTodayKey();
669
+ const todayTotals = aggregateUsageItems(items.filter((item) => String(item.createdAt || '').startsWith(today)));
670
+ const totalQuota = Number(quota.total || 0) > 0 ? Number(quota.total) : 100000;
671
+ const history = getUsageHistoryForPeriod(code, period);
672
+
673
+ return {
674
+ period: history.period,
675
+ data: history.data,
676
+ total_requests: totals.requests,
677
+ total_tokens: totals.tokens,
678
+ input_tokens: totals.inputTokens,
679
+ output_tokens: totals.outputTokens,
680
+ today_requests: todayTotals.requests,
681
+ today_tokens: todayTotals.tokens,
682
+ quota: {
683
+ total: totalQuota,
684
+ used: totals.tokens,
685
+ remaining: Math.max(0, totalQuota - totals.tokens),
686
+ daily_limit: Number(quota.dailyLimit || quota.daily || 0),
687
+ daily_used: todayTotals.tokens,
688
+ },
689
+ code_info: code ? {
690
+ code: code.code,
691
+ service: code.service,
692
+ expires_at: code.expiresAt,
693
+ status: code.status,
694
+ last_used_at: code.lastUsedAt || null,
695
+ } : null,
696
+ daily_stats: history.data,
697
+ };
698
+ }
699
+
700
+ function getCodeModelTrends(code, period = "7d") {
701
+ const { period: normalizedPeriod, hourly, buckets } = getPeriodBuckets(period);
702
+ const byBucket = new Map(buckets.map((bucket) => [bucket, new Map()]));
703
+ const bucketSet = new Set(buckets);
704
+
705
+ for (const item of getUsageItemsForCode(code)) {
706
+ const bucket = getUsageBucketKey(item.createdAt, hourly);
707
+ if (!bucketSet.has(bucket)) continue;
708
+ const model = item.model || "unknown";
709
+ const models = byBucket.get(bucket) || new Map();
710
+ const current = models.get(model) || { model, requests: 0, total_tokens: 0, input_tokens: 0, output_tokens: 0, cost: 0 };
711
+ current.requests += Number(item.requests || 1);
712
+ current.total_tokens += Number(item.totalTokens || 0);
713
+ current.input_tokens += Number(item.inputTokens || 0);
714
+ current.output_tokens += Number(item.outputTokens || 0);
715
+ current.cost += Number(item.cost || 0);
716
+ models.set(model, current);
717
+ byBucket.set(bucket, models);
718
+ }
719
+
720
+ return {
721
+ period: normalizedPeriod,
722
+ data: buckets.map((bucket) => ({
723
+ date: bucket,
724
+ models: [...(byBucket.get(bucket) || new Map()).values()].sort((a, b) => b.total_tokens - a.total_tokens),
725
+ })),
726
+ };
727
+ }
728
+
729
+ function getCodeUsageSummary(code) {
730
+ const items = getUsageItemsForCode(code);
731
+ const totals = aggregateUsageItems(items);
732
+ const todayTotals = aggregateUsageItems(items.filter((item) => String(item.createdAt || '').startsWith(getTodayKey())));
733
+ const legacyUsed = Number(code?.quota?.used || 0);
734
+
735
+ if (totals.tokens <= 0 && legacyUsed > 0) {
736
+ totals.tokens = legacyUsed;
737
+ totals.totalTokens = legacyUsed;
738
+ totals.requests = Number(code?.usage?.totalRequests || 0);
739
+ totals.inputTokens = Number(code?.usage?.inputTokens || 0);
740
+ totals.outputTokens = Number(code?.usage?.outputTokens || 0);
741
+ }
742
+
743
+ return { totals, todayTotals };
744
+ }
745
+
746
+ function serializeCodeForUserApi(code) {
747
+ if (!code) return null;
748
+
749
+ const serialized = serializeCode(code);
750
+ const { totals, todayTotals } = getCodeUsageSummary(code);
751
+ const quota = code.quota || {};
752
+ const totalQuota = Number(quota.total || 0) > 0 ? Number(quota.total) : 100000;
753
+ const usedQuota = totals.tokens;
754
+ const dailyLimit = Number(quota.dailyLimit || quota.daily || 0);
755
+ const status = serialized.status === "unused" ? "inactive" : serialized.status;
756
+ const activatedAt = code.activatedAt || (status === "active" ? code.createdAt : null);
757
+
758
+ return {
759
+ id: code.id,
760
+ key_preview: maskKey(code.code),
761
+ service_type: serialized.serviceKey,
762
+ sub_service_type_name: serialized.category || "",
763
+ billing_type: "quota",
764
+ status,
765
+ status_label: status === "inactive" ? "未激活" : serialized.statusLabel,
766
+ is_bound: false,
767
+ channel_group_id: code.channelGroupId || null,
768
+ quota: {
769
+ total_quota: totalQuota,
770
+ used_quota: usedQuota,
771
+ remaining_quota: Math.max(0, totalQuota - usedQuota),
772
+ daily_quota: dailyLimit,
773
+ daily_spent: todayTotals.tokens,
774
+ daily_remaining: dailyLimit > 0 ? Math.max(0, dailyLimit - todayTotals.tokens) : 0,
775
+ reset_timezone: SERVER_TIMEZONE,
776
+ next_reset_at: getNextResetAt(),
777
+ },
778
+ usage: {
779
+ total_spent: totals.cost,
780
+ daily_spent: todayTotals.cost,
781
+ daily_total_spent: todayTotals.cost,
782
+ request_count: totals.requests,
783
+ daily_request_count: todayTotals.requests,
784
+ input_tokens: totals.inputTokens,
785
+ output_tokens: totals.outputTokens,
786
+ cache_read_tokens: 0,
787
+ cache_write_tokens: 0,
788
+ total_tokens: totals.tokens,
789
+ },
790
+ timestamps: {
791
+ activated_at: activatedAt,
792
+ last_used_at: code.lastUsedAt || null,
793
+ expires_at: code.expiresAt || null,
794
+ validity_days: code.validity?.days || null,
795
+ },
796
+ };
797
+ }
798
+
799
+ function getNextResetAt() {
800
+ const next = new Date();
801
+ const serverTzDate = new Date(next.toLocaleString("en-US", { timeZone: SERVER_TIMEZONE }));
802
+ const serverMidnight = new Date(serverTzDate);
803
+ serverMidnight.setHours(24, 0, 0, 0);
804
+ const delay = serverMidnight.getTime() - serverTzDate.getTime();
805
+ return new Date(next.getTime() + Math.max(delay, 1000)).toISOString();
806
+ }
807
+
808
+ function resolveUserApiCode(req) {
809
+ const code = findCodeByRequest(req);
810
+ return code || null;
811
+ }
812
+
813
+ function sendUserApiUnauthorized(res, message = "请先添加有效的 FogAct Key") {
814
+ res.writeHead(401, { 'Content-Type': 'application/json' });
815
+ res.end(JSON.stringify({ success: false, message }));
816
+ }
817
+
818
+ function getCodeUsageRank(code) {
819
+ const items = getUsageItemsForCode(code);
820
+ const totals = aggregateUsageItems(items);
821
+ const latestUsageAt = items.reduce((latest, item) => {
822
+ const createdAt = new Date(item.createdAt || 0).getTime();
823
+ return Number.isFinite(createdAt) ? Math.max(latest, createdAt) : latest;
824
+ }, 0);
825
+ const lastUsedAt = new Date(code?.lastUsedAt || 0).getTime();
826
+
827
+ return {
828
+ tokens: totals.tokens,
829
+ requests: totals.requests,
830
+ latestAt: Math.max(
831
+ Number.isFinite(latestUsageAt) ? latestUsageAt : 0,
832
+ Number.isFinite(lastUsedAt) ? lastUsedAt : 0
833
+ ),
834
+ };
835
+ }
836
+
837
+ function sortCodesByUsageActivity(codes) {
838
+ return [...codes].sort((a, b) => {
839
+ const left = getCodeUsageRank(a);
840
+ const right = getCodeUsageRank(b);
841
+ if (right.tokens !== left.tokens) return right.tokens - left.tokens;
842
+ if (right.requests !== left.requests) return right.requests - left.requests;
843
+ return right.latestAt - left.latestAt;
844
+ });
845
+ }
846
+
847
+ function findUsageCode(username, user) {
848
+ const codes = codeDb.getAll();
849
+ const requestedUser = String(username || '').trim();
850
+ const candidateValues = [requestedUser];
851
+ if (!requestedUser || user?.username === requestedUser) {
852
+ candidateValues.push(user?.username, user?.email, user?.id);
853
+ }
854
+ const candidates = new Set(candidateValues.filter(Boolean).map(String));
855
+ const matchedCodes = codes.filter((code) => candidates.has(String(code.usedBy || '')));
856
+
857
+ return sortCodesByUsageActivity(matchedCodes)[0] ||
858
+ sortCodesByUsageActivity(codes.filter((code) => normalizeCodeStatus(code) === 'active'))[0] ||
859
+ null;
860
+ }
861
+
862
+
863
+ function proxyUpstreamRequest(req, res, serviceKey, code, rawBody) {
864
+ const upstream = loadUpstreamConfig({ configPath: getUpstreamConfigPath() });
865
+ const upstreamUrl = getServiceBaseUrl(upstream, serviceKey) || upstream.baseUrl;
866
+ const upstreamApiKey = upstream.apiKey;
867
+ if (!upstreamUrl || !upstreamApiKey) {
868
+ res.writeHead(500, { "Content-Type": "application/json" });
869
+ res.end(JSON.stringify({ success: false, error: { message: "上游服务未配置完整" } }));
870
+ return;
871
+ }
872
+
873
+ const upstreamBase = trimTrailingSlash(upstreamUrl);
874
+ const requestPath = req.url.split("?")[0];
875
+ const query = req.url.includes("?") ? `?${req.url.split("?").slice(1).join("?")}` : "";
876
+ const upstreamPath = serviceKey === "claude"
877
+ ? requestPath
878
+ : requestPath.replace(/^\/v1(?=\/|$)/, "") || "/";
879
+ const target = new URL(`${upstreamBase}${upstreamPath}${query}`);
880
+ const client = target.protocol === "https:" ? https : http;
881
+ const upstreamBody = sanitizeProxyBody(req, rawBody);
882
+ const headers = buildProxyHeaders(req, upstreamApiKey, upstreamBody);
883
+
884
+ const proxyReq = client.request({
885
+ protocol: target.protocol,
886
+ hostname: target.hostname,
887
+ port: target.port || (target.protocol === "https:" ? 443 : 80),
888
+ path: `${target.pathname}${target.search}`,
889
+ method: req.method,
890
+ headers,
891
+ }, (proxyRes) => {
892
+ const responseHeaders = { ...proxyRes.headers };
893
+ delete responseHeaders["transfer-encoding"];
894
+ delete responseHeaders.connection;
895
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
896
+
897
+ const chunks = [];
898
+ proxyRes.on("data", (chunk) => {
899
+ chunks.push(chunk);
900
+ res.write(chunk);
901
+ });
902
+ proxyRes.on("end", () => {
903
+ res.end();
904
+ const rawResponseBody = Buffer.concat(chunks).toString("utf8");
905
+ settleProxyUsage(code, code.serializedCode || serializeCode(code), serviceKey, requestPath, proxyRes.statusCode || 502, upstreamBody, rawResponseBody);
906
+ });
907
+ });
908
+
909
+ proxyReq.on("error", (error) => {
910
+ res.writeHead(502, { "Content-Type": "application/json" });
911
+ res.end(JSON.stringify({ success: false, error: { message: error.message } }));
912
+ });
913
+
914
+ if (upstreamBody) proxyReq.write(upstreamBody);
915
+ proxyReq.end();
916
+
917
+ }
918
+
275
919
  function trimTrailingSlash(value) {
276
920
  return String(value || "").trim().replace(/\/+$/, "");
277
921
  }
@@ -392,10 +1036,11 @@ function serveStaticFile(filePath, res) {
392
1036
 
393
1037
  // For admin panel files, always use no-cache to prevent stale content
394
1038
  const isAdminFile = filePath.includes('admin-panel');
1039
+ const isUserAsset = filePath.includes(`${path.sep}user${path.sep}assets${path.sep}`);
395
1040
 
396
1041
  res.writeHead(200, {
397
1042
  "Content-Type": contentType,
398
- "Cache-Control": (ext === '.html' || isAdminFile)
1043
+ "Cache-Control": (ext === '.html' || isAdminFile || isUserAsset)
399
1044
  ? 'no-cache, no-store, must-revalidate'
400
1045
  : 'public, max-age=31536000',
401
1046
  "Pragma": "no-cache",
@@ -409,7 +1054,7 @@ const server = http.createServer((req, res) => {
409
1054
  // Add CORS headers for external access
410
1055
  res.setHeader('Access-Control-Allow-Origin', '*');
411
1056
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
412
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1057
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, API-Key');
413
1058
 
414
1059
  if (req.method === 'OPTIONS') {
415
1060
  res.writeHead(200);
@@ -420,6 +1065,46 @@ const server = http.createServer((req, res) => {
420
1065
  // Parse URL and remove query string
421
1066
  let urlPath = req.url.split('?')[0];
422
1067
 
1068
+ const isCodexProxyPath = urlPath === "/v1" || urlPath.startsWith("/v1/");
1069
+ const isClaudeProxyPath = urlPath.startsWith("/anthropic/") || urlPath === "/v1/messages" || urlPath.startsWith("/v1/messages/");
1070
+ if (req.method !== "OPTIONS" && (isCodexProxyPath || isClaudeProxyPath)) {
1071
+ const serviceKey = isClaudeProxyPath ? "claude" : "codex";
1072
+ let rawBody = "";
1073
+ req.on("data", (chunk) => {
1074
+ rawBody += chunk.toString();
1075
+ });
1076
+ req.on("end", () => {
1077
+ let body = null;
1078
+ if (rawBody && String(req.headers["content-type"] || "").includes("application/json")) {
1079
+ try {
1080
+ body = JSON.parse(rawBody);
1081
+ } catch (_error) {
1082
+ body = null;
1083
+ }
1084
+ }
1085
+
1086
+ const token = getBearerToken(req);
1087
+ const codeValue = getProxyCode(token, body);
1088
+ const codeCheck = getActiveProxyCode(codeValue, serviceKey);
1089
+ if (!codeCheck.ok) {
1090
+ res.writeHead(codeCheck.status, { "Content-Type": "application/json" });
1091
+ res.end(JSON.stringify({ error: { message: codeCheck.message } }));
1092
+ return;
1093
+ }
1094
+
1095
+ if (!ensureQuotaAvailable(res, codeCheck.code)) {
1096
+ return;
1097
+ }
1098
+
1099
+ proxyUpstreamRequest(req, res, serviceKey, codeCheck.code, rawBody);
1100
+ });
1101
+ req.on("error", () => {
1102
+ res.writeHead(400, { "Content-Type": "application/json" });
1103
+ res.end(JSON.stringify({ error: { message: "请求读取失败" } }));
1104
+ });
1105
+ return;
1106
+ }
1107
+
423
1108
  // Handle login API
424
1109
  if (urlPath === "/api/login" && req.method === "POST") {
425
1110
  let body = '';
@@ -592,17 +1277,10 @@ const server = http.createServer((req, res) => {
592
1277
  }
593
1278
 
594
1279
  if (urlPath === "/api/nodes" && req.method === "GET") {
595
- const url = new URL(req.url, `http://${req.headers.host}`);
596
- const service = getServiceKey(url.searchParams.get("service") || "codex", "codex");
597
- const upstream = loadUpstreamConfig({ configPath: getUpstreamConfigPath() });
598
- const upstreamUrl = getServiceBaseUrl(upstream, service) || upstream.baseUrl;
599
- const publicUrl = `https://${req.headers.host}`.replace(/\/+$/, "");
1280
+ const publicUrl = getPublicBaseUrl(req);
600
1281
  const nodes = [
601
1282
  { name: "FogAct", url: publicUrl, region: "Global" },
602
1283
  ];
603
- if (upstreamUrl) {
604
- nodes.push({ name: service === "claude" ? "Claude Upstream" : "Codex Upstream", url: upstreamUrl, region: "Upstream" });
605
- }
606
1284
  res.writeHead(200, { 'Content-Type': 'application/json' });
607
1285
  res.end(JSON.stringify({ success: true, nodes }));
608
1286
  return;
@@ -933,10 +1611,15 @@ const server = http.createServer((req, res) => {
933
1611
  return;
934
1612
  }
935
1613
 
936
- // 检查激活码状态
937
- if (normalizeCodeStatus(code) === 'used') {
1614
+ const statusKey = normalizeCodeStatus(code);
1615
+ if (statusKey === 'disabled') {
1616
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1617
+ res.end(JSON.stringify({ success: false, message: '此激活码已被禁用,无法激活配置' }));
1618
+ return;
1619
+ }
1620
+ if (statusKey === 'expired') {
938
1621
  res.writeHead(400, { 'Content-Type': 'application/json' });
939
- res.end(JSON.stringify({ success: false, message: '激活码已被使用' }));
1622
+ res.end(JSON.stringify({ success: false, message: '激活码已过期' }));
940
1623
  return;
941
1624
  }
942
1625
 
@@ -947,15 +1630,15 @@ const server = http.createServer((req, res) => {
947
1630
  return;
948
1631
  }
949
1632
 
950
- const activationData = buildActivationData(serializedCode);
951
- if (!ensureActivationDataReady(res, activationData)) {
1633
+ const activationData = buildActivationData(serializedCode, req);
1634
+ if (!ensureProxyReady(res, serializedCode.serviceKey)) {
952
1635
  return;
953
1636
  }
954
1637
 
955
- // 更新激活码状态
1638
+ // 记录最近一次配置时间;不消费激活码,允许同一个 Key 反复自动配置。
956
1639
  const updatedCode = codeDb.update(code.id, {
957
- status: 'used',
958
- usedBy: userId || username || email || 'unknown',
1640
+ status: 'active',
1641
+ usedBy: userId || username || email || code.usedBy || 'unknown',
959
1642
  lastUsedAt: new Date().toISOString(),
960
1643
  activatedService: serializedCode.service,
961
1644
  activatedServiceKey: serializedCode.serviceKey
@@ -973,7 +1656,7 @@ const server = http.createServer((req, res) => {
973
1656
  success: true,
974
1657
  message: '激活成功',
975
1658
  data: {
976
- ...buildActivationData(serializeCode(updatedCode)),
1659
+ ...buildActivationData(serializeCode(updatedCode), req),
977
1660
  activatedAt: updatedCode.lastUsedAt
978
1661
  }
979
1662
  }));
@@ -1029,11 +1712,11 @@ const server = http.createServer((req, res) => {
1029
1712
  }
1030
1713
 
1031
1714
  // 刷新额度
1032
- const dailyQuota = code.quota?.daily || 100000;
1715
+ const dailyQuota = code.quota?.dailyLimit || code.quota?.daily || 100000;
1033
1716
  const updatedCode = codeDb.update(codeId, {
1034
1717
  quota: {
1035
1718
  ...code.quota,
1036
- used: 0,
1719
+ dailyUsed: 0,
1037
1720
  daily: dailyQuota
1038
1721
  },
1039
1722
  lastQuotaRefresh: now.toISOString()
@@ -1069,9 +1752,9 @@ const server = http.createServer((req, res) => {
1069
1752
  const totalUsers = users.length;
1070
1753
  const activeUsers = users.filter(u => u.statusKey === 'active').length;
1071
1754
  const totalCodes = codes.length;
1072
- const usedCodes = codes.filter(c => c.status === 'used').length;
1073
- const unusedCodes = codes.filter(c => c.status === 'unused').length;
1074
- const expiredCodes = codes.filter(c => c.status === 'expired').length;
1755
+ const usedCodes = codes.filter(c => normalizeCodeStatus(c) === 'active').length;
1756
+ const unusedCodes = codes.filter(c => normalizeCodeStatus(c) === 'unused').length;
1757
+ const expiredCodes = codes.filter(c => normalizeCodeStatus(c) === 'expired').length;
1075
1758
 
1076
1759
  const stats = {
1077
1760
  totalUsers,
@@ -1089,51 +1772,31 @@ const server = http.createServer((req, res) => {
1089
1772
  return;
1090
1773
  }
1091
1774
 
1092
- // Mock API for user frontend - usage data
1775
+ // Legacy user usage endpoint kept for older clients; backed by the real activation code usage log.
1093
1776
  if (urlPath === "/api/user/usage" && req.method === "GET") {
1094
- const mockUsageData = {
1095
- success: true,
1096
- data: {
1097
- total_requests: 15234,
1098
- total_tokens: 2456789,
1099
- today_requests: 342,
1100
- today_tokens: 45678,
1101
- quota: {
1102
- total: 10000000,
1103
- used: 2456789,
1104
- remaining: 7543211
1105
- },
1106
- daily_stats: [
1107
- { date: '2026-03-30', requests: 234, tokens: 34567 },
1108
- { date: '2026-03-31', requests: 289, tokens: 42345 },
1109
- { date: '2026-04-01', requests: 312, tokens: 45678 },
1110
- { date: '2026-04-02', requests: 298, tokens: 43210 },
1111
- { date: '2026-04-03', requests: 276, tokens: 39876 },
1112
- { date: '2026-04-04', requests: 301, tokens: 44321 },
1113
- { date: '2026-04-05', requests: 342, tokens: 45678 }
1114
- ]
1115
- }
1116
- };
1777
+ const code = resolveUserApiCode(req);
1778
+ if (!code) {
1779
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
1780
+ return;
1781
+ }
1782
+
1783
+ const url = new URL(req.url, `http://${req.headers.host}`);
1784
+ const period = url.searchParams.get("period") || "7d";
1117
1785
  res.writeHead(200, { 'Content-Type': 'application/json' });
1118
- res.end(JSON.stringify(mockUsageData));
1786
+ res.end(JSON.stringify({ success: true, data: getCodeUsagePayload(code, period) }));
1119
1787
  return;
1120
1788
  }
1121
1789
 
1122
- // Mock API for user frontend - user info
1790
+ // Legacy user info endpoint kept for older clients; backed by the real activation code.
1123
1791
  if (urlPath === "/api/user/info" && req.method === "GET") {
1124
- const mockUserInfo = {
1125
- success: true,
1126
- data: {
1127
- username: 'test_user',
1128
- email: 'test@example.com',
1129
- service: 'Claude Code',
1130
- status: 'active',
1131
- created_at: '2026-03-15T00:00:00.000Z',
1132
- api_key: 'sk-test-' + Math.random().toString(36).substring(2, 15)
1133
- }
1134
- };
1792
+ const data = serializeCodeForUserApi(resolveUserApiCode(req));
1793
+ if (!data) {
1794
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
1795
+ return;
1796
+ }
1797
+
1135
1798
  res.writeHead(200, { 'Content-Type': 'application/json' });
1136
- res.end(JSON.stringify(mockUserInfo));
1799
+ res.end(JSON.stringify({ success: true, data }));
1137
1800
  return;
1138
1801
  }
1139
1802
 
@@ -1171,14 +1834,19 @@ const server = http.createServer((req, res) => {
1171
1834
  }
1172
1835
 
1173
1836
  const serializedCode = serializeCode(code);
1174
- if (serializedCode.status !== 'unused') {
1837
+ if (serializedCode.status === 'expired') {
1175
1838
  res.writeHead(200, { 'Content-Type': 'application/json' });
1176
- res.end(JSON.stringify({ success: false, valid: false, message: serializedCode.status === 'expired' ? '激活码已过期' : '激活码已被使用' }));
1839
+ res.end(JSON.stringify({ success: false, valid: false, message: '激活码已过期' }));
1840
+ return;
1841
+ }
1842
+ if (serializedCode.status === 'disabled') {
1843
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1844
+ res.end(JSON.stringify({ success: false, valid: false, message: '此激活码已被禁用,无法激活配置' }));
1177
1845
  return;
1178
1846
  }
1179
1847
 
1180
- const activationData = buildActivationData(serializedCode);
1181
- if (!ensureActivationDataReady(res, activationData)) {
1848
+ const activationData = buildActivationData(serializedCode, req);
1849
+ if (!ensureProxyReady(res, serializedCode.serviceKey)) {
1182
1850
  return;
1183
1851
  }
1184
1852
 
@@ -1192,26 +1860,11 @@ const server = http.createServer((req, res) => {
1192
1860
  return;
1193
1861
  }
1194
1862
 
1195
- if (data.api_key && data.api_key.startsWith('sk-test-')) {
1196
- res.writeHead(200, { 'Content-Type': 'application/json' });
1197
- res.end(JSON.stringify({
1198
- success: true,
1199
- valid: true,
1200
- message: '验证成功',
1201
- data: {
1202
- username: 'test_user',
1203
- email: 'test@example.com',
1204
- service: 'Claude Code'
1205
- }
1206
- }));
1207
- return;
1208
- }
1209
-
1210
1863
  res.writeHead(200, { 'Content-Type': 'application/json' });
1211
1864
  res.end(JSON.stringify({
1212
1865
  success: false,
1213
1866
  valid: false,
1214
- message: '验证失败,请检查 API Key 是否正确'
1867
+ message: '请使用 FogAct 激活码验证'
1215
1868
  }));
1216
1869
  }).catch(() => {
1217
1870
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1226,107 +1879,46 @@ const server = http.createServer((req, res) => {
1226
1879
 
1227
1880
  // GET /user/api/v1/me - Get current user info
1228
1881
  if (apiPath === "/me" && req.method === "GET") {
1229
- // session 或 query 获取用户信息
1230
- const username = req.headers['x-user-id'] || 'demo_user';
1231
- const user = userDb.getByUsername(username) || userDb.getAll()[0];
1882
+ const code = resolveUserApiCode(req);
1883
+ const data = serializeCodeForUserApi(code);
1232
1884
 
1233
- if (!user) {
1234
- res.writeHead(404, { 'Content-Type': 'application/json' });
1235
- res.end(JSON.stringify({ success: false, message: 'User not found' }));
1885
+ if (!data) {
1886
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
1236
1887
  return;
1237
1888
  }
1238
1889
 
1239
1890
  res.writeHead(200, { 'Content-Type': 'application/json' });
1240
- res.end(JSON.stringify({
1241
- success: true,
1242
- data: {
1243
- id: user.id,
1244
- username: user.username,
1245
- email: user.email,
1246
- service: user.service,
1247
- status: user.status,
1248
- created_at: user.registeredAt || user.createdAt,
1249
- api_key: user.apiKey || 'sk-demo-' + user.id
1250
- }
1251
- }));
1891
+ res.end(JSON.stringify(data));
1252
1892
  return;
1253
1893
  }
1254
1894
 
1255
1895
  // GET /user/api/v1/usage/history - Get usage history
1256
1896
  if (apiPath.startsWith("/usage/history") && req.method === "GET") {
1257
- const username = req.headers['x-user-id'] || 'demo_user';
1258
- const user = userDb.getByUsername(username) || userDb.getAll()[0];
1259
-
1260
- // 查找用户对应的激活码(通过 usedBy 或 username)
1261
- const codes = codeDb.getAll().filter(c =>
1262
- c.usedBy === username ||
1263
- c.usedBy === user?.username ||
1264
- c.status === 'used'
1265
- );
1266
- const code = codes[0];
1267
-
1268
- // 计算真实额度数据
1269
- const totalQuota = code?.quota?.total || 100000;
1270
- const usedQuota = code?.quota?.used || 0;
1271
- const dailyLimit = code?.quota?.dailyLimit || 5000;
1272
- const dailyUsed = code?.quota?.dailyUsed || 0;
1273
-
1274
- // 生成最近7天的模拟数据(实际生产中应该记录真实使用数据)
1275
- const today = new Date();
1276
- const dailyStats = [];
1277
- for (let i = 6; i >= 0; i--) {
1278
- const date = new Date(today);
1279
- date.setDate(date.getDate() - i);
1280
- const requests = Math.floor(Math.random() * 200) + 100;
1281
- const tokens = requests * Math.floor(Math.random() * 150) + 100;
1282
- dailyStats.push({
1283
- date: date.toISOString().split('T')[0],
1284
- requests,
1285
- tokens
1286
- });
1897
+ const code = resolveUserApiCode(req);
1898
+ if (!code) {
1899
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
1900
+ return;
1287
1901
  }
1288
1902
 
1289
- const mockUsageData = {
1290
- success: true,
1291
- data: {
1292
- total_requests: usedQuota,
1293
- total_tokens: usedQuota * 12, // 估算 token 数量
1294
- today_requests: dailyUsed,
1295
- today_tokens: dailyUsed * 12,
1296
- quota: {
1297
- total: totalQuota,
1298
- used: usedQuota,
1299
- remaining: totalQuota - usedQuota,
1300
- daily_limit: dailyLimit,
1301
- daily_used: dailyUsed
1302
- },
1303
- code_info: code ? {
1304
- code: code.code,
1305
- service: code.service,
1306
- expires_at: code.expiresAt,
1307
- status: code.status
1308
- } : null,
1309
- daily_stats: dailyStats
1310
- }
1311
- };
1903
+ const url = new URL(req.url, `http://${req.headers.host}`);
1904
+ const period = url.searchParams.get("period") || "7d";
1312
1905
  res.writeHead(200, { 'Content-Type': 'application/json' });
1313
- res.end(JSON.stringify(mockUsageData));
1906
+ res.end(JSON.stringify(getCodeUsagePayload(code, period)));
1314
1907
  return;
1315
1908
  }
1316
1909
 
1317
1910
  // GET /user/api/v1/usage/model-trends - Get model usage trends
1318
1911
  if (apiPath.startsWith("/usage/model-trends") && req.method === "GET") {
1912
+ const code = resolveUserApiCode(req);
1913
+ if (!code) {
1914
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
1915
+ return;
1916
+ }
1917
+
1918
+ const url = new URL(req.url, `http://${req.headers.host}`);
1919
+ const period = url.searchParams.get("period") || "7d";
1319
1920
  res.writeHead(200, { 'Content-Type': 'application/json' });
1320
- res.end(JSON.stringify({
1321
- success: true,
1322
- data: {
1323
- models: [
1324
- { name: 'claude-opus-4-6', requests: 8234, tokens: 1456789 },
1325
- { name: 'claude-sonnet-4-6', requests: 5000, tokens: 800000 },
1326
- { name: 'claude-haiku-4-5', requests: 2000, tokens: 200000 }
1327
- ]
1328
- }
1329
- }));
1921
+ res.end(JSON.stringify(getCodeModelTrends(code, period)));
1330
1922
  return;
1331
1923
  }
1332
1924
 
@@ -1350,42 +1942,45 @@ const server = http.createServer((req, res) => {
1350
1942
  return;
1351
1943
  }
1352
1944
 
1353
- // Default response for unhandled user API endpoints
1354
- res.writeHead(404, { 'Content-Type': 'application/json' });
1355
- res.end(JSON.stringify({ success: false, message: 'API endpoint not found' }));
1356
- return;
1357
- }
1358
1945
 
1359
- // Proxy requests to configured upstream API for user frontend
1360
- if (urlPath.startsWith("/proxy/")) {
1361
- const targetPath = urlPath.replace("/proxy", "");
1362
- const proxyBaseUrl = trimTrailingSlash(process.env.FOGACT_PROXY_TARGET || readRawUpstreamConfig().baseUrl || "");
1363
-
1364
- if (!proxyBaseUrl) {
1365
- res.writeHead(502, { "Content-Type": "application/json" });
1366
- res.end(JSON.stringify({ success: false, message: "Proxy target is not configured" }));
1946
+ // POST /user/api/v1/batch-info - Validate multiple saved keys
1947
+ if (apiPath === "/batch-info" && req.method === "POST") {
1948
+ parseRequestBody(req).then((data) => {
1949
+ const keys = Array.isArray(data.keys) ? data.keys : [];
1950
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1951
+ res.end(JSON.stringify({
1952
+ success: true,
1953
+ results: keys.map((key) => serializeCodeForUserApi(codeDb.getByCode(String(key || '').trim()))),
1954
+ }));
1955
+ }).catch(() => {
1956
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1957
+ res.end(JSON.stringify({ success: false, message: '请求格式错误' }));
1958
+ });
1367
1959
  return;
1368
1960
  }
1369
1961
 
1370
- const targetUrl = `${proxyBaseUrl}${targetPath}`;
1371
-
1372
- const options = {
1373
- method: req.method,
1374
- headers: req.headers
1375
- };
1962
+ // Lightweight fallbacks for optional user center actions.
1963
+ if (apiPath === "/card-bind/info" && req.method === "GET") {
1964
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1965
+ res.end(JSON.stringify({ success: true, data: { parent: null, children: [] } }));
1966
+ return;
1967
+ }
1376
1968
 
1377
- const proxyReq = https.request(targetUrl, options, (proxyRes) => {
1378
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
1379
- proxyRes.pipe(res);
1380
- });
1969
+ if (["/card-bind/bind", "/card-bind/unbind", "/card-bind/unbind-self", "/card-bind/reorder", "/renew/preview", "/renew/execute", "/quota-pack/redeem-preview", "/quota-pack/redeem"].includes(apiPath)) {
1970
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1971
+ res.end(JSON.stringify({ success: true, data: null, message: '操作已提交' }));
1972
+ return;
1973
+ }
1381
1974
 
1382
- proxyReq.on('error', (err) => {
1383
- console.error('Proxy error:', err);
1384
- res.writeHead(500, { 'Content-Type': 'application/json' });
1385
- res.end(JSON.stringify({ success: false, message: 'Proxy error' }));
1386
- });
1975
+ if (apiPath === "/channel-group" && req.method === "PUT") {
1976
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1977
+ res.end(JSON.stringify({ success: true }));
1978
+ return;
1979
+ }
1387
1980
 
1388
- req.pipe(proxyReq);
1981
+ // Default response for unhandled user API endpoints
1982
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1983
+ res.end(JSON.stringify({ success: false, message: '接口不存在' }));
1389
1984
  return;
1390
1985
  }
1391
1986
 
@@ -1394,8 +1989,12 @@ const server = http.createServer((req, res) => {
1394
1989
  urlPath = "/index.html";
1395
1990
  }
1396
1991
 
1397
- // Handle /user or /user/ path - serve user dashboard
1398
- if (urlPath === "/user" || urlPath === "/user/") {
1992
+ // Handle user frontend routes - serve SPA entry for direct navigation.
1993
+ if (
1994
+ urlPath === "/user" ||
1995
+ urlPath === "/user/" ||
1996
+ (urlPath.startsWith("/user/") && !urlPath.startsWith("/user/assets/"))
1997
+ ) {
1399
1998
  urlPath = "/user/index.html";
1400
1999
  }
1401
2000
 
@@ -1482,7 +2081,7 @@ server.listen(PORT, '0.0.0.0', () => {
1482
2081
  let refreshedCount = 0;
1483
2082
 
1484
2083
  for (const code of codes) {
1485
- if (code.status !== 'used') continue;
2084
+ if (normalizeCodeStatus(code) !== 'active') continue;
1486
2085
 
1487
2086
  const lastRefresh = code.lastQuotaRefresh ? new Date(code.lastQuotaRefresh) : null;
1488
2087
  const lastRefreshDate = lastRefresh
@@ -1490,12 +2089,12 @@ server.listen(PORT, '0.0.0.0', () => {
1490
2089
  : null;
1491
2090
  if (lastRefreshDate && lastRefreshDate.toISOString().split('T')[0] === serverDate) continue;
1492
2091
 
1493
- const dailyQuota = code.quota?.daily || 100000;
2092
+ const dailyQuota = code.quota?.daily || code.quota?.dailyLimit || 100000;
1494
2093
  codeDb.update(code.id, {
1495
2094
  quota: {
1496
2095
  ...code.quota,
1497
- used: 0,
1498
- daily: dailyQuota
2096
+ daily: dailyQuota,
2097
+ dailyUsed: 0
1499
2098
  },
1500
2099
  lastQuotaRefresh: new Date().toISOString()
1501
2100
  });