creditdaddy 1.4.1 → 1.5.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/src/checkin.js CHANGED
@@ -1,393 +1,443 @@
1
- /**
2
- * 签到调度:普通账号按配置的间隔(默认每 2h,面板设置可调 1-8h、可全局关闭)扫描一次;
3
- * ZCode 自动活动领取使用独立的短周期调度。
4
- */
5
-
6
- import { loadAccounts, loadSettings, loadState, withAccounts, withState } from './store.js';
7
- import { productImpl } from './providers.js';
8
- import { productOf, PROVIDER_LABEL } from './constants.js';
9
- import { startUsageSyncScheduler } from './tenrouter.js';
10
- import { refreshContext } from './accounts.js';
11
- import { zcodeAutoClaim } from './zcodeAutoClaim.js';
12
- import { clearQuotaMark } from './zcodeGateway.js';
13
- import {
14
- autoClaimEnabled, autoClaimUntil, enableAutoClaimFor, setAutoClaimEnabled,
15
- claimIntervalMin, claimWindowMin,
16
- warmZcodeAppVersion,
17
- } from './zcodeClient.js';
18
- import { logger } from './logger.js';
19
- import { t as tr } from './i18n.js';
20
-
21
- /** 全局自动签到配置:默认开启、每 120 分钟一轮;面板「设置 → 通用」可关 / 调频。 */
22
- export async function autoCheckinEnabled() {
23
- return (await loadSettings()).autoCheckin !== false;
24
- }
25
- export async function checkinIntervalMin() {
26
- return Math.max(15, Number((await loadSettings()).checkinIntervalMin) || 120);
27
- }
28
- const TICK_JITTER_MS = 10 * 60 * 1000;
29
- const ZCODE_JITTER_MS = 15 * 1000;
30
- const ZCODE_FIRST_DELAY_MS = 2 * 60 * 1000;
31
-
32
- // 各产品「业务日」刷新时点(UTC+8):Qoder 每日 10:00 放出/重置领取资格,其余产品按 00:00。
33
- // 签到日 = 对应时点起的日历日:dayKey(now, provider) = (now + (8 - 刷新小时)h) 的 UTC 日期
34
- const DAY_REFRESH_HOUR_UTC8 = { qoder: 10, 'qoder-cn': 10 };
35
-
36
- let timerHandle = null;
37
- let zcodeTimerHandle = null;
38
- let zcodeExpiryTimerHandle = null;
39
- let zcodeExpiryAt = null;
40
- let zcodeNextTickAt = null;
41
- let zcodeLastTick = null;
42
- let zcodeTicking = false;
43
- let zcodeQueue = Promise.resolve();
44
- let tickQueue = Promise.resolve();
45
- let nextTickAt = null;
46
- let lastTick = null;
47
- let ticking = false;
48
-
49
- /** 调度器状态(面板展示用) */
50
- export async function getSchedulerInfo() {
51
- const enabled = autoClaimEnabled();
52
- const autoCk = await autoCheckinEnabled();
53
- return {
54
- enabled: autoCk,
55
- intervalMin: await checkinIntervalMin(),
56
- running: Boolean(timerHandle), nextTickAt: autoCk ? nextTickAt : null, lastTick, ticking,
57
- zcode: {
58
- enabled,
59
- intervalMin: claimIntervalMin(),
60
- windowMin: claimWindowMin(),
61
- autoOffAt: enabled ? autoClaimUntil() : null,
62
- nextTickAt: enabled ? zcodeNextTickAt : null,
63
- lastTick: zcodeLastTick,
64
- ticking: zcodeTicking,
65
- },
66
- };
67
- }
68
-
69
- /** 签到业务日。Qoder 以 10:00 (UTC+8) 为界,其余产品以 00:00 (UTC+8) 为界;默认 Qoder 口径(/api/status 展示用)。 */
70
- export function dayKey(nowMs = Date.now(), provider = 'qoder') {
71
- const h = DAY_REFRESH_HOUR_UTC8[provider] ?? 0;
72
- return new Date(nowMs + (8 - h) * 3600 * 1000).toISOString().slice(0, 10);
73
- }
74
-
75
- export async function msUntilNextTick(rand = Math.random) {
76
- return Math.max((await checkinIntervalMin()) * 60 * 1000 + Math.floor(rand() * TICK_JITTER_MS), 1000);
77
- }
78
-
79
- export function zcodeMsUntilNextTick(rand = Math.random) {
80
- const base = claimIntervalMin() * 60 * 1000;
81
- return Math.max(base + Math.floor(rand() * ZCODE_JITTER_MS), 1000);
82
- }
83
-
84
- /** 按用户配置的「自动领取计划」开启 ZCode 自动领取;首次资格检查两分钟后开始。
85
- * 运行时长在面板弹窗里配置(默认 60 分钟;0 = 一直运行直到手动关闭)。 */
86
- export function enableZcodeAutoClaimWindow() {
87
- const w = claimWindowMin();
88
- if (w > 0) enableAutoClaimFor(w * 60 * 1000);
89
- else setAutoClaimEnabled(true);
90
- syncZcodeScheduler(true);
91
- return autoClaimUntil();
92
- }
93
-
94
- /** 更新 ZCode 定时器,autoClaimUntil 是持久化的到期时间,重启后仍会生效。 */
95
- export function refreshZcodeScheduler() {
96
- syncZcodeScheduler();
97
- }
98
-
99
- function clearZcodeTimers() {
100
- if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
101
- if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
102
- zcodeTimerHandle = null;
103
- zcodeExpiryTimerHandle = null;
104
- zcodeExpiryAt = null;
105
- zcodeNextTickAt = null;
106
- }
107
-
108
- function scheduleZcodeNextTick(delay = zcodeMsUntilNextTick()) {
109
- if (!autoClaimEnabled()) {
110
- syncZcodeScheduler();
111
- return;
112
- }
113
- if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
114
- zcodeNextTickAt = new Date(Date.now() + delay).toISOString();
115
- zcodeTimerHandle = setTimeout(() => {
116
- zcodeTimerHandle = null;
117
- zcodeNextTickAt = null;
118
- runZcodeTick().catch((err) => logger.error('ZCODE-CLAIM', '资格轮询失败:{err}', { err: err?.message || err }))
119
- .finally(() => scheduleZcodeNextTick());
120
- }, delay);
121
- zcodeTimerHandle.unref?.();
122
- }
123
-
124
- function syncZcodeScheduler(startFresh = false) {
125
- if (!autoClaimEnabled()) {
126
- clearZcodeTimers();
127
- return;
128
- }
129
-
130
- const expiresAt = autoClaimUntil();
131
- if (startFresh && zcodeTimerHandle) {
132
- clearTimeout(zcodeTimerHandle);
133
- zcodeTimerHandle = null;
134
- zcodeNextTickAt = null;
135
- }
136
- if (!zcodeTimerHandle && !zcodeTicking) scheduleZcodeNextTick(ZCODE_FIRST_DELAY_MS);
137
-
138
- if (expiresAt && zcodeExpiryAt !== expiresAt) {
139
- if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
140
- zcodeExpiryAt = expiresAt;
141
- zcodeExpiryTimerHandle = setTimeout(() => {
142
- zcodeExpiryTimerHandle = null;
143
- setAutoClaimEnabled(false);
144
- clearZcodeTimers();
145
- logger.info('ZCODE-CLAIM', '自动领取时段已结束,自动领取已关闭', {});
146
- }, Math.max(0, expiresAt - Date.now()));
147
- zcodeExpiryTimerHandle.unref?.();
148
- } else if (!expiresAt && zcodeExpiryTimerHandle) {
149
- // 运行时长为 0(不自动关闭):清掉可能残留的到期定时器
150
- clearTimeout(zcodeExpiryTimerHandle);
151
- zcodeExpiryTimerHandle = null;
152
- zcodeExpiryAt = null;
153
- }
154
- }
155
-
156
- function runZcodeTick(accountIds, { force = false } = {}) {
157
- const run = zcodeQueue.then(async () => {
158
- if (!force && !autoClaimEnabled()) return { results: [], summary: 'ZCode 自动领取已关闭' };
159
- zcodeTicking = true;
160
- const results = [];
161
- const claimedIds = [];
162
- try {
163
- const accounts = (await loadAccounts()).filter((a) => a.provider === 'zcode'
164
- && (!accountIds || accountIds.has(a.id)));
165
- for (const account of accounts) {
166
- if (!force && !autoClaimEnabled()) break;
167
- const label = account.name || account.uid || account.id;
168
- try {
169
- const outcome = await zcodeAutoClaim(account);
170
- if (!outcome) continue;
171
- if (outcome.status === 'checked-in') {
172
- claimedIds.push(account.id);
173
- await clearQuotaMark(account.id);
174
- }
175
- results.push({ accountId: account.id, account: label, provider: 'zcode', status: outcome.status, message: outcome.message });
176
- const lastResult = { status: outcome.status, message: outcome.message, amount: 0, at: new Date().toISOString() };
177
- await withAccounts((list) => {
178
- const current = list.find((a) => a.id === account.id);
179
- if (current) current.lastResult = lastResult;
180
- });
181
- } catch (err) {
182
- logger.warn('ZCODE-CLAIM', '{label} 自动领取异常:{err}', { label, err: err?.message || err });
183
- }
184
- }
185
- zcodeLastTick = {
186
- at: new Date().toISOString(),
187
- summary: `ZCode 资格轮询完成:${results.length} 个账号返回活动结果`,
188
- claimedIds, // 面板据此即时刷新这些账号的额度
189
- };
190
- return { results, summary: zcodeLastTick.summary };
191
- } finally {
192
- zcodeTicking = false;
193
- }
194
- });
195
- zcodeQueue = run.catch(() => {});
196
- return run;
197
- }
198
-
199
- /** 手动触发一次 ZCode 资格轮询(面板单账号 / 全部领取走这里),与定时轮共用同一队列;手动轮不受开关限制。 */
200
- export function pollZcodeNow(accountIds) {
201
- return runZcodeTick(accountIds && accountIds.length ? new Set(accountIds) : null, { force: true });
202
- }
203
-
204
- async function getDoneMap(state) {
205
- // 任一口径下仍属于「今天」的记录都保留(0–10 点间 Qoder 记录的业务日还是昨天,不能误删)
206
- const keepDays = new Set([dayKey(Date.now(), 'qoder'), dayKey(Date.now(), 'workbuddy')]);
207
- const raw = state?.qoderDailyDone;
208
- const map = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
209
- for (const key of Object.keys(map)) if (!keepDays.has(map[key])) delete map[key];
210
- return map;
211
- }
212
-
213
- /** 多次调用排队串行执行,避免并发签到和 state.json 覆盖。 */
214
- export function runCheckinTick(opts = {}) {
215
- const run = tickQueue.then(() => runTickNow(opts));
216
- tickQueue = run.catch(() => {});
217
- return run;
218
- }
219
-
220
- async function runTickNow(opts) {
221
- ticking = true;
222
- try { return await runTickInner(opts); } finally { ticking = false; }
223
- }
224
-
225
- async function runTickInner(opts) {
226
- const allAccounts = await loadAccounts();
227
- // WB 国际版活跃请求全局开关(面板「设置 → 通用」):关闭时该轮与手动领取都不再打
228
- const wbActivity = (await loadSettings()).workbuddyActivity !== false;
229
- const accounts = allAccounts.filter(
230
- (a) => (!opts.provider || a.provider === opts.provider)
231
- && (!opts.product || productOf(a.provider) === opts.product)
232
- && (!opts.onlyAccountId || a.id === opts.onlyAccountId)
233
- && (wbActivity || a.provider !== 'workbuddy-intl')
234
- && typeof productImpl(a.provider)?.checkin === 'function'
235
- );
236
- const zAccounts = (opts.includeZcode !== false && autoClaimEnabled()
237
- && !opts.onlyAccountId
238
- && (!opts.provider || opts.provider === 'zcode')
239
- && (!opts.product || opts.product === 'zcode'))
240
- ? allAccounts.filter((a) => a.provider === 'zcode')
241
- : [];
242
-
243
- if (accounts.length === 0 && zAccounts.length === 0) {
244
- return { results: [], summary: '没有匹配的账号' };
245
- }
246
-
247
- const state = await loadState();
248
- const memo = await getDoneMap(state);
249
- const today = dayKey();
250
- const results = [];
251
- let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
252
-
253
- for (const account of accounts) {
254
- const pTag = `[${PROVIDER_LABEL[account.provider] || account.provider}]`;
255
- const label = `${pTag} ${account.name || account.id}`;
256
- // 「今日已领」按各产品自己的业务日界比对:Qoder 10 点翻日,其余 0 点翻日
257
- const todayFor = dayKey(Date.now(), account.provider);
258
- try {
259
- if (opts.skipIfCheckedToday && memo[account.id] === todayFor) {
260
- results.push({ accountId: account.id, account: label, provider: account.provider, status: 'already', memoized: true });
261
- continue;
262
- }
263
-
264
- const ctx = refreshContext(account, (message) => logger.info('CHECKIN', '{label}:{msg}', { label, msg: message }));
265
- let outcome = await productImpl(account.provider).checkin(account, ctx);
266
- outcome = { accountId: account.id, account: label, provider: account.provider, ...outcome };
267
- if (account.provider === 'qoder' && outcome.risk) {
268
- if (outcome.status === 'checked-in') {
269
- deviceClaim = { day: today, accountId: account.id, name: label };
270
- } else if (outcome.status === 'no-activity' && deviceClaim && deviceClaim.accountId !== account.id) {
271
- outcome = { ...outcome, status: 'limited', message: `本机今日国际版额度已由「${deviceClaim.name}」领取(Qoder 每台设备每天限领一次)` };
272
- }
273
- }
274
- results.push(outcome);
275
- if (outcome.uid && !account.uid) account.uid = outcome.uid;
276
- account.lastResult = {
277
- status: outcome.status,
278
- message: outcome.error || outcome.message || null,
279
- amount: outcome.claimedAmount || 0,
280
- ...(outcome.streakDays !== undefined ? { streakDays: outcome.streakDays + (outcome.status === 'checked-in' ? 1 : 0) } : {}),
281
- at: new Date().toISOString(),
282
- };
283
-
284
- if (outcome.status === 'checked-in') {
285
- memo[account.id] = todayFor;
286
- account.lastCheckin = new Date().toISOString();
287
- logger.info('CHECKIN', '{label} 领取成功 +{amount} Credits', { label, amount: outcome.claimedAmount });
288
- } else if (outcome.status === 'already') {
289
- memo[account.id] = todayFor;
290
- account.lastCheckin = account.lastCheckin || new Date().toISOString();
291
- logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '今日已领' });
292
- } else if (outcome.status === 'limited') {
293
- memo[account.id] = todayFor;
294
- logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message });
295
- } else if (outcome.status === 'no-activity') {
296
- logger.debug('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '当前无可领取的活动' });
297
- } else {
298
- logger.warn('CHECKIN', '{label} 领取失败:{err}', { label, err: outcome.error || outcome.status });
299
- }
300
- } catch (err) {
301
- results.push({ accountId: account.id, account: label, provider: account.provider, status: 'failed', error: err?.message || String(err) });
302
- account.lastResult = { status: 'failed', message: err?.message || String(err), amount: 0, at: new Date().toISOString() };
303
- logger.error('CHECKIN', '{label} 异常:{err}', { label, err: err?.message || err });
304
- }
305
- }
306
-
307
- if (accounts.length) {
308
- await withAccounts((all) => {
309
- for (const updated of accounts) {
310
- const current = all.find((a) => a.id === updated.id);
311
- if (!current) continue;
312
- current.lastCheckin = updated.lastCheckin;
313
- if (updated.lastResult) current.lastResult = updated.lastResult;
314
- if (updated.uid && !current.uid) current.uid = updated.uid;
315
- }
316
- });
317
- await withState((fresh) => {
318
- // 只覆盖签到轮自己负责的两个键:state.json 里还有网关写的 zcodeGatewayExhausted,
319
- // 拿本轮开头读到的旧快照整份落盘会把别人刚写的东西抹掉
320
- fresh.qoderDailyDone = memo;
321
- fresh.deviceClaim = deviceClaim;
322
- });
323
- }
324
-
325
- if (zAccounts.length) {
326
- const zResult = await runZcodeTick(new Set(zAccounts.map((a) => a.id)));
327
- results.push(...zResult.results);
328
- }
329
-
330
- const claimed = results.filter((r) => r.status === 'checked-in');
331
- const failed = results.filter((r) => r.status === 'failed');
332
- const none = results.filter((r) => r.status === 'no-activity');
333
- const limited = results.filter((r) => r.status === 'limited');
334
- const already = results.length - claimed.length - failed.length - none.length - limited.length;
335
- const totalCredits = claimed.reduce((sum, r) => sum + (r.claimedAmount || 0), 0);
336
- const summary = `签到汇总:成功 ${claimed.length}(+${totalCredits} Credits)、已领 ${already}`
337
- + (limited.length ? `、本机限领 ${limited.length}` : '')
338
- + `、无活动 ${none.length}、失败 ${failed.length}`;
339
- // 日志走 key+args(按界面语言实时重渲染);summary 原文继续供面板/状态展示
340
- logger.info('CHECKIN', limited.length
341
- ? '签到汇总:成功 {ok}(+{credits} Credits)、已领 {al}、本机限领 {limited}、无活动 {none}、失败 {fail}'
342
- : '签到汇总:成功 {ok}(+{credits} Credits)、已领 {al}、无活动 {none}、失败 {fail}',
343
- { ok: claimed.length, credits: totalCredits, al: already, limited: limited.length, none: none.length, fail: failed.length });
344
- lastTick = { at: new Date().toISOString(), summary };
345
- return { results, summary };
346
- }
347
-
348
- async function scheduleTick() {
349
- const delay = await msUntilNextTick();
350
- nextTickAt = new Date(Date.now() + delay).toISOString();
351
- timerHandle = setTimeout(async () => {
352
- try {
353
- await runCheckinTick({ skipIfCheckedToday: true, includeZcode: false });
354
- } catch (err) {
355
- logger.error('CHECKIN', '定时轮失败:{err}', { err: err?.message || err });
356
- } finally {
357
- scheduleTick();
358
- }
359
- }, delay);
360
- timerHandle.unref?.();
361
- logger.info('CHECKIN', '定时签到已启动,下次执行约 {min} 分钟后', { min: Math.round(delay / 60000) });
362
- }
363
-
364
- /** 启动普通签到调度与 ZCode 独立轮询调度。 */
365
- export async function startScheduler() {
366
- if (timerHandle) return;
367
- startUsageSyncScheduler();
368
- warmZcodeAppVersion();
369
- if (await autoCheckinEnabled()) {
370
- scheduleTick();
371
- setTimeout(() => {
372
- runCheckinTick({ skipIfCheckedToday: true, includeZcode: false }).catch((err) =>
373
- logger.error('CHECKIN', '引导轮失败:{err}', { err: err?.message || err }));
374
- }, 15000).unref?.();
375
- } else {
376
- logger.info('CHECKIN', '自动签到已关闭(面板设置可开启)');
377
- }
378
- syncZcodeScheduler();
379
- }
380
-
381
- /** 面板修改全局自动签到开关 / 频率后重同步(daemon PUT /api/settings 调用)。 */
382
- export async function resyncAutoCheckinScheduler() {
383
- if (timerHandle) { clearTimeout(timerHandle); timerHandle = null; nextTickAt = null; }
384
- if (await autoCheckinEnabled()) scheduleTick();
385
- else logger.info('CHECKIN', '自动签到已关闭');
386
- }
387
-
388
- export function stopScheduler() {
389
- if (timerHandle) clearTimeout(timerHandle);
390
- timerHandle = null;
391
- nextTickAt = null;
392
- clearZcodeTimers();
393
- }
1
+ /**
2
+ * 签到调度:普通账号按配置的间隔(默认每 2h,面板设置可调 1-8h、可全局关闭)扫描一次;
3
+ * ZCode 自动活动领取使用独立的短周期调度。
4
+ */
5
+
6
+ import { loadAccounts, loadSettings, loadState, withAccounts, withState } from './store.js';
7
+ import { productImpl } from './providers.js';
8
+ import { productOf, PROVIDER_LABEL } from './constants.js';
9
+ import { startUsageSyncScheduler } from './tenrouter.js';
10
+ import { refreshContext } from './accounts.js';
11
+ import { zcodeAutoClaim } from './zcodeAutoClaim.js';
12
+ import { clearQuotaMark } from './zcodeGateway.js';
13
+ import {
14
+ autoClaimEnabled, autoClaimUntil, enableAutoClaimFor, setAutoClaimEnabled,
15
+ claimIntervalMin, claimWindowMin,
16
+ warmZcodeAppVersion,
17
+ } from './zcodeClient.js';
18
+ import { logger } from './logger.js';
19
+ import { t as tr } from './i18n.js';
20
+
21
+ /** 全局自动签到配置:默认开启、每 120 分钟一轮;面板「设置 → 通用」可关 / 调频。 */
22
+ export async function autoCheckinEnabled() {
23
+ return (await loadSettings()).autoCheckin !== false;
24
+ }
25
+ export async function checkinIntervalMin() {
26
+ return Math.max(15, Number((await loadSettings()).checkinIntervalMin) || 120);
27
+ }
28
+ const TICK_JITTER_MS = 10 * 60 * 1000;
29
+ const ZCODE_JITTER_MS = 15 * 1000;
30
+ const ZCODE_FIRST_DELAY_MS = 2 * 60 * 1000;
31
+
32
+ // 各产品「业务日」刷新时点(UTC+8):Qoder 每日 10:00 放出/重置领取资格,其余产品按 00:00。
33
+ // 签到日 = 对应时点起的日历日:dayKey(now, provider) = (now + (8 - 刷新小时)h) 的 UTC 日期
34
+ const DAY_REFRESH_HOUR_UTC8 = { qoder: 10, 'qoder-cn': 10 };
35
+
36
+ let timerHandle = null;
37
+ let zcodeTimerHandle = null;
38
+ let zcodeExpiryTimerHandle = null;
39
+ let zcodeExpiryAt = null;
40
+ let zcodeNextTickAt = null;
41
+ let zcodeLastTick = null;
42
+ let zcodeTicking = false;
43
+ let zcodeQueue = Promise.resolve();
44
+ let tickQueue = Promise.resolve();
45
+ let nextTickAt = null;
46
+ let lastTick = null;
47
+ let ticking = false;
48
+
49
+ /** 调度器状态(面板展示用) */
50
+ export async function getSchedulerInfo() {
51
+ const enabled = autoClaimEnabled();
52
+ const autoCk = await autoCheckinEnabled();
53
+ return {
54
+ enabled: autoCk,
55
+ intervalMin: await checkinIntervalMin(),
56
+ running: Boolean(timerHandle), nextTickAt: autoCk ? nextTickAt : null, lastTick, ticking,
57
+ zcode: {
58
+ enabled,
59
+ intervalMin: claimIntervalMin(),
60
+ windowMin: claimWindowMin(),
61
+ autoOffAt: enabled ? autoClaimUntil() : null,
62
+ nextTickAt: enabled ? zcodeNextTickAt : null,
63
+ lastTick: zcodeLastTick,
64
+ ticking: zcodeTicking,
65
+ },
66
+ };
67
+ }
68
+
69
+ /** 签到业务日。Qoder 以 10:00 (UTC+8) 为界,其余产品以 00:00 (UTC+8) 为界;默认 Qoder 口径(/api/status 展示用)。 */
70
+ export function dayKey(nowMs = Date.now(), provider = 'qoder') {
71
+ const h = DAY_REFRESH_HOUR_UTC8[provider] ?? 0;
72
+ return new Date(nowMs + (8 - h) * 3600 * 1000).toISOString().slice(0, 10);
73
+ }
74
+
75
+ export async function msUntilNextTick(rand = Math.random) {
76
+ return Math.max((await checkinIntervalMin()) * 60 * 1000 + Math.floor(rand() * TICK_JITTER_MS), 1000);
77
+ }
78
+
79
+ export function zcodeMsUntilNextTick(rand = Math.random) {
80
+ const base = claimIntervalMin() * 60 * 1000;
81
+ return Math.max(base + Math.floor(rand() * ZCODE_JITTER_MS), 1000);
82
+ }
83
+
84
+ /** 按用户配置的「自动领取计划」开启 ZCode 自动领取;首次资格检查两分钟后开始。
85
+ * 运行时长在面板弹窗里配置(默认 60 分钟;0 = 一直运行直到手动关闭)。 */
86
+ export function enableZcodeAutoClaimWindow() {
87
+ const w = claimWindowMin();
88
+ if (w > 0) enableAutoClaimFor(w * 60 * 1000);
89
+ else setAutoClaimEnabled(true);
90
+ syncZcodeScheduler(true);
91
+ return autoClaimUntil();
92
+ }
93
+
94
+ /** 更新 ZCode 定时器,autoClaimUntil 是持久化的到期时间,重启后仍会生效。 */
95
+ export function refreshZcodeScheduler() {
96
+ syncZcodeScheduler();
97
+ }
98
+
99
+ function clearZcodeTimers() {
100
+ if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
101
+ if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
102
+ zcodeTimerHandle = null;
103
+ zcodeExpiryTimerHandle = null;
104
+ zcodeExpiryAt = null;
105
+ zcodeNextTickAt = null;
106
+ }
107
+
108
+ function scheduleZcodeNextTick(delay = zcodeMsUntilNextTick()) {
109
+ if (!autoClaimEnabled()) {
110
+ syncZcodeScheduler();
111
+ return;
112
+ }
113
+ if (zcodeTimerHandle) clearTimeout(zcodeTimerHandle);
114
+ zcodeNextTickAt = new Date(Date.now() + delay).toISOString();
115
+ zcodeTimerHandle = setTimeout(() => {
116
+ zcodeTimerHandle = null;
117
+ zcodeNextTickAt = null;
118
+ runZcodeTick().catch((err) => logger.error('ZCODE-CLAIM', '资格轮询失败:{err}', { err: err?.message || err }))
119
+ .finally(() => scheduleZcodeNextTick());
120
+ }, delay);
121
+ zcodeTimerHandle.unref?.();
122
+ }
123
+
124
+ function syncZcodeScheduler(startFresh = false) {
125
+ if (!autoClaimEnabled()) {
126
+ clearZcodeTimers();
127
+ return;
128
+ }
129
+
130
+ const expiresAt = autoClaimUntil();
131
+ if (startFresh && zcodeTimerHandle) {
132
+ clearTimeout(zcodeTimerHandle);
133
+ zcodeTimerHandle = null;
134
+ zcodeNextTickAt = null;
135
+ }
136
+ if (!zcodeTimerHandle && !zcodeTicking) scheduleZcodeNextTick(ZCODE_FIRST_DELAY_MS);
137
+
138
+ if (expiresAt && zcodeExpiryAt !== expiresAt) {
139
+ if (zcodeExpiryTimerHandle) clearTimeout(zcodeExpiryTimerHandle);
140
+ zcodeExpiryAt = expiresAt;
141
+ zcodeExpiryTimerHandle = setTimeout(() => {
142
+ zcodeExpiryTimerHandle = null;
143
+ setAutoClaimEnabled(false);
144
+ clearZcodeTimers();
145
+ logger.info('ZCODE-CLAIM', '自动领取时段已结束,自动领取已关闭', {});
146
+ }, Math.max(0, expiresAt - Date.now()));
147
+ zcodeExpiryTimerHandle.unref?.();
148
+ } else if (!expiresAt && zcodeExpiryTimerHandle) {
149
+ // 运行时长为 0(不自动关闭):清掉可能残留的到期定时器
150
+ clearTimeout(zcodeExpiryTimerHandle);
151
+ zcodeExpiryTimerHandle = null;
152
+ zcodeExpiryAt = null;
153
+ }
154
+ }
155
+
156
+ function runZcodeTick(accountIds, { force = false } = {}) {
157
+ const run = zcodeQueue.then(async () => {
158
+ if (!force && !autoClaimEnabled()) return { results: [], summary: 'ZCode 自动领取已关闭' };
159
+ zcodeTicking = true;
160
+ const results = [];
161
+ const claimedIds = [];
162
+ try {
163
+ const accounts = (await loadAccounts()).filter((a) => a.provider === 'zcode'
164
+ && (!accountIds || accountIds.has(a.id)));
165
+ for (const account of accounts) {
166
+ if (!force && !autoClaimEnabled()) break;
167
+ const label = account.name || account.uid || account.id;
168
+ try {
169
+ const outcome = await zcodeAutoClaim(account);
170
+ if (!outcome) continue;
171
+ if (outcome.status === 'checked-in') {
172
+ claimedIds.push(account.id);
173
+ await clearQuotaMark(account.id);
174
+ }
175
+ results.push({ accountId: account.id, account: label, provider: 'zcode', status: outcome.status, message: outcome.message });
176
+ const lastResult = { status: outcome.status, message: outcome.message, amount: 0, at: new Date().toISOString() };
177
+ await withAccounts((list) => {
178
+ const current = list.find((a) => a.id === account.id);
179
+ if (current) current.lastResult = lastResult;
180
+ });
181
+ } catch (err) {
182
+ logger.warn('ZCODE-CLAIM', '{label} 自动领取异常:{err}', { label, err: err?.message || err });
183
+ }
184
+ }
185
+ zcodeLastTick = {
186
+ at: new Date().toISOString(),
187
+ summary: `ZCode 资格轮询完成:${results.length} 个账号返回活动结果`,
188
+ claimedIds, // 面板据此即时刷新这些账号的额度
189
+ };
190
+ return { results, summary: zcodeLastTick.summary };
191
+ } finally {
192
+ zcodeTicking = false;
193
+ }
194
+ });
195
+ zcodeQueue = run.catch(() => {});
196
+ return run;
197
+ }
198
+
199
+ /** 手动触发一次 ZCode 资格轮询(面板单账号 / 全部领取走这里),与定时轮共用同一队列;手动轮不受开关限制。 */
200
+ export function pollZcodeNow(accountIds) {
201
+ return runZcodeTick(accountIds && accountIds.length ? new Set(accountIds) : null, { force: true });
202
+ }
203
+
204
+ async function getDoneMap(state) {
205
+ // 任一口径下仍属于「今天」的记录都保留(0–10 点间 Qoder 记录的业务日还是昨天,不能误删)
206
+ const keepDays = new Set([dayKey(Date.now(), 'qoder'), dayKey(Date.now(), 'workbuddy')]);
207
+ const raw = state?.qoderDailyDone;
208
+ const map = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : {};
209
+ for (const key of Object.keys(map)) if (!keepDays.has(map[key])) delete map[key];
210
+ return map;
211
+ }
212
+
213
+ /** 多次调用排队串行执行,避免并发签到和 state.json 覆盖。 */
214
+ export function runCheckinTick(opts = {}) {
215
+ const run = tickQueue.then(() => runTickNow(opts));
216
+ tickQueue = run.catch(() => {});
217
+ return run;
218
+ }
219
+
220
+ async function runTickNow(opts) {
221
+ ticking = true;
222
+ try { return await runTickInner(opts); } finally { ticking = false; }
223
+ }
224
+
225
+ async function runTickInner(opts) {
226
+ const allAccounts = await loadAccounts();
227
+ // WB 国际版活跃请求全局开关(面板「设置 → 通用」):关闭时该轮与手动领取都不再打
228
+ const wbActivity = (await loadSettings()).workbuddyActivity !== false;
229
+ const accounts = allAccounts.filter(
230
+ (a) => (!opts.provider || a.provider === opts.provider)
231
+ && (!opts.product || productOf(a.provider) === opts.product)
232
+ && (!opts.onlyAccountId || a.id === opts.onlyAccountId)
233
+ && (wbActivity || a.provider !== 'workbuddy-intl')
234
+ && typeof productImpl(a.provider)?.checkin === 'function'
235
+ );
236
+ const zAccounts = (opts.includeZcode !== false && autoClaimEnabled()
237
+ && !opts.onlyAccountId
238
+ && (!opts.provider || opts.provider === 'zcode')
239
+ && (!opts.product || opts.product === 'zcode'))
240
+ ? allAccounts.filter((a) => a.provider === 'zcode')
241
+ : [];
242
+
243
+ if (accounts.length === 0 && zAccounts.length === 0) {
244
+ return { results: [], summary: '没有匹配的账号' };
245
+ }
246
+
247
+ const state = await loadState();
248
+ const memo = await getDoneMap(state);
249
+ const today = dayKey();
250
+ const results = [];
251
+ let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
252
+
253
+ for (const account of accounts) {
254
+ const pTag = `[${PROVIDER_LABEL[account.provider] || account.provider}]`;
255
+ const label = `${pTag} ${account.name || account.id}`;
256
+ // 「今日已领」按各产品自己的业务日界比对:Qoder 10 点翻日,其余 0 点翻日
257
+ const todayFor = dayKey(Date.now(), account.provider);
258
+ try {
259
+ if (opts.skipIfCheckedToday && memo[account.id] === todayFor) {
260
+ results.push({ accountId: account.id, account: label, provider: account.provider, status: 'already', memoized: true });
261
+ continue;
262
+ }
263
+
264
+ const ctx = refreshContext(account, (message) => logger.info('CHECKIN', '{label}:{msg}', { label, msg: message }));
265
+ let outcome = await productImpl(account.provider).checkin(account, ctx);
266
+ outcome = { accountId: account.id, account: label, provider: account.provider, ...outcome };
267
+ if (account.provider === 'qoder' && outcome.risk) {
268
+ if (outcome.status === 'checked-in') {
269
+ deviceClaim = { day: today, accountId: account.id, name: label };
270
+ } else if (outcome.status === 'no-activity' && deviceClaim && deviceClaim.accountId !== account.id) {
271
+ outcome = { ...outcome, status: 'limited', message: `本机今日国际版额度已由「${deviceClaim.name}」领取(Qoder 每台设备每天限领一次)` };
272
+ }
273
+ }
274
+ results.push(outcome);
275
+ if (outcome.uid && !account.uid) account.uid = outcome.uid;
276
+ account.lastResult = {
277
+ status: outcome.status,
278
+ message: outcome.error || outcome.message || null,
279
+ amount: outcome.claimedAmount || 0,
280
+ ...(outcome.streakDays !== undefined ? { streakDays: outcome.streakDays + (outcome.status === 'checked-in' ? 1 : 0) } : {}),
281
+ at: new Date().toISOString(),
282
+ };
283
+
284
+ if (outcome.status === 'checked-in') {
285
+ memo[account.id] = todayFor;
286
+ account.lastCheckin = new Date().toISOString();
287
+ logger.info('CHECKIN', '{label} 领取成功 +{amount} Credits', { label, amount: outcome.claimedAmount });
288
+ } else if (outcome.status === 'already') {
289
+ memo[account.id] = todayFor;
290
+ account.lastCheckin = account.lastCheckin || new Date().toISOString();
291
+ logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '今日已领' });
292
+ } else if (outcome.status === 'limited') {
293
+ memo[account.id] = todayFor;
294
+ logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message });
295
+ } else if (outcome.status === 'no-activity') {
296
+ logger.debug('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '当前无可领取的活动' });
297
+ } else {
298
+ logger.warn('CHECKIN', '{label} 领取失败:{err}', { label, err: outcome.error || outcome.status });
299
+ }
300
+ } catch (err) {
301
+ results.push({ accountId: account.id, account: label, provider: account.provider, status: 'failed', error: err?.message || String(err) });
302
+ account.lastResult = { status: 'failed', message: err?.message || String(err), amount: 0, at: new Date().toISOString() };
303
+ logger.error('CHECKIN', '{label} 异常:{err}', { label, err: err?.message || err });
304
+ }
305
+ }
306
+
307
+ if (accounts.length) {
308
+ await withAccounts((all) => {
309
+ for (const updated of accounts) {
310
+ const current = all.find((a) => a.id === updated.id);
311
+ if (!current) continue;
312
+ current.lastCheckin = updated.lastCheckin;
313
+ if (updated.lastResult) current.lastResult = updated.lastResult;
314
+ if (updated.uid && !current.uid) current.uid = updated.uid;
315
+ }
316
+ });
317
+ await withState((fresh) => {
318
+ // 只覆盖签到轮自己负责的两个键:state.json 里还有网关写的 zcodeGatewayExhausted,
319
+ // 拿本轮开头读到的旧快照整份落盘会把别人刚写的东西抹掉
320
+ fresh.qoderDailyDone = memo;
321
+ fresh.deviceClaim = deviceClaim;
322
+ });
323
+ }
324
+
325
+ if (zAccounts.length) {
326
+ const zResult = await runZcodeTick(new Set(zAccounts.map((a) => a.id)));
327
+ results.push(...zResult.results);
328
+ }
329
+
330
+ const claimed = results.filter((r) => r.status === 'checked-in');
331
+ const failed = results.filter((r) => r.status === 'failed');
332
+ const none = results.filter((r) => r.status === 'no-activity');
333
+ const limited = results.filter((r) => r.status === 'limited');
334
+ const already = results.length - claimed.length - failed.length - none.length - limited.length;
335
+ const totalCredits = claimed.reduce((sum, r) => sum + (r.claimedAmount || 0), 0);
336
+ const summary = `签到汇总:成功 ${claimed.length}(+${totalCredits} Credits)、已领 ${already}`
337
+ + (limited.length ? `、本机限领 ${limited.length}` : '')
338
+ + `、无活动 ${none.length}、失败 ${failed.length}`;
339
+ // 日志走 key+args(按界面语言实时重渲染);summary 原文继续供面板/状态展示
340
+ logger.info('CHECKIN', limited.length
341
+ ? '签到汇总:成功 {ok}(+{credits} Credits)、已领 {al}、本机限领 {limited}、无活动 {none}、失败 {fail}'
342
+ : '签到汇总:成功 {ok}(+{credits} Credits)、已领 {al}、无活动 {none}、失败 {fail}',
343
+ { ok: claimed.length, credits: totalCredits, al: already, limited: limited.length, none: none.length, fail: failed.length });
344
+ lastTick = { at: new Date().toISOString(), summary };
345
+ return { results, summary };
346
+ }
347
+
348
+ async function scheduleTick() {
349
+ const delay = await msUntilNextTick();
350
+ nextTickAt = new Date(Date.now() + delay).toISOString();
351
+ timerHandle = setTimeout(async () => {
352
+ try {
353
+ await runCheckinTick({ skipIfCheckedToday: true, includeZcode: false });
354
+ } catch (err) {
355
+ logger.error('CHECKIN', '定时轮失败:{err}', { err: err?.message || err });
356
+ } finally {
357
+ scheduleTick();
358
+ }
359
+ }, delay);
360
+ timerHandle.unref?.();
361
+ logger.info('CHECKIN', '定时签到已启动,下次执行约 {min} 分钟后', { min: Math.round(delay / 60000) });
362
+ }
363
+
364
+ /** 启动普通签到调度与 ZCode 独立轮询调度。 */
365
+ export async function startScheduler() {
366
+ if (timerHandle) return;
367
+ startUsageSyncScheduler();
368
+ startWebSessionWatch();
369
+ warmZcodeAppVersion();
370
+ if (await autoCheckinEnabled()) {
371
+ scheduleTick();
372
+ setTimeout(() => {
373
+ runCheckinTick({ skipIfCheckedToday: true, includeZcode: false }).catch((err) =>
374
+ logger.error('CHECKIN', '引导轮失败:{err}', { err: err?.message || err }));
375
+ }, 15000).unref?.();
376
+ } else {
377
+ logger.info('CHECKIN', '自动签到已关闭(面板设置可开启)');
378
+ }
379
+ syncZcodeScheduler();
380
+ }
381
+
382
+ /** 面板修改全局自动签到开关 / 频率后重同步(daemon PUT /api/settings 调用)。 */
383
+ export async function resyncAutoCheckinScheduler() {
384
+ if (timerHandle) { clearTimeout(timerHandle); timerHandle = null; nextTickAt = null; }
385
+ if (await autoCheckinEnabled()) scheduleTick();
386
+ else logger.info('CHECKIN', '自动签到已关闭');
387
+ }
388
+
389
+ export function stopScheduler() {
390
+ if (timerHandle) clearTimeout(timerHandle);
391
+ timerHandle = null;
392
+ nextTickAt = null;
393
+ clearZcodeTimers();
394
+ }
395
+
396
+
397
+ // ── Qoder 网页会话保活监视(10R 联调反馈:三账号 cookie 齐过期才在 10R 卡面发现)──
398
+ // 网页会话 cookie 无法离线续期(必须真浏览器登录),但可以**提前发现**:每日用已存
399
+ // cookie 真探测一次(probeWebSession),失效落 meta.qoderWebDead——面板徽章从
400
+ // 「7 天陈旧」升级成「已失效」红色态,导出提示同步点名,不再等 10R 卡面亮红。
401
+ let wsWatchTimer = null;
402
+ const WS_WATCH_INTERVAL_MS = 6 * 3600 * 1000; // 每 6 小时一检(cookie 寿命以天计,足够密)
403
+
404
+ /**
405
+ * 保活监视单轮(导出以便回归测试直测):
406
+ * - probeWebSession 明确拒绝(返回 null)→ 判死,落 meta.qoderWebDead
407
+ * - 探测通过 → 清标记(复活)
408
+ * - 网络异常(抛错)→ 保守不动标记(下一轮再试,避免抖动误杀)
409
+ */
410
+ export async function runWebSessionWatchTick() {
411
+ const { probeWebSession } = await import('./qoderClient.js');
412
+ const accounts = await loadAccounts();
413
+ const qoders = accounts.filter((a) => String(a.provider || '').startsWith('qoder')
414
+ && a.meta?.qoderWebSession?.cookie);
415
+ for (const a of qoders) {
416
+ const cookie = a.meta.qoderWebSession.cookie;
417
+ let dead = false;
418
+ try {
419
+ const probe = await probeWebSession(a.provider, cookie);
420
+ dead = !probe?.user_id; // 探测接口不认这份 cookie = 失效
421
+ } catch { continue; } // 网络异常保守跳过(下一轮再试),只有明确被拒才判死
422
+ if (dead !== Boolean(a.meta.qoderWebDead)) {
423
+ await withAccounts((list) => {
424
+ const hit = list.find((x) => x.id === a.id);
425
+ if (!hit) return;
426
+ hit.meta = { ...(hit.meta || {}) };
427
+ if (dead) hit.meta.qoderWebDead = { at: new Date().toISOString() };
428
+ else delete hit.meta.qoderWebDead;
429
+ hit.updatedAt = new Date().toISOString();
430
+ });
431
+ if (dead) logger.warn('WEBSession', '{name} 网页会话已失效(探测接口拒绝)——请重新「浏览器登录」补 cookie', { name: a.name || a.id });
432
+ }
433
+ }
434
+ }
435
+
436
+ export function startWebSessionWatch() {
437
+ if (wsWatchTimer) return;
438
+ const tick = () => runWebSessionWatchTick().catch((e) =>
439
+ logger.warn('WEBSession', '网页会话保活监视失败:{err}', { err: e?.message || e }));
440
+ setTimeout(tick, 45 * 1000).unref?.(); // 启动 45 秒后先跑一轮首检
441
+ wsWatchTimer = setInterval(tick, WS_WATCH_INTERVAL_MS);
442
+ wsWatchTimer.unref?.();
443
+ }