creditdaddy 0.8.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 -0
- package/README.md +224 -0
- package/bin/creditdaddy.js +187 -0
- package/package.json +28 -0
- package/src/accounts.js +143 -0
- package/src/authDevice.js +205 -0
- package/src/checkin.js +200 -0
- package/src/constants.js +79 -0
- package/src/daemon.js +585 -0
- package/src/localDetect.js +147 -0
- package/src/logger.js +27 -0
- package/src/panel.html +1785 -0
- package/src/providers.js +75 -0
- package/src/qoderApp.js +330 -0
- package/src/qoderClient.js +199 -0
- package/src/qoderUmid.js +177 -0
- package/src/store.js +187 -0
- package/src/tenrouter.js +221 -0
- package/src/transfer.js +141 -0
- package/src/usageSync.js +319 -0
- package/src/workbuddyAuth.js +126 -0
- package/src/workbuddyClient.js +257 -0
- package/src/workbuddyLocal.js +133 -0
- package/src/zcodeAuth.js +131 -0
- package/src/zcodeClient.js +495 -0
- package/src/zcodeLocal.js +217 -0
- package/src/zcrypto.js +160 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZCode(智谱 GLM / Z.ai)API 客户端 — 额度查询。协议移植自 zcode-switch(MIT)src-tauri/src/quota.rs。
|
|
3
|
+
*
|
|
4
|
+
* GET https://open.bigmodel.cn/api/monitor/usage/quota/limit Coding Plan 窗口额度(提示次数 / 使用时长)
|
|
5
|
+
* GET https://open.bigmodel.cn/api/biz/subscription/list 当前订阅(套餐名 / 到期)
|
|
6
|
+
* GET https://zcode.z.ai/api/v1/zcode-plan/billing/balance Start Plan / Z.ai 渠道余额
|
|
7
|
+
* 以上都是 ZCode 客户端自身调用的官方接口;鉴权 token 在多个候选里依次尝试:
|
|
8
|
+
* config.json 中 coding-plan provider 的 apiKey → zcodejwttoken → oauth:<provider>:access_token
|
|
9
|
+
*
|
|
10
|
+
* 活动领取(同样移植自 zcode-switch claim.rs):
|
|
11
|
+
* GET https://zcode.z.ai/api/v1/zcode-plan/billing/preview 可领取的活动套餐列表
|
|
12
|
+
* POST https://zcode.z.ai/api/v1/zcode-plan/billing/claim 领取(需 X-Aliyun-Captcha-Verify-Param,验证码在面板里跑)
|
|
13
|
+
* GET https://zcode.z.ai/api/v1/client/configs data.configs.captcha:验证码 sceneId / prefix / region
|
|
14
|
+
* ZCode 没有“每日签到”,providers.js 中 ZCode 不提供 checkin。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import https from 'node:https';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import tls from 'node:tls';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
26
|
+
import { defaultSecret, safeDecrypt } from './zcrypto.js';
|
|
27
|
+
|
|
28
|
+
const QUOTA_LIMIT_URL = 'https://open.bigmodel.cn/api/monitor/usage/quota/limit';
|
|
29
|
+
const SUBSCRIPTION_URL = 'https://open.bigmodel.cn/api/biz/subscription/list';
|
|
30
|
+
const BILLING_BALANCE_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/balance';
|
|
31
|
+
const BILLING_PREVIEW_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/preview';
|
|
32
|
+
const BILLING_CLAIM_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/claim';
|
|
33
|
+
const CLIENT_CONFIGS_URL = 'https://zcode.z.ai/api/v1/client/configs';
|
|
34
|
+
// 与 zcode-switch 一致:billing 类接口对客户端版本有校验(版本过低直接 3001 parameter error),
|
|
35
|
+
// 所以优先上报本机已安装 ZCode 的版本(注册表读取),没有客户端时退回兜底值。
|
|
36
|
+
const APP_VERSION_FALLBACK = '3.11.2';
|
|
37
|
+
let appVersionCache = null;
|
|
38
|
+
export function zcodeAppVersion() {
|
|
39
|
+
if (appVersionCache) return appVersionCache;
|
|
40
|
+
let v = null;
|
|
41
|
+
if (process.platform === 'win32') {
|
|
42
|
+
for (const hive of [
|
|
43
|
+
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
44
|
+
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
45
|
+
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
46
|
+
]) {
|
|
47
|
+
try {
|
|
48
|
+
const out = execFileSync('reg', ['query', hive, '/s'], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
|
|
49
|
+
const m = out.match(/ZCode[^\r\n]*\r?\n(?:.*\r?\n)*?\s*DisplayVersion\s+REG_SZ\s+([\d.]+)/)
|
|
50
|
+
|| out.match(/DisplayName\s+REG_SZ\s+ZCode[^\r\n]*[\s\S]{0,400}?DisplayVersion\s+REG_SZ\s+([\d.]+)/);
|
|
51
|
+
if (m) { v = m[1]; break; }
|
|
52
|
+
} catch { /* 继续下一个 hive */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
appVersionCache = v || APP_VERSION_FALLBACK;
|
|
56
|
+
return appVersionCache;
|
|
57
|
+
}
|
|
58
|
+
/** 测试用:重置版本探测缓存 */
|
|
59
|
+
export function _resetAppVersionCache(v) { appVersionCache = v || null; }
|
|
60
|
+
|
|
61
|
+
// ── HTTP 出口:直连优先 / 代理优先(可切换),任一路成功即返回 ──
|
|
62
|
+
|
|
63
|
+
function codeHome() {
|
|
64
|
+
return process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME || path.join(os.homedir(), '.creditdaddy');
|
|
65
|
+
}
|
|
66
|
+
const NET_PREFS_FILE = () => path.join(codeHome(), 'zcode-net.json');
|
|
67
|
+
|
|
68
|
+
let proxyFirstCache = null;
|
|
69
|
+
export function proxyFirst() {
|
|
70
|
+
if (proxyFirstCache !== null) return proxyFirstCache;
|
|
71
|
+
try { proxyFirstCache = JSON.parse(fs.readFileSync(NET_PREFS_FILE(), 'utf8')).proxyFirst === true; }
|
|
72
|
+
catch { proxyFirstCache = false; }
|
|
73
|
+
return proxyFirstCache;
|
|
74
|
+
}
|
|
75
|
+
export function setProxyFirst(v) {
|
|
76
|
+
proxyFirstCache = v === true;
|
|
77
|
+
fs.mkdirSync(codeHome(), { recursive: true, mode: 0o700 });
|
|
78
|
+
fs.writeFileSync(NET_PREFS_FILE(), JSON.stringify({ proxyFirst: proxyFirstCache }, null, 2), { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const envProxy = () => process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || null;
|
|
82
|
+
|
|
83
|
+
/** 目标是否应绕过代理(loopback / NO_PROXY 后缀匹配) */
|
|
84
|
+
function proxyBypass(url) {
|
|
85
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
86
|
+
if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.endsWith('.local')) return true;
|
|
87
|
+
for (const pat of (process.env.NO_PROXY || process.env.no_proxy || '').split(',')) {
|
|
88
|
+
const p = pat.trim().toLowerCase();
|
|
89
|
+
if (!p) continue;
|
|
90
|
+
const sfx = p.startsWith('.') ? p.slice(1) : p;
|
|
91
|
+
if (host === sfx || host.endsWith('.' + sfx)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 经 HTTP 代理访问目标(纯 node 内置模块实现,保持核心零依赖):
|
|
98
|
+
* https 目标走 CONNECT 隧道 + TLS;http 目标走绝对 URI 转发。
|
|
99
|
+
* 支持代理认证(URL 内嵌 user:password → Proxy-Authorization)。
|
|
100
|
+
*/
|
|
101
|
+
function connectTunnel(proxyUrl, target) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const p = new URL(proxyUrl);
|
|
104
|
+
const headers = {};
|
|
105
|
+
if (p.username || p.password) {
|
|
106
|
+
const auth = `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`;
|
|
107
|
+
headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`;
|
|
108
|
+
}
|
|
109
|
+
const req = http.request({ host: p.hostname, port: Number(p.port) || 80, method: 'CONNECT', path: target, headers, timeout: 10000 });
|
|
110
|
+
req.on('connect', (res, socket) => {
|
|
111
|
+
if (res.statusCode !== 200) { socket.destroy(); reject(new Error(`代理隧道失败(CONNECT ${res.statusCode})`)); return; }
|
|
112
|
+
resolve(socket);
|
|
113
|
+
});
|
|
114
|
+
req.on('error', reject);
|
|
115
|
+
req.on('timeout', () => req.destroy(new Error('连接代理超时')));
|
|
116
|
+
req.end();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
121
|
+
const proxy = envProxy();
|
|
122
|
+
if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(HTTPS_PROXY 未设置)');
|
|
123
|
+
const u = new URL(url);
|
|
124
|
+
const p = new URL(proxy);
|
|
125
|
+
const isHttps = u.protocol === 'https:';
|
|
126
|
+
let requestOpts;
|
|
127
|
+
if (isHttps) {
|
|
128
|
+
const socket = await connectTunnel(proxy, `${u.hostname}:443`);
|
|
129
|
+
const tlsSocket = tls.connect({ socket, servername: u.hostname });
|
|
130
|
+
requestOpts = {
|
|
131
|
+
hostname: u.hostname, port: 443, path: u.pathname + u.search, method,
|
|
132
|
+
headers: { ...headers, Host: u.host }, timeout: timeoutMs,
|
|
133
|
+
createConnection: () => tlsSocket,
|
|
134
|
+
};
|
|
135
|
+
} else {
|
|
136
|
+
requestOpts = {
|
|
137
|
+
hostname: p.hostname, port: Number(p.port) || 80, path: url, method,
|
|
138
|
+
headers: { ...headers, Host: u.host }, timeout: timeoutMs,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const req = (isHttps ? https : http).request(requestOpts, (res) => {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
res.on('data', (c) => chunks.push(c));
|
|
145
|
+
res.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
|
|
146
|
+
});
|
|
147
|
+
req.on('error', reject);
|
|
148
|
+
req.on('timeout', () => req.destroy(new Error('请求超时')));
|
|
149
|
+
if (body != null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
|
150
|
+
req.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 统一出口:按「代理优先」开关决定请求顺序,首个成功的返回。
|
|
156
|
+
* - 默认直连优先、代理兜底(无代理环境时等同纯直连,零行为变化)
|
|
157
|
+
* - 代理优先适合直连不稳 / 被拦的网络
|
|
158
|
+
* 返回 fetch Response;调用方按业务 code 自行判定(业务错误也算“成功送达”,不再回退)。
|
|
159
|
+
*/
|
|
160
|
+
// 测试可替换代理请求实现(生产代码不传)
|
|
161
|
+
let viaProxyImpl = null;
|
|
162
|
+
export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
|
|
163
|
+
|
|
164
|
+
export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
|
|
165
|
+
const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs) };
|
|
166
|
+
const useProxy = Boolean(envProxy()) && !proxyBypass(url);
|
|
167
|
+
const direct = () => fetch(url, base);
|
|
168
|
+
const viaProxy = viaProxyImpl
|
|
169
|
+
? () => viaProxyImpl(url, { method, headers, body, timeoutMs })
|
|
170
|
+
: () => fetchViaProxy(url, { method, headers, body, timeoutMs });
|
|
171
|
+
const attempts = useProxy ? (proxyFirst() ? [viaProxy, direct] : [direct, viaProxy]) : [direct];
|
|
172
|
+
let lastErr = null;
|
|
173
|
+
for (const attempt of attempts) {
|
|
174
|
+
try { return await attempt(); } catch (e) { lastErr = e; }
|
|
175
|
+
}
|
|
176
|
+
throw lastErr || new Error('fetch failed');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function getJsonRace(url, headers) {
|
|
180
|
+
const res = await fetchJsonRace(url, { headers });
|
|
181
|
+
const text = await res.text();
|
|
182
|
+
try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 领取失败码 → 中文提示(取自 zcode-switch i18n.rs) */
|
|
186
|
+
const CLAIM_FAIL = {
|
|
187
|
+
1001: '套餐不存在',
|
|
188
|
+
1002: '活动已结束或套餐暂不可领取',
|
|
189
|
+
1003: '该套餐已经领取过',
|
|
190
|
+
1004: '不符合领取条件',
|
|
191
|
+
1005: '今日领取名额已用完',
|
|
192
|
+
3001: '领取参数错误,请刷新后重试',
|
|
193
|
+
3007: '验证码校验失败,请重试',
|
|
194
|
+
401: '请先登录后再领取',
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const platform = () => `${process.platform}-${process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch}`;
|
|
198
|
+
|
|
199
|
+
function zaiHeaders(token, deviceMid) {
|
|
200
|
+
const ver = zcodeAppVersion();
|
|
201
|
+
return {
|
|
202
|
+
'User-Agent': `ZCode/${ver}`,
|
|
203
|
+
'HTTP-Referer': 'https://zcode.z.ai',
|
|
204
|
+
'X-Title': 'Z Code@electron',
|
|
205
|
+
'X-ZCode-App-Version': ver,
|
|
206
|
+
'X-Platform': platform(),
|
|
207
|
+
'X-Release-Channel': 'stable',
|
|
208
|
+
'X-Client-Language': 'zh-CN',
|
|
209
|
+
'X-Client-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
|
210
|
+
'X-Os-Category': process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux',
|
|
211
|
+
...(deviceMid ? { 'X-Device-Mid': deviceMid } : {}),
|
|
212
|
+
Authorization: `Bearer ${token}`,
|
|
213
|
+
'x-request-id': crypto.randomUUID(),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function bigmodelHeaders(token) {
|
|
218
|
+
return { Authorization: `Bearer ${token}`, 'User-Agent': `ZCode/${zcodeAppVersion()}`, 'x-request-id': crypto.randomUUID() };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function getJson(url, headers) {
|
|
222
|
+
const res = await fetchJsonRace(url, { headers, timeoutMs: FETCH_TIMEOUT_MS });
|
|
223
|
+
const text = await res.text();
|
|
224
|
+
try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const businessOk = (v) => {
|
|
228
|
+
const code = v?.code;
|
|
229
|
+
return (code === undefined || code === 200 || code === 0) && v?.success !== false;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** 按 zcode-switch candidate_tokens 的顺序收集可用 token(去重) */
|
|
233
|
+
export function candidateTokens(account) {
|
|
234
|
+
const creds = account.meta?.credentials || {};
|
|
235
|
+
const config = account.meta?.config || {};
|
|
236
|
+
const secret = defaultSecret(os.homedir());
|
|
237
|
+
const out = [];
|
|
238
|
+
const add = (t) => { if (typeof t === 'string' && t.trim().length > 20 && !out.includes(t)) out.push(t); };
|
|
239
|
+
const providers = Object.entries(config.provider || {})
|
|
240
|
+
.sort(([, a], [, b]) => (b?.enabled === true) - (a?.enabled === true));
|
|
241
|
+
for (const [id, p] of providers) {
|
|
242
|
+
const k = p?.options?.apiKey;
|
|
243
|
+
if (id.includes('coding-plan') && typeof k === 'string' && !k.startsWith('enc:')) add(k);
|
|
244
|
+
}
|
|
245
|
+
add(safeDecrypt(creds.zcodejwttoken, secret));
|
|
246
|
+
const active = safeDecrypt(creds['oauth:active_provider'], secret) || 'zai';
|
|
247
|
+
for (const key of [`oauth:${active}:access_token`, 'oauth:bigmodel:access_token', 'oauth:zai:access_token']) {
|
|
248
|
+
add(safeDecrypt(creds[key], secret));
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Start Plan 渠道要用 zcodejwttoken 或 config 中 start-plan provider 的 key */
|
|
254
|
+
function billingTokens(account) {
|
|
255
|
+
const creds = account.meta?.credentials || {};
|
|
256
|
+
const config = account.meta?.config || {};
|
|
257
|
+
const secret = defaultSecret(os.homedir());
|
|
258
|
+
const out = [];
|
|
259
|
+
const jwt = safeDecrypt(creds.zcodejwttoken, secret);
|
|
260
|
+
if (jwt && jwt.length > 20) out.push(jwt);
|
|
261
|
+
for (const [id, p] of Object.entries(config.provider || {})) {
|
|
262
|
+
const k = p?.options?.apiKey;
|
|
263
|
+
if (id.includes('start-plan') && typeof k === 'string' && !k.startsWith('enc:') && k.length > 20 && !out.includes(k)) out.push(k);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 领取用的 token:zcodejwttoken 优先,其次 start-plan key(与 claim.rs claim_token 一致) */
|
|
269
|
+
function claimToken(account) {
|
|
270
|
+
const t = billingTokens(account);
|
|
271
|
+
if (!t.length) throw new Error('账号快照里没有可用的 zcodejwttoken,请在 ZCode 重新登录(或重新本机导入)后再领取');
|
|
272
|
+
return t[0];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const iso = (ms) => (Number.isFinite(Number(ms)) && Number(ms) > 0 ? new Date(Number(ms)).toISOString() : null);
|
|
276
|
+
|
|
277
|
+
/** BigModel quota/limit → 统一结构(unit 字段标明非积分单位,面板不计入“剩余积分”合计) */
|
|
278
|
+
export function normalizeQuotaLimit(limitResp, subResp) {
|
|
279
|
+
const limits = Array.isArray(limitResp?.data?.limits) ? limitResp.data.limits : [];
|
|
280
|
+
const parts = limits.map((l) => {
|
|
281
|
+
const unitLabel = l.type === 'TOKENS_LIMIT' ? '次' : l.type === 'TIME_LIMIT' ? '分钟' : '';
|
|
282
|
+
const name = l.type === 'TOKENS_LIMIT' ? '提示次数' : l.type === 'TIME_LIMIT' ? '使用时长' : String(l.type || '额度');
|
|
283
|
+
const total = Number(l.usage);
|
|
284
|
+
const used = Number(l.currentValue);
|
|
285
|
+
const remaining = Number.isFinite(Number(l.remaining)) ? Number(l.remaining) : Math.max(0, total - used);
|
|
286
|
+
return {
|
|
287
|
+
name, unit: unitLabel,
|
|
288
|
+
total: Number.isFinite(total) ? total : 0,
|
|
289
|
+
used: Number.isFinite(used) ? used : 0,
|
|
290
|
+
remaining: Number.isFinite(remaining) ? remaining : 0,
|
|
291
|
+
expiresAt: iso(l.nextResetTime),
|
|
292
|
+
recurring: true,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
const sub = businessOk(subResp) && Array.isArray(subResp?.data)
|
|
296
|
+
? (subResp.data.find((s) => s.status === 'VALID' && s.inCurrentPeriod !== false) || subResp.data[0])
|
|
297
|
+
: null;
|
|
298
|
+
// 主额度优先取“使用时长”,与 zcode-switch 一致
|
|
299
|
+
const main = parts.find((p) => p.unit === '分钟' && p.total > 0) || parts.find((p) => p.total > 0) || null;
|
|
300
|
+
return {
|
|
301
|
+
total: main?.total || 0,
|
|
302
|
+
used: main?.used || 0,
|
|
303
|
+
remaining: main?.remaining || 0,
|
|
304
|
+
unit: main?.unit || '',
|
|
305
|
+
plan: sub?.productName || limitResp?.data?.level || null,
|
|
306
|
+
planExpiresAt: sub?.endTime || sub?.expireTime || null,
|
|
307
|
+
parts,
|
|
308
|
+
empty: parts.length === 0,
|
|
309
|
+
exceeded: false,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** zcode.z.ai billing/balance → 统一结构 */
|
|
314
|
+
export function normalizeBalance(balanceResp) {
|
|
315
|
+
const d = balanceResp?.data || {};
|
|
316
|
+
const epochMs = (v) => iso(Number(v) * (Number(v) < 1e12 ? 1000 : 1));
|
|
317
|
+
const plans = Array.isArray(d.plans) ? d.plans.filter((p) => String(p.status || '').toLowerCase() === 'active') : [];
|
|
318
|
+
const balanceParts = (Array.isArray(d.balances) ? d.balances : []).map((b) => {
|
|
319
|
+
const total = Number(b.total_units);
|
|
320
|
+
const used = Number(b.used_units);
|
|
321
|
+
const remaining = Number(b.remaining_units ?? b.available_units);
|
|
322
|
+
return {
|
|
323
|
+
name: b.show_name || b.name || b.entitlement_id || b.plan_id || '额度',
|
|
324
|
+
unit: b.unit_type === 'token' ? 'Token' : (b.unit_type || ''),
|
|
325
|
+
total: Number.isFinite(total) ? total : 0,
|
|
326
|
+
used: Number.isFinite(used) ? used : 0,
|
|
327
|
+
remaining: Number.isFinite(remaining) ? remaining : Math.max(0, (total || 0) - (used || 0)),
|
|
328
|
+
expiresAt: epochMs(b.expire_at || b.period_end || 0),
|
|
329
|
+
recurring: Boolean(b.period && b.period !== 'one_time'),
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
// balances 可能为空(例如活动权益还没到 effective_at):此时从生效套餐的权益里派生展示额度。
|
|
333
|
+
// effective_at 在未来的权益标记 pending,面板显示「xx 生效」而不是「剩余 xx」。
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
const entitlementParts = plans.flatMap((p) =>
|
|
336
|
+
(Array.isArray(p.entitlements) ? p.entitlements : [])
|
|
337
|
+
.filter((e) => e.unit_type === 'token' && Number(e.grant_units) > 0)
|
|
338
|
+
.map((e) => {
|
|
339
|
+
const grant = Number(e.grant_units);
|
|
340
|
+
const startsAt = epochMs(e.effective_at || p.starts_at);
|
|
341
|
+
const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
|
|
342
|
+
return {
|
|
343
|
+
name: e.show_name || e.entitlement_id || '活动额度',
|
|
344
|
+
unit: 'Token',
|
|
345
|
+
total: grant,
|
|
346
|
+
used: 0,
|
|
347
|
+
remaining: pending ? 0 : grant,
|
|
348
|
+
pending,
|
|
349
|
+
startsAt,
|
|
350
|
+
expiresAt: epochMs(p.ends_at),
|
|
351
|
+
recurring: Boolean(e.period && e.period !== 'one_time'),
|
|
352
|
+
};
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
const parts = balanceParts.length ? balanceParts : entitlementParts;
|
|
356
|
+
// 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
|
|
357
|
+
const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
|
|
358
|
+
const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
|
|
359
|
+
return {
|
|
360
|
+
total: sum('total'), used: sum('used'), remaining: sum('remaining'),
|
|
361
|
+
unit: parts[0]?.unit || 'Token',
|
|
362
|
+
plan: plans[0]?.name || plans[0]?.plan_id || null,
|
|
363
|
+
planEndsAt,
|
|
364
|
+
parts,
|
|
365
|
+
empty: parts.length === 0 && plans.length === 0,
|
|
366
|
+
exceeded: false,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const PLAN_PERIOD_LABEL = { daily: '每日', weekly: '每周', monthly: '每月' };
|
|
371
|
+
const fmtGrantUnits = (n) => (n >= 1e8 ? `${+(n / 1e8).toFixed(1)}亿` : n >= 1e4 ? `${+(n / 1e4).toFixed(1)}万` : String(Math.round(n)));
|
|
372
|
+
|
|
373
|
+
/** billing/preview → 可领取活动列表(parse_plan 的移植) */
|
|
374
|
+
export function normalizePlans(previewResp) {
|
|
375
|
+
const plans = Array.isArray(previewResp?.data?.plans) ? previewResp.data.plans : [];
|
|
376
|
+
const out = [];
|
|
377
|
+
for (const p of plans) {
|
|
378
|
+
const planId = String(p.plan_id || p.planId || '').trim();
|
|
379
|
+
if (!planId) continue;
|
|
380
|
+
const grantItems = (Array.isArray(p.entitlements) ? p.entitlements : [])
|
|
381
|
+
.filter((e) => e.meter === 'model_usage' && e.unit_type === 'token' && String(e.show_name || '').trim())
|
|
382
|
+
.map((e) => ({
|
|
383
|
+
name: String(e.show_name).trim(),
|
|
384
|
+
units: Number(e.grant_units || e.grantUnits || 0),
|
|
385
|
+
period: String(e.period || 'one_time'),
|
|
386
|
+
}));
|
|
387
|
+
out.push({
|
|
388
|
+
planId,
|
|
389
|
+
name: String(p.name || '').trim(),
|
|
390
|
+
description: String(p.description || '').trim(),
|
|
391
|
+
priority: Number(p.priority || 0),
|
|
392
|
+
grants: grantItems.map((g) => `${g.name} · ${fmtGrantUnits(g.units)} Token(${PLAN_PERIOD_LABEL[g.period] || '一次性'})`),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
out.sort((a, b) => b.priority - a.priority || a.planId.localeCompare(b.planId));
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* 额度查询:先试 BigModel Coding Plan(quota/limit),再试 Z.ai / Start Plan(billing/balance)。
|
|
401
|
+
* 都没有有效套餐时返回 { empty: true }(不是错误:很多账号只用免费额度)。
|
|
402
|
+
*/
|
|
403
|
+
export async function fetchZcodeQuota(account) {
|
|
404
|
+
const tokens = candidateTokens(account);
|
|
405
|
+
if (!tokens.length) throw new Error('账号快照里没有可用 token,请在 ZCode 登录后重新导入');
|
|
406
|
+
let lastErr = null;
|
|
407
|
+
let authFails = 0;
|
|
408
|
+
for (const t of tokens) {
|
|
409
|
+
try {
|
|
410
|
+
const limit = await getJson(QUOTA_LIMIT_URL, bigmodelHeaders(t));
|
|
411
|
+
if (businessOk(limit)) {
|
|
412
|
+
const sub = await getJson(SUBSCRIPTION_URL, bigmodelHeaders(t)).catch(() => null);
|
|
413
|
+
return { ...normalizeQuotaLimit(limit, sub), source: 'bigmodel' };
|
|
414
|
+
}
|
|
415
|
+
if (limit?.code === 401) authFails++;
|
|
416
|
+
else lastErr = limit?.msg || lastErr;
|
|
417
|
+
} catch (e) { lastErr = e.message; }
|
|
418
|
+
}
|
|
419
|
+
for (const t of billingTokens(account)) {
|
|
420
|
+
try {
|
|
421
|
+
const bal = await getJson(`${BILLING_BALANCE_URL}?app_version=${zcodeAppVersion()}`, zaiHeaders(t, account.meta?.deviceMid));
|
|
422
|
+
if (businessOk(bal)) return { ...normalizeBalance(bal), source: 'zcode.z.ai' };
|
|
423
|
+
if (bal?.code === 401) authFails++;
|
|
424
|
+
} catch (e) { lastErr = e.message; }
|
|
425
|
+
}
|
|
426
|
+
if (authFails && authFails >= tokens.length) throw new Error('鉴权失败,登录可能已过期,请在 ZCode 重新登录后重新导入');
|
|
427
|
+
// “当前用户不存在coding plan”之类属于无套餐,按空额度返回
|
|
428
|
+
if (!lastErr || /不存在|no.*plan/i.test(lastErr)) {
|
|
429
|
+
return { total: 0, used: 0, remaining: 0, unit: '', plan: null, parts: [], empty: true, exceeded: false };
|
|
430
|
+
}
|
|
431
|
+
throw new Error(`额度查询失败:${lastErr}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── 活动领取 ──
|
|
435
|
+
|
|
436
|
+
/** 可领取的活动列表。账户当前没有可领活动时返回 [](不是错误)。 */
|
|
437
|
+
export async function fetchClaimPlans(account) {
|
|
438
|
+
const token = claimToken(account);
|
|
439
|
+
const v = await getJson(
|
|
440
|
+
`${BILLING_PREVIEW_URL}?app_version=${zcodeAppVersion()}&platform=${platform()}`,
|
|
441
|
+
zaiHeaders(token, account.meta?.deviceMid),
|
|
442
|
+
);
|
|
443
|
+
if (v?.code !== 0) throw new Error(v?.msg || v?.message || `查询活动列表失败(code ${v?.code})`);
|
|
444
|
+
return { plans: normalizePlans(v), serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** 验证码配置(无需登录)。当前服务端未下发时返回 { enabled: false }。 */
|
|
448
|
+
export async function fetchCaptchaConfig() {
|
|
449
|
+
const v = await getJson(CLIENT_CONFIGS_URL, zaiHeaders(''));
|
|
450
|
+
if (v?.code !== 0) throw new Error('获取验证码配置失败');
|
|
451
|
+
const c = v?.data?.configs?.captcha || {};
|
|
452
|
+
return {
|
|
453
|
+
enabled: c.enabled === true,
|
|
454
|
+
region: typeof c.region === 'string' ? c.region : '',
|
|
455
|
+
prefix: typeof c.prefix === 'string' ? c.prefix : '',
|
|
456
|
+
sceneId: typeof c.sceneId === 'string' ? c.sceneId : '',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 领取活动套餐。captchaParam 为空时不带验证码头(服务端风控可能拒绝,错误信息原样返回)。
|
|
462
|
+
* 返回 { planName, startsAt, endsAt, serverTime };失败抛出带 code / nextAt 的 Error。
|
|
463
|
+
*/
|
|
464
|
+
export async function claimPlan(account, planId, { captchaParam = '', region = '' } = {}) {
|
|
465
|
+
const token = claimToken(account);
|
|
466
|
+
const headers = zaiHeaders(token, account.meta?.deviceMid);
|
|
467
|
+
if (captchaParam && captchaParam.trim()) headers['X-Aliyun-Captcha-Verify-Param'] = captchaParam.trim();
|
|
468
|
+
if (region && region.trim()) headers['X-Aliyun-Captcha-Verify-Region'] = region.trim();
|
|
469
|
+
const res = await fetchJsonRace(BILLING_CLAIM_URL, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
472
|
+
body: { plan_id: planId },
|
|
473
|
+
timeoutMs: 25_000,
|
|
474
|
+
});
|
|
475
|
+
const text = await res.text();
|
|
476
|
+
let v;
|
|
477
|
+
try { v = JSON.parse(text); } catch { v = { code: res.status, msg: text.slice(0, 200) }; }
|
|
478
|
+
const code = Number(v?.code ?? -1);
|
|
479
|
+
if (code !== 0) {
|
|
480
|
+
const serverMsg = v?.msg || v?.message || '';
|
|
481
|
+
const base = CLAIM_FAIL[code] || '领取失败';
|
|
482
|
+
const err = new Error(serverMsg ? `${base}(${serverMsg})` : base);
|
|
483
|
+
err.code = code;
|
|
484
|
+
// 1005(名额用完)带上套餐结束时间,方便提示下次什么时候再试
|
|
485
|
+
if (code === 1005 && v?.data?.plan?.ends_at) err.nextAt = new Date(v.data.plan.ends_at * 1000).toISOString();
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
const plan = v?.data?.plan || {};
|
|
489
|
+
return {
|
|
490
|
+
planName: plan.name || planId,
|
|
491
|
+
startsAt: plan.starts_at ? new Date(plan.starts_at * 1000).toISOString() : null,
|
|
492
|
+
endsAt: plan.ends_at ? new Date(plan.ends_at * 1000).toISOString() : null,
|
|
493
|
+
serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null,
|
|
494
|
+
};
|
|
495
|
+
}
|