pixivflow 2.22.1 → 2.23.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.
Files changed (42) hide show
  1. package/dist/commands/SchedulerCommand.js +12 -3
  2. package/dist/commands/scheduler-runtime.d.ts +29 -0
  3. package/dist/commands/scheduler-runtime.js +54 -1
  4. package/dist/download/exec/DownloadExecutor.js +11 -2
  5. package/dist/download/handlers/IllustrationTargetHandler.js +15 -0
  6. package/dist/download/handlers/NovelTargetHandler.js +12 -2
  7. package/dist/interfaces/IDatabase.d.ts +11 -0
  8. package/dist/logger.d.ts +24 -0
  9. package/dist/logger.js +88 -11
  10. package/dist/observability/classify.d.ts +7 -0
  11. package/dist/observability/classify.js +51 -0
  12. package/dist/observability/context.d.ts +10 -0
  13. package/dist/observability/context.js +22 -0
  14. package/dist/observability/index.d.ts +7 -0
  15. package/dist/observability/index.js +36 -0
  16. package/dist/observability/types.d.ts +23 -0
  17. package/dist/observability/types.js +3 -0
  18. package/dist/package.json +1 -1
  19. package/dist/scheduler/RecoveryOutcome.d.ts +24 -0
  20. package/dist/scheduler/RecoveryOutcome.js +44 -0
  21. package/dist/scheduler/ScheduleTriggerServer.d.ts +4 -0
  22. package/dist/scheduler/SlotBusinessStatus.d.ts +32 -0
  23. package/dist/scheduler/SlotBusinessStatus.js +43 -0
  24. package/dist/scheduler/SlotCoordinator.d.ts +7 -0
  25. package/dist/scheduler/SlotCoordinator.js +13 -0
  26. package/dist/storage/Database.d.ts +4 -0
  27. package/dist/storage/Database.js +7 -0
  28. package/dist/storage/DatabaseMigration.js +21 -0
  29. package/dist/storage/repositories/SystemErrorRepository.d.ts +19 -0
  30. package/dist/storage/repositories/SystemErrorRepository.js +92 -0
  31. package/dist/version.js +1 -1
  32. package/dist/webui/package.json +1 -1
  33. package/dist/webui/routes/admin-logs.d.ts +3 -0
  34. package/dist/webui/routes/admin-logs.js +9 -0
  35. package/dist/webui/routes/handlers/admin-logs-handlers.d.ts +6 -0
  36. package/dist/webui/routes/handlers/admin-logs-handlers.js +106 -0
  37. package/dist/webui/routes/handlers/system-errors-handlers.d.ts +6 -0
  38. package/dist/webui/routes/handlers/system-errors-handlers.js +59 -0
  39. package/dist/webui/routes/system-errors.d.ts +3 -0
  40. package/dist/webui/routes/system-errors.js +9 -0
  41. package/dist/webui/server/server-routes.js +5 -0
  42. package/package.json +1 -1
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.22.1",
4
+ "version": "2.23.0",
5
5
  "private": true
6
6
  }
@@ -0,0 +1,24 @@
1
+ import type { SlotItemRecord } from '../storage/repositories/SlotRepository';
2
+ /**
3
+ * Business outcome for one manual recovery request (「放宽条件重试」).
4
+ *
5
+ * Deliberately NOT the raw execution cell status: a recovery that found only
6
+ * already-published works is a NORMAL end (`recovery_duplicate_only`), never an
7
+ * `internal_error`. Callers (ScheduleTriggerServer / Mini App) get the
8
+ * user-facing `message` and admin-facing `business_state` together so the UI
9
+ * never has to translate internal terms.
10
+ */
11
+ export type RecoveryOutcomeState = 'recovery_pending' | 'recovery_running' | 'recovery_success' | 'recovery_no_candidate' | 'recovery_duplicate_only' | 'recovery_failed';
12
+ export declare function recoveryUserMessage(state: RecoveryOutcomeState): string;
13
+ /**
14
+ * Map one durable recovery cell to a recovery business outcome.
15
+ *
16
+ * - terminal `submitted` (+ workId) → success
17
+ * - terminal `no_candidate` with terminal reason `duplicate_exhausted` →
18
+ * duplicate_only
19
+ * - terminal `no_candidate` otherwise → no_candidate
20
+ * - terminal `failed` → failed
21
+ * - non-terminal (`pending`/`selected`/`running`/`delivery_pending`) → running/pending
22
+ */
23
+ export declare function recoveryOutcomeFor(cell: SlotItemRecord | null): RecoveryOutcomeState;
24
+ //# sourceMappingURL=RecoveryOutcome.d.ts.map
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recoveryUserMessage = recoveryUserMessage;
4
+ exports.recoveryOutcomeFor = recoveryOutcomeFor;
5
+ const USER_MESSAGES = {
6
+ recovery_pending: '正在等待恢复任务...',
7
+ recovery_running: '正在尝试扩大搜索范围...',
8
+ recovery_success: '恢复成功,已找到并发布新作品。',
9
+ recovery_no_candidate: '扩大搜索范围后,仍未找到符合条件的新作品。\n任务已正常结束。',
10
+ recovery_duplicate_only: '扩大搜索范围后,找到的作品均已发布过。\n没有新的内容可发布。',
11
+ recovery_failed: '恢复任务执行失败。\n请查看日志或稍后重试。',
12
+ };
13
+ function recoveryUserMessage(state) {
14
+ return USER_MESSAGES[state];
15
+ }
16
+ /**
17
+ * Map one durable recovery cell to a recovery business outcome.
18
+ *
19
+ * - terminal `submitted` (+ workId) → success
20
+ * - terminal `no_candidate` with terminal reason `duplicate_exhausted` →
21
+ * duplicate_only
22
+ * - terminal `no_candidate` otherwise → no_candidate
23
+ * - terminal `failed` → failed
24
+ * - non-terminal (`pending`/`selected`/`running`/`delivery_pending`) → running/pending
25
+ */
26
+ function recoveryOutcomeFor(cell) {
27
+ if (!cell)
28
+ return 'recovery_pending';
29
+ if (cell.status === 'submitted')
30
+ return 'recovery_success';
31
+ if (cell.status === 'no_candidate') {
32
+ return cell.terminalReasonCode === 'duplicate_exhausted'
33
+ ? 'recovery_duplicate_only'
34
+ : 'recovery_no_candidate';
35
+ }
36
+ if (cell.status === 'duplicate')
37
+ return 'recovery_duplicate_only';
38
+ if (cell.status === 'failed')
39
+ return 'recovery_failed';
40
+ if (['pending', 'selected', 'artifact_ready', 'delivery_pending'].includes(cell.status))
41
+ return 'recovery_running';
42
+ return 'recovery_pending';
43
+ }
44
+ //# sourceMappingURL=RecoveryOutcome.js.map
@@ -126,6 +126,10 @@ export interface TriggerHandlers {
126
126
  slotId: string;
127
127
  state: string;
128
128
  slotStatus: string;
129
+ /** Recovery business outcome (recovery_success / no_candidate / duplicate_only / failed / running / pending). */
130
+ business_state?: string;
131
+ /** User-facing copy; never leaks internal error names. */
132
+ message?: string;
129
133
  } | null;
130
134
  }
131
135
  export declare class ScheduleTriggerServer {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Business outcome taxonomy for one schedule occurrence.
3
+ *
4
+ * RULES (intent):
5
+ * - "no content to publish" is a NORMAL endpoint, not a system failure.
6
+ * - System failures (pixiv/network/download/processing/delivery) must be kept
7
+ * disjoint from business no-content states so monitoring and Mini App never
8
+ * show "internal_error" for an empty but healthy run.
9
+ *
10
+ * This is a DERIVED verdict over the existing terminal cell ledger; it does not
11
+ * change the persisted slot phase machine (pending/running/success/partial/
12
+ * failed remain untouched for ledger compatibility).
13
+ */
14
+ export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'failed';
15
+ export interface SlotBusinessCounts {
16
+ total: number;
17
+ submitted: number;
18
+ no_match: number;
19
+ duplicate: number;
20
+ /** Count of no_candidate cells whose terminal reason is duplicate_exhausted. */
21
+ duplicate_exhausted?: number;
22
+ executor_failed: number;
23
+ delivery_failed: number;
24
+ }
25
+ /** Pure projection: durable cell counts -> one business verdict. No I/O. */
26
+ export declare function classifySlotBusinessStatus(counts: SlotBusinessCounts): SlotBusinessStatus;
27
+ /**
28
+ * User-facing (Mini App / notification) copy for a terminal business status.
29
+ * Internal error names (OperationCancelledError, internal_error) never leak.
30
+ */
31
+ export declare function userMessageForSlotBusinessStatus(status: SlotBusinessStatus): string;
32
+ //# sourceMappingURL=SlotBusinessStatus.d.ts.map
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifySlotBusinessStatus = classifySlotBusinessStatus;
4
+ exports.userMessageForSlotBusinessStatus = userMessageForSlotBusinessStatus;
5
+ /** Pure projection: durable cell counts -> one business verdict. No I/O. */
6
+ function classifySlotBusinessStatus(counts) {
7
+ const nonSubmitted = counts.total - counts.submitted;
8
+ const systemFailed = counts.executor_failed > 0 || counts.delivery_failed > 0;
9
+ const allNoContentAsDuplicate = counts.duplicate + (counts.duplicate_exhausted ?? 0) === nonSubmitted;
10
+ if (counts.submitted > 0 && nonSubmitted === 0)
11
+ return 'success';
12
+ if (counts.submitted > 0)
13
+ return 'partial_success';
14
+ if (nonSubmitted === 0)
15
+ return 'success'; // defensive: 0 targets / all submitted
16
+ if (!systemFailed) {
17
+ // Every non-submitted cell was a BUSINESS no-content verdict; all of them
18
+ // came from already-delivered candidates => duplicate_only.
19
+ if (allNoContentAsDuplicate)
20
+ return 'duplicate_only';
21
+ return 'no_candidate';
22
+ }
23
+ return 'failed';
24
+ }
25
+ /**
26
+ * User-facing (Mini App / notification) copy for a terminal business status.
27
+ * Internal error names (OperationCancelledError, internal_error) never leak.
28
+ */
29
+ function userMessageForSlotBusinessStatus(status) {
30
+ switch (status) {
31
+ case 'success':
32
+ return '任务完成,已发布本轮内容。';
33
+ case 'partial_success':
34
+ return '任务部分完成,部分内容已发布。';
35
+ case 'no_candidate':
36
+ return '本轮没有发现新的可发布作品。任务已正常完成。';
37
+ case 'duplicate_only':
38
+ return '本轮没有发现新的可发布作品。任务已正常完成。';
39
+ case 'failed':
40
+ return '本轮任务遇到系统异常,请稍后重试或检查日志。';
41
+ }
42
+ }
43
+ //# sourceMappingURL=SlotBusinessStatus.js.map
@@ -3,6 +3,7 @@ import { Database } from '../storage/Database';
3
3
  import { CellStatus, SlotItemRecord, SlotRecord, SlotStatus } from '../storage/repositories/SlotRepository';
4
4
  import { TriggerSource } from './OccurrenceResolver';
5
5
  import { TargetOutcome } from './TargetOutcome';
6
+ import { SlotBusinessStatus } from './SlotBusinessStatus';
6
7
  import { TargetExecutionContext, WorkBinding } from './WorkIdentity';
7
8
  /**
8
9
  * Execution-lease TTL and heartbeat cadence.
@@ -113,11 +114,17 @@ export interface ScheduleOutcomeCells {
113
114
  */
114
115
  export interface ScheduleOutcomeRecord {
115
116
  event: 'schedule.outcome';
117
+ /** Outcome taxonomy version; bump when business_status/reason gains codes. */
118
+ outcome_version: 1;
116
119
  schedule_id: string;
117
120
  slot_id: string;
118
121
  occurrence_at: string | undefined;
119
122
  occurrence_date: string;
120
123
  status: ScheduleOutcomeStatus;
124
+ /** Derived business verdict: success / partial_success / no_candidate / duplicate_only / failed. */
125
+ business_status: SlotBusinessStatus;
126
+ /** Monitoring gate: true only for system failures (business_status === 'failed'). */
127
+ alertable: boolean;
121
128
  /** From the slot row's own started_at/completed_at columns, when both exist. */
122
129
  duration_ms: number | undefined;
123
130
  cells: ScheduleOutcomeCells;
@@ -5,6 +5,7 @@ exports.timezoneForSchedule = timezoneForSchedule;
5
5
  const logger_1 = require("../logger");
6
6
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
7
7
  const TargetOutcome_1 = require("./TargetOutcome");
8
+ const SlotBusinessStatus_1 = require("./SlotBusinessStatus");
8
9
  const WorkIdentity_1 = require("./WorkIdentity");
9
10
  /**
10
11
  * Execution-lease TTL and heartbeat cadence.
@@ -504,13 +505,25 @@ class SlotCoordinator {
504
505
  ? Math.max(0, completedMs - startedMs)
505
506
  : undefined;
506
507
  const occurrenceAt = slotRec?.occurrenceAt ?? slot.occurrenceAt;
508
+ const businessStatus = (0, SlotBusinessStatus_1.classifySlotBusinessStatus)({
509
+ total: cellRows.length,
510
+ submitted,
511
+ no_match,
512
+ duplicate,
513
+ duplicate_exhausted: targetsDetail.filter((t) => t.terminal_reason_code === 'duplicate_exhausted').length,
514
+ executor_failed,
515
+ delivery_failed,
516
+ });
507
517
  return {
508
518
  event: 'schedule.outcome',
519
+ outcome_version: 1,
509
520
  schedule_id: slotRec?.scheduleId ?? slot.scheduleId,
510
521
  slot_id: slot.slotId,
511
522
  occurrence_at: isoUtcOrUndefined(occurrenceAt),
512
523
  occurrence_date: slotRec?.occurrenceDate ?? slot.occurrenceDate,
513
524
  status: status,
525
+ business_status: businessStatus,
526
+ alertable: businessStatus === 'failed',
514
527
  duration_ms,
515
528
  cells: {
516
529
  total: cellRows.length,
@@ -4,6 +4,7 @@ import { DeliveryRepository } from './repositories/DeliveryRepository';
4
4
  import { OutboxRepository } from './repositories/OutboxRepository';
5
5
  import { MetadataRepository } from './repositories/MetadataRepository';
6
6
  import { SQLiteRateLimitStateStore } from './repositories/RateLimitStateRepository';
7
+ import { SystemErrorRepository } from './repositories/SystemErrorRepository';
7
8
  export interface AccessTokenStore {
8
9
  accessToken: string;
9
10
  expiresAt: number;
@@ -46,6 +47,7 @@ export declare class Database implements IDatabase {
46
47
  private outboxRepo;
47
48
  private metadataRepo;
48
49
  private rateLimitStateStore;
50
+ private systemErrorRepo;
49
51
  constructor(databasePath: string);
50
52
  migrate(): void;
51
53
  /** Absolute path of the SQLite file (used to locate sibling cache dirs). */
@@ -60,6 +62,8 @@ export declare class Database implements IDatabase {
60
62
  get metadata(): MetadataRepository;
61
63
  /** Persistent 429 gate state adapter for @redtidev/pixiv-client. */
62
64
  get rateLimitState(): SQLiteRateLimitStateStore;
65
+ /** Durable system-error ledger (observability; never throws). */
66
+ get systemErrors(): SystemErrorRepository;
63
67
  /** Raw transactional boundary for atomic multi-table intents. */
64
68
  transaction<T>(fn: () => T): T;
65
69
  /** Expose a prepared-statement helper if needed by services (pragmas etc). */
@@ -17,6 +17,7 @@ const DeliveryRepository_1 = require("./repositories/DeliveryRepository");
17
17
  const OutboxRepository_1 = require("./repositories/OutboxRepository");
18
18
  const MetadataRepository_1 = require("./repositories/MetadataRepository");
19
19
  const RateLimitStateRepository_1 = require("./repositories/RateLimitStateRepository");
20
+ const SystemErrorRepository_1 = require("./repositories/SystemErrorRepository");
20
21
  const NodeSqliteDriver_1 = require("./drivers/NodeSqliteDriver");
21
22
  class Database {
22
23
  databasePath;
@@ -33,6 +34,7 @@ class Database {
33
34
  outboxRepo;
34
35
  metadataRepo;
35
36
  rateLimitStateStore;
37
+ systemErrorRepo;
36
38
  constructor(databasePath) {
37
39
  this.databasePath = databasePath;
38
40
  try {
@@ -60,6 +62,7 @@ class Database {
60
62
  this.outboxRepo = new OutboxRepository_1.OutboxRepository(this.db);
61
63
  this.metadataRepo = new MetadataRepository_1.MetadataRepository(this.db);
62
64
  this.rateLimitStateStore = new RateLimitStateRepository_1.SQLiteRateLimitStateStore(this.db);
65
+ this.systemErrorRepo = new SystemErrorRepository_1.SystemErrorRepository(this.db);
63
66
  }
64
67
  catch (error) {
65
68
  throw new errors_1.DatabaseError(`Failed to initialize database at ${this.databasePath}`, error instanceof Error ? error : undefined);
@@ -95,6 +98,10 @@ class Database {
95
98
  get rateLimitState() {
96
99
  return this.rateLimitStateStore;
97
100
  }
101
+ /** Durable system-error ledger (observability; never throws). */
102
+ get systemErrors() {
103
+ return this.systemErrorRepo;
104
+ }
98
105
  /** Raw transactional boundary for atomic multi-table intents. */
99
106
  transaction(fn) {
100
107
  return this.db.transaction(fn)();
@@ -222,6 +222,27 @@ class DatabaseMigration {
222
222
  state TEXT NOT NULL,
223
223
  updated_at INTEGER NOT NULL
224
224
  )`,
225
+ // Durable error event ledger for observability (structured error
226
+ // taxonomy; populated by download/system handlers and shown in the
227
+ // admin API). Append-only until an operator marks a row resolved.
228
+ `CREATE TABLE IF NOT EXISTS system_errors (
229
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
230
+ service TEXT NOT NULL DEFAULT 'pixivflow',
231
+ component TEXT,
232
+ bot_id TEXT,
233
+ schedule_id TEXT,
234
+ slot_id TEXT,
235
+ pixiv_id TEXT,
236
+ stage TEXT,
237
+ error_type TEXT NOT NULL,
238
+ message TEXT,
239
+ http_status INTEGER,
240
+ retryable INTEGER NOT NULL DEFAULT 0,
241
+ trace_id TEXT,
242
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
243
+ resolved_at DATETIME
244
+ )`,
245
+ `CREATE INDEX IF NOT EXISTS idx_system_errors_bot_created ON system_errors(bot_id, created_at)`,
225
246
  ];
226
247
  // Phase 1: create tables (idempotent). Must run before any PRAGMA-based
227
248
  // column check, otherwise a fresh DB would report the table as missing and
@@ -0,0 +1,19 @@
1
+ import { BaseRepository } from './BaseRepository';
2
+ import { SystemErrorInput, SystemErrorRow } from '../../observability/types';
3
+ export declare class SystemErrorRepository extends BaseRepository {
4
+ record(input: SystemErrorInput): void;
5
+ list(opts?: {
6
+ limit?: number;
7
+ errorType?: string;
8
+ botId?: string;
9
+ stage?: string;
10
+ resolved?: boolean;
11
+ from?: string;
12
+ to?: string;
13
+ }): SystemErrorRow[];
14
+ markResolved(id: number): {
15
+ changes: number;
16
+ };
17
+ countSince(botId: string | null, hours: number): number;
18
+ }
19
+ //# sourceMappingURL=SystemErrorRepository.d.ts.map
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SystemErrorRepository = void 0;
4
+ const BaseRepository_1 = require("./BaseRepository");
5
+ class SystemErrorRepository extends BaseRepository_1.BaseRepository {
6
+ record(input) {
7
+ this.db
8
+ .prepare(`INSERT INTO system_errors
9
+ (service, component, bot_id, schedule_id, slot_id, pixiv_id, stage, error_type, message, http_status, retryable, trace_id)
10
+ VALUES
11
+ (@service, @component, @botId, @scheduleId, @slotId, @pixivId, @stage, @errorType, @message, @httpStatus, @retryable, @traceId)`)
12
+ .run({
13
+ service: input.service ?? 'pixivflow',
14
+ component: input.component ?? null,
15
+ botId: input.bot_id ?? null,
16
+ scheduleId: input.schedule_id ?? null,
17
+ slotId: input.slot_id ?? null,
18
+ pixivId: input.pixiv_id ?? null,
19
+ stage: input.stage ?? null,
20
+ errorType: input.error_type ?? 'INTERNAL_ERROR',
21
+ message: input.message.slice(0, 5000),
22
+ httpStatus: input.http_status ?? null,
23
+ retryable: input.retryable ? 1 : 0,
24
+ traceId: input.trace_id ?? null,
25
+ });
26
+ }
27
+ list(opts = {}) {
28
+ const where = [];
29
+ const params = [];
30
+ if (opts.errorType) {
31
+ where.push('error_type = ?');
32
+ params.push(opts.errorType);
33
+ }
34
+ if (opts.botId) {
35
+ where.push('bot_id = ?');
36
+ params.push(opts.botId);
37
+ }
38
+ if (opts.stage) {
39
+ where.push('stage = ?');
40
+ params.push(opts.stage);
41
+ }
42
+ if (typeof opts.resolved === 'boolean') {
43
+ where.push(opts.resolved ? 'resolved_at IS NOT NULL' : 'resolved_at IS NULL');
44
+ }
45
+ if (opts.from) {
46
+ where.push('created_at >= ?');
47
+ params.push(opts.from);
48
+ }
49
+ if (opts.to) {
50
+ where.push('created_at <= ?');
51
+ params.push(opts.to);
52
+ }
53
+ const limit = Math.max(1, Math.min(Number(opts.limit) || 50, 500));
54
+ const sql = `SELECT * FROM system_errors${where.length ? ' WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${limit}`;
55
+ return this.db.prepare(sql).all(...params).map(toRow);
56
+ }
57
+ markResolved(id) {
58
+ const info = this.db.prepare(`UPDATE system_errors SET resolved_at = CURRENT_TIMESTAMP WHERE id = ? AND resolved_at IS NULL`).run(id);
59
+ return { changes: Number(info.changes ?? 0) };
60
+ }
61
+ countSince(botId, hours) {
62
+ const cutoffMs = Date.now() - hours * 60 * 60 * 1000;
63
+ const cutoff = new Date(cutoffMs).toISOString().replace('T', ' ').slice(0, 19);
64
+ if (botId) {
65
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM system_errors WHERE bot_id = ? AND created_at >= ?`).get(botId, cutoff);
66
+ return Number(row.n ?? 0);
67
+ }
68
+ const row = this.db.prepare(`SELECT COUNT(*) AS n FROM system_errors WHERE created_at >= ?`).get(cutoff);
69
+ return Number(row.n ?? 0);
70
+ }
71
+ }
72
+ exports.SystemErrorRepository = SystemErrorRepository;
73
+ function toRow(r) {
74
+ return {
75
+ id: Number(r.id),
76
+ service: r.service,
77
+ component: r.component ?? undefined,
78
+ bot_id: r.bot_id ?? undefined,
79
+ schedule_id: r.schedule_id ?? undefined,
80
+ slot_id: r.slot_id ?? undefined,
81
+ pixiv_id: r.pixiv_id ?? undefined,
82
+ stage: r.stage ?? undefined,
83
+ error_type: r.error_type,
84
+ message: r.message,
85
+ http_status: r.http_status ?? null,
86
+ retryable: Boolean(r.retryable),
87
+ trace_id: r.trace_id ?? undefined,
88
+ created_at: r.created_at,
89
+ resolved_at: r.resolved_at,
90
+ };
91
+ }
92
+ //# sourceMappingURL=SystemErrorRepository.js.map
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BUILD = void 0;
4
4
  // GENERATED by scripts/write-version.js — do not edit manually.
5
- exports.BUILD = { version: '2.22.1', commit: 'd7d248938926' };
5
+ exports.BUILD = { version: '2.23.0', commit: 'f664f8cc1c6a' };
6
6
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow-webui-backend",
4
- "version": "2.22.1",
4
+ "version": "2.23.0",
5
5
  "description": "PixivFlow WebUI Backend - CommonJS module"
6
6
  }
@@ -0,0 +1,3 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
3
+ //# sourceMappingURL=admin-logs.d.ts.map
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const express_1 = require("express");
4
+ const admin_logs_handlers_1 = require("./handlers/admin-logs-handlers");
5
+ const router = (0, express_1.Router)();
6
+ router.get('/', admin_logs_handlers_1.getAdminLogs);
7
+ router.get('/download', admin_logs_handlers_1.downloadLogs);
8
+ exports.default = router;
9
+ //# sourceMappingURL=admin-logs.js.map
@@ -0,0 +1,6 @@
1
+ import { Request, Response } from 'express';
2
+ /** GET /admin/logs — list log files (size + mtime) in the data dir. */
3
+ export declare function getAdminLogs(_req: Request, res: Response): Promise<void>;
4
+ /** GET /admin/logs/download[?service=&bot_id=&level=&stage=&from=&to=&file=] */
5
+ export declare function downloadLogs(req: Request, res: Response): Promise<void>;
6
+ //# sourceMappingURL=admin-logs-handlers.d.ts.map
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getAdminLogs = getAdminLogs;
4
+ exports.downloadLogs = downloadLogs;
5
+ const fs_1 = require("fs");
6
+ const path_1 = require("path");
7
+ const node_zlib_1 = require("node:zlib");
8
+ const logger_1 = require("../../../logger");
9
+ const config_1 = require("../../../config");
10
+ function logDir() {
11
+ const config = (0, config_1.loadConfig)((0, config_1.getConfigPath)());
12
+ if (config.storage?.databasePath && config.storage.databasePath.startsWith('/')) {
13
+ return (0, path_1.dirname)(config.storage.databasePath);
14
+ }
15
+ const candidate = (0, path_1.join)(process.cwd(), 'data');
16
+ return (0, fs_1.existsSync)(candidate) ? candidate : (0, path_1.dirname)((0, path_1.join)(process.cwd(), 'data', 'pixiv-downloader.log'));
17
+ }
18
+ function parseOrRaw(line) {
19
+ try {
20
+ return { parsed: JSON.parse(line), raw: line };
21
+ }
22
+ catch {
23
+ return { raw: line };
24
+ }
25
+ }
26
+ function matchesFilter(line, q) {
27
+ const { parsed, raw } = parseOrRaw(line);
28
+ const val = (key) => (parsed ? parsed[key] : undefined);
29
+ const timestamp = parsed?.timestamp ?? raw.match(/\[([\d\-T:.]+Z)\]/)?.[1] ?? '';
30
+ if (q.service && !(val('service') === q.service || raw.includes(q.service)))
31
+ return false;
32
+ if (q.bot_id && !(val('bot_id') === q.bot_id || raw.includes(q.bot_id)))
33
+ return false;
34
+ if (q.stage && !(val('stage') === q.stage || raw.includes(q.stage)))
35
+ return false;
36
+ if (q.level) {
37
+ const lvl = val('level');
38
+ if (lvl) {
39
+ if (String(lvl).toLowerCase() !== q.level.toLowerCase())
40
+ return false;
41
+ }
42
+ else if (!raw.includes(`[${q.level.toUpperCase()}]`))
43
+ return false;
44
+ }
45
+ if (q.from && timestamp && timestamp < q.from)
46
+ return false;
47
+ if (q.to && timestamp && timestamp > q.to)
48
+ return false;
49
+ return true;
50
+ }
51
+ function fileInfo(full) {
52
+ const st = (0, fs_1.statSync)(full);
53
+ return { name: full.split('/').pop(), size: st.size, updated: st.mtime.toISOString() };
54
+ }
55
+ /** GET /admin/logs — list log files (size + mtime) in the data dir. */
56
+ async function getAdminLogs(_req, res) {
57
+ try {
58
+ const dir = logDir();
59
+ let names = [];
60
+ try {
61
+ names = (0, fs_1.readdirSync)(dir);
62
+ }
63
+ catch {
64
+ names = [];
65
+ }
66
+ const files = names
67
+ .filter((n) => /^pixiv-downloader.*\.(log|gz)$/.test(n))
68
+ .map((n) => fileInfo((0, path_1.join)(dir, n)))
69
+ .sort((a, b) => b.updated.localeCompare(a.updated));
70
+ res.json({ files });
71
+ }
72
+ catch (error) {
73
+ logger_1.logger.error('admin logs list failed', { error: error instanceof Error ? error.message : String(error) });
74
+ res.status(500).json({ error: 'list failed' });
75
+ }
76
+ }
77
+ /** GET /admin/logs/download[?service=&bot_id=&level=&stage=&from=&to=&file=] */
78
+ async function downloadLogs(req, res) {
79
+ try {
80
+ const requested = String(req.query.file ?? '');
81
+ const file = requested && !requested.includes('/') && !requested.includes('..') ? requested : 'pixiv-downloader.log';
82
+ const full = (0, path_1.join)(logDir(), file);
83
+ if (!(0, fs_1.existsSync)(full)) {
84
+ res.status(404).json({ error: `log file not found: ${file}` });
85
+ return;
86
+ }
87
+ const lines = (0, fs_1.readFileSync)(full, 'utf-8').split('\n').filter((l) => l.trim());
88
+ const filtered = lines.filter((l) => matchesFilter(l, {
89
+ service: req.query.service ? String(req.query.service) : undefined,
90
+ bot_id: req.query.bot_id ? String(req.query.bot_id) : undefined,
91
+ level: req.query.level ? String(req.query.level) : undefined,
92
+ stage: req.query.stage ? String(req.query.stage) : undefined,
93
+ from: req.query.from ? String(req.query.from) : undefined,
94
+ to: req.query.to ? String(req.query.to) : undefined,
95
+ }));
96
+ const gz = (0, node_zlib_1.gzipSync)(filtered.join('\n') + (filtered.length ? '\n' : ''));
97
+ res.setHeader('Content-Type', 'application/gzip');
98
+ res.setHeader('Content-Disposition', `attachment; filename="${file}.filtered.gz"`);
99
+ res.send(gz);
100
+ }
101
+ catch (error) {
102
+ logger_1.logger.error('admin logs download failed', { error: error instanceof Error ? error.message : String(error) });
103
+ res.status(500).json({ error: 'download failed' });
104
+ }
105
+ }
106
+ //# sourceMappingURL=admin-logs-handlers.js.map
@@ -0,0 +1,6 @@
1
+ import { Request, Response } from 'express';
2
+ /** GET /admin/system-errors?limit=&error_type=&bot_id=&stage=&resolved=&from=&to= */
3
+ export declare function listSystemErrors(req: Request, res: Response): Promise<void>;
4
+ /** POST /admin/system-errors/:id/resolve */
5
+ export declare function resolveSystemError(req: Request, res: Response): Promise<void>;
6
+ //# sourceMappingURL=system-errors-handlers.d.ts.map
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listSystemErrors = listSystemErrors;
4
+ exports.resolveSystemError = resolveSystemError;
5
+ const Database_1 = require("../../../storage/Database");
6
+ const config_1 = require("../../../config");
7
+ const logger_1 = require("../../../logger");
8
+ function db() {
9
+ const config = (0, config_1.loadConfig)((0, config_1.getConfigPath)());
10
+ const database = new Database_1.Database(config.storage.databasePath);
11
+ database.migrate();
12
+ return database;
13
+ }
14
+ /** GET /admin/system-errors?limit=&error_type=&bot_id=&stage=&resolved=&from=&to= */
15
+ async function listSystemErrors(req, res) {
16
+ let database = null;
17
+ try {
18
+ database = db();
19
+ const rows = database.systemErrors.list({
20
+ limit: Number(req.query.limit ?? 50),
21
+ errorType: req.query.error_type ? String(req.query.error_type) : undefined,
22
+ botId: req.query.bot_id ? String(req.query.bot_id) : undefined,
23
+ stage: req.query.stage ? String(req.query.stage) : undefined,
24
+ resolved: req.query.resolved === 'true' ? true : req.query.resolved === 'false' ? false : undefined,
25
+ from: req.query.from ? String(req.query.from) : undefined,
26
+ to: req.query.to ? String(req.query.to) : undefined,
27
+ });
28
+ res.json({ data: rows });
29
+ }
30
+ catch (error) {
31
+ logger_1.logger.error('list system errors failed', { error: error instanceof Error ? error.message : String(error) });
32
+ res.status(500).json({ error: 'list failed' });
33
+ }
34
+ finally {
35
+ database?.close();
36
+ }
37
+ }
38
+ /** POST /admin/system-errors/:id/resolve */
39
+ async function resolveSystemError(req, res) {
40
+ let database = null;
41
+ try {
42
+ const id = Number(req.params.id);
43
+ if (!Number.isFinite(id)) {
44
+ res.status(400).json({ error: 'invalid id' });
45
+ return;
46
+ }
47
+ database = db();
48
+ const result = database.systemErrors.markResolved(id);
49
+ res.json({ success: result.changes > 0, changes: result.changes });
50
+ }
51
+ catch (error) {
52
+ logger_1.logger.error('resolve system error failed', { error: error instanceof Error ? error.message : String(error) });
53
+ res.status(500).json({ error: 'resolve failed' });
54
+ }
55
+ finally {
56
+ database?.close();
57
+ }
58
+ }
59
+ //# sourceMappingURL=system-errors-handlers.js.map
@@ -0,0 +1,3 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
3
+ //# sourceMappingURL=system-errors.d.ts.map