creditdaddy 1.5.2 → 1.6.2
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/build.json +3 -3
- package/package.json +1 -1
- package/src/assets/donate-alipay.svg +1 -0
- package/src/assets/donate-ifdian.svg +1 -0
- package/src/assets/donate-wechat.svg +1 -0
- package/src/checkin.js +11 -10
- package/src/constants.js +3 -1
- package/src/cookieSites.js +30 -11
- package/src/daemon.js +2062 -1961
- package/src/doubaoClient.js +168 -0
- package/src/externalQuota.js +113 -9
- package/src/formatDuration.js +17 -0
- package/src/i18n/en.json +1734 -1755
- package/src/i18n/ja.json +1734 -1755
- package/src/i18n/ko.json +1734 -1755
- package/src/i18n/zh-TW.json +1734 -1755
- package/src/longcatClient.js +24 -15
- package/src/minimaxClient.js +29 -0
- package/src/minimaxGateway.js +459 -419
- package/src/mirasimClient.js +3 -0
- package/src/panel.html +393 -126
- package/src/providers.js +39 -3
- package/src/qoderApp.js +10 -1
- package/src/tenrouter.js +27 -3
- package/src/traeClient.js +23 -15
- package/src/traeGateway.js +52 -4
- package/src/traeLocal.js +14 -4
- package/src/usageSync.js +10 -2
- package/src/workbuddyClient.js +0 -45
- package/src/workbuddyLocal.js +40 -0
- package/src/zcodeAutoClaim.js +21 -4
- package/src/zcodeClient.js +8 -0
- package/src/zcodeGateway.js +763 -616
package/src/zcodeGateway.js
CHANGED
|
@@ -1,635 +1,782 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ZCode 免费额度网关 — Start Plan 体验包(GLM-5.3-Flash)的本地补全代理。
|
|
3
|
-
*
|
|
4
|
-
* 数据面:POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态,10router
|
|
5
|
-
* 建一个 anthropic-compatible 自定义节点指向这里即可)。
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* →
|
|
10
|
-
* →
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
import
|
|
1
|
+
/**
|
|
2
|
+
* ZCode 免费额度网关 — Start Plan 体验包(GLM-5.3-Flash)的本地补全代理。
|
|
3
|
+
*
|
|
4
|
+
* 数据面:POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态,10router
|
|
5
|
+
* 建一个 anthropic-compatible 自定义节点指向这里即可)。
|
|
6
|
+
*
|
|
7
|
+
* 链路:账号轮转(store 里 provider=zcode、可解析出 zcodejwttoken 的账号;
|
|
8
|
+
* 每个请求换一个队首,多任务分摊到各账号,见 rotationQueue)
|
|
9
|
+
* → 验证码(复用桌面版注册的隐藏窗口 provider,与活动领取共用同一个求解器)
|
|
10
|
+
* → POST zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages
|
|
11
|
+
* → SSE / JSON 原样流式回传。
|
|
12
|
+
*
|
|
13
|
+
* 失败分类:3007/3012(验证码)→ 作废 token 重解重试;401(JWT 失效)→ 拉黑账号;
|
|
14
|
+
* 402/额度不足 → 标记耗尽;429 → 冷却(退避阶梯 30s → 1min → 2min → 5min → 10min 封顶,成功即清零)。
|
|
15
|
+
* 全部失败返回 502 + 各账号结论。
|
|
16
|
+
*
|
|
17
|
+
* 仅限本机回路使用:daemon 只绑 127.0.0.1,网关不做鉴权(跨机场景需要先给
|
|
18
|
+
* daemon 加绑定/密钥,见 README)。NAS/纯 CLI 环境没有验证码提供者,网关开不了。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { claimToken, fetchCaptchaConfig, fetchJsonRace, fetchZcodeQuota } from './zcodeClient.js';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// ZCode 客户端 plan 请求形状(从本机客户端真实流量捕获的载荷模板:系统提示词数组 +
|
|
27
|
+
// currentDate 提醒块 + metadata 会话形状)。网关请求镜像此形状——这就是 405/3012 风控的通过票。
|
|
28
|
+
// 提示词已脱敏(去掉抓包来源的用户路径/会话 id),避免把第三方隐私随请求外发(见 issue #16)。
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
let PLAN_SHAPE = null;
|
|
31
|
+
try { PLAN_SHAPE = JSON.parse(readFileSync(path.join(__dirname, 'zcodePlanShape.json'), 'utf8')); } catch {}
|
|
32
|
+
|
|
33
|
+
import { getZcodeCaptchaProvider } from './zcodeAutoClaim.js';
|
|
34
|
+
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
35
|
+
|
|
36
|
+
// 整链补全提供者(桌面版注册):隐藏窗口内「真 Chromium 求解验证码 + 同源发起补全」,
|
|
37
|
+
// 规避 Node fetch 的 TLS 指纹风控。签名 ({ captchaCfg, jwt, rawBody }) → { status, contentType, body }。
|
|
38
|
+
let completionProvider = null;
|
|
39
|
+
export function setZcodeCompletionProvider(fn) { completionProvider = typeof fn === 'function' ? fn : null; }
|
|
40
|
+
export function getZcodeCompletionProvider() { return completionProvider; }
|
|
41
|
+
import { loadAccounts, loadSettings, saveSettings, loadState, withState } from './store.js';
|
|
42
|
+
import crypto from 'node:crypto';
|
|
43
|
+
import os from 'node:os';
|
|
42
44
|
import { logger, summarizeAttempts } from './logger.js';
|
|
43
|
-
import { t } from './i18n.js';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
45
|
+
import { t } from './i18n.js';
|
|
46
|
+
import { formatDurationMs } from './formatDuration.js';
|
|
47
|
+
|
|
48
|
+
const PLAN_MESSAGES_URL = 'https://zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages';
|
|
49
|
+
const CAPTCHA_CACHE_TTL_MS = 30_000;
|
|
50
|
+
const ACCOUNT_COOLING_MS = 5 * 60_000; // 额度不足(402)等「非限流」失败的冷却
|
|
51
|
+
const MAX_ATTEMPTS = 6;
|
|
52
|
+
// 429 限流的退避阶梯:30s → 1min → 2min → 5min → 10min,之后一直停在 10min。
|
|
53
|
+
// 前四档基本翻倍(上游限流窗口多在分钟级,起步 30s 能快速恢复),
|
|
54
|
+
// 2min → 5min 那跳是陡的(×2.5):说明再往后就是真被持续限流了,给到 10min 兜底后不再涨,
|
|
55
|
+
// 避免「一直被限流的号」被永久排除在轮换之外。见 markRateLimited。
|
|
56
|
+
const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
|
|
57
|
+
// 上游补全墙钟上限(响应头到齐后 body 仍可流式跑这么久)
|
|
58
|
+
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
59
|
+
// 连接段(DNS/TCP/TLS/首字节)限时:上游黑洞挂死时几秒内换下一条路 / 代理兜底,
|
|
60
|
+
// 而不是吊满 10 分钟——这是「网关很慢 / 卡住」体感的主要来源
|
|
61
|
+
const UPSTREAM_CONNECT_MS = 15_000;
|
|
62
|
+
|
|
63
|
+
// ── 开关(持久化在 settings.zcodeGateway) ──
|
|
64
|
+
|
|
65
|
+
export async function gatewayEnabled() {
|
|
66
|
+
return (await loadSettings()).zcodeGateway === true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function setGatewayEnabled(v) {
|
|
70
|
+
const on = v === true;
|
|
71
|
+
await saveSettings({ zcodeGateway: on });
|
|
72
|
+
// 开启网关视为「重新开始」:清掉进程内的拉黑/冷却状态,
|
|
73
|
+
// 否则重新登录导入后旧黑名单仍会把账号挡在轮换队列外(与 MiniMax 网关同款 bug)。
|
|
74
|
+
if (on) {
|
|
75
|
+
dead.clear();
|
|
76
|
+
cooling.clear();
|
|
77
|
+
coolingStreak.clear();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── 账号轮换状态(进程内;重启即清零) ──
|
|
82
|
+
|
|
83
|
+
let rrIndex = 0;
|
|
84
|
+
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
85
|
+
// accountId → 连续 429 次数(成功即清零):决定本次 429 走到退避阶梯的哪一档
|
|
86
|
+
const coolingStreak = new Map();
|
|
87
|
+
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新登录导入,自动复活)
|
|
88
|
+
// 会话粘滞(用户选 A):sessionId → { accountId, at }。同一段对话的多轮固定用同一个号,
|
|
89
|
+
// TTL 到期让位给轮转,避免冷会话长期占号。声明放这里是因为 __resetForTests 要清它。
|
|
90
|
+
const sessionSticky = new Map();
|
|
91
|
+
|
|
92
|
+
function markCooling(id, ms = ACCOUNT_COOLING_MS) { cooling.set(id, Date.now() + ms); }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 429 限流:按连续次数走退避阶梯(30s → 1min → 2min → 5min → 10min 封顶)。
|
|
96
|
+
* 上游限流窗口通常在分钟级,从 30s 起步能快速恢复,又不会像固定 5 分钟那样
|
|
97
|
+
* 一直撞在限流窗口里(日志里清一色「冷却 5 分钟」就是这么来的)。
|
|
98
|
+
* 成功时 resetCoolingStreak 清零,所以偶发 429 不会累积。
|
|
99
|
+
*/
|
|
100
|
+
function markRateLimited(id) {
|
|
101
|
+
const streak = (coolingStreak.get(id) || 0) + 1;
|
|
102
|
+
coolingStreak.set(id, streak);
|
|
103
|
+
const ms = RATE_LIMIT_BACKOFF_MS[Math.min(streak, RATE_LIMIT_BACKOFF_MS.length) - 1];
|
|
104
|
+
cooling.set(id, Date.now() + ms);
|
|
105
|
+
return ms;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 账号补全成功:清零连续 429 计数(下次 429 重新从 30 秒起算) */
|
|
109
|
+
function resetCoolingStreak(id) { coolingStreak.delete(id); }
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
|
|
113
|
+
* 生产不走这里——冷却窗口只能自然到期。
|
|
114
|
+
*/
|
|
115
|
+
export function __setCoolingForTests(accountId, untilMs) { cooling.set(accountId, untilMs); }
|
|
116
|
+
|
|
117
|
+
/** 测试专用:清空进程内轮换/冷却/拉黑/验证码缓存/统计状态 */
|
|
118
|
+
export function __resetForTests() {
|
|
119
|
+
rrIndex = 0;
|
|
120
|
+
cooling.clear();
|
|
121
|
+
coolingStreak.clear();
|
|
122
|
+
dead.clear();
|
|
123
|
+
quotaCache.clear();
|
|
124
|
+
sessionSticky.clear();
|
|
125
|
+
marksHydrated = false;
|
|
126
|
+
captchaCache = null;
|
|
127
|
+
stats.lastCallAt = null;
|
|
128
|
+
stats.calls = 0;
|
|
129
|
+
stats.lastAccount = null;
|
|
130
|
+
}
|
|
131
|
+
function markDead(account) {
|
|
132
|
+
dead.set(account.id, credFingerprint(account));
|
|
133
|
+
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 拉黑时的凭据指纹:重新登录/重新导入会整体替换 meta.credentials,指纹变化即可复活 */
|
|
137
|
+
function credFingerprint(a) {
|
|
138
|
+
return `${a.meta?.credentials?.zcodejwttoken || ''}|${a.token || ''}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 账号是否仍处于拉黑状态。拉黑记的是「当时那份凭据」的指纹:
|
|
143
|
+
* 重新登录导入换了 JWT 后指纹不匹配 → 自动复活(清黑名单与冷却)。
|
|
144
|
+
*/
|
|
145
|
+
function isDead(account) {
|
|
146
|
+
const fp = dead.get(account.id);
|
|
147
|
+
if (fp === undefined) return false;
|
|
148
|
+
if (fp === credFingerprint(account)) return true;
|
|
149
|
+
dead.delete(account.id);
|
|
150
|
+
cooling.delete(account.id);
|
|
151
|
+
coolingStreak.delete(account.id);
|
|
112
152
|
logger.info('ZCODE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
'
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 可参与轮换的账号(有可解析 plan JWT、未冷却/未拉黑),队首 = 本次主用号。
|
|
158
|
+
* @param {string|null} sessionId 调用方带来的会话标识;命中粘滞时该号优先
|
|
159
|
+
*/
|
|
160
|
+
async function rotationQueue(sessionId = null) {
|
|
161
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'zcode');
|
|
162
|
+
await hydrateMarks();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const ready = [];
|
|
165
|
+
for (const a of all) {
|
|
166
|
+
if (isDead(a)) continue;
|
|
167
|
+
if (isQuotaExhausted(a.id)) continue; // 0 额度打标,不再轮换
|
|
168
|
+
if ((cooling.get(a.id) || 0) > now) continue;
|
|
169
|
+
try { claimToken(a); ready.push(a); } catch { /* 快照里没有 plan JWT,跳过 */ }
|
|
170
|
+
}
|
|
171
|
+
if (!ready.length) return [];
|
|
172
|
+
|
|
173
|
+
// 会话粘滞优先:同一段对话的多轮固定用同一个号(用户选 A)。
|
|
174
|
+
// 粘住的号必须仍在可用集里——被拉黑/冷却/打标后粘滞自动让位给轮转。
|
|
175
|
+
if (sessionId) {
|
|
176
|
+
const pinned = stickyFor(sessionId);
|
|
177
|
+
if (pinned) {
|
|
178
|
+
const hit = ready.find((a) => a.id === pinned);
|
|
179
|
+
if (hit) return [hit, ...ready.filter((a) => a.id !== pinned)];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 轮转:每个请求换一个队首,让多任务分摊到各账号(rrIndex 是「下一轮的起点」,
|
|
184
|
+
// 每轮自增 1)。老实现只把 rrIndex 归一化到 ready.length 内、从不自增,
|
|
185
|
+
// 于是队首恒为账号库里的第一个号——「多任务全走一个账号」就是这么来的。
|
|
186
|
+
const start = rrIndex % ready.length;
|
|
187
|
+
rrIndex += 1;
|
|
188
|
+
return [...ready.slice(start), ...ready.slice(0, start)];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 队列为空时说清原因(与 rotationQueue 同序分类)——额度耗尽不该提示「重新登录导入」 */
|
|
192
|
+
async function unavailableReason() {
|
|
193
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'zcode');
|
|
194
|
+
if (!all.length) return '没有 ZCode 账号:请在 ZCode 登录后本机导入';
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const n = { dead: 0, exhausted: 0, cooling: 0, noJwt: 0 };
|
|
197
|
+
for (const a of all) {
|
|
198
|
+
if (isDead(a)) n.dead++;
|
|
199
|
+
else if (isQuotaExhausted(a.id)) n.exhausted++;
|
|
200
|
+
else if ((cooling.get(a.id) || 0) > now) n.cooling++;
|
|
201
|
+
else n.noJwt++; // 未拉黑/未打标/未冷却却不在队列 → 快照里没有可解析的 plan JWT
|
|
202
|
+
}
|
|
203
|
+
if (n.noJwt === all.length) {
|
|
204
|
+
return '没有可用的 ZCode 账号(需要账号快照里有 zcodejwttoken:在 ZCode 重新登录后本机导入)';
|
|
205
|
+
}
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (n.exhausted) parts.push(`${n.exhausted} 个额度耗尽(${Math.round(QUOTA_TTL_MS / 60_000)} 分钟内自动重探,无需重新登录)`);
|
|
208
|
+
if (n.cooling) parts.push(`${n.cooling} 个临时冷却中(稍后自动恢复)`);
|
|
209
|
+
if (n.dead) parts.push(`${n.dead} 个 JWT 已失效(需在 ZCode 重新登录后本机导入)`);
|
|
210
|
+
if (n.noJwt) parts.push(`${n.noJwt} 个缺少 zcodejwttoken(需在 ZCode 重新登录后本机导入)`);
|
|
211
|
+
return `没有可用的 ZCode 账号:${parts.join(';')}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── 验证码(与活动领取共用桌面版隐藏窗口求解器;30s 内复用同一 token) ──
|
|
215
|
+
|
|
216
|
+
let captchaCache = null; // { param, region, at }
|
|
217
|
+
|
|
218
|
+
// 调用统计(进程内):面板「10Router 已接入」活标签的数据源
|
|
219
|
+
const stats = { lastCallAt: null, calls: 0, lastAccount: null };
|
|
220
|
+
|
|
221
|
+
// 额度打标:0 额度账号不再轮换(额度缓存 10 分钟,到期自动重探;
|
|
222
|
+
// 1005/1113 失败时也会即时探测确认——剩余>0 视为瞬时错误继续用)
|
|
223
|
+
const QUOTA_TTL_MS = 10 * 60_000;
|
|
224
|
+
const quotaCache = new Map(); // accountId → { remaining, at }
|
|
225
|
+
|
|
226
|
+
let marksHydrated = false;
|
|
227
|
+
async function hydrateMarks() {
|
|
228
|
+
if (marksHydrated) return;
|
|
229
|
+
marksHydrated = true;
|
|
230
|
+
try {
|
|
231
|
+
const st = await loadState();
|
|
232
|
+
const saved = st?.zcodeGatewayExhausted || {};
|
|
233
|
+
for (const [id, e] of Object.entries(saved)) {
|
|
234
|
+
if (e && Date.now() - e.at < QUOTA_TTL_MS && !quotaCache.has(id)) quotaCache.set(id, e);
|
|
235
|
+
}
|
|
236
|
+
} catch { /* 状态读不到就从零开始 */ }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function persistMark(accountId, entry) {
|
|
240
|
+
try {
|
|
241
|
+
await withState((st) => {
|
|
242
|
+
st.zcodeGatewayExhausted = { ...(st.zcodeGatewayExhausted || {}), [accountId]: entry };
|
|
243
|
+
});
|
|
244
|
+
} catch { /* 持久化失败不影响内存态 */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 账号刚领到新额度(活动领取成功):清掉耗尽打标(内存 + state.json)和额度不足导致的冷却,
|
|
249
|
+
* 让它立刻回到轮换——否则要等打标 10 分钟 / 冷却 30 分钟到期。JWT 拉黑不受影响。
|
|
250
|
+
*/
|
|
251
|
+
export async function clearQuotaMark(accountId) {
|
|
252
|
+
quotaCache.delete(accountId);
|
|
253
|
+
if (!dead.has(accountId)) cooling.delete(accountId);
|
|
254
|
+
resetCoolingStreak(accountId); // 领到新额度 = 状态已恢复,429 计数重新从 30 秒起算
|
|
255
|
+
try {
|
|
256
|
+
await withState((st) => {
|
|
257
|
+
if (st?.zcodeGatewayExhausted?.[accountId]) delete st.zcodeGatewayExhausted[accountId];
|
|
258
|
+
});
|
|
259
|
+
} catch { /* 持久化失败不影响内存态 */ }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isQuotaExhausted(id) {
|
|
263
|
+
const q = quotaCache.get(id);
|
|
264
|
+
return Boolean(q && Date.now() - q.at < QUOTA_TTL_MS && q.remaining <= 0);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function probeQuota(account) {
|
|
268
|
+
const r = await fetchZcodeQuota(account).catch(() => null);
|
|
269
|
+
const remaining = Number(r?.remaining);
|
|
270
|
+
if (Number.isFinite(remaining)) {
|
|
271
|
+
quotaCache.set(account.id, { remaining, at: Date.now() });
|
|
272
|
+
return remaining;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function ensureCaptcha(force = false) {
|
|
278
|
+
if (!force && captchaCache && Date.now() - captchaCache.at < CAPTCHA_CACHE_TTL_MS) return captchaCache;
|
|
279
|
+
const provider = getZcodeCaptchaProvider();
|
|
280
|
+
if (!provider) {
|
|
281
|
+
throw Object.assign(new Error('本环境没有验证码提供者(网关补全需要桌面版 CreditDaddy 运行)'), { code: 'NO_CAPTCHA_PROVIDER' });
|
|
282
|
+
}
|
|
283
|
+
const cfg = await fetchCaptchaConfig();
|
|
284
|
+
if (!cfg.enabled || !cfg.sceneId) {
|
|
285
|
+
throw Object.assign(new Error('验证码配置不可用'), { code: 'NO_CAPTCHA_CONFIG' });
|
|
286
|
+
}
|
|
287
|
+
const { captchaParam, region } = await provider(cfg);
|
|
288
|
+
captchaCache = { param: captchaParam, region, at: Date.now() };
|
|
289
|
+
return captchaCache;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function invalidateCaptcha() { captchaCache = null; }
|
|
293
|
+
|
|
294
|
+
// ── 数据面 ──
|
|
295
|
+
|
|
296
|
+
/** 友好化上游错误描述 */
|
|
297
|
+
function formatUpstreamError(status, text) {
|
|
298
|
+
const content = String(text || '').trim();
|
|
299
|
+
|
|
300
|
+
// HTML 错误页面 → 提取关键信息
|
|
301
|
+
if (content.includes('<html>') || content.includes('<h1>')) {
|
|
302
|
+
const titleMatch = content.match(/<h1[^>]*>(.*?)<\/h1>/i);
|
|
303
|
+
if (titleMatch) return `HTML 错误页:${titleMatch[1].trim()}`;
|
|
304
|
+
return 'HTML 错误页(上游服务异常)';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// JSON 错误 → 提取业务码和消息
|
|
308
|
+
try {
|
|
309
|
+
const json = JSON.parse(content);
|
|
310
|
+
const codes = [json.code, json.status, json.error?.code];
|
|
311
|
+
const codeStr = codes.find(c => typeof c === 'number');
|
|
312
|
+
const msg = json.message || json.error?.message || '';
|
|
313
|
+
return codeStr ? (msg ? `[code:${codeStr}] ${msg}` : `[code:${codeStr}]`) : '未知错误';
|
|
314
|
+
} catch {
|
|
315
|
+
// 普通文本错误 → 截断显示
|
|
316
|
+
const preview = content.length > 60 ? `${content.slice(0, 60)}...` : content;
|
|
317
|
+
return preview || '(无响应内容)';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readRawBody(req, limitBytes = 20 * 1024 * 1024) {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const chunks = [];
|
|
324
|
+
let size = 0;
|
|
325
|
+
req.on('data', (c) => {
|
|
326
|
+
size += c.length;
|
|
327
|
+
if (size > limitBytes) {
|
|
328
|
+
// 只 pause 不 destroy:destroy 会把 socket 打死,调用方随后写的 413 就发不出去,
|
|
329
|
+
// 客户端只看到 ECONNRESET,分不清「自己请求体写太大」和「网络断了」。
|
|
330
|
+
// 响应写完(res 'finish')再由调用方收尾断开。
|
|
331
|
+
req.pause();
|
|
332
|
+
reject(Object.assign(new Error(`request body too large (limit ${Math.floor(limitBytes / 1024 / 1024)}MB)`), { status: 413 }));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
chunks.push(c);
|
|
336
|
+
});
|
|
337
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
338
|
+
req.on('error', reject);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 业务码可能包在 HTTP 200 里(上游对部分错误回 200 + {code}),两类都要看。
|
|
343
|
+
// 裸数字必须加 \b 词边界:否则 "code":4010 会被当成 401 认证失败(→ 账号被永久拉黑),
|
|
344
|
+
// 耗时 10050ms / request-id=11137 / 30071 也会被当成额度耗尽或验证码(→ 冷却 30 分钟 / 空跑一次验证码)。
|
|
345
|
+
const isCaptchaError = (status, text) =>
|
|
346
|
+
status === 405 || /"code"\s*:\s*(3007|3012)\b|\b(3007|3012)\b|captcha|unusual activity/i.test(text);
|
|
347
|
+
|
|
348
|
+
const isAuthError = (status, text) =>
|
|
349
|
+
status === 401 || /"code"\s*:\s*401\b|\b401\b|令牌已过期|验证不正确/.test(text);
|
|
350
|
+
|
|
351
|
+
const isExhausted = (status, text) =>
|
|
352
|
+
status === 402 || /"code"\s*:\s*(1005|1113)\b|\b(1113|1005)\b|余额不足|无可用资源包|exceed quota|insufficient/i.test(text);
|
|
353
|
+
|
|
354
|
+
// 上游成功也会带 code(0 / 200),别把它们当业务错误——与 zcodeClient.businessOk 同一口径
|
|
355
|
+
const BUSINESS_OK = new Set([0, 200]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 从调用方 body 的 metadata 里取会话标识。宽容解析三种形状:
|
|
359
|
+
* {"user_id": "{\"session_id\":\"ses_x\",\"account_uuid\":\"...\"}"} (上游同形,最常见)
|
|
360
|
+
* {"session_id": "ses_x"} (裸字段)
|
|
361
|
+
* {"user_id": "ses_x"} (字符串直给)
|
|
362
|
+
* @returns {{sessionId: string|null, accountUuid: string|null}}
|
|
363
|
+
*/
|
|
364
|
+
export function parseSessionMeta(metadata) {
|
|
365
|
+
const out = { sessionId: null, accountUuid: null };
|
|
366
|
+
if (!metadata || typeof metadata !== 'object') return out;
|
|
367
|
+
// 顶层直给的会话字段也算(调用方可能直接放 metadata.session_id)
|
|
368
|
+
out.sessionId = String(metadata.session_id || metadata.conversation_id || metadata.chat_id || '').slice(0, 64) || null;
|
|
369
|
+
const uid = metadata.user_id ?? metadata.userId ?? metadata.user;
|
|
370
|
+
let obj = null;
|
|
371
|
+
if (typeof uid === 'object') obj = uid;
|
|
372
|
+
else if (typeof uid === 'string') {
|
|
373
|
+
const s = uid.trim();
|
|
374
|
+
if (!s) return out;
|
|
375
|
+
if (s.startsWith('{')) {
|
|
376
|
+
try { obj = JSON.parse(s); } catch { out.sessionId = s.slice(0, 64); return out; }
|
|
377
|
+
} else out.sessionId = s.slice(0, 64);
|
|
378
|
+
}
|
|
379
|
+
if (obj && typeof obj === 'object') {
|
|
380
|
+
out.sessionId = String(obj.session_id || obj.sessionId || obj.conversation_id || obj.chat_id || out.sessionId || '').slice(0, 64) || out.sessionId;
|
|
381
|
+
out.accountUuid = String(obj.account_uuid || obj.accountUuid || '').slice(0, 64) || null;
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── 会话粘滞:同一会话的多轮对话固定用同一个号 ──
|
|
387
|
+
// 用户选 A:同一段对话不跳号(跳号会让上下文承接出现割裂),新会话才轮转。
|
|
388
|
+
// 只粘「活着的」会话:TTL 到期即让位给轮转,避免某个冷会话长期占着一个号不放。
|
|
389
|
+
const SESSION_STICKY_TTL_MS = 30 * 60_000;
|
|
390
|
+
|
|
391
|
+
/** 取会话当前粘住的号(过期即失效) */
|
|
392
|
+
function stickyFor(sessionId) {
|
|
393
|
+
const e = sessionSticky.get(sessionId);
|
|
394
|
+
if (!e) return null;
|
|
395
|
+
if (Date.now() - e.at >= SESSION_STICKY_TTL_MS) {
|
|
396
|
+
sessionSticky.delete(sessionId);
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
return e.accountId;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 会话粘住的号仍可用才粘;否则落到轮转队首,由调用方重新绑定 */
|
|
403
|
+
function touchSticky(sessionId, accountId) {
|
|
404
|
+
if (!sessionId) return;
|
|
405
|
+
sessionSticky.set(sessionId, { accountId, at: Date.now() });
|
|
406
|
+
}
|
|
407
|
+
/** 从上游 JSON 信封里取业务码;字符串数字("3007")也认,取不到返回 null */
|
|
408
|
+
function businessCode(v) {
|
|
409
|
+
if (!v || typeof v !== 'object' || v.code === undefined || v.code === null) return null;
|
|
410
|
+
const n = Number(v.code);
|
|
411
|
+
return Number.isFinite(n) ? n : null;
|
|
412
|
+
}
|
|
413
|
+
// 组装与 ZCode 客户端真实流量一致的 plan 请求(镜像 R1 捕获,2026-09-29)
|
|
414
|
+
function buildPlanRequest(rawBody, { token, userId }) {
|
|
415
|
+
const ver = '3.14.3';
|
|
416
|
+
let bodyObj;
|
|
417
|
+
try { bodyObj = JSON.parse(rawBody); } catch { bodyObj = null; }
|
|
418
|
+
if (!bodyObj || typeof bodyObj !== 'object') bodyObj = { model: 'glm-5.3-flash', messages: [] };
|
|
419
|
+
const rawMeta = parseSessionMeta(bodyObj.metadata);
|
|
420
|
+
// 客户端形状:system = ZCode 系统提示词数组;首条 user 消息前插 currentDate 提醒块;
|
|
421
|
+
// metadata.user_id = 会话形状字符串
|
|
422
|
+
if (PLAN_SHAPE) {
|
|
423
|
+
bodyObj.system = PLAN_SHAPE.system;
|
|
424
|
+
const msgs = Array.isArray(bodyObj.messages) ? bodyObj.messages : [];
|
|
425
|
+
const reminderText = PLAN_SHAPE.reminderText
|
|
426
|
+
? PLAN_SHAPE.reminderText.replace(/Today's date is [^.]+\./, `Today's date is ${new Date().toISOString().slice(0, 10)}.`)
|
|
427
|
+
: null;
|
|
428
|
+
if (reminderText) {
|
|
429
|
+
const first = msgs[0];
|
|
430
|
+
const firstIsUser = first?.role === 'user';
|
|
431
|
+
const already = firstIsUser && typeof first.content === 'string' && first.content.includes('<system-reminder>');
|
|
432
|
+
if (!already) {
|
|
433
|
+
const block = { type: 'text', text: reminderText };
|
|
434
|
+
if (firstIsUser && Array.isArray(first.content)) first.content = [block, ...first.content];
|
|
435
|
+
else if (firstIsUser) first.content = [block, { type: 'text', text: String(first.content ?? '') }];
|
|
436
|
+
else msgs.unshift({ role: 'user', content: [block, { type: 'text', text: '.' }] });
|
|
437
|
+
}
|
|
438
|
+
bodyObj.messages = msgs;
|
|
439
|
+
}
|
|
440
|
+
// 会话标识:调用方传了就原样透传(会话粘滞的锚点),没传才随机生成。
|
|
441
|
+
// 注意上游只认这一个字段——网关此前每次都 new 一个新 session_id,
|
|
442
|
+
// 于是上游眼里每请求都是新会话,同一段对话的上下文连续性也丢了。
|
|
443
|
+
bodyObj.metadata = {
|
|
444
|
+
user_id: JSON.stringify({
|
|
445
|
+
account_uuid: rawMeta?.accountUuid || '',
|
|
446
|
+
session_id: rawMeta?.sessionId || 'ses_' + crypto.randomUUID().slice(0, 16),
|
|
447
|
+
}),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const headers = {
|
|
451
|
+
'Content-Type': 'application/json',
|
|
452
|
+
Accept: '*/*',
|
|
453
|
+
'accept-encoding': 'identity', // 网关不做压缩透传——转发链上有字符串化环节,gzip 字节会损坏
|
|
454
|
+
'User-Agent': `ZCode/${ver} ai-sdk/anthropic/3.0.81`,
|
|
455
|
+
'X-ZCode-App-Version': ver,
|
|
456
|
+
'X-ZCode-Agent': 'glm',
|
|
457
|
+
'X-Title': 'Z Code@cli',
|
|
458
|
+
'HTTP-Referer': 'https://zcode.z.ai',
|
|
459
|
+
'X-Client-Language': 'zh-CN',
|
|
460
|
+
'X-Client-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
|
461
|
+
'X-Platform': 'win32-x64',
|
|
462
|
+
'X-Os-Category': 'windows',
|
|
463
|
+
'X-Os-Version': os.release?.() || '10.0.19045',
|
|
464
|
+
'X-Release-Channel': 'production',
|
|
465
|
+
'x-zcode-session-type': 'main',
|
|
466
|
+
'x-zcode-trace-id': crypto.randomUUID(),
|
|
467
|
+
'x-request-id': crypto.randomUUID(),
|
|
468
|
+
'anthropic-version': '2023-06-01',
|
|
469
|
+
Authorization: `Bearer ${token}`,
|
|
470
|
+
};
|
|
471
|
+
return { headers, body: JSON.stringify(bodyObj) };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const LOOPBACK_RE = /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/;
|
|
475
|
+
function isLoopbackRemote(remote) {
|
|
476
|
+
return !remote || LOOPBACK_RE.test(String(remote));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** 规范化远端地址(剥 IPv4-mapped IPv6 前缀),与白名单条目比对 */
|
|
480
|
+
export function remoteHost(remote) {
|
|
481
|
+
return String(remote || '').replace(/^::ffff:/i, '').trim();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 白名单匹配:精确 IP / 主机名,或 `*` 结尾的前缀通配(如 192.168.31.*) */
|
|
485
|
+
export function remoteAllowed(remote, allowList) {
|
|
486
|
+
const host = remoteHost(remote);
|
|
487
|
+
if (!host) return false;
|
|
488
|
+
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
489
|
+
const e = String(entry || '').trim();
|
|
490
|
+
if (!e) return false;
|
|
372
491
|
if (e.endsWith('*')) {
|
|
373
492
|
// 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
|
|
374
493
|
// 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
|
|
375
494
|
const prefix = e.slice(0, -1);
|
|
376
495
|
if (!prefix.endsWith('.')) return false;
|
|
377
496
|
return host.toLowerCase().startsWith(prefix.toLowerCase());
|
|
378
|
-
}
|
|
379
|
-
return host.toLowerCase() === e.toLowerCase();
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* 网关数据面入口。返回 true 表示响应已写出(含失败结论)。
|
|
385
|
-
*
|
|
386
|
-
* 访问控制:本机回环始终放行;开局域网后仅 IP 白名单内的机器可访问——
|
|
387
|
-
* zcode-free 本就是 10Router 侧免授权供应商,10Router 所在机器加白即可,不再保留虚拟 key 鉴权。
|
|
388
|
-
*/
|
|
389
|
-
export async function handleGateway(req, res) {
|
|
390
|
-
if (req.method !== 'POST') {
|
|
391
|
-
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
392
|
-
return res.end(JSON.stringify({ error: 'POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
393
|
-
}
|
|
394
|
-
// settings 一次读齐:开关 + 局域网鉴权共用,省掉一次 settings.json 磁盘读(热路径每请求都走)
|
|
395
|
-
const settings = await loadSettings();
|
|
396
|
-
if (settings.zcodeGateway !== true) {
|
|
397
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
398
|
-
return res.end(JSON.stringify({ error: 'ZCode 免费额度网关未开启(面板 → ZCode → 网关开关)' }));
|
|
399
|
-
}
|
|
400
|
-
// 首发不带验证码(客户端真实流量多数直过),所以纯 daemon / NAS 也能用;
|
|
401
|
-
// 只有当上游索要验证码且本环境没有求解提供者时才失败(见 attempts 汇总)。
|
|
402
|
-
|
|
403
|
-
// 鉴权:本机回环免密;开局域网后仅白名单内 IP 免密直连(如 10Router 所在机器——zcode-free 本就是免授权供应商)
|
|
404
|
-
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
405
|
-
if (!isLoopbackRemote(remote)) {
|
|
406
|
-
if (settings.zcodeGatewayLan !== true) {
|
|
407
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
408
|
-
return res.end(JSON.stringify({ error: '网关未允许局域网访问(面板 → ZCode → 接口设置)' }));
|
|
409
|
-
}
|
|
410
|
-
if (!remoteAllowed(remote, settings.zcodeGatewayAllow)) {
|
|
411
|
-
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
412
|
-
return res.end(JSON.stringify({ error: `来源 ${remoteHost(remote) || '未知'} 不在网关 IP 白名单内(面板 → ZCode → 接口设置里添加)` }));
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
let rawBody;
|
|
417
|
-
try { rawBody = await readRawBody(req); } catch (e) {
|
|
418
|
-
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
419
|
-
// 超限时请求体还没读完:先把 413 发出去,发完再断开,避免客户端继续往这灌数据
|
|
420
|
-
res.on('finish', () => req.destroy());
|
|
421
|
-
return res.end(JSON.stringify({ error: e.message }));
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
+
}
|
|
498
|
+
return host.toLowerCase() === e.toLowerCase();
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 网关数据面入口。返回 true 表示响应已写出(含失败结论)。
|
|
504
|
+
*
|
|
505
|
+
* 访问控制:本机回环始终放行;开局域网后仅 IP 白名单内的机器可访问——
|
|
506
|
+
* zcode-free 本就是 10Router 侧免授权供应商,10Router 所在机器加白即可,不再保留虚拟 key 鉴权。
|
|
507
|
+
*/
|
|
508
|
+
export async function handleGateway(req, res) {
|
|
509
|
+
if (req.method !== 'POST') {
|
|
510
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
511
|
+
return res.end(JSON.stringify({ error: 'POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
512
|
+
}
|
|
513
|
+
// settings 一次读齐:开关 + 局域网鉴权共用,省掉一次 settings.json 磁盘读(热路径每请求都走)
|
|
514
|
+
const settings = await loadSettings();
|
|
515
|
+
if (settings.zcodeGateway !== true) {
|
|
516
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
517
|
+
return res.end(JSON.stringify({ error: 'ZCode 免费额度网关未开启(面板 → ZCode → 网关开关)' }));
|
|
518
|
+
}
|
|
519
|
+
// 首发不带验证码(客户端真实流量多数直过),所以纯 daemon / NAS 也能用;
|
|
520
|
+
// 只有当上游索要验证码且本环境没有求解提供者时才失败(见 attempts 汇总)。
|
|
521
|
+
|
|
522
|
+
// 鉴权:本机回环免密;开局域网后仅白名单内 IP 免密直连(如 10Router 所在机器——zcode-free 本就是免授权供应商)
|
|
523
|
+
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
524
|
+
if (!isLoopbackRemote(remote)) {
|
|
525
|
+
if (settings.zcodeGatewayLan !== true) {
|
|
526
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
527
|
+
return res.end(JSON.stringify({ error: '网关未允许局域网访问(面板 → ZCode → 接口设置)' }));
|
|
528
|
+
}
|
|
529
|
+
if (!remoteAllowed(remote, settings.zcodeGatewayAllow)) {
|
|
530
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
531
|
+
return res.end(JSON.stringify({ error: `来源 ${remoteHost(remote) || '未知'} 不在网关 IP 白名单内(面板 → ZCode → 接口设置里添加)` }));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let rawBody;
|
|
536
|
+
try { rawBody = await readRawBody(req); } catch (e) {
|
|
537
|
+
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
538
|
+
// 超限时请求体还没读完:先把 413 发出去,发完再断开,避免客户端继续往这灌数据
|
|
539
|
+
res.on('finish', () => req.destroy());
|
|
540
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 会话标识在 body 里(不在头里):客户端/10Router 若传了 metadata.user_id
|
|
544
|
+
// 就作为粘滞锚点;没传则 sessionId 为 null,纯轮转。
|
|
545
|
+
let sessionId = null;
|
|
546
|
+
try {
|
|
547
|
+
const parsed = JSON.parse(rawBody);
|
|
548
|
+
sessionId = parseSessionMeta(parsed?.metadata)?.sessionId;
|
|
549
|
+
} catch { /* 非 JSON 体走轮转 */ }
|
|
550
|
+
|
|
551
|
+
const queue = await rotationQueue(sessionId);
|
|
552
|
+
if (!queue.length) {
|
|
553
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
554
|
+
return res.end(JSON.stringify({ error: await unavailableReason() }));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
stats.lastCallAt = new Date().toISOString();
|
|
558
|
+
stats.calls += 1;
|
|
559
|
+
|
|
560
|
+
const attempted = [];
|
|
561
|
+
// 客户端断开 → 取消当前上游请求。整个重试循环共用一个 controller/listener:
|
|
562
|
+
// 放在循环里每轮 new + req.on('close') 会在同一 req 上累积最多 MAX_ATTEMPTS 个监听器
|
|
563
|
+
// (MaxListenersExceededWarning + 泄漏)。
|
|
564
|
+
const clientAbort = new AbortController();
|
|
565
|
+
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
566
|
+
// 首发不带验证码(客户端真实流量多数直过);上游 3007/403 时求解并在此后的
|
|
567
|
+
// 尝试上挂新 token(zcode-api 同策略)。provider 为 null(NAS/CLI)时首过路径依然可用。
|
|
568
|
+
let captcha = null;
|
|
569
|
+
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length * 2); i++) {
|
|
570
|
+
const account = queue[i % queue.length];
|
|
571
|
+
// 本轮已被打标/冷却/拉黑的号不再重打(第二圈只为验证码重试准备)
|
|
572
|
+
if (isDead(account) || isQuotaExhausted(account.id) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
573
|
+
const label = account.name || account.uid || account.id;
|
|
574
|
+
let token;
|
|
575
|
+
try { token = claimToken(account); } catch (e) {
|
|
576
|
+
attempted.push({ account: label, ok: false, error: e.message });
|
|
577
|
+
markDead(account);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let upstream;
|
|
582
|
+
try {
|
|
583
|
+
// 请求面完全镜像 ZCode 客户端真实流量(R1 捕获):全套 identity 头 + 客户端
|
|
584
|
+
// 系统提示词/提醒块/metadata 形状。验证码不预挂——服务端未风控时直过;
|
|
585
|
+
// 返回 3007/403 再求解挂上重试(zcode-api 同策略)。
|
|
586
|
+
const built = buildPlanRequest(rawBody, { token, userId: account.uid });
|
|
587
|
+
const headers = captcha
|
|
588
|
+
? { ...built.headers, 'X-Aliyun-Captcha-Verify-Param': captcha.param, 'X-Aliyun-Captcha-Verify-Region': captcha.region }
|
|
589
|
+
: built.headers;
|
|
590
|
+
upstream = await fetchJsonRace(PLAN_MESSAGES_URL, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers,
|
|
593
|
+
body: built.body,
|
|
594
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
595
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
596
|
+
signal: clientAbort.signal,
|
|
597
|
+
});
|
|
598
|
+
} catch (e) {
|
|
599
|
+
if (clientAbort.signal.aborted) return; // 客户端先断了
|
|
600
|
+
attempted.push({ account: label, ok: false, error: e.message });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (upstream.ok) {
|
|
605
|
+
// 上游会把业务错误包在 HTTP 200 + application/json 里(非 SSE)——先验形再转发,
|
|
606
|
+
// 否则客户端拿到「200 但不是 Message」的假成功(cc CLI 的 StreamNoEventsError 就是它)
|
|
607
|
+
const ct = (upstream.headers.get('content-type') || '').toLowerCase();
|
|
608
|
+
if (ct.includes('application/json')) {
|
|
609
|
+
const text = await upstream.text().catch(() => '');
|
|
610
|
+
let v;
|
|
611
|
+
try { v = JSON.parse(text); } catch { v = null; }
|
|
612
|
+
const code = businessCode(v);
|
|
613
|
+
// 上游成功信封也可能带 code(0 / 200),那不是业务错误;字符串数字("3007")同样要拦
|
|
614
|
+
if (code !== null && !BUSINESS_OK.has(code)) {
|
|
615
|
+
if (isCaptchaError(200, text)) {
|
|
616
|
+
invalidateCaptcha();
|
|
617
|
+
attempted.push({ account: label, ok: false, error: '验证码被拒(200 业务码)', captcha: true });
|
|
618
|
+
try { captcha = await ensureCaptcha(true); } catch { captcha = null; }
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (isAuthError(200, text)) {
|
|
622
|
+
markDead(account);
|
|
623
|
+
attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑' });
|
|
497
624
|
logger.warn('ZCODE-GW', '{label} JWT 失效(200 业务码)', { label });
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
if (isExhausted(200, text)) {
|
|
501
|
-
const remaining = await probeQuota(account).catch(() => null);
|
|
502
|
-
if (remaining === null) {
|
|
503
|
-
// 探不到额度 ≠ 确认耗尽:不打标(否则一次查询失败就把号挡 10 分钟),只短冷却
|
|
504
|
-
markCooling(account.id, 2 * 60_000);
|
|
505
|
-
attempted.push({ account: label, ok: false, error: '额度被拒且额度查询失败,短冷却' });
|
|
506
|
-
} else if (remaining <= 0) {
|
|
507
|
-
// 确认 0 额度 → 打标(持久化到 state.json,重启不复轮换)
|
|
508
|
-
const entry = { remaining, at: Date.now() };
|
|
509
|
-
quotaCache.set(account.id, entry);
|
|
510
|
-
await persistMark(account.id, entry);
|
|
511
|
-
attempted.push({ account: label, ok: false, error: `额度耗尽(剩余 ${remaining}),已打标` });
|
|
512
|
-
} else {
|
|
513
|
-
// 还有额度却被拒 → 瞬时错误,短冷却继续用
|
|
514
|
-
markCooling(account.id, 2 * 60_000);
|
|
515
|
-
attempted.push({ account: label, ok: false, error: `额度接口瞬时拒绝(剩余 ${remaining})` });
|
|
516
|
-
}
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (isExhausted(200, text)) {
|
|
628
|
+
const remaining = await probeQuota(account).catch(() => null);
|
|
629
|
+
if (remaining === null) {
|
|
630
|
+
// 探不到额度 ≠ 确认耗尽:不打标(否则一次查询失败就把号挡 10 分钟),只短冷却
|
|
631
|
+
markCooling(account.id, 2 * 60_000);
|
|
632
|
+
attempted.push({ account: label, ok: false, error: '额度被拒且额度查询失败,短冷却' });
|
|
633
|
+
} else if (remaining <= 0) {
|
|
634
|
+
// 确认 0 额度 → 打标(持久化到 state.json,重启不复轮换)
|
|
635
|
+
const entry = { remaining, at: Date.now() };
|
|
636
|
+
quotaCache.set(account.id, entry);
|
|
637
|
+
await persistMark(account.id, entry);
|
|
638
|
+
attempted.push({ account: label, ok: false, error: `额度耗尽(剩余 ${remaining}),已打标` });
|
|
639
|
+
} else {
|
|
640
|
+
// 还有额度却被拒 → 瞬时错误,短冷却继续用
|
|
641
|
+
markCooling(account.id, 2 * 60_000);
|
|
642
|
+
attempted.push({ account: label, ok: false, error: `额度接口瞬时拒绝(剩余 ${remaining})` });
|
|
643
|
+
}
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
519
646
|
logger.warn('ZCODE-GW', '{label} 上游 200 业务错误:{err} ({snippet})', { label, err: formatUpstreamError(200, text), snippet: text.slice(0, 80) });
|
|
520
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
521
|
-
res.end(text);
|
|
522
|
-
return true;
|
|
523
|
-
}
|
|
524
|
-
// 真 Message(非流式)——原样转发
|
|
647
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
648
|
+
res.end(text);
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
651
|
+
// 真 Message(非流式)——原样转发
|
|
525
652
|
logger.debug('ZCODE-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
|
|
526
|
-
stats.lastAccount = label;
|
|
527
|
-
cooling.delete(account.id);
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
653
|
+
stats.lastAccount = label;
|
|
654
|
+
cooling.delete(account.id);
|
|
655
|
+
resetCoolingStreak(account.id);
|
|
656
|
+
touchSticky(sessionId, account.id); // 会话粘住这个号,后续轮不再跳
|
|
657
|
+
res.writeHead(upstream.status, { 'Content-Type': ct || 'application/json', 'Cache-Control': 'no-cache' });
|
|
658
|
+
res.end(text);
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
532
661
|
logger.debug('ZCODE-GW', '{label} 补全成功 ({status})', { label, status: upstream.status });
|
|
533
|
-
stats.lastAccount = label;
|
|
534
|
-
cooling.delete(account.id);
|
|
535
|
-
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
662
|
+
stats.lastAccount = label;
|
|
663
|
+
cooling.delete(account.id);
|
|
664
|
+
resetCoolingStreak(account.id);
|
|
665
|
+
touchSticky(sessionId, account.id); // 会话粘住这个号,后续轮不再跳
|
|
666
|
+
// 注意:undici 已自动解压上游 gzip——body 是明文,绝不能再带 content-encoding 头
|
|
667
|
+
// (带着会让客户端对明文做 gunzip → 乱码)
|
|
668
|
+
const out = {
|
|
669
|
+
'Content-Type': upstream.headers.get('content-type') || 'application/json',
|
|
670
|
+
'Cache-Control': 'no-cache',
|
|
671
|
+
};
|
|
672
|
+
res.writeHead(upstream.status, out);
|
|
673
|
+
try {
|
|
674
|
+
const reader = upstream.body.getReader();
|
|
675
|
+
for (;;) {
|
|
676
|
+
const { done, value } = await reader.read();
|
|
677
|
+
if (done) break;
|
|
678
|
+
if (!res.write(Buffer.from(value))) {
|
|
679
|
+
await new Promise((r) => res.once('drain', r));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} catch (e) {
|
|
683
|
+
// 客户端主动断开(点停止/换一句)就会在这里结束——属正常收尾,不是故障,降为 debug
|
|
553
684
|
logger.debug('ZCODE-GW', '流式转发中断:{err}', { err: e.message });
|
|
554
|
-
}
|
|
555
|
-
res.end();
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const text = await upstream.text().catch(() => '');
|
|
560
|
-
// 验证码类失败:作废 token 并强制重解(有提供者时下一次尝试带上新 token)
|
|
561
|
-
if (isCaptchaError(upstream.status, text)) {
|
|
562
|
-
invalidateCaptcha();
|
|
563
|
-
attempted.push({ account: label, ok: false, error: `验证码被拒(${upstream.status})`, captcha: true });
|
|
685
|
+
}
|
|
686
|
+
res.end();
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const text = await upstream.text().catch(() => '');
|
|
691
|
+
// 验证码类失败:作废 token 并强制重解(有提供者时下一次尝试带上新 token)
|
|
692
|
+
if (isCaptchaError(upstream.status, text)) {
|
|
693
|
+
invalidateCaptcha();
|
|
694
|
+
attempted.push({ account: label, ok: false, error: `验证码被拒(${upstream.status})`, captcha: true });
|
|
564
695
|
logger.warn('ZCODE-GW', '{label} 验证码被拒 ({err})', { label, err: formatUpstreamError(upstream.status, text) });
|
|
565
|
-
try { captcha = await ensureCaptcha(true); } catch (e) {
|
|
696
|
+
try { captcha = await ensureCaptcha(true); } catch (e) {
|
|
566
697
|
logger.warn('ZCODE-GW', '验证码重解失败:{err}', { err: e.message });
|
|
567
|
-
captcha = null;
|
|
568
|
-
}
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
if (isAuthError(upstream.status, text)) {
|
|
572
|
-
markDead(account);
|
|
573
|
-
attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑(重新登录后再导入)' });
|
|
698
|
+
captcha = null;
|
|
699
|
+
}
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (isAuthError(upstream.status, text)) {
|
|
703
|
+
markDead(account);
|
|
704
|
+
attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑(重新登录后再导入)' });
|
|
574
705
|
logger.debug('ZCODE-GW', '{label} JWT 失效,拉黑', { label });
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
if (isExhausted(upstream.status, text)) {
|
|
578
|
-
markCooling(account.id, 30 * 60_000);
|
|
579
|
-
attempted.push({ account: label, ok: false, error: '额度不足/无资源包' });
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (isExhausted(upstream.status, text)) {
|
|
709
|
+
markCooling(account.id, 30 * 60_000);
|
|
710
|
+
attempted.push({ account: label, ok: false, error: '额度不足/无资源包' });
|
|
580
711
|
logger.debug('ZCODE-GW', '{label} 额度不足 ({status})', { label, status: upstream.status });
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
if (upstream.status === 429) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (upstream.status === 429) {
|
|
715
|
+
const ms = markRateLimited(account.id);
|
|
716
|
+
const dur = formatDurationMs(ms);
|
|
717
|
+
const streak = coolingStreak.get(account.id);
|
|
718
|
+
attempted.push({ account: label, ok: false, error: `429 限流,冷却 ${dur}` });
|
|
719
|
+
logger.debug('ZCODE-GW', '{label} 429 限流,冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
// 其他错误:友好化展示 + 原样透传给客户端
|
|
723
|
+
const errorSummary = formatUpstreamError(upstream.status, text);
|
|
591
724
|
logger.warn('ZCODE-GW', '{label} 上游错误 {status} — {summary}', { label, status: upstream.status, summary: errorSummary });
|
|
592
|
-
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
|
|
593
|
-
res.end(text);
|
|
594
|
-
return true;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const failedDetail = summarizeAttempts(attempted);
|
|
725
|
+
res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
|
|
726
|
+
res.end(text);
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const failedDetail = summarizeAttempts(attempted);
|
|
598
731
|
logger.warn('ZCODE-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
599
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
600
|
-
res.end(JSON.stringify({ error: 'ZCode 网关:所有账号尝试均失败', attempts: attempted }));
|
|
601
|
-
return true;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// ── 面板状态 ──
|
|
605
|
-
|
|
606
|
-
export async function gatewayStatus() {
|
|
607
|
-
const enabled = await gatewayEnabled();
|
|
608
|
-
const hasCaptcha = Boolean(getZcodeCaptchaProvider());
|
|
609
|
-
const all = (await loadAccounts()).filter((a) => a.provider === 'zcode');
|
|
610
|
-
let withJwt = 0;
|
|
611
|
-
for (const a of all) { try { claimToken(a); withJwt++; } catch { /* 无 plan JWT */ } }
|
|
612
|
-
const now = Date.now();
|
|
613
|
-
const settings = await loadSettings();
|
|
614
|
-
return {
|
|
615
|
-
enabled,
|
|
616
|
-
hasCaptcha,
|
|
617
|
-
lan: settings.zcodeGatewayLan === true,
|
|
618
|
-
allow: Array.isArray(settings.zcodeGatewayAllow) ? settings.zcodeGatewayAllow : [],
|
|
619
|
-
suggestAllow: gatewayHostSuggestion(),
|
|
620
|
-
stats: {
|
|
621
|
-
lastCallAt: stats.lastCallAt,
|
|
622
|
-
calls: stats.calls,
|
|
623
|
-
lastAccount: stats.lastAccount,
|
|
624
|
-
},
|
|
625
|
-
exhausted: all
|
|
626
|
-
.filter((a) => isQuotaExhausted(a.id))
|
|
627
|
-
.map((a) => ({ id: a.id, name: a.name || a.uid || a.id, remaining: quotaCache.get(a.id)?.remaining ?? 0 })),
|
|
628
|
-
accounts: all.length,
|
|
629
|
-
accountsWithJwt: withJwt,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
732
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
733
|
+
res.end(JSON.stringify({ error: 'ZCode 网关:所有账号尝试均失败', attempts: attempted }));
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── 面板状态 ──
|
|
738
|
+
|
|
739
|
+
export async function gatewayStatus() {
|
|
740
|
+
const enabled = await gatewayEnabled();
|
|
741
|
+
const hasCaptcha = Boolean(getZcodeCaptchaProvider());
|
|
742
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'zcode');
|
|
743
|
+
let withJwt = 0;
|
|
744
|
+
for (const a of all) { try { claimToken(a); withJwt++; } catch { /* 无 plan JWT */ } }
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
const settings = await loadSettings();
|
|
747
|
+
return {
|
|
748
|
+
enabled,
|
|
749
|
+
hasCaptcha,
|
|
750
|
+
lan: settings.zcodeGatewayLan === true,
|
|
751
|
+
allow: Array.isArray(settings.zcodeGatewayAllow) ? settings.zcodeGatewayAllow : [],
|
|
752
|
+
suggestAllow: gatewayHostSuggestion(),
|
|
753
|
+
stats: {
|
|
754
|
+
lastCallAt: stats.lastCallAt,
|
|
755
|
+
calls: stats.calls,
|
|
756
|
+
lastAccount: stats.lastAccount,
|
|
757
|
+
},
|
|
758
|
+
exhausted: all
|
|
759
|
+
.filter((a) => isQuotaExhausted(a.id))
|
|
760
|
+
.map((a) => ({ id: a.id, name: a.name || a.uid || a.id, remaining: quotaCache.get(a.id)?.remaining ?? 0 })),
|
|
761
|
+
accounts: all.length,
|
|
762
|
+
accountsWithJwt: withJwt,
|
|
763
|
+
// 会话粘滞:当前有多少段会话各自钉在一个号上(面板「接口使用中」徽标据此说明)
|
|
764
|
+
stickySessions: sessionSticky.size,
|
|
765
|
+
// cooling 保持字符串数组(历史形状,测试与面板按名字读);冷却细节走 coolingDetail
|
|
766
|
+
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
767
|
+
coolingDetail: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now)
|
|
768
|
+
.map((a) => {
|
|
769
|
+
const remainMs = (cooling.get(a.id) || 0) - now;
|
|
770
|
+
return {
|
|
771
|
+
id: a.id, name: a.name || a.uid || a.id,
|
|
772
|
+
// 秒数为准:退避阶梯第一档只有 30s,按分钟四舍五入会显示成 1 分钟
|
|
773
|
+
remainMs, remainSeconds: Math.max(0, Math.ceil(remainMs / 1000)),
|
|
774
|
+
remainText: formatDurationMs(remainMs),
|
|
775
|
+
streak: coolingStreak.get(a.id) || 0,
|
|
776
|
+
};
|
|
777
|
+
}),
|
|
778
|
+
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
779
|
+
endpoint: '/gateway/zcode/v1/messages',
|
|
780
|
+
note: hasCaptcha ? null : '需要桌面版 CreditDaddy(隐藏窗口验证码),纯 CLI / NAS 环境不可用',
|
|
781
|
+
};
|
|
782
|
+
}
|