feat(api): integrate identity, notification scope and connection settings into control plane

- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
chick
2026-09-07 00:45:18 +08:00
parent 7168e9dba2
commit 635527dd26
22 changed files with 754 additions and 46 deletions
@@ -74,6 +74,42 @@ function seed(db: Database.Database, tag: string): void {
1,
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO jobs
(id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,updated_at)
VALUES (?,?,?,?,'succeeded',?,?,?,?,?)`,
).run(
`job-${tag}`,
`job-${tag}`,
'probe',
'R0',
'operator',
`${tag}-req`,
'digest',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO job_items
(id,job_id,instance_id,attempt_number,status,created_at,updated_at)
VALUES (?,?,?,1,'succeeded',?,?)`,
).run(
`item-${tag}`,
`job-${tag}`,
`node-${tag}`,
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
db.prepare(
`INSERT INTO job_attempts (id,job_id,status,started_at,created_at)
VALUES (?,?,?,?,?)`,
).run(
`attempt-${tag}`,
`job-${tag}`,
'succeeded',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
);
}
describe('ComponentBackupService', () => {
@@ -124,8 +160,14 @@ describe('ComponentBackupService', () => {
version: 'test',
backupDirectory,
});
const written = await restoreService.restore(created.filename, ['devices']);
expect(written).toEqual({ devices: 8 });
const result = await restoreService.restore(created.filename, ['devices']);
expect(result.written).toEqual({ devices: 8 });
// The state right before the merge is snapshotted first, so the restore stays reversible.
expect(result.safetyBackup).not.toBe(created.filename);
const safety = await restoreService.preview(result.safetyBackup);
expect(safety.note).toBe(`恢复前自动备份:${created.filename}`);
expect(safety.automatic).toBe(true);
expect(safety.components.map((component) => component.key)).toEqual(['devices']);
const instances = target.prepare('SELECT id FROM instances ORDER BY id').all() as {
id: string;
}[];
@@ -188,7 +230,7 @@ describe('ComponentBackupService', () => {
service.updateAutoSettings({
enabled: true,
components: ['devices'],
timeOfDay: '03:30',
timeOfDay: '02:00',
weekday: -1,
maximumCount: 2,
});
@@ -221,7 +263,7 @@ describe('ComponentBackupService', () => {
});
expect(service.autoSettings()).toMatchObject({
enabled: false,
timeOfDay: '03:30',
timeOfDay: '02:00',
components: ['devices', 'notifications', 'automation'],
});
});
@@ -14,6 +14,7 @@ export type BackupComponentKey =
| 'notificationRecords'
| 'automation'
| 'automationRecords'
| 'jobs'
| 'sms'
| 'settings'
| 'audit';
@@ -52,6 +53,11 @@ export const BACKUP_COMPONENTS: Readonly<Record<BackupComponentKey, ComponentSpe
description: '每次定时执行的状态与结果。',
tables: ['scheduled_runs'],
},
jobs: {
label: '命令历史',
description: '集中下发的设备操作命令与逐设备执行明细。',
tables: ['jobs', 'job_items', 'job_attempts'],
},
sms: {
label: '短信记录',
description: '集中保存的跨设备短信正文与会话。',
@@ -125,6 +131,20 @@ export interface AutoBackupSettings {
readonly lastRunAt: string | null;
}
export interface RestoreResult {
readonly written: Partial<Record<BackupComponentKey, number>>;
/** Filename of the snapshot taken just before the merge, so a restore is always reversible. */
readonly safetyBackup: string;
}
/** Download metadata without parsing an already validated archive again. */
export interface ComponentBackupFile {
readonly filename: string;
readonly path: string;
readonly sizeBytes: number;
readonly createdAt: string;
}
export class ComponentBackupError extends Error {
constructor(
readonly code:
@@ -292,11 +312,14 @@ export class ComponentBackupService {
async restore(
filename: string,
components: readonly BackupComponentKey[],
): Promise<Partial<Record<BackupComponentKey, number>>> {
): Promise<RestoreResult> {
const safe = this.#safeName(filename);
const keys = normalizeComponents(components);
const { document } = await this.#read(safe);
if (document.formatVersion > FORMAT_VERSION) throw new ComponentBackupError('INCOMPATIBLE');
// Snapshot the units that are about to be overwritten first: a restore must always be
// reversible, and the state right before the merge is the only thing worth returning to.
const safety = await this.create(keys, `恢复前自动备份:${safe}`, true);
const written: Partial<Record<BackupComponentKey, number>> = {};
const restore = this.#db.transaction(() => {
for (const key of keys) {
@@ -314,7 +337,12 @@ export class ComponentBackupService {
}
});
restore();
return written;
return { written, safetyBackup: safety.filename };
}
/** Where the backup files live, shown so an operator can mirror the folder off-console. */
directory(): string {
return this.#directory;
}
async remove(filename: string): Promise<{ filename: string }> {
@@ -322,6 +350,23 @@ export class ComponentBackupService {
return { filename: this.#safeName(filename) };
}
/** Resolves a listed archive to a path that stays inside the backup directory. */
async backupFile(filename: string): Promise<ComponentBackupFile | undefined> {
const safe = this.#safeName(filename);
const path = join(this.#directory, safe);
const details = await stat(path).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (!details?.isFile()) return undefined;
return {
filename: safe,
path,
sizeBytes: details.size,
createdAt: details.mtime.toISOString(),
};
}
autoSettings(): AutoBackupSettings {
return this.#readAuto();
}
@@ -536,9 +581,9 @@ export class ComponentBackupService {
const fallback = {
enabled: false,
components: ['devices', 'notifications', 'automation'] as BackupComponentKey[],
timeOfDay: '03:30',
timeOfDay: '02:00',
weekday: -1,
maximumCount: 14,
maximumCount: 10,
};
const source: Record<string, unknown> = { ...fallback, ...(stored ?? {}) };
let components: BackupComponentKey[] = fallback.components;
@@ -30,6 +30,8 @@ export interface MaintenanceOverview {
};
readonly storage: {
readonly databaseBytes: number;
/** SQLite file path; empty for in-memory databases. */
readonly databasePath: string;
/** Write-ahead log file size; 0 when the database is not in WAL mode. */
readonly walBytes: number;
/** Total size of the retained backup files. */
@@ -195,6 +197,7 @@ export class SystemMaintenanceService {
},
storage: {
databaseBytes: pageCount * pageSize,
databasePath: databaseFile,
walBytes,
backupBytes: await this.#backupBytes(),
reclaimableBytes: freelist * pageSize,