creditdaddy 0.9.3 → 1.1.1
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/README.md +289 -234
- package/bin/creditdaddy.js +205 -187
- package/package.json +2 -2
- package/src/accounts.js +25 -7
- package/src/catpawClient.js +103 -0
- package/src/catpawLocal.js +229 -0
- package/src/checkin.js +178 -67
- package/src/constants.js +18 -2
- package/src/daemon.js +819 -596
- package/src/localDetect.js +2 -2
- package/src/logger.js +43 -2
- package/src/mirasimClient.js +189 -0
- package/src/mirasimLocal.js +386 -0
- package/src/panel.html +881 -155
- package/src/providers.js +113 -8
- package/src/qoderApp.js +438 -330
- package/src/qoderClient.js +40 -2
- package/src/qoderUmid.js +25 -4
- package/src/store.js +27 -1
- package/src/tenrouter.js +253 -221
- package/src/tenrouterAccounts.js +163 -0
- package/src/transfer.js +24 -5
- package/src/usageSync.js +214 -28
- package/src/workbuddyAuth.js +127 -126
- package/src/workbuddyClient.js +302 -302
- package/src/zcodeAutoClaim.js +1 -0
- package/src/zcodeClient.js +549 -515
- package/src/zcodeGateway.js +574 -0
- package/src/zcodeLocal.js +103 -1
- package/src/zcodePlanShape.json +29 -0
package/src/daemon.js
CHANGED
|
@@ -1,596 +1,819 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
|
|
3
|
-
*
|
|
4
|
-
* 架构参考 WorkDaddy:
|
|
5
|
-
* - 默认只监听 127.0.0.1,数据不出本机
|
|
6
|
-
* - 无依赖,Node 18+ 原生 http 模块
|
|
7
|
-
* - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
|
|
8
|
-
*
|
|
9
|
-
* API:
|
|
10
|
-
* GET /api/accounts 账号列表(脱敏)
|
|
11
|
-
* POST /api/accounts 添加账号 {name, provider, token}
|
|
12
|
-
* PATCH /api/accounts/:id 修改备注名 {name}
|
|
13
|
-
* DELETE /api/accounts/:id 删除账号
|
|
14
|
-
* POST /api/accounts/:id/checkin 手动为单个账号签到
|
|
15
|
-
* GET /api/accounts/:id/quota 查询积分(统一结构)
|
|
16
|
-
* POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
|
|
17
|
-
* GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
|
|
18
|
-
* POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
|
|
19
|
-
* GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
|
|
20
|
-
* POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
|
|
21
|
-
* POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai}
|
|
22
|
-
* POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
|
|
23
|
-
* GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
|
|
24
|
-
* POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
|
|
25
|
-
* POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
|
|
26
|
-
* GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
|
|
27
|
-
* POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
|
|
28
|
-
* GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
|
|
29
|
-
* GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
|
|
30
|
-
* POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* GET /api/
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
import
|
|
44
|
-
import
|
|
45
|
-
import
|
|
46
|
-
import
|
|
47
|
-
|
|
48
|
-
} from '
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*/
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
237
|
-
const body = await readBody(req)
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (p === '/api/
|
|
273
|
-
const body = await readBody(req).catch(() => ({}));
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return json(res, 200,
|
|
341
|
-
} catch (e) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
if (p === '/api/
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* CreditDaddy 守护进程 — 本地 HTTP API + Web 面板。
|
|
3
|
+
*
|
|
4
|
+
* 架构参考 WorkDaddy:
|
|
5
|
+
* - 默认只监听 127.0.0.1,数据不出本机
|
|
6
|
+
* - 无依赖,Node 18+ 原生 http 模块
|
|
7
|
+
* - 监听回环地址时校验 Host 头(防 DNS 重绑定),跨站 Origin 一律拒绝(防 CSRF)
|
|
8
|
+
*
|
|
9
|
+
* API:
|
|
10
|
+
* GET /api/accounts 账号列表(脱敏)
|
|
11
|
+
* POST /api/accounts 添加账号 {name, provider, token}
|
|
12
|
+
* PATCH /api/accounts/:id 修改备注名 {name}
|
|
13
|
+
* DELETE /api/accounts/:id 删除账号
|
|
14
|
+
* POST /api/accounts/:id/checkin 手动为单个账号签到
|
|
15
|
+
* GET /api/accounts/:id/quota 查询积分(统一结构)
|
|
16
|
+
* POST /api/accounts/:id/switch 切换 WorkBuddy / ZCode 客户端当前登录账号 {force?}
|
|
17
|
+
* GET /api/accounts/:id/zcode/plans ZCode 可领取的活动列表
|
|
18
|
+
* POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
|
|
19
|
+
* GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
|
|
20
|
+
* POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
|
|
21
|
+
* POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai}
|
|
22
|
+
* POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
|
|
23
|
+
* GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
|
|
24
|
+
* POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
|
|
25
|
+
* POST /api/local/import 导入扫描候选 {candidateId, provider?, name?}
|
|
26
|
+
* GET /api/tenrouter 10Router 集成配置(key 脱敏);PUT 保存 {endpoint, key?, syncEnabled?, sources?};DELETE 清除
|
|
27
|
+
* POST /api/tenrouter/test 测试地址与 key {endpoint?, key?}
|
|
28
|
+
* GET /api/tenrouter/quotas 10Router 其他供应商额度总览(?force=1 跳过缓存)
|
|
29
|
+
* GET /api/tenrouter/health 10Router 自身健康(/api/health 转发:ok / driver / lastDriverError)
|
|
30
|
+
* POST /api/tenrouter/sync 立即同步本机用量到 10Router {dryRun?}
|
|
31
|
+
* POST /api/tenrouter/sync-accounts 把本机账号推送到 10Router 连接(OAuth 通道需已配面板密码)
|
|
32
|
+
* GET /api/qoder/umid Qoder 设备身份组件状态(Linux / fnOS)
|
|
33
|
+
* POST /api/qoder/umid/install 下载官方 qodercli 并提取设备身份组件
|
|
34
|
+
* GET /api/logs 最近日志
|
|
35
|
+
* GET /api/status 守护进程状态
|
|
36
|
+
* GET /api/settings 面板设置(访问密码开关状态,不回显密码)
|
|
37
|
+
* PUT /api/settings 设置/修改/关闭面板访问密码 {panelKey? | disable?}
|
|
38
|
+
* POST /api/export 导出账号 {password, provider?}(口令必填,10router 兼容加密文件)
|
|
39
|
+
* POST /api/import 导入账号 {data, password?}(CreditDaddy / 10router 导出文件)
|
|
40
|
+
* GET / Web 面板
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import http from 'node:http';
|
|
44
|
+
import crypto from 'node:crypto';
|
|
45
|
+
import os from 'node:os';
|
|
46
|
+
import fs from 'node:fs/promises';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
49
|
+
import { logger, getLogs } from './logger.js';
|
|
50
|
+
import {
|
|
51
|
+
loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings,
|
|
52
|
+
} from './store.js';
|
|
53
|
+
import { addAccount, importAccounts, refreshContext } from './accounts.js';
|
|
54
|
+
import { productImpl } from './providers.js';
|
|
55
|
+
import { readWorkbuddySessions, writeWorkbuddySession, workbuddyAuthDir, currentWorkbuddyUid } from './workbuddyLocal.js';
|
|
56
|
+
import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZcodeUid, currentZcodeIdentity, detectZcode, ensureVirtualDeviceMid, terminateZcode, zcodeRunning } from './zcodeLocal.js';
|
|
57
|
+
import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
|
|
58
|
+
import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
|
|
59
|
+
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled } from './zcodeClient.js';
|
|
60
|
+
import { exportAccounts, parseImport, TransferError } from './transfer.js';
|
|
61
|
+
import { syncAccountsTo10r } from './tenrouterAccounts.js';
|
|
62
|
+
import { runCheckinTick, getSchedulerInfo, dayKey, enableZcodeAutoClaimForOneHour, refreshZcodeScheduler, pollZcodeNow } from './checkin.js';
|
|
63
|
+
import { detectQoderApps, readQoderAppAccounts, riskIdentityAvailable, riskIdentitySource } from './qoderApp.js';
|
|
64
|
+
import { umidInfo, installUmid } from './qoderUmid.js';
|
|
65
|
+
import * as tenrouter from './tenrouter.js';
|
|
66
|
+
import * as zcodeGateway from './zcodeGateway.js';
|
|
67
|
+
import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
|
|
68
|
+
import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
|
|
69
|
+
import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
|
|
70
|
+
|
|
71
|
+
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw'];
|
|
72
|
+
|
|
73
|
+
/** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
|
|
74
|
+
* 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
|
|
75
|
+
* 保证安装向导密码在 NAS 部署里始终有效。设置后所有 /api/* 需要 x-qd-key 头;密码不回显。 */
|
|
76
|
+
let PANEL_KEY = '';
|
|
77
|
+
|
|
78
|
+
async function initPanelKey() {
|
|
79
|
+
let s = {};
|
|
80
|
+
try { s = await loadSettings(); } catch {}
|
|
81
|
+
const env = process.env.CREDITDADDY_PASSWORD || process.env.QODERDADDY_PASSWORD || '';
|
|
82
|
+
PANEL_KEY = typeof s.panelKey === 'string' && s.panelKey ? s.panelKey : env;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
86
|
+
const PANEL_FILE = path.join(__dirname, 'panel.html');
|
|
87
|
+
const DEFAULT_PORT = 47860;
|
|
88
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
89
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
90
|
+
|
|
91
|
+
class HttpError extends Error {
|
|
92
|
+
constructor(status, message) { super(message); this.status = status; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function json(res, code, obj) {
|
|
96
|
+
const body = JSON.stringify(obj);
|
|
97
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
98
|
+
res.end(body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readBody(req) {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let size = 0;
|
|
104
|
+
for await (const c of req) {
|
|
105
|
+
size += c.length;
|
|
106
|
+
if (size > MAX_BODY_BYTES) throw new HttpError(413, '请求体过大');
|
|
107
|
+
chunks.push(c);
|
|
108
|
+
}
|
|
109
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
110
|
+
try { return raw ? JSON.parse(raw) : {}; } catch { throw new HttpError(400, '请求体不是合法 JSON'); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 常量时间比较,避免通过响应时间逐字符猜出密钥 */
|
|
114
|
+
function keyMatches(given) {
|
|
115
|
+
const a = Buffer.from(String(given));
|
|
116
|
+
const b = Buffer.from(PANEL_KEY);
|
|
117
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hostnameOf(hostHeader) {
|
|
121
|
+
try { return new URL(`http://${hostHeader}`).hostname; } catch { return ''; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 本机自身的地址 / 主机名(60s 缓存):非回环监听时用于放行合法 Host,挡 DNS 重绑定 */
|
|
125
|
+
let localHostCache = { at: 0, set: null };
|
|
126
|
+
function localHosts() {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
if (!localHostCache.set || now - localHostCache.at > 60_000) {
|
|
129
|
+
const s = new Set(LOOPBACK_HOSTS);
|
|
130
|
+
try { s.add(os.hostname().toLowerCase()); } catch {}
|
|
131
|
+
try {
|
|
132
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
133
|
+
for (const it of list || []) {
|
|
134
|
+
if (!it?.address) continue;
|
|
135
|
+
const a = it.address.toLowerCase();
|
|
136
|
+
s.add(a);
|
|
137
|
+
if (a.includes(':')) s.add(`[${a}]`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {}
|
|
141
|
+
localHostCache = { at: now, set: s };
|
|
142
|
+
}
|
|
143
|
+
return localHostCache.set;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 浏览器侧防护,返回拒绝原因(null = 放行):
|
|
148
|
+
* - Host 头必须是本机地址(挡住 DNS 重绑定读取 token):
|
|
149
|
+
* · 回环监听:只允许 127.0.0.1 / localhost / ::1
|
|
150
|
+
* · 非回环监听(0.0.0.0 / 局域网 IP):未设访问密钥时,允许本机全部网卡地址与主机名;
|
|
151
|
+
* 设了密钥时密钥本身就是门槛(重绑定攻击者拿不到密钥),不再校验 Host
|
|
152
|
+
* - 带 Origin 的请求必须与 Host 同源(挡住任意网页跨站 POST 添加/导入账号、触发签到)
|
|
153
|
+
* 非浏览器客户端(curl、CLI、托盘菜单)不带 Origin,不受影响。
|
|
154
|
+
*/
|
|
155
|
+
export function rejectForeignRequest(headers, bindHost) {
|
|
156
|
+
const host = headers.host || '';
|
|
157
|
+
const hostName = hostnameOf(host).toLowerCase();
|
|
158
|
+
const loopbackBind = LOOPBACK_HOSTS.has(bindHost);
|
|
159
|
+
if (loopbackBind && !LOOPBACK_HOSTS.has(hostName)) {
|
|
160
|
+
return 'Host 不被允许';
|
|
161
|
+
}
|
|
162
|
+
if (!loopbackBind && !PANEL_KEY && !localHosts().has(hostName)) {
|
|
163
|
+
return 'Host 不被允许';
|
|
164
|
+
}
|
|
165
|
+
const origin = headers.origin;
|
|
166
|
+
if (origin) {
|
|
167
|
+
let originHost = '';
|
|
168
|
+
try { originHost = new URL(origin).host; } catch {}
|
|
169
|
+
if (originHost !== host) return '跨站请求被拒绝';
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* PUT /api/settings 的实际处理(鉴权已在入口完成):
|
|
176
|
+
* { panelKey: '新密码' } 设置 / 修改面板访问密码(≥4 位、不含空白)
|
|
177
|
+
* { disable: true } 关闭面板访问密码(导出账号的加密口令不受影响,始终必填)
|
|
178
|
+
* 口令校验规则与导出加密一致(sealTransfer:至少 4 个字符)。
|
|
179
|
+
*/
|
|
180
|
+
async function handleSettingsPut(res, body) {
|
|
181
|
+
if (body?.disable === true) {
|
|
182
|
+
await saveSettings({ panelKey: '' });
|
|
183
|
+
await initPanelKey(); // 环境变量部署(fnOS)下关闭后仍保留安装向导密码
|
|
184
|
+
logger.warn('DAEMON', PANEL_KEY ? '面板访问密码已在设置里关闭,但部署环境仍注入密码(保持开启)' : '面板访问密码已关闭(导出账号仍需加密口令)');
|
|
185
|
+
return json(res, 200, { ok: true, panelKeyEnabled: Boolean(PANEL_KEY) });
|
|
186
|
+
}
|
|
187
|
+
const next = typeof body?.panelKey === 'string' ? body.panelKey.trim() : '';
|
|
188
|
+
if (!next) return json(res, 400, { error: '请提供新密码,或提交 disable 关闭' });
|
|
189
|
+
if (next.length < 4) return json(res, 400, { error: '面板访问密码至少 4 位' });
|
|
190
|
+
if (/\s/.test(next)) return json(res, 400, { error: '面板访问密码不能包含空格' });
|
|
191
|
+
await saveSettings({ panelKey: next });
|
|
192
|
+
await initPanelKey();
|
|
193
|
+
logger.info('DAEMON', '面板访问密码已更新');
|
|
194
|
+
return json(res, 200, { ok: true, panelKeyEnabled: Boolean(PANEL_KEY) });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function handleApi(req, res, url) {
|
|
198
|
+
if (PANEL_KEY) {
|
|
199
|
+
// 只认 x-qd-key 头:query 传密会落入 fnOS / 反代的访问日志
|
|
200
|
+
const key = req.headers['x-qd-key'] || '';
|
|
201
|
+
if (!keyMatches(key)) {
|
|
202
|
+
return json(res, 401, { error: '需要访问密钥(x-qd-key 头)', code: 'PANEL_KEY' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const p = url.pathname;
|
|
206
|
+
const method = req.method;
|
|
207
|
+
|
|
208
|
+
// 账号列表
|
|
209
|
+
if (p === '/api/accounts' && method === 'GET') {
|
|
210
|
+
const accounts = await loadAccounts();
|
|
211
|
+
return json(res, 200, {
|
|
212
|
+
accounts: accounts.map(publicAccount),
|
|
213
|
+
providers: PROVIDER_LABEL,
|
|
214
|
+
dataDir: dataDir(),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 添加账号(自动拉取 userinfo 补全名字)
|
|
219
|
+
if (p === '/api/accounts' && method === 'POST') {
|
|
220
|
+
const body = await readBody(req);
|
|
221
|
+
let result;
|
|
222
|
+
try {
|
|
223
|
+
result = await addAccount({ name: body.name, provider: body.provider, token: body.token });
|
|
224
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
225
|
+
const { account, duplicate } = result;
|
|
226
|
+
if (duplicate) return json(res, 409, { error: `该账号已存在(${account.name || account.id})` });
|
|
227
|
+
if (account.verified === false) {
|
|
228
|
+
logger.warn('DAEMON', `账号 ${account.name || account.id} 校验失败:${account.verifyError}`);
|
|
229
|
+
}
|
|
230
|
+
logger.info('DAEMON', `添加账号 ${account.name || account.id}(${PROVIDER_LABEL[account.provider]})`);
|
|
231
|
+
return json(res, 201, { account: publicAccount(account) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 修改备注名
|
|
235
|
+
const idMatch = p.match(/^\/api\/accounts\/([\w-]+)$/);
|
|
236
|
+
if (idMatch && method === 'PATCH') {
|
|
237
|
+
const body = await readBody(req);
|
|
238
|
+
const name = String(body?.name || '').trim().slice(0, 64) || null;
|
|
239
|
+
const found = await withAccounts((accounts) => {
|
|
240
|
+
const a = accounts.find((x) => x.id === idMatch[1]);
|
|
241
|
+
if (a) a.name = name;
|
|
242
|
+
return a || null;
|
|
243
|
+
});
|
|
244
|
+
if (!found) return json(res, 404, { error: '账号不存在' });
|
|
245
|
+
return json(res, 200, { account: publicAccount(found) });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 删除账号
|
|
249
|
+
const delMatch = idMatch;
|
|
250
|
+
if (delMatch && method === 'DELETE') {
|
|
251
|
+
const removed = await withAccounts((accounts) => {
|
|
252
|
+
const i = accounts.findIndex((a) => a.id === delMatch[1]);
|
|
253
|
+
return i < 0 ? null : accounts.splice(i, 1)[0];
|
|
254
|
+
});
|
|
255
|
+
if (!removed) return json(res, 404, { error: '账号不存在' });
|
|
256
|
+
logger.info('DAEMON', `删除账号 ${removed.name || removed.id}`);
|
|
257
|
+
return json(res, 200, { ok: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 单账号签到
|
|
261
|
+
const checkinMatch = p.match(/^\/api\/accounts\/([\w-]+)\/checkin$/);
|
|
262
|
+
if (checkinMatch && method === 'POST') {
|
|
263
|
+
const accounts = await loadAccounts();
|
|
264
|
+
const account = accounts.find((a) => a.id === checkinMatch[1]);
|
|
265
|
+
const { results, summary } = account?.provider === 'zcode'
|
|
266
|
+
? await pollZcodeNow([account.id])
|
|
267
|
+
: await runCheckinTick({ onlyAccountId: checkinMatch[1], skipIfCheckedToday: false });
|
|
268
|
+
return json(res, 200, { results, summary });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 全部签到(默认跳过今日已签;传 skipIfCheckedToday:false 强制全部重签)
|
|
272
|
+
if (p === '/api/checkin' && method === 'POST') {
|
|
273
|
+
const body = await readBody(req).catch(() => ({}));
|
|
274
|
+
const opts = {
|
|
275
|
+
provider: body?.provider || undefined,
|
|
276
|
+
product: body?.product || undefined,
|
|
277
|
+
skipIfCheckedToday: body?.skipIfCheckedToday !== false,
|
|
278
|
+
};
|
|
279
|
+
const { results, summary } = opts.provider === 'zcode' || opts.product === 'zcode'
|
|
280
|
+
? await pollZcodeNow()
|
|
281
|
+
: await runCheckinTick(opts);
|
|
282
|
+
return json(res, 200, { results, summary });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 积分查询(统一结构:total / used / remaining / parts[])
|
|
286
|
+
const quotaMatch = p.match(/^\/api\/accounts\/([\w-]+)\/quota$/);
|
|
287
|
+
if (quotaMatch && method === 'GET') {
|
|
288
|
+
const accounts = await loadAccounts();
|
|
289
|
+
const account = accounts.find((a) => a.id === quotaMatch[1]);
|
|
290
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
291
|
+
try {
|
|
292
|
+
const log = (m) => logger.info('QUOTA', `${account.name || account.id}:${m}`);
|
|
293
|
+
const ctx = { log, ...refreshContext(account, log) };
|
|
294
|
+
return json(res, 200, { quota: await productImpl(account.provider).quota(account, ctx) });
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return json(res, 502, { error: e.message });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ZCode 活动领取
|
|
301
|
+
const zcodePlansMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/plans$/);
|
|
302
|
+
if (zcodePlansMatch && method === 'GET') {
|
|
303
|
+
const accounts = await loadAccounts();
|
|
304
|
+
const account = accounts.find((a) => a.id === zcodePlansMatch[1]);
|
|
305
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
306
|
+
if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
|
|
307
|
+
try {
|
|
308
|
+
return json(res, 200, await fetchClaimPlans(account));
|
|
309
|
+
} catch (e) {
|
|
310
|
+
return json(res, 502, { error: e.message });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const zcodeClaimMatch = p.match(/^\/api\/accounts\/([\w-]+)\/zcode\/claim$/);
|
|
314
|
+
if (zcodeClaimMatch && method === 'POST') {
|
|
315
|
+
const body = await readBody(req).catch(() => ({}));
|
|
316
|
+
if (!body?.planId || typeof body.planId !== 'string') return json(res, 400, { error: '缺少 planId' });
|
|
317
|
+
const accounts = await loadAccounts();
|
|
318
|
+
const account = accounts.find((a) => a.id === zcodeClaimMatch[1]);
|
|
319
|
+
if (!account) return json(res, 404, { error: '账号不存在' });
|
|
320
|
+
if (account.provider !== 'zcode') return json(res, 400, { error: '只有 ZCode 账号支持活动领取' });
|
|
321
|
+
try {
|
|
322
|
+
const outcome = await claimPlan(account, body.planId.slice(0, 128), {
|
|
323
|
+
captchaParam: typeof body.captchaParam === 'string' ? body.captchaParam : '',
|
|
324
|
+
region: typeof body.region === 'string' ? body.region : '',
|
|
325
|
+
});
|
|
326
|
+
logger.info('DAEMON', `ZCode ${account.name || account.id} 领取成功:${outcome.planName}`);
|
|
327
|
+
await zcodeGateway.clearQuotaMark(account.id);
|
|
328
|
+
await withAccounts((list) => {
|
|
329
|
+
const cur = list.find((a) => a.id === account.id);
|
|
330
|
+
if (cur) cur.lastResult = { status: 'checked-in', message: `已领取:${outcome.planName}`, amount: 0, at: new Date().toISOString() };
|
|
331
|
+
});
|
|
332
|
+
return json(res, 200, outcome);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
logger.warn('DAEMON', `ZCode ${account.name || account.id} 领取失败:${e.message}`);
|
|
335
|
+
return json(res, 502, { error: e.message, code: e.code, nextAt: e.nextAt });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (p === '/api/zcode/captcha-config' && method === 'GET') {
|
|
339
|
+
try {
|
|
340
|
+
return json(res, 200, await fetchCaptchaConfig());
|
|
341
|
+
} catch (e) {
|
|
342
|
+
return json(res, 502, { error: e.message });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// ZCode 出口偏好:默认「直连优先、代理兜底」,可切「代理优先、直连兜底」;代理地址可面板配置(存 zcode-net.json,0600)
|
|
346
|
+
// 自动领取开启时附带 1 小时时限(autoClaimUntil),到期调度器自动关闭并停止轮询
|
|
347
|
+
if (p === '/api/zcode-gateway' && method === 'GET') {
|
|
348
|
+
return json(res, 200, await zcodeGateway.gatewayStatus());
|
|
349
|
+
}
|
|
350
|
+
if (p === '/api/zcode-gateway' && method === 'PUT') {
|
|
351
|
+
const body = await readBody(req).catch(() => ({}));
|
|
352
|
+
if (body?.enabled !== undefined) await zcodeGateway.setGatewayEnabled(body.enabled === true);
|
|
353
|
+
if (body?.lan !== undefined) {
|
|
354
|
+
await (await import('./store.js')).saveSettings({ zcodeGatewayLan: body.lan === true });
|
|
355
|
+
logger.info('DAEMON', `ZCode 网关局域网访问:${body.lan === true ? '开(重启后绑定 0.0.0.0)' : '关(仅本机)'}`);
|
|
356
|
+
}
|
|
357
|
+
return json(res, 200, await zcodeGateway.gatewayStatus());
|
|
358
|
+
}
|
|
359
|
+
if (p === '/api/zcode/net' && method === 'GET') {
|
|
360
|
+
const u = proxyUrl();
|
|
361
|
+
const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
|
|
362
|
+
const ac = autoClaimEnabled();
|
|
363
|
+
return json(res, 200, { proxyFirst: proxyFirst(), proxyUrlMasked: masked, autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null, hasEnvProxy: Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy) });
|
|
364
|
+
}
|
|
365
|
+
if (p === '/api/zcode/net' && method === 'PUT') {
|
|
366
|
+
const body = await readBody(req).catch(() => ({}));
|
|
367
|
+
try {
|
|
368
|
+
if (body?.proxyFirst !== undefined) setProxyFirst(body.proxyFirst === true);
|
|
369
|
+
if (body?.proxyUrl !== undefined) setProxyUrl(body.proxyUrl);
|
|
370
|
+
if (body?.autoClaim !== undefined) {
|
|
371
|
+
if (body.autoClaim === true) enableZcodeAutoClaimForOneHour(); // 开 = 开启 1 小时时段(2 分钟后首轮,之后每 ~2 分钟)
|
|
372
|
+
else { setAutoClaimEnabled(false); refreshZcodeScheduler(); } // 关 = 立即停表
|
|
373
|
+
}
|
|
374
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
375
|
+
const ac = autoClaimEnabled();
|
|
376
|
+
logger.info('DAEMON', `ZCode 出口:${proxyFirst() ? '代理优先' : '直连优先'},自动领取:${ac ? '开' : '关'},代理 ${proxyUrl() || '(环境变量/未设置)'}`);
|
|
377
|
+
return json(res, 200, { proxyFirst: proxyFirst(), autoClaim: ac, autoClaimUntil: ac ? autoClaimUntil() : null });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 切换 WorkBuddy / ZCode 客户端当前登录账号
|
|
381
|
+
const switchMatch = p.match(/^\/api\/accounts\/([\w-]+)\/switch$/);
|
|
382
|
+
if (switchMatch && method === 'POST') {
|
|
383
|
+
const body = await readBody(req).catch(() => ({}));
|
|
384
|
+
const accounts = await loadAccounts();
|
|
385
|
+
const target = accounts.find((a) => a.id === switchMatch[1]);
|
|
386
|
+
if (!target) return json(res, 404, { error: '账号不存在' });
|
|
387
|
+
if (target.provider === 'zcode') {
|
|
388
|
+
// 防丢号:先把 ZCode 当前登录(凭据 + config.json + 设备 ID)同步 / 保存进账号库,再覆盖
|
|
389
|
+
const live = zcodeLiveAccount();
|
|
390
|
+
if (live && live.uid !== target.uid) {
|
|
391
|
+
await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 ZCode 当前登录失败:' + e.message));
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
// 强制切换:先关掉运行中的 ZCode,否则它会把内存里的旧登录覆盖回文件,切换等于没切
|
|
395
|
+
let closedClient = false;
|
|
396
|
+
if (body?.force === true) {
|
|
397
|
+
const t = terminateZcode();
|
|
398
|
+
closedClient = t.closed === true;
|
|
399
|
+
if (closedClient) logger.info('DAEMON', '已关闭 ZCode 客户端(强制切换)');
|
|
400
|
+
else if (t.running) logger.warn('DAEMON', '未能完全结束 ZCode 进程,继续强制切换');
|
|
401
|
+
}
|
|
402
|
+
ensureVirtualDeviceMid(target);
|
|
403
|
+
const r = zcodeSwitchTo(target, { force: body?.force === true });
|
|
404
|
+
// 虚拟设备 ID 首次生成时需落盘
|
|
405
|
+
await withAccounts((list) => {
|
|
406
|
+
const cur = list.find((a) => a.id === target.id);
|
|
407
|
+
if (cur) cur.meta = { ...(cur.meta || {}), deviceMid: target.meta.deviceMid };
|
|
408
|
+
});
|
|
409
|
+
logger.info('DAEMON', r.alreadyActive ? `ZCode 当前已是 ${target.name || target.id}` : `ZCode 已切换到 ${target.name || target.id}`);
|
|
410
|
+
return json(res, 200, { ok: true, closedClient, ...r });
|
|
411
|
+
} catch (e) {
|
|
412
|
+
return json(res, e.zcodeRunning ? 409 : 400, { error: e.message, code: e.zcodeRunning ? 'ZCODE_RUNNING' : undefined });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (target.provider === 'mirasim') {
|
|
416
|
+
// 防丢号:先把当前登录同步进账号库
|
|
417
|
+
try {
|
|
418
|
+
const live = await mirasimLiveAccount();
|
|
419
|
+
if (live && live.uid !== target.uid) {
|
|
420
|
+
await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 mirasim 当前登录失败:' + e.message));
|
|
421
|
+
}
|
|
422
|
+
} catch (e) {}
|
|
423
|
+
try {
|
|
424
|
+
let closedClient = false;
|
|
425
|
+
if (body?.force === true) {
|
|
426
|
+
const t = terminateMirasim();
|
|
427
|
+
closedClient = t.closed === true;
|
|
428
|
+
if (closedClient) logger.info('DAEMON', '已关闭 Mirasim 客户端(强制切换)');
|
|
429
|
+
}
|
|
430
|
+
const r = await mirasimSwitchTo(target, { force: body?.force === true });
|
|
431
|
+
logger.info('DAEMON', r.alreadyActive ? `mirasim 当前已是 ${target.name || target.id}` : `mirasim 已切换到 ${target.name || target.id}`);
|
|
432
|
+
return json(res, 200, { ok: true, closedClient, ...r });
|
|
433
|
+
} catch (e) {
|
|
434
|
+
return json(res, e.mirasimRunning ? 409 : 400, { error: e.message, code: e.mirasimRunning ? 'MIRASIM_RUNNING' : undefined });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (target.provider === 'catpaw') {
|
|
438
|
+
// 防丢号:先把妙手当前登录同步进账号库
|
|
439
|
+
try {
|
|
440
|
+
const live = await catpawLiveAccount();
|
|
441
|
+
if (live && live.token !== target.token) {
|
|
442
|
+
await addAccount(live, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步妙手当前登录失败:' + e.message));
|
|
443
|
+
}
|
|
444
|
+
} catch (e) {}
|
|
445
|
+
try {
|
|
446
|
+
let closedClient = false;
|
|
447
|
+
if (body?.force === true) {
|
|
448
|
+
const t = terminateCatpaw();
|
|
449
|
+
closedClient = t.closed === true;
|
|
450
|
+
if (closedClient) logger.info('DAEMON', '已关闭妙手客户端(强制切换)');
|
|
451
|
+
else if (t.running) logger.warn('DAEMON', '未能完全结束妙手进程,继续强制切换');
|
|
452
|
+
}
|
|
453
|
+
const r = await catpawSwitchTo(target, { force: body?.force === true });
|
|
454
|
+
logger.info('DAEMON', r.alreadyActive ? `妙手当前已是 ${target.name || target.id}` : `妙手已切换到 ${target.name || target.id}(重新打开客户端生效)`);
|
|
455
|
+
return json(res, 200, { ok: true, closedClient, ...r });
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return json(res, e.catpawRunning ? 409 : 400, { error: e.message, code: e.catpawRunning ? 'CATPAW_RUNNING' : undefined });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!target.provider.startsWith('workbuddy')) return json(res, 400, { error: '只有 WorkBuddy / ZCode / mirasim / 妙手 账号支持切换' });
|
|
461
|
+
// 先把客户端当前会话的最新 token 收回账号库,避免被覆盖后丢失
|
|
462
|
+
const cur = readWorkbuddySessions().accounts.find((a) => a.current);
|
|
463
|
+
if (cur && cur.uid !== target.uid) {
|
|
464
|
+
const { file: _f, fileTime: _t, current: _c, ...rec } = cur;
|
|
465
|
+
await addAccount(rec, { trusted: true }).catch((e) => logger.warn('DAEMON', '同步 WorkBuddy 当前会话失败:' + e.message));
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const r = writeWorkbuddySession(target);
|
|
469
|
+
logger.info('DAEMON', `WorkBuddy 已切换到 ${target.name || target.id}`);
|
|
470
|
+
return json(res, 200, { ok: true, file: r.file, previousUid: r.previousUid });
|
|
471
|
+
} catch (e) {
|
|
472
|
+
return json(res, 400, { error: e.message });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── 设备码登录(Qoder) ──
|
|
477
|
+
if (p === '/api/auth/device/start' && method === 'POST') {
|
|
478
|
+
const body = await readBody(req).catch(() => ({}));
|
|
479
|
+
try {
|
|
480
|
+
const kind = LOGIN_KINDS.includes(body?.provider) ? body.provider : 'qoder';
|
|
481
|
+
const flow = await startDeviceFlow(kind);
|
|
482
|
+
return json(res, 200, flow);
|
|
483
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
484
|
+
}
|
|
485
|
+
if (p === '/api/auth/device/poll' && method === 'POST') {
|
|
486
|
+
const body = await readBody(req).catch(() => ({}));
|
|
487
|
+
if (!body?.sessionId) return json(res, 400, { error: '缺少 sessionId' });
|
|
488
|
+
try {
|
|
489
|
+
return json(res, 200, await pollDeviceFlow(body.sessionId));
|
|
490
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Qoder 网页会话(桌面版登录窗口关闭前抓取,用于逐资源包用量明细) ──
|
|
494
|
+
// Cookie 先探测归属(响应里的 user_id),只写到同 uid 的 qoder 账号上;探测失败视为会话无效。
|
|
495
|
+
if (p === '/api/auth/qoder-web-session' && method === 'POST') {
|
|
496
|
+
const body = await readBody(req).catch(() => ({}));
|
|
497
|
+
const cookie = String(body?.cookie || '').trim();
|
|
498
|
+
const kind = body?.kind === 'qoder-cn' ? 'qoder-cn' : 'qoder';
|
|
499
|
+
if (!cookie || cookie.length > 16384) return json(res, 400, { error: '缺少 cookie' });
|
|
500
|
+
const { probeWebSession } = await import('./qoderClient.js');
|
|
501
|
+
const probe = await probeWebSession(kind, cookie);
|
|
502
|
+
if (!probe?.user_id) return json(res, 400, { error: '网页会话无效(探测接口未通过)' });
|
|
503
|
+
const now = new Date().toISOString();
|
|
504
|
+
const bound = await withAccounts((list) => {
|
|
505
|
+
const hits = list.filter((a) => a.provider === kind && a.uid === probe.user_id);
|
|
506
|
+
for (const a of hits) {
|
|
507
|
+
a.meta = { ...(a.meta || {}), qoderWebSession: { cookie, capturedAt: now } };
|
|
508
|
+
a.updatedAt = now;
|
|
509
|
+
}
|
|
510
|
+
return hits.length;
|
|
511
|
+
});
|
|
512
|
+
if (!bound) {
|
|
513
|
+
// 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
|
|
514
|
+
const pending = (await loadSettings()).pendingQoderWebSessions || {};
|
|
515
|
+
await saveSettings({ pendingQoderWebSessions: { ...pending, [probe.user_id]: { kind, cookie, capturedAt: now } } });
|
|
516
|
+
logger.info('DAEMON', `收到 Qoder 网页会话(uid ${probe.user_id.slice(0, 8)}…),暂无匹配账号,已暂存待绑定`);
|
|
517
|
+
} else {
|
|
518
|
+
logger.info('DAEMON', `已绑定 Qoder 网页会话到 ${bound} 个账号(逐资源包明细可用)`);
|
|
519
|
+
}
|
|
520
|
+
return json(res, 200, { ok: true, bound });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ── 本机检测 / 凭据扫描 ──
|
|
524
|
+
if (p === '/api/local/detect' && method === 'GET') {
|
|
525
|
+
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), legacy: detectInstalls() });
|
|
526
|
+
}
|
|
527
|
+
if (p === '/api/local/scan' && method === 'POST') {
|
|
528
|
+
const existing = await loadAccounts();
|
|
529
|
+
const known = (provider, token, uid) => existing.some((a) => (!provider || a.provider === provider)
|
|
530
|
+
&& (a.token === token || (uid && a.uid === uid)));
|
|
531
|
+
const candidates = [];
|
|
532
|
+
const errors = [];
|
|
533
|
+
const addRecord = (record, extra) => {
|
|
534
|
+
candidates.push({
|
|
535
|
+
id: putCandidate({ token: record.token, record }),
|
|
536
|
+
kind: 'app', provider: record.provider,
|
|
537
|
+
name: record.name, email: record.email, uid: record.uid, phone: record.meta?.phone ? record.meta.phone.slice(0, 3) + '****' + record.meta.phone.slice(-4) : null,
|
|
538
|
+
tokenMasked: record.token.slice(0, 5) + '...' + record.token.slice(-4),
|
|
539
|
+
expiresAt: record.expiresAt, hasRefreshToken: Boolean(record.refreshToken),
|
|
540
|
+
imported: known(record.provider, record.token, record.uid),
|
|
541
|
+
...extra,
|
|
542
|
+
});
|
|
543
|
+
};
|
|
544
|
+
// 1) Qoder 客户端:解密 auth.v1.dat
|
|
545
|
+
const app = await readQoderAppAccounts();
|
|
546
|
+
errors.push(...app.errors);
|
|
547
|
+
for (const c of app.accounts) {
|
|
548
|
+
addRecord({
|
|
549
|
+
provider: c.provider, token: c.token, name: c.user.name || c.user.email, uid: c.user.id, email: c.user.email,
|
|
550
|
+
refreshToken: c.refreshToken, expiresAt: c.expiresAt, source: 'local-app',
|
|
551
|
+
}, { source: c.source });
|
|
552
|
+
}
|
|
553
|
+
// 2) WorkBuddy 客户端:当前会话 + 历史会话
|
|
554
|
+
const wb = readWorkbuddySessions();
|
|
555
|
+
errors.push(...wb.errors);
|
|
556
|
+
for (const c of wb.accounts) {
|
|
557
|
+
const { file: _f, fileTime: _t, current, ...record } = c;
|
|
558
|
+
addRecord({ ...record, source: current ? 'workbuddy-current' : 'workbuddy-history' }, { source: c.source, current });
|
|
559
|
+
}
|
|
560
|
+
// 3) ZCode 客户端:解密 ~/.zcode/v2/credentials.json 的当前登录
|
|
561
|
+
try {
|
|
562
|
+
const z = zcodeLiveAccount();
|
|
563
|
+
if (z) addRecord(z, { source: 'ZCode 当前登录', current: true });
|
|
564
|
+
} catch (e) { errors.push({ file: '~/.zcode/v2/credentials.json', error: e.message }); }
|
|
565
|
+
// 4) mirasim 客户端:解密 ~/.mirasim/setting.json 的当前登录
|
|
566
|
+
try {
|
|
567
|
+
const m = await mirasimLiveAccount();
|
|
568
|
+
if (m) addRecord(m, { source: 'mirasim 当前登录', current: true });
|
|
569
|
+
} catch (e) {
|
|
570
|
+
// MISSING_SECRET_KEY / DECRYPT_FAILED 的文案已在 mirasimLocal 里写清检查过的位置
|
|
571
|
+
errors.push({ file: '~/.mirasim/setting.json', error: e.message });
|
|
572
|
+
}
|
|
573
|
+
// 5) 妙手客户端:解密 %APPDATA%\catpaw-moon\catx-credential.json 的当前登录
|
|
574
|
+
try {
|
|
575
|
+
const cp = await catpawLiveAccount();
|
|
576
|
+
if (cp) addRecord(cp, { source: '妙手当前登录', current: true });
|
|
577
|
+
} catch (e) { errors.push({ file: 'catpaw-moon/catx-credential.json', error: e.message }); }
|
|
578
|
+
// 6) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
|
|
579
|
+
const det = detectInstalls();
|
|
580
|
+
const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
|
|
581
|
+
if (det.cliDir.exists) dirs.push(det.cliDir.path);
|
|
582
|
+
const legacy = await scanLocalTokens(dirs);
|
|
583
|
+
for (const c of legacy.candidates) {
|
|
584
|
+
candidates.push({ ...c, provider: null, source: '旧版 Qoder IDE / CLI 文件', imported: known(null, peekCandidate(c.id)?.token) });
|
|
585
|
+
}
|
|
586
|
+
return json(res, 200, { candidates, errors, scanned: legacy.scanned, scannedDirs: [...dirs, wb.dir] });
|
|
587
|
+
}
|
|
588
|
+
if (p === '/api/local/import' && method === 'POST') {
|
|
589
|
+
const body = await readBody(req);
|
|
590
|
+
const cand = peekCandidate(String(body?.candidateId || ''));
|
|
591
|
+
if (!cand) return json(res, 404, { error: '候选不存在或已过期,请重新扫描' });
|
|
592
|
+
const record = cand.record || {
|
|
593
|
+
provider: body.provider === 'qoder-cn' ? 'qoder-cn' : 'qoder',
|
|
594
|
+
token: cand.token, source: 'local-scan',
|
|
595
|
+
};
|
|
596
|
+
const { account, duplicate, updated } = await addAccount(
|
|
597
|
+
{ ...record, name: body.name || record.name || null },
|
|
598
|
+
{ trusted: Boolean(cand.record) },
|
|
599
|
+
);
|
|
600
|
+
if (duplicate && !updated) return json(res, 409, { error: '该账号已存在,信息已是最新' });
|
|
601
|
+
logger.info('DAEMON', (updated ? '已用本机凭据更新:' : '从本机导入账号:') + (account.name || account.id));
|
|
602
|
+
return json(res, updated ? 200 : 201, { account: publicAccount(account), updated });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── 10Router 集成(虚拟 key:额度总览 + 用量同步) ──
|
|
606
|
+
if (p === '/api/tenrouter' && method === 'GET') {
|
|
607
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
608
|
+
}
|
|
609
|
+
if (p === '/api/tenrouter' && method === 'PUT') {
|
|
610
|
+
const body = await readBody(req).catch(() => ({}));
|
|
611
|
+
try {
|
|
612
|
+
await tenrouter.updateConfig({ endpoint: body?.endpoint, key: body?.key, adminPassword: body?.adminPassword, syncEnabled: body?.syncEnabled, sources: body?.sources });
|
|
613
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
614
|
+
} catch (e) { return json(res, 400, { error: e.message }); }
|
|
615
|
+
}
|
|
616
|
+
if (p === '/api/tenrouter' && method === 'DELETE') {
|
|
617
|
+
await tenrouter.updateConfig({ endpoint: '' });
|
|
618
|
+
return json(res, 200, await tenrouter.publicConfig());
|
|
619
|
+
}
|
|
620
|
+
if (p === '/api/tenrouter/test' && method === 'POST') {
|
|
621
|
+
const body = await readBody(req).catch(() => ({}));
|
|
622
|
+
let override;
|
|
623
|
+
try {
|
|
624
|
+
// 可以在保存前测试面板里填的新地址 / key;key 留空则用已保存的
|
|
625
|
+
if (body?.endpoint) override = { endpoint: tenrouter.normalizeEndpoint(body.endpoint) };
|
|
626
|
+
if (body?.key && String(body.key).trim()) override = { ...(override || {}), key: String(body.key).trim() };
|
|
627
|
+
} catch (e) { return json(res, 400, { ok: false, error: '地址格式不正确:' + e.message }); }
|
|
628
|
+
return json(res, 200, await tenrouter.testConnection(override));
|
|
629
|
+
}
|
|
630
|
+
if (p === '/api/tenrouter/quotas' && method === 'GET') {
|
|
631
|
+
try {
|
|
632
|
+
return json(res, 200, await tenrouter.fetchQuotas({ force: url.searchParams.get('force') === '1' }));
|
|
633
|
+
} catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 502, { error: e.message, code: e.code }); }
|
|
634
|
+
}
|
|
635
|
+
if (p === '/api/tenrouter/health' && method === 'GET') {
|
|
636
|
+
return json(res, 200, await tenrouter.fetchHealth());
|
|
637
|
+
}
|
|
638
|
+
if (p === '/api/tenrouter/sync' && method === 'POST') {
|
|
639
|
+
const body = await readBody(req).catch(() => ({}));
|
|
640
|
+
try {
|
|
641
|
+
return json(res, 200, await tenrouter.runUsageSync({ dryRun: body?.dryRun === true }));
|
|
642
|
+
} catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
|
|
643
|
+
}
|
|
644
|
+
if (p === '/api/tenrouter/sync-accounts' && method === 'POST') {
|
|
645
|
+
const body = await readBody(req).catch(() => ({}));
|
|
646
|
+
try {
|
|
647
|
+
// 请求里带了面板密码就先落盘(下次同步不必重填),否则用已保存的
|
|
648
|
+
if (typeof body?.adminPassword === 'string' && body.adminPassword) {
|
|
649
|
+
await tenrouter.updateConfig({ adminPassword: body.adminPassword });
|
|
650
|
+
}
|
|
651
|
+
const c = tenrouter.loadConfig();
|
|
652
|
+
return json(res, 200, await syncAccountsTo10r({ endpoint: c.endpoint, key: c.key, adminPassword: c.adminPassword, accounts: await loadAccounts() }));
|
|
653
|
+
} catch (e) { return json(res, e.code === 'NOT_CONFIGURED' ? 409 : 500, { error: e.message, code: e.code }); }
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Qoder 设备身份组件(Linux / fnOS:从官方 qodercli 提取 UMID,国际版签到用)
|
|
657
|
+
if (p === '/api/qoder/umid' && method === 'GET') {
|
|
658
|
+
return json(res, 200, { ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
|
|
659
|
+
}
|
|
660
|
+
if (p === '/api/qoder/umid/install' && method === 'POST') {
|
|
661
|
+
try {
|
|
662
|
+
await installUmid();
|
|
663
|
+
return json(res, 200, { ok: true, ...umidInfo(), riskIdentity: riskIdentityAvailable(), riskSource: riskIdentitySource() });
|
|
664
|
+
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 日志
|
|
668
|
+
if (p === '/api/logs' && method === 'GET') {
|
|
669
|
+
return json(res, 200, { logs: getLogs(200) });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 状态
|
|
673
|
+
if (p === '/api/status' && method === 'GET') {
|
|
674
|
+
const accounts = await loadAccounts();
|
|
675
|
+
const state = await loadState();
|
|
676
|
+
// 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
|
|
677
|
+
const cpToken = currentCatpawToken();
|
|
678
|
+
return json(res, 200, {
|
|
679
|
+
ok: true,
|
|
680
|
+
app: 'CreditDaddy',
|
|
681
|
+
version: APP_VERSION,
|
|
682
|
+
homepage: PROJECT_URL,
|
|
683
|
+
accountsCount: accounts.length,
|
|
684
|
+
dataDir: dataDir(),
|
|
685
|
+
today: dayKey(),
|
|
686
|
+
todayDone: state?.qoderDailyDone || {},
|
|
687
|
+
scheduler: getSchedulerInfo(),
|
|
688
|
+
riskIdentity: riskIdentityAvailable(),
|
|
689
|
+
riskSource: riskIdentitySource(),
|
|
690
|
+
umid: umidInfo(),
|
|
691
|
+
platform: process.platform,
|
|
692
|
+
workbuddyCurrentUid: currentWorkbuddyUid(),
|
|
693
|
+
zcodeCurrentUid: currentZcodeUid(),
|
|
694
|
+
zcodeCurrentIdentity: currentZcodeIdentity(),
|
|
695
|
+
zcodeClient: (() => { const d = detectZcode(); return { installed: d.exists, signedIn: d.signedIn, running: zcodeRunning() }; })(),
|
|
696
|
+
mirasimCurrentUid: currentMirasimUid(),
|
|
697
|
+
mirasimClient: (() => { const d = detectMirasim(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
|
|
698
|
+
catpawCurrentUid: (cpToken && accounts.find((a) => a.provider === 'catpaw' && a.token === cpToken)?.uid) || null,
|
|
699
|
+
catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
|
|
700
|
+
keyRequired: Boolean(PANEL_KEY),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Panel 设置:GET 读取(只回状态不回显密码),PUT 设置 / 修改 / 关闭访问密码
|
|
705
|
+
if (p === '/api/settings' && method === 'GET') {
|
|
706
|
+
return json(res, 200, { panelKeyEnabled: Boolean(PANEL_KEY) });
|
|
707
|
+
}
|
|
708
|
+
if (p === '/api/settings' && method === 'PUT') {
|
|
709
|
+
const body = await readBody(req).catch(() => ({}));
|
|
710
|
+
return await handleSettingsPut(res, body);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// 导出(加密口令必填——账号含 token,不允许再导出明文文件)
|
|
714
|
+
if (p === '/api/export' && method === 'POST') {
|
|
715
|
+
const body = await readBody(req).catch(() => ({}));
|
|
716
|
+
const provider = PROVIDERS.includes(body?.provider) ? body.provider : undefined;
|
|
717
|
+
const product = PRODUCT_IDS.includes(body?.product) ? body.product : undefined;
|
|
718
|
+
const password = typeof body?.password === 'string' ? body.password : '';
|
|
719
|
+
if (password.length < 4) return json(res, 400, { error: '导出必须设置加密口令(至少 4 位)', code: 'PASSWORD_REQUIRED' });
|
|
720
|
+
try {
|
|
721
|
+
return json(res, 200, exportAccounts(await loadAccounts(), { password, provider, product }));
|
|
722
|
+
} catch (e) {
|
|
723
|
+
if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
|
|
724
|
+
throw e;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 导入
|
|
729
|
+
if (p === '/api/import' && method === 'POST') {
|
|
730
|
+
const body = await readBody(req);
|
|
731
|
+
// 新格式 {data, password};兼容直接 POST 文件内容
|
|
732
|
+
const data = body && typeof body === 'object' && !Array.isArray(body) && 'data' in body ? body.data : body;
|
|
733
|
+
let parsed;
|
|
734
|
+
try {
|
|
735
|
+
parsed = parseImport(data, { password: body?.password });
|
|
736
|
+
} catch (e) {
|
|
737
|
+
if (e instanceof TransferError) return json(res, 400, { error: e.message, code: e.code });
|
|
738
|
+
throw e;
|
|
739
|
+
}
|
|
740
|
+
const r = await importAccounts(parsed.accounts);
|
|
741
|
+
const skipped = r.skipped + parsed.skipped;
|
|
742
|
+
logger.info('DAEMON', `导入完成(${parsed.source}):新增 ${r.added},续期 ${r.updated},跳过 ${skipped}`);
|
|
743
|
+
return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return json(res, 404, { error: '未知接口' });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
|
|
750
|
+
export function getPanelKey() { return PANEL_KEY; }
|
|
751
|
+
|
|
752
|
+
export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
|
|
753
|
+
await initPanelKey();
|
|
754
|
+
let panelHtml = '';
|
|
755
|
+
fs.readFile(PANEL_FILE, 'utf8').then((h) => { panelHtml = h; }).catch(() => {});
|
|
756
|
+
|
|
757
|
+
const server = http.createServer(async (req, res) => {
|
|
758
|
+
const url = new URL(req.url, 'http://localhost');
|
|
759
|
+
try {
|
|
760
|
+
const denied = rejectForeignRequest(req.headers, host);
|
|
761
|
+
if (denied) {
|
|
762
|
+
logger.warn('DAEMON', `拒绝请求 ${req.method} ${url.pathname}:${denied}(Host=${req.headers.host || ''} Origin=${req.headers.origin || ''})`);
|
|
763
|
+
return json(res, 403, { error: denied });
|
|
764
|
+
}
|
|
765
|
+
// ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
|
|
766
|
+
// /v1/messages 别名与 zcode-api 端点路径同形——10router 的 zcode-free 供应商
|
|
767
|
+
// 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
|
|
768
|
+
if (url.pathname === '/gateway/v1/messages' || url.pathname === '/v1/messages') {
|
|
769
|
+
return await zcodeGateway.handleGateway(req, res);
|
|
770
|
+
}
|
|
771
|
+
if (url.pathname.startsWith('/api/')) {
|
|
772
|
+
return await handleApi(req, res, url);
|
|
773
|
+
}
|
|
774
|
+
// Web 面板
|
|
775
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
776
|
+
if (!panelHtml) { try { panelHtml = await fs.readFile(PANEL_FILE, 'utf8'); } catch {} }
|
|
777
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
778
|
+
return res.end(panelHtml || '<h1>CreditDaddy</h1><p>panel.html 缺失</p>');
|
|
779
|
+
}
|
|
780
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
781
|
+
res.end('Not Found');
|
|
782
|
+
} catch (err) {
|
|
783
|
+
const status = err instanceof HttpError ? err.status : 500;
|
|
784
|
+
if (status >= 500) logger.error('DAEMON', `请求处理失败:${err?.message || err}`);
|
|
785
|
+
if (!res.headersSent) json(res, status, { error: err?.message || '内部错误' });
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
return new Promise((resolve) => {
|
|
790
|
+
let attempts = 0;
|
|
791
|
+
const bind = (p) => {
|
|
792
|
+
// 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
|
|
793
|
+
const onError = (err) => {
|
|
794
|
+
server.off('listening', onListening);
|
|
795
|
+
if (err && err.code === 'EADDRINUSE' && attempts < 10) {
|
|
796
|
+
attempts += 1;
|
|
797
|
+
logger.warn('DAEMON', `端口 ${p} 被占用,改试 ${p + 1}`);
|
|
798
|
+
server.close();
|
|
799
|
+
setTimeout(() => bind(p + 1), 300);
|
|
800
|
+
} else {
|
|
801
|
+
logger.error('DAEMON', `监听失败:${err?.message || err}`);
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
const onListening = () => {
|
|
805
|
+
server.off('error', onError);
|
|
806
|
+
const bound = server.address().port;
|
|
807
|
+
logger.info('DAEMON', `CreditDaddy 守护进程已启动:http://${host}:${bound}`);
|
|
808
|
+
if ((host === '0.0.0.0' || host === '::') && !PANEL_KEY) {
|
|
809
|
+
logger.warn('DAEMON', '监听 0.0.0.0 且未设置 CREDITDADDY_PASSWORD —— 局域网内任何人可访问账号 API,建议设置访问密钥');
|
|
810
|
+
}
|
|
811
|
+
resolve({ server, port: bound });
|
|
812
|
+
};
|
|
813
|
+
server.once('error', onError);
|
|
814
|
+
server.once('listening', onListening);
|
|
815
|
+
server.listen(p, host);
|
|
816
|
+
};
|
|
817
|
+
bind(Number(port) || DEFAULT_PORT);
|
|
818
|
+
});
|
|
819
|
+
}
|