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 +481 -22
- package/frontend/admin/admin-panel-v2.js +115 -7
- package/frontend/admin/index.html +3 -1
- package/frontend/user/assets/CardBind-CsCxihhP-fogact-live.js +1 -1
- package/frontend/user/assets/CardBind-CsCxihhP.js +1 -1
- package/lib/commands/restore.js +136 -47
- package/lib/commands/test.js +53 -18
- package/lib/services/activation-orchestrator.js +1 -0
- package/lib/services/database.js +58 -0
- package/lib/services/node-service.js +36 -18
- 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, 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
|
-
|
|
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(
|
|
207
|
-
return CODE_STATUS_LABEL_MAP[normalizeCodeStatus(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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 (["/
|
|
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;
|