pixivflow 2.22.0 → 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.
- package/dist/commands/SchedulerCommand.js +12 -3
- package/dist/commands/scheduler-runtime.d.ts +29 -0
- package/dist/commands/scheduler-runtime.js +54 -1
- package/dist/delivery/OutboxWorker.js +6 -8
- package/dist/download/exec/DownloadExecutor.js +11 -2
- package/dist/download/handlers/IllustrationTargetHandler.js +15 -0
- package/dist/download/handlers/NovelTargetHandler.js +12 -2
- package/dist/interfaces/IDatabase.d.ts +11 -0
- package/dist/logger.d.ts +24 -0
- package/dist/logger.js +88 -11
- package/dist/observability/classify.d.ts +7 -0
- package/dist/observability/classify.js +51 -0
- package/dist/observability/context.d.ts +10 -0
- package/dist/observability/context.js +22 -0
- package/dist/observability/index.d.ts +7 -0
- package/dist/observability/index.js +36 -0
- package/dist/observability/types.d.ts +23 -0
- package/dist/observability/types.js +3 -0
- package/dist/package.json +1 -1
- package/dist/scheduler/RecoveryOutcome.d.ts +24 -0
- package/dist/scheduler/RecoveryOutcome.js +44 -0
- package/dist/scheduler/ScheduleTriggerServer.d.ts +4 -0
- package/dist/scheduler/SlotBusinessStatus.d.ts +32 -0
- package/dist/scheduler/SlotBusinessStatus.js +43 -0
- package/dist/scheduler/SlotCoordinator.d.ts +7 -0
- package/dist/scheduler/SlotCoordinator.js +13 -0
- package/dist/storage/Database.d.ts +4 -0
- package/dist/storage/Database.js +7 -0
- package/dist/storage/DatabaseMigration.js +21 -0
- package/dist/storage/repositories/SystemErrorRepository.d.ts +19 -0
- package/dist/storage/repositories/SystemErrorRepository.js +92 -0
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/dist/webui/routes/admin-logs.d.ts +3 -0
- package/dist/webui/routes/admin-logs.js +9 -0
- package/dist/webui/routes/handlers/admin-logs-handlers.d.ts +6 -0
- package/dist/webui/routes/handlers/admin-logs-handlers.js +106 -0
- package/dist/webui/routes/handlers/system-errors-handlers.d.ts +6 -0
- package/dist/webui/routes/handlers/system-errors-handlers.js +59 -0
- package/dist/webui/routes/system-errors.d.ts +3 -0
- package/dist/webui/routes/system-errors.js +9 -0
- package/dist/webui/server/server-routes.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.classifySystemError = void 0;
|
|
18
|
+
exports.recordSystemError = recordSystemError;
|
|
19
|
+
const logger_1 = require("../logger");
|
|
20
|
+
const classify_1 = require("./classify");
|
|
21
|
+
var classify_2 = require("./classify");
|
|
22
|
+
Object.defineProperty(exports, "classifySystemError", { enumerable: true, get: function () { return classify_2.classifySystemError; } });
|
|
23
|
+
__exportStar(require("./types"), exports);
|
|
24
|
+
/** Record a structured system error with taxonomy; never throws. */
|
|
25
|
+
function recordSystemError(database, input, classified = null) {
|
|
26
|
+
try {
|
|
27
|
+
const cls = classified ?? (0, classify_1.classifySystemError)(new Error(input.message), input.http_status ?? null, input.stage);
|
|
28
|
+
database.systemErrors.record({ ...input, ...cls });
|
|
29
|
+
logger_1.logger.error(`system_error recorded`, { error_type: cls.error_type, retryable: cls.retryable, ...input });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// Observability must never break the business path.
|
|
33
|
+
console.error('Failed to persist system error', error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SystemErrorType = 'PIXIV_AUTH_FAILED' | 'PIXIV_RATE_LIMITED' | 'PIXIV_NOT_FOUND' | 'PIXIV_CDN_FORBIDDEN' | 'NETWORK_TIMEOUT' | 'DOWNLOAD_CORRUPTED' | 'IMAGE_PROCESS_FAILED' | 'TELEGRAM_UPLOAD_FAILED' | 'CONFIG_ERROR' | 'INTERNAL_ERROR';
|
|
2
|
+
export interface SystemErrorClassification {
|
|
3
|
+
error_type: SystemErrorType;
|
|
4
|
+
retryable: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface SystemErrorInput extends Partial<SystemErrorClassification> {
|
|
7
|
+
service?: string;
|
|
8
|
+
component?: string;
|
|
9
|
+
bot_id?: string;
|
|
10
|
+
schedule_id?: string;
|
|
11
|
+
slot_id?: string;
|
|
12
|
+
pixiv_id?: string;
|
|
13
|
+
stage?: string;
|
|
14
|
+
message: string;
|
|
15
|
+
http_status?: number | null;
|
|
16
|
+
trace_id?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SystemErrorRow extends SystemErrorInput {
|
|
19
|
+
id: number;
|
|
20
|
+
created_at: string;
|
|
21
|
+
resolved_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/package.json
CHANGED
|
@@ -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). */
|
package/dist/storage/Database.js
CHANGED
|
@@ -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.
|
|
5
|
+
exports.BUILD = { version: '2.23.0', commit: 'f664f8cc1c6a' };
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/webui/package.json
CHANGED
|
@@ -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
|