fogact 1.2.4 → 1.2.6

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.
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, usageDb, initializeSampleData } = require("../lib/services/database");
8
+ const { userDb, codeDb, usageDb, cardMergeDb, 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");
@@ -49,6 +49,9 @@ const CODE_STATUS_MAP = {
49
49
  "禁用": "disabled",
50
50
  expired: "expired",
51
51
  "已过期": "expired",
52
+ merged: "merged",
53
+ "已合并": "merged",
54
+ "已注销": "merged",
52
55
  };
53
56
 
54
57
  const CODE_STATUS_LABEL_MAP = {
@@ -56,6 +59,7 @@ const CODE_STATUS_LABEL_MAP = {
56
59
  active: "活跃",
57
60
  disabled: "已禁用",
58
61
  expired: "已过期",
62
+ merged: "已注销",
59
63
  };
60
64
 
61
65
  const HOP_BY_HOP_HEADERS = new Set([
@@ -188,6 +192,13 @@ function normalizeCodeStatus(codeOrStatus, expiresAt) {
188
192
  typeof codeOrStatus === "object" && codeOrStatus !== null
189
193
  ? codeOrStatus.expiresAt
190
194
  : expiresAt;
195
+ const normalizedStatus = rawStatus === undefined || rawStatus === null || rawStatus === ""
196
+ ? "unused"
197
+ : CODE_STATUS_MAP[String(rawStatus).trim().toLowerCase()] || "unused";
198
+
199
+ if (["disabled", "merged"].includes(normalizedStatus)) {
200
+ return normalizedStatus;
201
+ }
191
202
 
192
203
  if (rawExpiresAt) {
193
204
  const expiry = new Date(rawExpiresAt);
@@ -196,15 +207,11 @@ function normalizeCodeStatus(codeOrStatus, expiresAt) {
196
207
  }
197
208
  }
198
209
 
199
- if (rawStatus === undefined || rawStatus === null || rawStatus === "") {
200
- return "unused";
201
- }
202
-
203
- return CODE_STATUS_MAP[String(rawStatus).trim().toLowerCase()] || "unused";
210
+ return normalizedStatus;
204
211
  }
205
212
 
206
- function getCodeStatusLabel(status) {
207
- return CODE_STATUS_LABEL_MAP[normalizeCodeStatus(status)] || "未激活";
213
+ function getCodeStatusLabel(codeOrStatus, expiresAt) {
214
+ return CODE_STATUS_LABEL_MAP[normalizeCodeStatus(codeOrStatus, expiresAt)] || "未激活";
208
215
  }
209
216
 
210
217
  function serializeUser(user) {
@@ -231,7 +238,7 @@ function serializeCode(code) {
231
238
  serviceLabel: service,
232
239
  allowedModels: code.allowedModels || (getServiceKey(service) === "codex" ? ["codex"] : ["claude"]),
233
240
  status,
234
- statusLabel: getCodeStatusLabel(status),
241
+ statusLabel: getCodeStatusLabel(code),
235
242
  isExpired: status === "expired",
236
243
  };
237
244
  }
@@ -280,6 +287,51 @@ function buildActivationData(serializedCode, req) {
280
287
  };
281
288
  }
282
289
 
290
+ function normalizeCodeSpecPayload(payload = {}) {
291
+ const quota = { ...(payload.quota || {}) };
292
+ const billingType = normalizeBillingType({ ...payload, quota });
293
+ const cycleType = normalizeCycleType({ ...payload, quota });
294
+ const quotaUnit = normalizeQuotaUnit({ ...payload, quota });
295
+ const resetTimezone = "Asia/Shanghai";
296
+ const subServiceType = String(payload.subServiceType || payload.sub_service_type || payload.category || "标准运营").trim() || "标准运营";
297
+ const durationDays = Number(payload.durationDays || payload.duration || payload.validity?.days || quota.periodDays || 0);
298
+
299
+ quota.billingType = billingType;
300
+ quota.cycleType = cycleType;
301
+ quota.unit = quotaUnit;
302
+ quota.resetTimezone = resetTimezone;
303
+ if (quota.dailyQuota !== undefined && quota.dailyLimit === undefined) quota.dailyLimit = Number(quota.dailyQuota || 0);
304
+ if (quota.dailyLimit !== undefined && quota.dailyQuota === undefined) quota.dailyQuota = Number(quota.dailyLimit || 0);
305
+ if (quota.daily !== undefined && quota.dailyLimit === undefined) quota.dailyLimit = Number(quota.daily || 0);
306
+ if (quota.dailyQuota !== undefined) quota.dailyQuota = Number(quota.dailyQuota || 0);
307
+ if (quota.dailyLimit !== undefined) quota.dailyLimit = Number(quota.dailyLimit || 0);
308
+ if (durationDays > 0) quota.periodDays = durationDays;
309
+ if (billingType === "duration" && Number(quota.dailyQuota || quota.dailyLimit || 0) > 0 && durationDays > 0) {
310
+ quota.total = Number(quota.dailyQuota || quota.dailyLimit || 0) * durationDays;
311
+ } else if (quota.total !== undefined) {
312
+ quota.total = Number(quota.total || 0);
313
+ }
314
+
315
+ return {
316
+ billingType,
317
+ cycleType,
318
+ quotaUnit,
319
+ resetTimezone,
320
+ subServiceType,
321
+ category: subServiceType,
322
+ quota,
323
+ };
324
+ }
325
+
326
+ function withCodeSpec(payload = {}) {
327
+ const spec = normalizeCodeSpecPayload(payload);
328
+ return {
329
+ ...payload,
330
+ ...spec,
331
+ quota: spec.quota,
332
+ };
333
+ }
334
+
283
335
  function ensureProxyReady(res, serviceKey) {
284
336
  const upstream = loadUpstreamConfig({ configPath: getUpstreamConfigPath() });
285
337
  const upstreamUrl = getServiceBaseUrl(upstream, serviceKey) || upstream.baseUrl;
@@ -336,6 +388,9 @@ function getActiveProxyCode(codeValue, serviceKey) {
336
388
  if (serializedCode.status === "expired") {
337
389
  return { ok: false, status: 403, message: "激活码已过期" };
338
390
  }
391
+ if (serializedCode.status === "merged") {
392
+ return { ok: false, status: 403, message: "此激活码已合并注销,无法继续使用" };
393
+ }
339
394
 
340
395
  return { ok: true, code, serializedCode };
341
396
  }
@@ -759,8 +814,12 @@ function serializeCodeForUserApi(code) {
759
814
  id: code.id,
760
815
  key_preview: maskKey(code.code),
761
816
  service_type: serialized.serviceKey,
762
- sub_service_type_name: serialized.category || "",
763
- billing_type: "quota",
817
+ sub_service_type_name: serialized.subServiceType || serialized.sub_service_type || serialized.category || "",
818
+ billing_type: normalizeBillingType(code),
819
+ cycle_type: normalizeCycleType(code),
820
+ quota_unit: normalizeQuotaUnit(code),
821
+ reset_timezone: serialized.resetTimezone || serialized.reset_timezone || quota.resetTimezone || SERVER_TIMEZONE,
822
+ allowed_models: serialized.allowedModels,
764
823
  status,
765
824
  status_label: status === "inactive" ? "未激活" : serialized.statusLabel,
766
825
  is_bound: false,
@@ -772,7 +831,8 @@ function serializeCodeForUserApi(code) {
772
831
  daily_quota: dailyLimit,
773
832
  daily_spent: todayTotals.tokens,
774
833
  daily_remaining: dailyLimit > 0 ? Math.max(0, dailyLimit - todayTotals.tokens) : 0,
775
- reset_timezone: SERVER_TIMEZONE,
834
+ unit: normalizeQuotaUnit(code),
835
+ reset_timezone: serialized.resetTimezone || serialized.reset_timezone || quota.resetTimezone || SERVER_TIMEZONE,
776
836
  next_reset_at: getNextResetAt(),
777
837
  },
778
838
  usage: {
@@ -815,6 +875,286 @@ function sendUserApiUnauthorized(res, message = "请先添加有效的 FogAct Ke
815
875
  res.end(JSON.stringify({ success: false, message }));
816
876
  }
817
877
 
878
+ function normalizeBillingType(code) {
879
+ const raw = String(code?.billingType || code?.billing_type || code?.quota?.billingType || code?.quota?.type || code?.type || "quota").toLowerCase();
880
+ if (["monthly", "duration", "subscription"].includes(raw)) return "duration";
881
+ if (["fixed", "quota", "balance"].includes(raw)) return "quota";
882
+ if (["count", "request", "requests"].includes(raw)) return "count";
883
+ return raw || "quota";
884
+ }
885
+
886
+ function normalizeCycleType(code) {
887
+ const raw = String(code?.cycleType || code?.cycle_type || code?.quota?.cycleType || code?.quota?.cycle || code?.quota?.type || code?.type || "fixed").toLowerCase();
888
+ if (["monthly", "month", "duration", "subscription"].includes(raw)) return "monthly";
889
+ if (["daily", "day"].includes(raw)) return "daily";
890
+ if (["fixed", "quota", "one-time", "onetime"].includes(raw)) return "fixed";
891
+ return raw || "fixed";
892
+ }
893
+
894
+ function normalizeQuotaUnit(code) {
895
+ return String(code?.quotaUnit || code?.quota_unit || code?.quota?.unit || "tokens").trim().toLowerCase() || "tokens";
896
+ }
897
+
898
+ function getCodeSubServiceType(code) {
899
+ return String(code?.subServiceType || code?.sub_service_type || code?.category || "").trim();
900
+ }
901
+
902
+ function getDailyQuota(code) {
903
+ return Number(code?.quota?.dailyQuota ?? code?.quota?.dailyLimit ?? code?.quota?.daily ?? 0);
904
+ }
905
+
906
+ function getTotalQuota(code) {
907
+ return Number(code?.quota?.total ?? code?.quota?.totalQuota ?? 0);
908
+ }
909
+
910
+ function getCodeDurationDays(code) {
911
+ const configured = Number(code?.durationDays ?? code?.validity?.days ?? code?.quota?.periodDays ?? 0);
912
+ if (Number.isFinite(configured) && configured > 0) return configured;
913
+ if (!code?.createdAt || !code?.expiresAt) return 0;
914
+
915
+ const start = new Date(code.createdAt).getTime();
916
+ const end = new Date(code.expiresAt).getTime();
917
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
918
+ return (end - start) / 86400000;
919
+ }
920
+
921
+ function getCardSpec(code) {
922
+ const serialized = serializeCode(code);
923
+ return {
924
+ serviceType: serialized.serviceKey,
925
+ billingType: normalizeBillingType(code),
926
+ cycleType: normalizeCycleType(code),
927
+ quotaUnit: normalizeQuotaUnit(code),
928
+ resetTimezone: code?.resetTimezone || code?.reset_timezone || code?.quota?.resetTimezone || code?.quota?.reset_timezone || SERVER_TIMEZONE,
929
+ subServiceType: getCodeSubServiceType(code),
930
+ allowedModels: Array.isArray(serialized.allowedModels) ? serialized.allowedModels.map(String) : [],
931
+ dailyQuota: getDailyQuota(code),
932
+ totalQuota: getTotalQuota(code),
933
+ durationDays: getCodeDurationDays(code),
934
+ };
935
+ }
936
+
937
+ function isAllowedModelsCompatible(parentModels, childModels) {
938
+ if (!childModels.length) return true;
939
+ if (!parentModels.length) return false;
940
+ const parentSet = new Set(parentModels);
941
+ return childModels.every((model) => parentSet.has(model));
942
+ }
943
+
944
+ function addDays(date, days) {
945
+ return new Date(date.getTime() + days * 86400000);
946
+ }
947
+
948
+ function getMergeBaseDate(parentCode) {
949
+ const expiresAt = parentCode?.expiresAt ? new Date(parentCode.expiresAt) : null;
950
+ const now = new Date();
951
+ if (expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt > now) return expiresAt;
952
+ return now;
953
+ }
954
+
955
+ function validateCardMerge(parentCode, childCode) {
956
+ if (!parentCode) return { ok: false, message: "当前父卡不存在" };
957
+ if (!childCode) return { ok: false, message: "子卡不存在" };
958
+ if (parentCode.id === childCode.id) return { ok: false, message: "不能合并自己" };
959
+
960
+ const parentStatus = normalizeCodeStatus(parentCode);
961
+ const childStatus = normalizeCodeStatus(childCode);
962
+ if (parentStatus === "disabled") return { ok: false, message: "父卡已禁用,不能叠卡" };
963
+ if (parentStatus === "merged") return { ok: false, message: "父卡已注销,不能叠卡" };
964
+ if (!['active', 'expired'].includes(parentStatus)) return { ok: false, message: "父卡必须是活跃或已过期状态" };
965
+ if (childStatus !== "unused") return { ok: false, message: "子卡必须是未激活状态,不能使用已激活/已过期/已禁用卡" };
966
+ if (childCode.enabled === false) return { ok: false, message: "子卡已禁用,不能叠卡" };
967
+ if (childCode.usedBy || childCode.activatedAt || childCode.mergedInto) return { ok: false, message: "子卡必须是未激活且未使用的卡" };
968
+
969
+ const parentSpec = getCardSpec(parentCode);
970
+ const childSpec = getCardSpec(childCode);
971
+ const checks = [
972
+ ["serviceType", "服务类型不一致,Codex 和 Claude 不能互通额度"],
973
+ ["billingType", "计费类型不一致,时长/额度/次数不能混叠"],
974
+ ["cycleType", "周期类型不一致,月卡/日包/固定有效期不能混叠"],
975
+ ["quotaUnit", "额度单位不一致,token/次数/余额不能混用"],
976
+ ["subServiceType", "套餐类型不一致,暂不支持跨套餐叠卡"],
977
+ ];
978
+
979
+ for (const [field, message] of checks) {
980
+ if (parentSpec[field] !== childSpec[field]) return { ok: false, message };
981
+ }
982
+
983
+ if (parentSpec.resetTimezone !== "Asia/Shanghai" || childSpec.resetTimezone !== "Asia/Shanghai") {
984
+ return { ok: false, message: "叠卡只支持北京时间刷新额度" };
985
+ }
986
+
987
+ if (!isAllowedModelsCompatible(parentSpec.allowedModels, childSpec.allowedModels)) {
988
+ return { ok: false, message: "子卡模型权限不能超过父卡" };
989
+ }
990
+
991
+ return { ok: true, parentSpec, childSpec };
992
+ }
993
+
994
+ function previewCardMerge(parentCode, childCode) {
995
+ const validation = validateCardMerge(parentCode, childCode);
996
+ if (!validation.ok) return validation;
997
+
998
+ const { parentSpec, childSpec } = validation;
999
+ const oldExpiresAt = parentCode.expiresAt || null;
1000
+ let addedDays = 0;
1001
+ let addedQuota = 0;
1002
+ let childTotalValue = 0;
1003
+ let mergeMode = "quota_add";
1004
+ let newExpiresAt = oldExpiresAt;
1005
+
1006
+ if (parentSpec.billingType === "duration") {
1007
+ if (parentSpec.dailyQuota <= 0 || childSpec.dailyQuota <= 0 || childSpec.durationDays <= 0) {
1008
+ return { ok: false, message: "周期卡必须配置每日额度和有效天数" };
1009
+ }
1010
+ childTotalValue = childSpec.dailyQuota * childSpec.durationDays;
1011
+ addedDays = childTotalValue / parentSpec.dailyQuota;
1012
+ mergeMode = parentSpec.dailyQuota === childSpec.dailyQuota ? "duration_equal" : "duration_value_convert";
1013
+ newExpiresAt = addDays(getMergeBaseDate(parentCode), addedDays).toISOString();
1014
+ } else if (parentSpec.billingType === "quota") {
1015
+ addedQuota = childSpec.totalQuota;
1016
+ addedDays = childSpec.durationDays;
1017
+ if (addedQuota <= 0) return { ok: false, message: "额度卡必须配置可叠加总额度" };
1018
+ if (addedDays > 0) newExpiresAt = addDays(getMergeBaseDate(parentCode), addedDays).toISOString();
1019
+ } else if (parentSpec.billingType === "count") {
1020
+ addedQuota = childSpec.totalQuota;
1021
+ addedDays = childSpec.durationDays;
1022
+ if (addedQuota <= 0) return { ok: false, message: "次数卡必须配置可叠加次数" };
1023
+ if (addedDays > 0) newExpiresAt = addDays(getMergeBaseDate(parentCode), addedDays).toISOString();
1024
+ mergeMode = "count_add";
1025
+ } else {
1026
+ return { ok: false, message: "暂不支持该计费类型叠卡" };
1027
+ }
1028
+
1029
+ return {
1030
+ ok: true,
1031
+ parentSpec,
1032
+ childSpec,
1033
+ mergeMode,
1034
+ oldExpiresAt,
1035
+ newExpiresAt,
1036
+ addedDays,
1037
+ addedQuota,
1038
+ childTotalValue,
1039
+ };
1040
+ }
1041
+
1042
+ function serializeMergeCode(code) {
1043
+ if (!code) return null;
1044
+ const data = serializeCodeForUserApi(code);
1045
+ return {
1046
+ id: code.id,
1047
+ code: code.code,
1048
+ key_preview: data?.key_preview || maskKey(code.code),
1049
+ service_type: data?.service_type || getServiceKey(code.service),
1050
+ sub_service_type_name: data?.sub_service_type_name || getCodeSubServiceType(code),
1051
+ billing_type: data?.billing_type || normalizeBillingType(code),
1052
+ cycle_type: normalizeCycleType(code),
1053
+ quota_unit: normalizeQuotaUnit(code),
1054
+ daily_quota: getDailyQuota(code),
1055
+ total_quota: getTotalQuota(code),
1056
+ status: data?.status || normalizeCodeStatus(code),
1057
+ status_label: data?.status_label || getCodeStatusLabel(code.status),
1058
+ expires_at: code.expiresAt || null,
1059
+ };
1060
+ }
1061
+
1062
+ function serializeMergeRecord(record) {
1063
+ return {
1064
+ id: record.id,
1065
+ key_preview: maskKey(record.childCode),
1066
+ child_code_id: record.childCodeId,
1067
+ child_code: record.childCode,
1068
+ status: "merged",
1069
+ status_label: "已合并",
1070
+ billing_type: record.billingType,
1071
+ cycle_type: record.cycleType,
1072
+ quota_unit: record.quotaUnit,
1073
+ daily_quota: record.childDailyQuota,
1074
+ total_quota: record.childTotalValue || record.addedQuota,
1075
+ added_days: record.addedDays,
1076
+ added_quota: record.addedQuota,
1077
+ merge_mode: record.mergeMode,
1078
+ created_at: record.createdAt,
1079
+ expires_at: record.newExpiresAt,
1080
+ };
1081
+ }
1082
+
1083
+ function getCardMergeInfo(parentCode) {
1084
+ const records = parentCode ? cardMergeDb.getByParent(parentCode.id) : [];
1085
+ return {
1086
+ success: true,
1087
+ data: {
1088
+ parent: serializeMergeCode(parentCode),
1089
+ children: records.map(serializeMergeRecord).sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)),
1090
+ mode: "merge",
1091
+ description: "子卡合并后会注销,额度或时长一次性转入当前卡。",
1092
+ },
1093
+ };
1094
+ }
1095
+
1096
+ function mergeChildIntoParent(parentCode, childCode) {
1097
+ const preview = previewCardMerge(parentCode, childCode);
1098
+ if (!preview.ok) return preview;
1099
+
1100
+ const parentQuota = { ...(parentCode.quota || {}) };
1101
+ const childQuota = { ...(childCode.quota || {}) };
1102
+ const parentUpdates = {
1103
+ status: normalizeCodeStatus(parentCode) === "expired" ? "active" : parentCode.status,
1104
+ lastMergedAt: new Date().toISOString(),
1105
+ };
1106
+
1107
+ if (preview.parentSpec.billingType === "duration") {
1108
+ const parentDurationDays = Number(parentCode.durationDays || parentQuota.periodDays || 0);
1109
+ parentQuota.total = Number(parentQuota.total || 0) + preview.childTotalValue;
1110
+ parentQuota.periodDays = parentDurationDays + preview.addedDays;
1111
+ parentUpdates.expiresAt = preview.newExpiresAt;
1112
+ parentUpdates.durationDays = parentDurationDays + preview.addedDays;
1113
+ parentUpdates.quota = parentQuota;
1114
+ } else {
1115
+ parentQuota.total = Number(parentQuota.total || 0) + preview.addedQuota;
1116
+ parentUpdates.quota = parentQuota;
1117
+ if (preview.newExpiresAt) parentUpdates.expiresAt = preview.newExpiresAt;
1118
+ }
1119
+
1120
+ const updatedParent = codeDb.update(parentCode.id, parentUpdates);
1121
+ const updatedChild = codeDb.update(childCode.id, {
1122
+ status: "merged",
1123
+ enabled: false,
1124
+ mergedInto: parentCode.id,
1125
+ mergedAt: new Date().toISOString(),
1126
+ quota: {
1127
+ ...childQuota,
1128
+ used: Number(childQuota.total || 0),
1129
+ merged: true,
1130
+ },
1131
+ });
1132
+
1133
+ const record = cardMergeDb.create({
1134
+ parentCodeId: parentCode.id,
1135
+ parentCode: parentCode.code,
1136
+ childCodeId: childCode.id,
1137
+ childCode: childCode.code,
1138
+ serviceType: preview.parentSpec.serviceType,
1139
+ subServiceType: preview.parentSpec.subServiceType,
1140
+ billingType: preview.parentSpec.billingType,
1141
+ cycleType: preview.parentSpec.cycleType,
1142
+ quotaUnit: preview.parentSpec.quotaUnit,
1143
+ timezone: "Asia/Shanghai",
1144
+ mergeMode: preview.mergeMode,
1145
+ parentDailyQuota: preview.parentSpec.dailyQuota,
1146
+ childDailyQuota: preview.childSpec.dailyQuota,
1147
+ childDays: preview.childSpec.durationDays,
1148
+ childTotalValue: preview.childTotalValue,
1149
+ addedDays: preview.addedDays,
1150
+ addedQuota: preview.addedQuota,
1151
+ oldExpiresAt: preview.oldExpiresAt,
1152
+ newExpiresAt: preview.newExpiresAt,
1153
+ });
1154
+
1155
+ return { ok: true, parent: updatedParent, child: updatedChild, record, preview };
1156
+ }
1157
+
818
1158
  function getCodeUsageRank(code) {
819
1159
  const items = getUsageItemsForCode(code);
820
1160
  const totals = aggregateUsageItems(items);
@@ -1496,7 +1836,7 @@ const server = http.createServer((req, res) => {
1496
1836
  for (const service of services) {
1497
1837
  for (let i = 0; i < count; i++) {
1498
1838
  const serviceKey = getServiceKey(service);
1499
- const newCode = codeDb.create({
1839
+ const newCode = codeDb.create(withCodeSpec({
1500
1840
  ...(codeData.code && services.length === 1 && count === 1 ? { code: codeData.code } : {}),
1501
1841
  service,
1502
1842
  serviceKey,
@@ -1505,7 +1845,15 @@ const server = http.createServer((req, res) => {
1505
1845
  expiresAt,
1506
1846
  notes: codeData.notes || codeData.note,
1507
1847
  status: "unused",
1508
- });
1848
+ type: codeData.type,
1849
+ billingType: codeData.billingType,
1850
+ cycleType: codeData.cycleType,
1851
+ quotaUnit: codeData.quotaUnit,
1852
+ resetTimezone: "Asia/Shanghai",
1853
+ subServiceType: codeData.subServiceType,
1854
+ category: codeData.category,
1855
+ durationDays: codeData.duration,
1856
+ }));
1509
1857
  createdCodes.push(serializeCode(newCode));
1510
1858
  }
1511
1859
  }
@@ -1533,6 +1881,13 @@ const server = http.createServer((req, res) => {
1533
1881
 
1534
1882
  const codeId = urlPath.split('/')[3];
1535
1883
  parseRequestBody(req).then((updates) => {
1884
+ const existingCode = codeDb.getById(codeId);
1885
+ if (!existingCode) {
1886
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1887
+ res.end(JSON.stringify({ success: false, message: '激活码不存在' }));
1888
+ return;
1889
+ }
1890
+
1536
1891
  const payload = { ...updates };
1537
1892
  if (payload.service !== undefined) {
1538
1893
  payload.service = normalizeService(payload.service);
@@ -1542,13 +1897,22 @@ const server = http.createServer((req, res) => {
1542
1897
  if (payload.status !== undefined) {
1543
1898
  payload.status = normalizeCodeStatus(payload.status);
1544
1899
  }
1900
+ const normalized = withCodeSpec({
1901
+ ...existingCode,
1902
+ ...payload,
1903
+ quota: { ...(existingCode.quota || {}), ...(payload.quota || {}) },
1904
+ });
1905
+ Object.assign(payload, {
1906
+ billingType: normalized.billingType,
1907
+ cycleType: normalized.cycleType,
1908
+ quotaUnit: normalized.quotaUnit,
1909
+ resetTimezone: normalized.resetTimezone,
1910
+ subServiceType: normalized.subServiceType,
1911
+ category: normalized.category,
1912
+ quota: normalized.quota,
1913
+ });
1545
1914
 
1546
1915
  const updatedCode = codeDb.update(codeId, payload);
1547
- if (!updatedCode) {
1548
- res.writeHead(404, { 'Content-Type': 'application/json' });
1549
- res.end(JSON.stringify({ success: false, message: '激活码不存在' }));
1550
- return;
1551
- }
1552
1916
 
1553
1917
  res.writeHead(200, { 'Content-Type': 'application/json' });
1554
1918
  res.end(JSON.stringify({ success: true, data: serializeCode(updatedCode) }));
@@ -1622,6 +1986,11 @@ const server = http.createServer((req, res) => {
1622
1986
  res.end(JSON.stringify({ success: false, message: '激活码已过期' }));
1623
1987
  return;
1624
1988
  }
1989
+ if (statusKey === 'merged') {
1990
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1991
+ res.end(JSON.stringify({ success: false, message: '此激活码已合并注销,无法激活配置' }));
1992
+ return;
1993
+ }
1625
1994
 
1626
1995
  // 检查是否过期
1627
1996
  if (code.expiresAt && new Date(code.expiresAt) < new Date()) {
@@ -1755,6 +2124,7 @@ const server = http.createServer((req, res) => {
1755
2124
  const usedCodes = codes.filter(c => normalizeCodeStatus(c) === 'active').length;
1756
2125
  const unusedCodes = codes.filter(c => normalizeCodeStatus(c) === 'unused').length;
1757
2126
  const expiredCodes = codes.filter(c => normalizeCodeStatus(c) === 'expired').length;
2127
+ const mergedCodes = codes.filter(c => normalizeCodeStatus(c) === 'merged').length;
1758
2128
 
1759
2129
  const stats = {
1760
2130
  totalUsers,
@@ -1763,6 +2133,7 @@ const server = http.createServer((req, res) => {
1763
2133
  usedCodes,
1764
2134
  unusedCodes,
1765
2135
  expiredCodes,
2136
+ mergedCodes,
1766
2137
  systemStatus: '运行正常',
1767
2138
  uptime: Math.floor(process.uptime() / 86400) + ' 天'
1768
2139
  };
@@ -1844,6 +2215,11 @@ const server = http.createServer((req, res) => {
1844
2215
  res.end(JSON.stringify({ success: false, valid: false, message: '此激活码已被禁用,无法激活配置' }));
1845
2216
  return;
1846
2217
  }
2218
+ if (serializedCode.status === 'merged') {
2219
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2220
+ res.end(JSON.stringify({ success: false, valid: false, message: '此激活码已合并注销,无法激活配置' }));
2221
+ return;
2222
+ }
1847
2223
 
1848
2224
  const activationData = buildActivationData(serializedCode, req);
1849
2225
  if (!ensureProxyReady(res, serializedCode.serviceKey)) {
@@ -1959,14 +2335,97 @@ const server = http.createServer((req, res) => {
1959
2335
  return;
1960
2336
  }
1961
2337
 
1962
- // Lightweight fallbacks for optional user center actions.
2338
+ // Card merge APIs: child cards are merged into the current parent card and then disabled.
1963
2339
  if (apiPath === "/card-bind/info" && req.method === "GET") {
2340
+ const code = resolveUserApiCode(req);
2341
+ if (!code) {
2342
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
2343
+ return;
2344
+ }
2345
+
1964
2346
  res.writeHead(200, { 'Content-Type': 'application/json' });
1965
- res.end(JSON.stringify({ success: true, data: { parent: null, children: [] } }));
2347
+ res.end(JSON.stringify(getCardMergeInfo(code)));
2348
+ return;
2349
+ }
2350
+
2351
+ if (apiPath === "/card-bind/bind" && req.method === "POST") {
2352
+ const parentCode = resolveUserApiCode(req);
2353
+ if (!parentCode) {
2354
+ sendUserApiUnauthorized(res, "FogAct Key 不存在或已失效");
2355
+ return;
2356
+ }
2357
+
2358
+ parseRequestBody(req).then((data) => {
2359
+ const childKeys = Array.isArray(data.child_keys) ? data.child_keys : [];
2360
+ if (!childKeys.length) {
2361
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2362
+ res.end(JSON.stringify({ success: false, message: '请输入要合并的子卡' }));
2363
+ return;
2364
+ }
2365
+
2366
+ const uniqueChildKeys = [...new Set(childKeys.map((key) => String(key || '').trim()).filter(Boolean))];
2367
+ if (uniqueChildKeys.length !== childKeys.length) {
2368
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2369
+ res.end(JSON.stringify({ success: false, message: '子卡列表不能包含空值或重复卡' }));
2370
+ return;
2371
+ }
2372
+
2373
+ let previewParent = codeDb.getById(parentCode.id) || parentCode;
2374
+ const childCodes = [];
2375
+ for (const childKey of uniqueChildKeys) {
2376
+ const childCode = codeDb.getByCode(childKey);
2377
+ const preview = previewCardMerge(previewParent, childCode);
2378
+ if (!preview.ok) {
2379
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2380
+ res.end(JSON.stringify({ success: false, message: preview.message }));
2381
+ return;
2382
+ }
2383
+ childCodes.push(childCode);
2384
+ previewParent = {
2385
+ ...previewParent,
2386
+ expiresAt: preview.newExpiresAt || previewParent.expiresAt,
2387
+ quota: preview.parentSpec.billingType === "duration"
2388
+ ? previewParent.quota
2389
+ : { ...(previewParent.quota || {}), total: Number(previewParent.quota?.total || 0) + preview.addedQuota },
2390
+ status: normalizeCodeStatus(previewParent) === "expired" ? "active" : previewParent.status,
2391
+ };
2392
+ }
2393
+
2394
+ const merged = [];
2395
+ for (const childCode of childCodes) {
2396
+ const result = mergeChildIntoParent(codeDb.getById(parentCode.id) || parentCode, childCode);
2397
+ if (!result.ok) {
2398
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2399
+ res.end(JSON.stringify({ success: false, message: result.message }));
2400
+ return;
2401
+ }
2402
+ merged.push(serializeMergeRecord(result.record));
2403
+ }
2404
+
2405
+ const latestParent = codeDb.getById(parentCode.id);
2406
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2407
+ res.end(JSON.stringify({
2408
+ success: true,
2409
+ message: `成功合并 ${merged.length} 张子卡`,
2410
+ data: {
2411
+ parent: serializeCodeForUserApi(latestParent),
2412
+ children: merged,
2413
+ },
2414
+ }));
2415
+ }).catch(() => {
2416
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2417
+ res.end(JSON.stringify({ success: false, message: '请求格式错误' }));
2418
+ });
2419
+ return;
2420
+ }
2421
+
2422
+ if (["/card-bind/unbind", "/card-bind/unbind-self", "/card-bind/reorder"].includes(apiPath)) {
2423
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2424
+ res.end(JSON.stringify({ success: false, message: '叠卡合并后子卡会注销,不支持解绑或排序' }));
1966
2425
  return;
1967
2426
  }
1968
2427
 
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)) {
2428
+ if (["/renew/preview", "/renew/execute", "/quota-pack/redeem-preview", "/quota-pack/redeem"].includes(apiPath)) {
1970
2429
  res.writeHead(200, { 'Content-Type': 'application/json' });
1971
2430
  res.end(JSON.stringify({ success: true, data: null, message: '操作已提交' }));
1972
2431
  return;