creditdaddy 1.3.0 → 1.4.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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * CreditDaddy 后台守护 —— 让 `creditdaddy start` 把 daemon 丢到后台跑,终端可以随手关掉。
3
+ *
4
+ * 为什么不直接把日志接到 daemon-YYYY-MM-DD.log:
5
+ * 同一个数据目录可能同时有多个实例(后台实例 + 用户手动跑的前台 daemon + 桌面版),
6
+ * 它们以 O_APPEND 共享当天文件。logger.js 关闭流时会 gzip 并清空原文件;子进程那侧
7
+ * 单独接 background.log,两个实例互不干扰,也避开 cleanupArchive 的文件名前缀约定。
8
+ *
9
+ * 状态落在 <数据目录>/daemon.json(不是 .pid),因为要记的不止 pid —— daemon 端口被占
10
+ * 时会自动 +1 重试最多 10 次(见 daemon.js 的 EADDRINUSE 分支),真正在听的端口只有
11
+ * startDaemon() resolve 后的 r.port 知道,所以由 daemon 进程自己写这个文件。
12
+ *
13
+ * 「在运行」= pid 存活 **且** 端口应答:pid 会被系统回收、文件会被手删,只查 pid 会误报;
14
+ * 只查端口又会把同端口的桌面版误认成 CLI 实例。状态查询走带鉴权的 /api/status(不新增
15
+ * 任何未鉴权端点),401 同样算「活着」—— 说明服务在,只是要面板密码。
16
+ */
17
+
18
+ import { spawn, execFileSync } from 'node:child_process';
19
+ import fsSync from 'node:fs';
20
+ import fs from 'node:fs/promises';
21
+ import http from 'node:http';
22
+ import net from 'node:net';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dataDir } from './store.js';
26
+ import { logger } from './logger.js';
27
+ import { t } from './i18n.js';
28
+
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+ const CLI_ENTRY = path.join(__dirname, '..', 'bin', 'creditdaddy.js');
31
+ const DEFAULT_PORT = 47860;
32
+ const DEFAULT_HOST = '127.0.0.1';
33
+
34
+ // 后台启动最多等这么久:daemon 要读账号库、建 HTTP server、起签到定时器,冷启动慢一点正常
35
+ const READY_TIMEOUT_MS = 30_000;
36
+ const READY_INTERVAL_MS = 250;
37
+ // /api/status 需要 DPAPI 解密与本机进程探测,冷机/高负载下单次可达 2-4s(实测 Windows 稳态 ~1.9s)。
38
+ // 2.5s 的探测超时和稳态耗时贴边,机器一忙就出现「探针永远超时 → 误判启动失败」的抖动,宽限到 8s。
39
+ const PROBE_TIMEOUT_MS = 8000;
40
+ const STOP_TIMEOUT_MS = 8000;
41
+
42
+ /** 状态文件路径:跟随 store.js 的 dataDir(),尊重 CREDITDADDY_HOME */
43
+ export function runtimeFile() {
44
+ return path.join(dataDir(), 'daemon.json');
45
+ }
46
+
47
+ /** 后台实例的 stdout/stderr 落点(与按日归档的 daemon-*.log 分开,见文件头) */
48
+ export function backgroundLogFile() {
49
+ return path.join(dataDir(), 'logs', 'background.log');
50
+ }
51
+
52
+ /** 读状态文件。文件不存在 / 内容损坏都返回 null(调用方按「没有后台实例」处理) */
53
+ export function readRuntime() {
54
+ try {
55
+ const raw = fsSync.readFileSync(runtimeFile(), 'utf8');
56
+ const r = JSON.parse(raw);
57
+ if (!r || typeof r !== 'object' || !Number.isInteger(r.pid) || r.pid <= 0) return null;
58
+ return r;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export async function writeRuntime(rec) {
65
+ const file = runtimeFile();
66
+ try {
67
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
68
+ await fs.writeFile(file, JSON.stringify(rec, null, 2), { mode: 0o600 });
69
+ return true;
70
+ } catch (e) {
71
+ logger.warn('BG', '写状态文件失败:{err}', { err: e?.message || e });
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** 删状态文件。仅当 pid 与自己一致时才删——避免把后一个实例的状态误删 */
77
+ export async function clearRuntime(expectedPid = null) {
78
+ try {
79
+ const cur = readRuntime();
80
+ if (expectedPid != null && cur && cur.pid !== expectedPid) return false;
81
+ await fs.unlink(runtimeFile());
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 进程是否还活着。
90
+ * ESRCH = 确实没有;EPERM = 在、只是当前用户无权发信号(不能当成已死);
91
+ * 抛到调用方的其他异常同样按「活着」处理——宁可多报一次运行中,也不要误杀。
92
+ */
93
+ export function isAlive(pid) {
94
+ if (!Number.isInteger(pid) || pid <= 0) return false;
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ } catch (e) {
99
+ return e?.code !== 'ESRCH';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 面板访问密码,取值顺序与 daemon.js 的 initPanelKey 保持一致:
105
+ * 面板设置里存的 panelKey 优先(用户可能已改过),环境变量兜底。
106
+ */
107
+ export async function readPanelKey() {
108
+ try {
109
+ const { loadSettings } = await import('./store.js');
110
+ const s = await loadSettings();
111
+ if (typeof s.panelKey === 'string' && s.panelKey) return s.panelKey;
112
+ } catch { /* 读不到就退到环境变量 */ }
113
+ return process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
114
+ }
115
+
116
+ /**
117
+ * 探一次 /api/status。200 与 401 都算 reachable(详见文件头)。
118
+ * 不带 Origin,能过 daemon 的 rejectForeignRequest(非浏览器客户端不受该校验约束)。
119
+ */
120
+ export function probeStatus(rec, timeoutMs = PROBE_TIMEOUT_MS) {
121
+ const port = Number(rec?.port);
122
+ const host = rec?.host || DEFAULT_HOST;
123
+ const probeHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
124
+ if (!Number.isInteger(port) || port <= 0) return Promise.resolve({ reachable: false });
125
+
126
+ return new Promise((resolve) => {
127
+ const req = http.request(
128
+ { host: probeHost, port, path: '/api/status', method: 'GET', headers: { Host: `${probeHost}:${port}` } },
129
+ (res) => {
130
+ const chunks = [];
131
+ res.on('data', (c) => chunks.push(c));
132
+ res.on('end', () => {
133
+ const reachable = res.statusCode === 200 || res.statusCode === 401;
134
+ let body = null;
135
+ if (res.statusCode === 200) {
136
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { body = null; }
137
+ }
138
+ resolve({
139
+ reachable,
140
+ status: res.statusCode,
141
+ needsKey: res.statusCode === 401,
142
+ version: body?.version || null,
143
+ accountsCount: Number.isInteger(body?.accountsCount) ? body.accountsCount : null,
144
+ nextTickAt: body?.scheduler?.nextTickAt || null,
145
+ });
146
+ });
147
+ },
148
+ );
149
+ // 401 也要把面板密码带上再试一次:daemon 可能在跑但设了访问密码,
150
+ // 拿不到 version 不影响「在运行」的判定,但要账号数就得认证。
151
+ req.on('error', () => resolve({ reachable: false }));
152
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ reachable: false }); });
153
+ readPanelKey().then((key) => {
154
+ if (key) req.setHeader('x-qd-key', key);
155
+ req.end();
156
+ });
157
+ });
158
+ }
159
+
160
+ /** 后台实例的完整状态:文件 + 进程 + 端口三处都查 */
161
+ export async function statusRuntime() {
162
+ const rec = readRuntime();
163
+ if (!rec) return { running: false, reason: 'no-state' };
164
+ const pidAlive = isAlive(rec.pid);
165
+ let probe = pidAlive ? await probeStatus(rec) : { reachable: false };
166
+ // pid 还在但立刻探不通 = 大概率正在启动途中的瞬断(listening 刚触发、首个请求还没排到)。
167
+ // 只多等一拍再探一次:错了方向是放行双实例(状态互写)比错一刀拦死更危险
168
+ if (pidAlive && !probe.reachable) {
169
+ await new Promise((r) => setTimeout(r, 350));
170
+ probe = await probeStatus(rec);
171
+ }
172
+ return { running: pidAlive && probe.reachable, pidAlive, probe, ...rec };
173
+ }
174
+
175
+ /**
176
+ * 端口已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
177
+ * 后台启动前先探,避免子进程悄悄漂到 47861 让用户摸不着面板在哪。
178
+ */
179
+ /**
180
+ * 端口是否已被别的东西占了?(和「本 daemon 已在上一个端口跑着」是两回事)
181
+ *
182
+ * 用「自己试着 bind 一下」判断,而不是发 HTTP 请求探活:非 HTTP 的程序(数据库、
183
+ * 别的服务)占着端口时不会回话,HTTP 探测会一直等到超时,然后误报「端口空闲」,
184
+ * 子进程就这么悄悄漂到了下一个端口,用户摸不着面板到底在哪。绑一下立刻 close,
185
+ * 唯一目的就是拿到 EADDRINUSE 这个答案。
186
+ */
187
+ export function portTaken(port, host = DEFAULT_HOST) {
188
+ return new Promise((resolve) => {
189
+ const probe = net.createServer();
190
+ probe.once('error', (e) => resolve(e?.code === 'EADDRINUSE'));
191
+ probe.once('listening', () => probe.close(() => resolve(false)));
192
+ try {
193
+ probe.listen(port, host);
194
+ } catch {
195
+ resolve(true);
196
+ }
197
+ });
198
+ }
199
+
200
+ /** 等后台实例真正开始应答(子进程要先写状态文件,再能被探到) */
201
+ async function waitReady(timeoutMs = READY_TIMEOUT_MS) {
202
+ const deadline = Date.now() + timeoutMs;
203
+ while (Date.now() < deadline) {
204
+ const rec = readRuntime();
205
+ if (rec && isAlive(rec.pid)) {
206
+ const probe = await probeStatus(rec);
207
+ if (probe.reachable) return { ok: true, rec, probe };
208
+ }
209
+ await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
210
+ }
211
+ return { ok: false };
212
+ }
213
+
214
+ /**
215
+ * 后台拉起 daemon。
216
+ * 已有一个活着的实例就直接返回(幂等)——脚本里 `start` 连跑两次不该起两个服务。
217
+ */
218
+ export async function startBackground({ port = DEFAULT_PORT, host = DEFAULT_HOST } = {}) {
219
+ const cur = await statusRuntime();
220
+ if (cur.running) return { already: true, rec: cur };
221
+
222
+ const stale = readRuntime();
223
+ if (stale) await clearRuntime(); // 残留状态(进程已死 / 端口失联),先清掉再起
224
+
225
+ if (await portTaken(port, host)) {
226
+ return { already: false, error: `端口 ${port} 已被其他程序占用` };
227
+ }
228
+
229
+ const logFile = backgroundLogFile();
230
+ try {
231
+ await fs.mkdir(path.dirname(logFile), { recursive: true });
232
+ } catch { /* 目录建不了就退回继承父进程 stdio,daemon 本身不受影响 */ }
233
+
234
+ let out = 'ignore';
235
+ let logFd = null;
236
+ try {
237
+ logFd = fsSync.openSync(logFile, 'a');
238
+ out = logFd;
239
+ } catch { /* 落不了盘就让 daemon 继承 stdio */ }
240
+
241
+ let child;
242
+ try {
243
+ child = spawn(process.execPath, [CLI_ENTRY, 'daemon', '--port', String(port), '--host', host], {
244
+ detached: true, // 独立进程组:关掉终端不影响它
245
+ windowsHide: true, // Windows 上不要弹控制台窗口
246
+ stdio: ['ignore', out, out],
247
+ env: { ...process.env, CREDITDADDY_BG: '1' }, // 让 daemon 知道自己跑在后台
248
+ });
249
+ } catch (e) {
250
+ if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
251
+ return { already: false, error: `启动失败:${e?.message || e}` };
252
+ }
253
+
254
+ if (logFd != null) try { fsSync.closeSync(logFd); } catch {}
255
+ child.unref(); // 父进程立刻可以退出,子进程继续活着
256
+
257
+ const ready = await waitReady();
258
+ if (!ready.ok) {
259
+ // 没等到应答:把刚起的进程收掉,否则会留一个查不到状态的孤儿
260
+ await killPid(child.pid);
261
+ return { already: false, error: `启动超时(${Math.round(READY_TIMEOUT_MS / 1000)}s),日志见 ${logFile}` };
262
+ }
263
+ return { already: false, rec: ready.rec };
264
+ }
265
+
266
+ /**
267
+ * 杀掉一个 pid。
268
+ * Windows 没有进程组概念,process.kill(-pid) 会直接抛 EINVAL,所以用 taskkill /T 杀进程树;
269
+ * taskkill 不可用(精简系统 / 容器)时退回单杀。
270
+ */
271
+ export async function killPid(pid) {
272
+ if (!Number.isInteger(pid) || pid <= 0) return false;
273
+ if (process.platform === 'win32') {
274
+ try {
275
+ execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
276
+ windowsHide: true, stdio: 'ignore', timeout: 10_000,
277
+ });
278
+ return true;
279
+ } catch { /* 进程可能已经没了,或 taskkill 不可用 */ }
280
+ }
281
+ try {
282
+ // POSIX 下 detached 起的子进程自成进程组,-pid 才能连它自己拉起的子进程一起收掉
283
+ process.kill(-pid, 'SIGTERM');
284
+ return true;
285
+ } catch { /* 不是组长或已退出 */ }
286
+ try {
287
+ process.kill(pid, 'SIGTERM');
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ /** 端口是否已从监听状态退下 */
295
+ async function waitPortFree(port, host = DEFAULT_HOST, timeoutMs = STOP_TIMEOUT_MS) {
296
+ const deadline = Date.now() + timeoutMs;
297
+ while (Date.now() < deadline) {
298
+ if (!(await portTaken(port, host))) return true;
299
+ await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
300
+ }
301
+ return false;
302
+ }
303
+
304
+ /**
305
+ * 等进程真正从进程表里消失。
306
+ * SIGTERM 只是投递信号,handler 跑完 + 内核回收之间还有几十毫秒到几秒的窗口——
307
+ * 端口先释放、pid 后回收是常态。不等它就报 stopped:true,调用方紧接着的
308
+ * isAlive(pid) 仍会读到 true,误判「停不干净」。
309
+ */
310
+ async function waitPidDead(pid, timeoutMs = STOP_TIMEOUT_MS) {
311
+ const deadline = Date.now() + timeoutMs;
312
+ while (Date.now() < deadline) {
313
+ if (!isAlive(pid)) return true;
314
+ await new Promise((r) => setTimeout(r, READY_INTERVAL_MS));
315
+ }
316
+ return false;
317
+ }
318
+
319
+ /**
320
+ * 停掉后台实例。
321
+ * 没在跑也正常返回(幂等)——脚本里 `stop` 后紧接 `start` 不该因为「本来就没跑」而中断。
322
+ */
323
+ export async function stopDaemon() {
324
+ const rec = readRuntime();
325
+ if (!rec) return { stopped: false, reason: 'no-state' };
326
+
327
+ if (!isAlive(rec.pid)) {
328
+ await clearRuntime();
329
+ return { stopped: false, reason: 'not-running', stale: true };
330
+ }
331
+
332
+ await killPid(rec.pid);
333
+ const free = await waitPortFree(rec.port, rec.host);
334
+ // 端口释放不等于进程退出:SIGTERM 只是投递,handler 收尾 + 内核回收还有窗口。
335
+ // 必须等到 pid 真的没了再报 stopped,否则调用方紧接着 isAlive(pid) 仍读到 true。
336
+ const dead = await waitPidDead(rec.pid);
337
+ await clearRuntime();
338
+ if (!free) logger.warn('BG', '端口 {port} 迟迟没有释放,进程可能仍在收尾', { port: rec.port });
339
+ if (!dead) logger.warn('BG', '进程 {pid} 在 {sec}s 内没有退出干净', { pid: rec.pid, sec: Math.round(STOP_TIMEOUT_MS / 1000) });
340
+ return { stopped: true, rec, portFree: free, pidDead: dead };
341
+ }
package/src/checkin.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 签到调度:普通账号每 ~2h 扫描一次;ZCode 自动活动领取使用独立的短周期调度。
2
+ * 签到调度:普通账号按配置的间隔(默认每 2h,面板设置可调 1-8h、可全局关闭)扫描一次;
3
+ * ZCode 自动活动领取使用独立的短周期调度。
3
4
  */
4
5
 
5
- import { loadAccounts, loadState, withAccounts, withState } from './store.js';
6
+ import { loadAccounts, loadSettings, loadState, withAccounts, withState } from './store.js';
6
7
  import { productImpl } from './providers.js';
7
- import { productOf } from './constants.js';
8
+ import { productOf, PROVIDER_LABEL } from './constants.js';
8
9
  import { startUsageSyncScheduler } from './tenrouter.js';
9
10
  import { refreshContext } from './accounts.js';
10
11
  import { zcodeAutoClaim } from './zcodeAutoClaim.js';
@@ -15,8 +16,15 @@ import {
15
16
  warmZcodeAppVersion,
16
17
  } from './zcodeClient.js';
17
18
  import { logger } from './logger.js';
19
+ import { t as tr } from './i18n.js';
18
20
 
19
- const TICK_MS = 2 * 60 * 60 * 1000;
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
+ }
20
28
  const TICK_JITTER_MS = 10 * 60 * 1000;
21
29
  const ZCODE_JITTER_MS = 15 * 1000;
22
30
  const ZCODE_FIRST_DELAY_MS = 2 * 60 * 1000;
@@ -39,10 +47,13 @@ let lastTick = null;
39
47
  let ticking = false;
40
48
 
41
49
  /** 调度器状态(面板展示用) */
42
- export function getSchedulerInfo() {
50
+ export async function getSchedulerInfo() {
43
51
  const enabled = autoClaimEnabled();
52
+ const autoCk = await autoCheckinEnabled();
44
53
  return {
45
- running: Boolean(timerHandle), nextTickAt, lastTick, ticking,
54
+ enabled: autoCk,
55
+ intervalMin: await checkinIntervalMin(),
56
+ running: Boolean(timerHandle), nextTickAt: autoCk ? nextTickAt : null, lastTick, ticking,
46
57
  zcode: {
47
58
  enabled,
48
59
  intervalMin: claimIntervalMin(),
@@ -61,8 +72,8 @@ export function dayKey(nowMs = Date.now(), provider = 'qoder') {
61
72
  return new Date(nowMs + (8 - h) * 3600 * 1000).toISOString().slice(0, 10);
62
73
  }
63
74
 
64
- export function msUntilNextTick(nowMs = Date.now(), rand = Math.random) {
65
- return Math.max(TICK_MS + Math.floor(rand() * TICK_JITTER_MS), 1000);
75
+ export async function msUntilNextTick(rand = Math.random) {
76
+ return Math.max((await checkinIntervalMin()) * 60 * 1000 + Math.floor(rand() * TICK_JITTER_MS), 1000);
66
77
  }
67
78
 
68
79
  export function zcodeMsUntilNextTick(rand = Math.random) {
@@ -104,7 +115,7 @@ function scheduleZcodeNextTick(delay = zcodeMsUntilNextTick()) {
104
115
  zcodeTimerHandle = setTimeout(() => {
105
116
  zcodeTimerHandle = null;
106
117
  zcodeNextTickAt = null;
107
- runZcodeTick().catch((err) => logger.error('ZCODE-CLAIM', `资格轮询失败:${err?.message || err}`))
118
+ runZcodeTick().catch((err) => logger.error('ZCODE-CLAIM', '资格轮询失败:{err}', { err: err?.message || err }))
108
119
  .finally(() => scheduleZcodeNextTick());
109
120
  }, delay);
110
121
  zcodeTimerHandle.unref?.();
@@ -131,7 +142,7 @@ function syncZcodeScheduler(startFresh = false) {
131
142
  zcodeExpiryTimerHandle = null;
132
143
  setAutoClaimEnabled(false);
133
144
  clearZcodeTimers();
134
- logger.info('ZCODE-CLAIM', '自动领取时段已结束,自动领取已关闭');
145
+ logger.info('ZCODE-CLAIM', '自动领取时段已结束,自动领取已关闭', {});
135
146
  }, Math.max(0, expiresAt - Date.now()));
136
147
  zcodeExpiryTimerHandle.unref?.();
137
148
  } else if (!expiresAt && zcodeExpiryTimerHandle) {
@@ -168,7 +179,7 @@ function runZcodeTick(accountIds, { force = false } = {}) {
168
179
  if (current) current.lastResult = lastResult;
169
180
  });
170
181
  } catch (err) {
171
- logger.warn('ZCODE-CLAIM', `${label} 自动领取异常:${err?.message || err}`);
182
+ logger.warn('ZCODE-CLAIM', '{label} 自动领取异常:{err}', { label, err: err?.message || err });
172
183
  }
173
184
  }
174
185
  zcodeLastTick = {
@@ -213,10 +224,13 @@ async function runTickNow(opts) {
213
224
 
214
225
  async function runTickInner(opts) {
215
226
  const allAccounts = await loadAccounts();
227
+ // WB 国际版活跃请求全局开关(面板「设置 → 通用」):关闭时该轮与手动领取都不再打
228
+ const wbActivity = (await loadSettings()).workbuddyActivity !== false;
216
229
  const accounts = allAccounts.filter(
217
230
  (a) => (!opts.provider || a.provider === opts.provider)
218
231
  && (!opts.product || productOf(a.provider) === opts.product)
219
232
  && (!opts.onlyAccountId || a.id === opts.onlyAccountId)
233
+ && (wbActivity || a.provider !== 'workbuddy-intl')
220
234
  && typeof productImpl(a.provider)?.checkin === 'function'
221
235
  );
222
236
  const zAccounts = (opts.includeZcode !== false && autoClaimEnabled()
@@ -237,7 +251,8 @@ async function runTickInner(opts) {
237
251
  let deviceClaim = state?.deviceClaim?.day === today ? state.deviceClaim : null;
238
252
 
239
253
  for (const account of accounts) {
240
- const label = account.name || account.id;
254
+ const pTag = `[${PROVIDER_LABEL[account.provider] || account.provider}]`;
255
+ const label = `${pTag} ${account.name || account.id}`;
241
256
  // 「今日已领」按各产品自己的业务日界比对:Qoder 10 点翻日,其余 0 点翻日
242
257
  const todayFor = dayKey(Date.now(), account.provider);
243
258
  try {
@@ -246,7 +261,7 @@ async function runTickInner(opts) {
246
261
  continue;
247
262
  }
248
263
 
249
- const ctx = refreshContext(account, (message) => logger.info('CHECKIN', `${label}:${message}`));
264
+ const ctx = refreshContext(account, (message) => logger.info('CHECKIN', '{label}:{msg}', { label, msg: message }));
250
265
  let outcome = await productImpl(account.provider).checkin(account, ctx);
251
266
  outcome = { accountId: account.id, account: label, provider: account.provider, ...outcome };
252
267
  if (account.provider === 'qoder' && outcome.risk) {
@@ -269,23 +284,23 @@ async function runTickInner(opts) {
269
284
  if (outcome.status === 'checked-in') {
270
285
  memo[account.id] = todayFor;
271
286
  account.lastCheckin = new Date().toISOString();
272
- logger.info('CHECKIN', `${label} 领取成功 +${outcome.claimedAmount} Credits`);
287
+ logger.info('CHECKIN', '{label} 领取成功 +{amount} Credits', { label, amount: outcome.claimedAmount });
273
288
  } else if (outcome.status === 'already') {
274
289
  memo[account.id] = todayFor;
275
290
  account.lastCheckin = account.lastCheckin || new Date().toISOString();
276
- logger.info('CHECKIN', `${label}:${outcome.message || '今日已领'}`);
291
+ logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '今日已领' });
277
292
  } else if (outcome.status === 'limited') {
278
293
  memo[account.id] = todayFor;
279
- logger.info('CHECKIN', `${label}:${outcome.message}`);
294
+ logger.info('CHECKIN', '{label}:{msg}', { label, msg: outcome.message });
280
295
  } else if (outcome.status === 'no-activity') {
281
- logger.debug('CHECKIN', `${label}:${outcome.message || '当前无可领取的活动'}`);
296
+ logger.debug('CHECKIN', '{label}:{msg}', { label, msg: outcome.message || '当前无可领取的活动' });
282
297
  } else {
283
- logger.warn('CHECKIN', `${label} 领取失败:${outcome.error || outcome.status}`);
298
+ logger.warn('CHECKIN', '{label} 领取失败:{err}', { label, err: outcome.error || outcome.status });
284
299
  }
285
300
  } catch (err) {
286
301
  results.push({ accountId: account.id, account: label, provider: account.provider, status: 'failed', error: err?.message || String(err) });
287
302
  account.lastResult = { status: 'failed', message: err?.message || String(err), amount: 0, at: new Date().toISOString() };
288
- logger.error('CHECKIN', `${label} 异常:${err?.message || err}`);
303
+ logger.error('CHECKIN', '{label} 异常:{err}', { label, err: err?.message || err });
289
304
  }
290
305
  }
291
306
 
@@ -321,37 +336,53 @@ async function runTickInner(opts) {
321
336
  const summary = `签到汇总:成功 ${claimed.length}(+${totalCredits} Credits)、已领 ${already}`
322
337
  + (limited.length ? `、本机限领 ${limited.length}` : '')
323
338
  + `、无活动 ${none.length}、失败 ${failed.length}`;
324
- logger.info('CHECKIN', summary);
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 });
325
344
  lastTick = { at: new Date().toISOString(), summary };
326
345
  return { results, summary };
327
346
  }
328
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
+
329
364
  /** 启动普通签到调度与 ZCode 独立轮询调度。 */
330
- export function startScheduler() {
365
+ export async function startScheduler() {
331
366
  if (timerHandle) return;
332
367
  startUsageSyncScheduler();
333
368
  warmZcodeAppVersion();
334
- const scheduleNext = () => {
335
- const delay = msUntilNextTick();
336
- nextTickAt = new Date(Date.now() + delay).toISOString();
337
- timerHandle = setTimeout(async () => {
338
- try {
339
- await runCheckinTick({ skipIfCheckedToday: true, includeZcode: false });
340
- } catch (err) {
341
- logger.error('CHECKIN', `定时轮失败:${err?.message || err}`);
342
- } finally {
343
- scheduleNext();
344
- }
345
- }, delay);
346
- timerHandle.unref?.();
347
- logger.info('CHECKIN', `定时签到已启动,下次执行约 ${Math.round(delay / 60000)} 分钟后`);
348
- };
349
- scheduleNext();
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
+ }
350
378
  syncZcodeScheduler();
351
- setTimeout(() => {
352
- runCheckinTick({ skipIfCheckedToday: true, includeZcode: false }).catch((err) =>
353
- logger.error('CHECKIN', `引导轮失败:${err?.message || err}`));
354
- }, 15000).unref?.();
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', '自动签到已关闭');
355
386
  }
356
387
 
357
388
  export function stopScheduler() {