creditdaddy 1.2.0 → 1.3.0
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/LICENSE +21 -21
- package/README.md +16 -8
- package/package.json +2 -2
- package/src/accounts.js +18 -7
- package/src/authDevice.js +3 -0
- package/src/catpawClient.js +103 -103
- package/src/catpawLocal.js +229 -229
- package/src/checkin.js +7 -2
- package/src/constants.js +4 -2
- package/src/daemon.js +73 -11
- package/src/localDetect.js +147 -147
- package/src/logger.js +60 -7
- package/src/minimaxAuth.js +127 -0
- package/src/minimaxClient.js +330 -0
- package/src/minimaxGateway.js +404 -0
- package/src/minimaxLocal.js +259 -0
- package/src/mirasimClient.js +189 -189
- package/src/panel.html +214 -29
- package/src/providers.js +26 -0
- package/src/qoderApp.js +3 -9
- package/src/qoderClient.js +237 -237
- package/src/qoderUmid.js +198 -198
- package/src/store.js +49 -6
- package/src/tenrouter.js +8 -0
- package/src/tenrouterAccounts.js +7 -0
- package/src/transfer.js +24 -1
- package/src/usageSync.js +505 -505
- package/src/winProc.js +36 -0
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -97
- package/src/zcodeClient.js +30 -13
- package/src/zcodeGateway.js +79 -33
- package/src/zcrypto.js +160 -160
package/src/zcodeAutoClaim.js
CHANGED
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ZCode 活动自动领取 — 签到调度器每轮调用。
|
|
3
|
-
*
|
|
4
|
-
* 策略(对齐用户手动路径,逐级尝试):
|
|
5
|
-
* 1. billing/preview 查可领活动;为空 → 无事可做(返回 null,不留痕迹)
|
|
6
|
-
* 2. 先直接 claim(不带验证码:服务端未风控时可直接成功)
|
|
7
|
-
* 3. 返回 3007/3001(需要验证码 / 参数被拒)且有验证码提供者 → 拉 captcha 配置,
|
|
8
|
-
* 让提供者(桌面版的隐藏窗口跑阿里云验证码 SDK 静默验证)拿 captchaVerifyParam 后重试
|
|
9
|
-
* 4. 没有提供者(NAS / 纯 CLI)或验证码需要人工 → 标记「需手动领取」,卡片上提示
|
|
10
|
-
* - 1003(已领取过)按成功口径处理并记忆
|
|
11
|
-
* 结果写入账号 lastResult(签到日历记忆用的是 status checked-in/already,第二天照常重查 preview)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { logger } from './logger.js';
|
|
15
|
-
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
|
|
16
|
-
|
|
17
|
-
// 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
|
|
18
|
-
let captchaProvider = null;
|
|
19
|
-
export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'function' ? fn : null; }
|
|
20
|
-
export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
|
|
21
|
-
export function getZcodeCaptchaProvider() { return captchaProvider; }
|
|
22
|
-
|
|
23
|
-
const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* 为单个 ZCode 账号执行一次自动领取。
|
|
27
|
-
* @returns {null | { status, message, claims: Array }} null = preview 为空或查询失败(已记日志,不上卡片)
|
|
28
|
-
*/
|
|
29
|
-
export async function zcodeAutoClaim(account) {
|
|
30
|
-
const label = account.name || account.uid || account.id;
|
|
31
|
-
let plans;
|
|
32
|
-
try {
|
|
33
|
-
plans = (await fetchClaimPlans(account)).plans;
|
|
34
|
-
} catch (e) {
|
|
35
|
-
logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
if (!plans.length) return null;
|
|
39
|
-
|
|
40
|
-
const claims = [];
|
|
41
|
-
let anySuccess = false;
|
|
42
|
-
let needManual = false;
|
|
43
|
-
|
|
44
|
-
for (const plan of plans) {
|
|
45
|
-
const planName = plan.name || plan.planId;
|
|
46
|
-
// 第 2 步:先试不带验证码
|
|
47
|
-
try {
|
|
48
|
-
const r = await claimPlan(account, plan.planId, {});
|
|
49
|
-
claims.push({ plan: planName, via: 'direct', ok: true });
|
|
50
|
-
anySuccess = true;
|
|
51
|
-
logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
|
|
52
|
-
continue;
|
|
53
|
-
} catch (e) {
|
|
54
|
-
if (e.code === 1003) {
|
|
55
|
-
claims.push({ plan: planName, ok: true, already: true });
|
|
56
|
-
logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
if (e.code !== 3007 && e.code !== 3001) {
|
|
60
|
-
claims.push({ plan: planName, ok: false, message: e.message });
|
|
61
|
-
logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// 第 3 步:需要验证码
|
|
66
|
-
if (!captchaProvider) {
|
|
67
|
-
needManual = true;
|
|
68
|
-
claims.push({ plan: planName, ok: false, needManual: true });
|
|
69
|
-
logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
const cfg = await fetchCaptchaConfig();
|
|
74
|
-
if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
|
|
75
|
-
const { captchaParam, region } = await captchaProvider(cfg);
|
|
76
|
-
await claimPlan(account, plan.planId, { captchaParam, region });
|
|
77
|
-
claims.push({ plan: planName, via: 'captcha', ok: true });
|
|
78
|
-
anySuccess = true;
|
|
79
|
-
logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
|
|
80
|
-
} catch (e2) {
|
|
81
|
-
needManual = true;
|
|
82
|
-
claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
|
|
83
|
-
logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
|
|
88
|
-
const status = names.length ? 'checked-in'
|
|
89
|
-
: claims.every((c) => c.already) ? 'already'
|
|
90
|
-
: 'failed';
|
|
91
|
-
const message = status === 'checked-in'
|
|
92
|
-
? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
|
|
93
|
-
: status === 'already' ? '活动均已领取过'
|
|
94
|
-
: needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
|
|
95
|
-
: `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
|
|
96
|
-
return { status, message, claims };
|
|
97
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ZCode 活动自动领取 — 签到调度器每轮调用。
|
|
3
|
+
*
|
|
4
|
+
* 策略(对齐用户手动路径,逐级尝试):
|
|
5
|
+
* 1. billing/preview 查可领活动;为空 → 无事可做(返回 null,不留痕迹)
|
|
6
|
+
* 2. 先直接 claim(不带验证码:服务端未风控时可直接成功)
|
|
7
|
+
* 3. 返回 3007/3001(需要验证码 / 参数被拒)且有验证码提供者 → 拉 captcha 配置,
|
|
8
|
+
* 让提供者(桌面版的隐藏窗口跑阿里云验证码 SDK 静默验证)拿 captchaVerifyParam 后重试
|
|
9
|
+
* 4. 没有提供者(NAS / 纯 CLI)或验证码需要人工 → 标记「需手动领取」,卡片上提示
|
|
10
|
+
* - 1003(已领取过)按成功口径处理并记忆
|
|
11
|
+
* 结果写入账号 lastResult(签到日历记忆用的是 status checked-in/already,第二天照常重查 preview)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { logger } from './logger.js';
|
|
15
|
+
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig } from './zcodeClient.js';
|
|
16
|
+
|
|
17
|
+
// 桌面版(Electron 主进程)注册的隐藏窗口验证码实现;NAS / CLI 为 null(走「需手动领取」分支)
|
|
18
|
+
let captchaProvider = null;
|
|
19
|
+
export function setZcodeCaptchaProvider(fn) { captchaProvider = typeof fn === 'function' ? fn : null; }
|
|
20
|
+
export function hasZcodeCaptchaProvider() { return Boolean(captchaProvider); }
|
|
21
|
+
export function getZcodeCaptchaProvider() { return captchaProvider; }
|
|
22
|
+
|
|
23
|
+
const grantsText = (plan) => (plan.grants && plan.grants.length ? plan.grants.join(';') : '');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 为单个 ZCode 账号执行一次自动领取。
|
|
27
|
+
* @returns {null | { status, message, claims: Array }} null = preview 为空或查询失败(已记日志,不上卡片)
|
|
28
|
+
*/
|
|
29
|
+
export async function zcodeAutoClaim(account) {
|
|
30
|
+
const label = account.name || account.uid || account.id;
|
|
31
|
+
let plans;
|
|
32
|
+
try {
|
|
33
|
+
plans = (await fetchClaimPlans(account)).plans;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
logger.debug('ZCODE-CLAIM', `${label} 活动查询失败:${e.message}`);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (!plans.length) return null;
|
|
39
|
+
|
|
40
|
+
const claims = [];
|
|
41
|
+
let anySuccess = false;
|
|
42
|
+
let needManual = false;
|
|
43
|
+
|
|
44
|
+
for (const plan of plans) {
|
|
45
|
+
const planName = plan.name || plan.planId;
|
|
46
|
+
// 第 2 步:先试不带验证码
|
|
47
|
+
try {
|
|
48
|
+
const r = await claimPlan(account, plan.planId, {});
|
|
49
|
+
claims.push({ plan: planName, via: 'direct', ok: true });
|
|
50
|
+
anySuccess = true;
|
|
51
|
+
logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(免验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
|
|
52
|
+
continue;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.code === 1003) {
|
|
55
|
+
claims.push({ plan: planName, ok: true, already: true });
|
|
56
|
+
logger.info('ZCODE-CLAIM', `${label}「${planName}」已领取过`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (e.code !== 3007 && e.code !== 3001) {
|
|
60
|
+
claims.push({ plan: planName, ok: false, message: e.message });
|
|
61
|
+
logger.warn('ZCODE-CLAIM', `${label} 领取「${planName}」失败:${e.message}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 第 3 步:需要验证码
|
|
66
|
+
if (!captchaProvider) {
|
|
67
|
+
needManual = true;
|
|
68
|
+
claims.push({ plan: planName, ok: false, needManual: true });
|
|
69
|
+
logger.info('ZCODE-CLAIM', `${label}「${planName}」需要验证码,本环境无法自动通过,请在面板手动领取`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const cfg = await fetchCaptchaConfig();
|
|
74
|
+
if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
|
|
75
|
+
const { captchaParam, region } = await captchaProvider(cfg);
|
|
76
|
+
await claimPlan(account, plan.planId, { captchaParam, region });
|
|
77
|
+
claims.push({ plan: planName, via: 'captcha', ok: true });
|
|
78
|
+
anySuccess = true;
|
|
79
|
+
logger.info('ZCODE-CLAIM', `${label} 已领取「${planName}」(静默验证码)${grantsText(plan) ? ':' + grantsText(plan) : ''}`);
|
|
80
|
+
} catch (e2) {
|
|
81
|
+
needManual = true;
|
|
82
|
+
claims.push({ plan: planName, ok: false, needManual: true, message: e2.message });
|
|
83
|
+
logger.warn('ZCODE-CLAIM', `${label}「${planName}」验证码未完成:${e2.message}(可在面板手动领取)`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const names = claims.filter((c) => c.ok && !c.already).map((c) => c.plan);
|
|
88
|
+
const status = names.length ? 'checked-in'
|
|
89
|
+
: claims.every((c) => c.already) ? 'already'
|
|
90
|
+
: 'failed';
|
|
91
|
+
const message = status === 'checked-in'
|
|
92
|
+
? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
|
|
93
|
+
: status === 'already' ? '活动均已领取过'
|
|
94
|
+
: needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
|
|
95
|
+
: `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
|
|
96
|
+
return { status, message, claims };
|
|
97
|
+
}
|
package/src/zcodeClient.js
CHANGED
|
@@ -205,7 +205,7 @@ function connectTunnel(proxyUrl, target) {
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
208
|
+
async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
|
|
209
209
|
const proxy = effectiveProxy();
|
|
210
210
|
if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(面板配置或 HTTPS_PROXY 环境变量)');
|
|
211
211
|
const u = new URL(url);
|
|
@@ -227,12 +227,22 @@ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, t
|
|
|
227
227
|
};
|
|
228
228
|
}
|
|
229
229
|
return new Promise((resolve, reject) => {
|
|
230
|
+
// request 的 timeout 是「无活动」超时:对端每 14 秒吐一个字节就能一直吊着请求不放。
|
|
231
|
+
// 这里再补一个整条请求的墙钟上限,并接上外部 abort(客户端断开 / 上层取消)。
|
|
232
|
+
const deadline = setTimeout(() => req.destroy(new Error('请求超时')), timeoutMs);
|
|
233
|
+
const onAbort = () => req.destroy(Object.assign(new Error('请求已取消'), { name: 'AbortError' }));
|
|
234
|
+
if (signal) {
|
|
235
|
+
if (signal.aborted) { clearTimeout(deadline); reject(signal.reason || new Error('请求已取消')); return; }
|
|
236
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
237
|
+
}
|
|
238
|
+
const done = (fn, arg) => { clearTimeout(deadline); signal?.removeEventListener('abort', onAbort); fn(arg); };
|
|
230
239
|
const req = (isHttps ? https : http).request(requestOpts, (res) => {
|
|
231
240
|
const chunks = [];
|
|
232
241
|
res.on('data', (c) => chunks.push(c));
|
|
233
|
-
res.on('end', () => resolve
|
|
242
|
+
res.on('end', () => done(resolve, new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
|
|
243
|
+
res.on('error', (e) => done(reject, e));
|
|
234
244
|
});
|
|
235
|
-
req.on('error', reject);
|
|
245
|
+
req.on('error', (e) => done(reject, e));
|
|
236
246
|
req.on('timeout', () => req.destroy(new Error('请求超时')));
|
|
237
247
|
if (body != null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
|
238
248
|
req.end();
|
|
@@ -250,18 +260,25 @@ let viaProxyImpl = null;
|
|
|
250
260
|
export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
|
|
251
261
|
|
|
252
262
|
export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
|
|
253
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
254
|
-
const abortSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
|
|
255
|
-
const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
|
|
256
263
|
const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
// 超时信号必须每次尝试各建一个:共用一个的话,代理优先模式下第一条路吃完 timeoutMs,
|
|
265
|
+
// 兜底那条就对着已触发的 signal 发请求,必定瞬间失败——「代理优先」就此形同虚设
|
|
266
|
+
const makeAttempt = () => {
|
|
267
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
268
|
+
const abortSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
|
|
269
|
+
const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
|
|
270
|
+
return {
|
|
271
|
+
direct: () => fetch(url, base),
|
|
272
|
+
viaProxy: () => (viaProxyImpl
|
|
273
|
+
? viaProxyImpl(url, { method, headers, body, timeoutMs, signal: abortSignal })
|
|
274
|
+
: fetchViaProxy(url, { method, headers, body, timeoutMs, signal: abortSignal })),
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
const order = useProxy ? (proxyFirst() ? ['viaProxy', 'direct'] : ['direct', 'viaProxy']) : ['direct'];
|
|
262
278
|
let lastErr = null;
|
|
263
|
-
for (const
|
|
264
|
-
|
|
279
|
+
for (const which of order) {
|
|
280
|
+
const attempts = makeAttempt();
|
|
281
|
+
try { return await attempts[which](); } catch (e) { lastErr = e; }
|
|
265
282
|
}
|
|
266
283
|
throw lastErr || new Error('fetch failed');
|
|
267
284
|
}
|
package/src/zcodeGateway.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ZCode 免费额度网关 — Start Plan 体验包(GLM-5.3-Flash)的本地补全代理。
|
|
3
3
|
*
|
|
4
|
-
* 数据面:POST /gateway/v1/messages(Anthropic /v1/messages 形态,10router
|
|
4
|
+
* 数据面:POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态,10router
|
|
5
5
|
* 建一个 anthropic-compatible 自定义节点指向这里即可)。
|
|
6
6
|
*
|
|
7
7
|
* 链路:账号轮换(store 里 provider=zcode、可解析出 zcodejwttoken 的账号)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* daemon 加绑定/密钥,见 README)。NAS/纯 CLI 环境没有验证码提供者,网关开不了。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { claimToken,
|
|
19
|
+
import { claimToken, fetchCaptchaConfig, fetchJsonRace, fetchZcodeQuota } from './zcodeClient.js';
|
|
20
20
|
import { readFileSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -35,7 +35,7 @@ import { gatewayHostSuggestion } from './tenrouter.js';
|
|
|
35
35
|
let completionProvider = null;
|
|
36
36
|
export function setZcodeCompletionProvider(fn) { completionProvider = typeof fn === 'function' ? fn : null; }
|
|
37
37
|
export function getZcodeCompletionProvider() { return completionProvider; }
|
|
38
|
-
import { loadAccounts, loadSettings, saveSettings, loadState,
|
|
38
|
+
import { loadAccounts, loadSettings, saveSettings, loadState, withState } from './store.js';
|
|
39
39
|
import crypto from 'node:crypto';
|
|
40
40
|
import os from 'node:os';
|
|
41
41
|
import { logger } from './logger.js';
|
|
@@ -53,14 +53,21 @@ export async function gatewayEnabled() {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
export async function setGatewayEnabled(v) {
|
|
56
|
-
|
|
56
|
+
const on = v === true;
|
|
57
|
+
await saveSettings({ zcodeGateway: on });
|
|
58
|
+
// 开启网关视为「重新开始」:清掉进程内的拉黑/冷却状态,
|
|
59
|
+
// 否则重新登录导入后旧黑名单仍会把账号挡在轮换队列外(与 MiniMax 网关同款 bug)。
|
|
60
|
+
if (on) {
|
|
61
|
+
dead.clear();
|
|
62
|
+
cooling.clear();
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
// ── 账号轮换状态(进程内;重启即清零) ──
|
|
60
67
|
|
|
61
68
|
let rrIndex = 0;
|
|
62
69
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
63
|
-
const dead = new
|
|
70
|
+
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新登录导入,自动复活)
|
|
64
71
|
|
|
65
72
|
function markCooling(id, ms = ACCOUNT_COOLING_MS) { cooling.set(id, Date.now() + ms); }
|
|
66
73
|
|
|
@@ -76,7 +83,29 @@ export function __resetForTests() {
|
|
|
76
83
|
stats.calls = 0;
|
|
77
84
|
stats.lastAccount = null;
|
|
78
85
|
}
|
|
79
|
-
function markDead(
|
|
86
|
+
function markDead(account) {
|
|
87
|
+
dead.set(account.id, credFingerprint(account));
|
|
88
|
+
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 拉黑时的凭据指纹:重新登录/重新导入会整体替换 meta.credentials,指纹变化即可复活 */
|
|
92
|
+
function credFingerprint(a) {
|
|
93
|
+
return `${a.meta?.credentials?.zcodejwttoken || ''}|${a.token || ''}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 账号是否仍处于拉黑状态。拉黑记的是「当时那份凭据」的指纹:
|
|
98
|
+
* 重新登录导入换了 JWT 后指纹不匹配 → 自动复活(清黑名单与冷却)。
|
|
99
|
+
*/
|
|
100
|
+
function isDead(account) {
|
|
101
|
+
const fp = dead.get(account.id);
|
|
102
|
+
if (fp === undefined) return false;
|
|
103
|
+
if (fp === credFingerprint(account)) return true;
|
|
104
|
+
dead.delete(account.id);
|
|
105
|
+
cooling.delete(account.id);
|
|
106
|
+
logger.info('ZCODE-GW', `${account.name || account.uid || account.id} 凭据已更新,解除拉黑`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
80
109
|
|
|
81
110
|
/** 可参与轮换的账号(有可解析 plan JWT、未冷却/未拉黑),按轮转序排列 */
|
|
82
111
|
async function rotationQueue() {
|
|
@@ -85,7 +114,7 @@ async function rotationQueue() {
|
|
|
85
114
|
const now = Date.now();
|
|
86
115
|
const ready = [];
|
|
87
116
|
for (const a of all) {
|
|
88
|
-
if (
|
|
117
|
+
if (isDead(a)) continue;
|
|
89
118
|
if (isQuotaExhausted(a.id)) continue; // 0 额度打标,不再轮换
|
|
90
119
|
if ((cooling.get(a.id) || 0) > now) continue;
|
|
91
120
|
try { claimToken(a); ready.push(a); } catch { /* 快照里没有 plan JWT,跳过 */ }
|
|
@@ -102,7 +131,7 @@ async function unavailableReason() {
|
|
|
102
131
|
const now = Date.now();
|
|
103
132
|
const n = { dead: 0, exhausted: 0, cooling: 0, noJwt: 0 };
|
|
104
133
|
for (const a of all) {
|
|
105
|
-
if (
|
|
134
|
+
if (isDead(a)) n.dead++;
|
|
106
135
|
else if (isQuotaExhausted(a.id)) n.exhausted++;
|
|
107
136
|
else if ((cooling.get(a.id) || 0) > now) n.cooling++;
|
|
108
137
|
else n.noJwt++; // 未拉黑/未打标/未冷却却不在队列 → 快照里没有可解析的 plan JWT
|
|
@@ -145,10 +174,9 @@ async function hydrateMarks() {
|
|
|
145
174
|
|
|
146
175
|
async function persistMark(accountId, entry) {
|
|
147
176
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
await saveState({ ...st, zcodeGatewayExhausted: saved });
|
|
177
|
+
await withState((st) => {
|
|
178
|
+
st.zcodeGatewayExhausted = { ...(st.zcodeGatewayExhausted || {}), [accountId]: entry };
|
|
179
|
+
});
|
|
152
180
|
} catch { /* 持久化失败不影响内存态 */ }
|
|
153
181
|
}
|
|
154
182
|
|
|
@@ -160,12 +188,9 @@ export async function clearQuotaMark(accountId) {
|
|
|
160
188
|
quotaCache.delete(accountId);
|
|
161
189
|
if (!dead.has(accountId)) cooling.delete(accountId);
|
|
162
190
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
delete saved[accountId];
|
|
167
|
-
await saveState({ ...st, zcodeGatewayExhausted: saved });
|
|
168
|
-
}
|
|
191
|
+
await withState((st) => {
|
|
192
|
+
if (st?.zcodeGatewayExhausted?.[accountId]) delete st.zcodeGatewayExhausted[accountId];
|
|
193
|
+
});
|
|
169
194
|
} catch { /* 持久化失败不影响内存态 */ }
|
|
170
195
|
}
|
|
171
196
|
|
|
@@ -234,7 +259,14 @@ function readRawBody(req, limitBytes = 20 * 1024 * 1024) {
|
|
|
234
259
|
let size = 0;
|
|
235
260
|
req.on('data', (c) => {
|
|
236
261
|
size += c.length;
|
|
237
|
-
if (size > limitBytes) {
|
|
262
|
+
if (size > limitBytes) {
|
|
263
|
+
// 只 pause 不 destroy:destroy 会把 socket 打死,调用方随后写的 413 就发不出去,
|
|
264
|
+
// 客户端只看到 ECONNRESET,分不清「自己请求体写太大」和「网络断了」。
|
|
265
|
+
// 响应写完(res 'finish')再由调用方收尾断开。
|
|
266
|
+
req.pause();
|
|
267
|
+
reject(Object.assign(new Error(`request body too large (limit ${Math.floor(limitBytes / 1024 / 1024)}MB)`), { status: 413 }));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
238
270
|
chunks.push(c);
|
|
239
271
|
});
|
|
240
272
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
@@ -242,16 +274,26 @@ function readRawBody(req, limitBytes = 20 * 1024 * 1024) {
|
|
|
242
274
|
});
|
|
243
275
|
}
|
|
244
276
|
|
|
245
|
-
// 业务码可能包在 HTTP 200 里(上游对部分错误回 200 + {code}
|
|
277
|
+
// 业务码可能包在 HTTP 200 里(上游对部分错误回 200 + {code}),两类都要看。
|
|
278
|
+
// 裸数字必须加 \b 词边界:否则 "code":4010 会被当成 401 认证失败(→ 账号被永久拉黑),
|
|
279
|
+
// 耗时 10050ms / request-id=11137 / 30071 也会被当成额度耗尽或验证码(→ 冷却 30 分钟 / 空跑一次验证码)。
|
|
246
280
|
const isCaptchaError = (status, text) =>
|
|
247
|
-
status === 405 || /"code"\s*:\s*(3007|3012)
|
|
281
|
+
status === 405 || /"code"\s*:\s*(3007|3012)\b|\b(3007|3012)\b|captcha|unusual activity/i.test(text);
|
|
248
282
|
|
|
249
283
|
const isAuthError = (status, text) =>
|
|
250
|
-
status === 401 || /"code"\s*:\s*401
|
|
284
|
+
status === 401 || /"code"\s*:\s*401\b|\b401\b|令牌已过期|验证不正确/.test(text);
|
|
251
285
|
|
|
252
286
|
const isExhausted = (status, text) =>
|
|
253
|
-
status === 402 || /"code"\s*:\s*(1005|1113)
|
|
254
|
-
|
|
287
|
+
status === 402 || /"code"\s*:\s*(1005|1113)\b|\b(1113|1005)\b|余额不足|无可用资源包|exceed quota|insufficient/i.test(text);
|
|
288
|
+
|
|
289
|
+
// 上游成功也会带 code(0 / 200),别把它们当业务错误——与 zcodeClient.businessOk 同一口径
|
|
290
|
+
const BUSINESS_OK = new Set([0, 200]);
|
|
291
|
+
/** 从上游 JSON 信封里取业务码;字符串数字("3007")也认,取不到返回 null */
|
|
292
|
+
function businessCode(v) {
|
|
293
|
+
if (!v || typeof v !== 'object' || v.code === undefined || v.code === null) return null;
|
|
294
|
+
const n = Number(v.code);
|
|
295
|
+
return Number.isFinite(n) ? n : null;
|
|
296
|
+
}
|
|
255
297
|
// 组装与 ZCode 客户端真实流量一致的 plan 请求(镜像 R1 捕获,2026-09-29)
|
|
256
298
|
function buildPlanRequest(rawBody, { token, userId }) {
|
|
257
299
|
const ver = '3.14.3';
|
|
@@ -335,7 +377,7 @@ export function remoteAllowed(remote, allowList) {
|
|
|
335
377
|
export async function handleGateway(req, res) {
|
|
336
378
|
if (req.method !== 'POST') {
|
|
337
379
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
338
|
-
return res.end(JSON.stringify({ error: 'POST /gateway/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
380
|
+
return res.end(JSON.stringify({ error: 'POST /gateway/zcode/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
339
381
|
}
|
|
340
382
|
if (!(await gatewayEnabled())) {
|
|
341
383
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
@@ -361,6 +403,8 @@ export async function handleGateway(req, res) {
|
|
|
361
403
|
let rawBody;
|
|
362
404
|
try { rawBody = await readRawBody(req); } catch (e) {
|
|
363
405
|
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
406
|
+
// 超限时请求体还没读完:先把 413 发出去,发完再断开,避免客户端继续往这灌数据
|
|
407
|
+
res.on('finish', () => req.destroy());
|
|
364
408
|
return res.end(JSON.stringify({ error: e.message }));
|
|
365
409
|
}
|
|
366
410
|
|
|
@@ -380,12 +424,12 @@ export async function handleGateway(req, res) {
|
|
|
380
424
|
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length * 2); i++) {
|
|
381
425
|
const account = queue[i % queue.length];
|
|
382
426
|
// 本轮已被打标/冷却/拉黑的号不再重打(第二圈只为验证码重试准备)
|
|
383
|
-
if (
|
|
427
|
+
if (isDead(account) || isQuotaExhausted(account.id) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
384
428
|
const label = account.name || account.uid || account.id;
|
|
385
429
|
let token;
|
|
386
430
|
try { token = claimToken(account); } catch (e) {
|
|
387
431
|
attempted.push({ account: label, ok: false, error: e.message });
|
|
388
|
-
markDead(account
|
|
432
|
+
markDead(account);
|
|
389
433
|
continue;
|
|
390
434
|
}
|
|
391
435
|
|
|
@@ -421,7 +465,9 @@ export async function handleGateway(req, res) {
|
|
|
421
465
|
const text = await upstream.text().catch(() => '');
|
|
422
466
|
let v;
|
|
423
467
|
try { v = JSON.parse(text); } catch { v = null; }
|
|
424
|
-
|
|
468
|
+
const code = businessCode(v);
|
|
469
|
+
// 上游成功信封也可能带 code(0 / 200),那不是业务错误;字符串数字("3007")同样要拦
|
|
470
|
+
if (code !== null && !BUSINESS_OK.has(code)) {
|
|
425
471
|
if (isCaptchaError(200, text)) {
|
|
426
472
|
invalidateCaptcha();
|
|
427
473
|
attempted.push({ account: label, ok: false, error: '验证码被拒(200 业务码)', captcha: true });
|
|
@@ -429,7 +475,7 @@ export async function handleGateway(req, res) {
|
|
|
429
475
|
continue;
|
|
430
476
|
}
|
|
431
477
|
if (isAuthError(200, text)) {
|
|
432
|
-
markDead(account
|
|
478
|
+
markDead(account);
|
|
433
479
|
attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑' });
|
|
434
480
|
logger.info('ZCODE-GW', `${label} JWT 失效(200 业务码)`);
|
|
435
481
|
continue;
|
|
@@ -505,7 +551,7 @@ export async function handleGateway(req, res) {
|
|
|
505
551
|
continue;
|
|
506
552
|
}
|
|
507
553
|
if (isAuthError(upstream.status, text)) {
|
|
508
|
-
markDead(account
|
|
554
|
+
markDead(account);
|
|
509
555
|
attempted.push({ account: label, ok: false, error: 'JWT 已失效,账号拉黑(重新登录后再导入)' });
|
|
510
556
|
logger.debug('ZCODE-GW', `${label} JWT 失效,拉黑`);
|
|
511
557
|
continue;
|
|
@@ -562,9 +608,9 @@ export async function gatewayStatus() {
|
|
|
562
608
|
.map((a) => ({ id: a.id, name: a.name || a.uid || a.id, remaining: quotaCache.get(a.id)?.remaining ?? 0 })),
|
|
563
609
|
accounts: all.length,
|
|
564
610
|
accountsWithJwt: withJwt,
|
|
565
|
-
cooling: all.filter((a) => (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
566
|
-
dead:
|
|
567
|
-
endpoint: '/gateway/v1/messages',
|
|
611
|
+
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
612
|
+
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
613
|
+
endpoint: '/gateway/zcode/v1/messages',
|
|
568
614
|
note: hasCaptcha ? null : '需要桌面版 CreditDaddy(隐藏窗口验证码),纯 CLI / NAS 环境不可用',
|
|
569
615
|
};
|
|
570
616
|
}
|