feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type {
|
||||
AdminAuditQuery,
|
||||
AdminOperationAuditItem,
|
||||
AdminOperationAuditResponse,
|
||||
PrivateContentAccessAuditItem,
|
||||
PrivateContentAccessAuditResponse,
|
||||
} from "@dada/shared-contracts";
|
||||
|
||||
interface AuditCursor {
|
||||
logId: string;
|
||||
occurredAt: number;
|
||||
}
|
||||
|
||||
interface AdminOperationRow {
|
||||
actor_ref: string;
|
||||
actor_type: "system" | "super_admin";
|
||||
after_summary: string | null;
|
||||
before_summary: string | null;
|
||||
expires_at: number;
|
||||
log_id: string;
|
||||
occurred_at: number;
|
||||
operation_type: string;
|
||||
result: "failed" | "succeeded";
|
||||
target_ref: string;
|
||||
target_type: string;
|
||||
}
|
||||
|
||||
interface PrivateContentAccessRow {
|
||||
actor_ref: string;
|
||||
content_type: "image" | "prompt";
|
||||
expires_at: number;
|
||||
log_id: string;
|
||||
occurred_at: number;
|
||||
target_ref: string;
|
||||
}
|
||||
|
||||
export class AdminAuditQueryError extends Error {
|
||||
constructor() {
|
||||
super("admin_audit_query_invalid");
|
||||
this.name = "AdminAuditQueryError";
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(row: { log_id: string; occurred_at: number }) {
|
||||
return Buffer.from(JSON.stringify([row.occurred_at, row.log_id]), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | undefined): AuditCursor | undefined {
|
||||
if (!cursor) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
||||
if (!Array.isArray(parsed) || parsed.length !== 2 || !Number.isSafeInteger(parsed[0])
|
||||
|| typeof parsed[1] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$/.test(parsed[1])) {
|
||||
throw new AdminAuditQueryError();
|
||||
}
|
||||
return { occurredAt: parsed[0] as number, logId: parsed[1] };
|
||||
} catch (error) {
|
||||
if (error instanceof AdminAuditQueryError) throw error;
|
||||
throw new AdminAuditQueryError();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined) {
|
||||
if (limit === undefined) return 50;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new AdminAuditQueryError();
|
||||
return limit;
|
||||
}
|
||||
|
||||
function pageRows<Row extends { log_id: string; occurred_at: number }>(rows: Row[], limit: number) {
|
||||
const hasMore = rows.length > limit;
|
||||
const items = hasMore ? rows.slice(0, limit) : rows;
|
||||
return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]!) : null };
|
||||
}
|
||||
|
||||
function iso(value: number) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
export function listAdminOperationAudit(
|
||||
database: BetterSqlite3.Database,
|
||||
query: AdminAuditQuery,
|
||||
clock: () => number = Date.now,
|
||||
): AdminOperationAuditResponse {
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
const limit = normalizeLimit(query.limit);
|
||||
const rows = (cursor
|
||||
? database.prepare(`
|
||||
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
|
||||
occurred_at, operation_type, result, target_ref, target_type
|
||||
FROM admin_operation_logs
|
||||
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
|
||||
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
|
||||
: database.prepare(`
|
||||
SELECT actor_ref, actor_type, after_summary, before_summary, expires_at, log_id,
|
||||
occurred_at, operation_type, result, target_ref, target_type
|
||||
FROM admin_operation_logs
|
||||
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||
`).all(limit + 1)) as AdminOperationRow[];
|
||||
const page = pageRows(rows, limit);
|
||||
const items: AdminOperationAuditItem[] = page.items.map((row) => ({
|
||||
actor_ref: row.actor_ref,
|
||||
actor_type: row.actor_type,
|
||||
after_summary: row.after_summary,
|
||||
before_summary: row.before_summary,
|
||||
expires_at: iso(row.expires_at),
|
||||
log_id: row.log_id,
|
||||
occurred_at: iso(row.occurred_at),
|
||||
operation_type: row.operation_type,
|
||||
result: row.result,
|
||||
target_ref: row.target_ref,
|
||||
target_type: row.target_type,
|
||||
}));
|
||||
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
|
||||
}
|
||||
|
||||
export function listPrivateContentAccessAudit(
|
||||
database: BetterSqlite3.Database,
|
||||
query: AdminAuditQuery,
|
||||
clock: () => number = Date.now,
|
||||
): PrivateContentAccessAuditResponse {
|
||||
const cursor = decodeCursor(query.cursor);
|
||||
const limit = normalizeLimit(query.limit);
|
||||
const rows = (cursor
|
||||
? database.prepare(`
|
||||
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
|
||||
FROM private_content_access_logs
|
||||
WHERE occurred_at < ? OR (occurred_at = ? AND log_id < ?)
|
||||
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||
`).all(cursor.occurredAt, cursor.occurredAt, cursor.logId, limit + 1)
|
||||
: database.prepare(`
|
||||
SELECT actor_ref, content_type, expires_at, log_id, occurred_at, target_ref
|
||||
FROM private_content_access_logs
|
||||
ORDER BY occurred_at DESC, log_id DESC LIMIT ?
|
||||
`).all(limit + 1)) as PrivateContentAccessRow[];
|
||||
const page = pageRows(rows, limit);
|
||||
const items: PrivateContentAccessAuditItem[] = page.items.map((row) => ({
|
||||
actor_ref: row.actor_ref,
|
||||
content_type: row.content_type,
|
||||
expires_at: iso(row.expires_at),
|
||||
log_id: row.log_id,
|
||||
occurred_at: iso(row.occurred_at),
|
||||
target_ref: row.target_ref,
|
||||
}));
|
||||
return { generated_at: iso(clock()), items, next_cursor: page.nextCursor };
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
AccountProfileUpdateRequestSchema,
|
||||
AccountProfileUpdateResponseSchema,
|
||||
AccountSettingsResponseSchema,
|
||||
AdminAuditQuerySchema,
|
||||
AdminAuthenticatedUserSchema,
|
||||
AdminGenerationRecordSchema,
|
||||
AdminGenerationListResponseSchema,
|
||||
AdminOverviewResponseSchema,
|
||||
AdminOperationAuditItemSchema,
|
||||
AdminOperationAuditResponseSchema,
|
||||
AdminServicesResponseSchema,
|
||||
AdminServiceHealthCheckRequestSchema,
|
||||
AdminServiceLimitRequestSchema,
|
||||
@@ -78,6 +81,8 @@ import {
|
||||
PrivateContentNoticeAckRequestSchema,
|
||||
PrivateContentNoticeAckResponseSchema,
|
||||
PrivateContentPromptResponseSchema,
|
||||
PrivateContentAccessAuditItemSchema,
|
||||
PrivateContentAccessAuditResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ExportFormatSchema,
|
||||
@@ -129,6 +134,7 @@ import {
|
||||
type AdminLoginCompleteRequest,
|
||||
type AdminLoginSendRequest,
|
||||
type AdminOverviewResponse,
|
||||
type AdminAuditQuery,
|
||||
type AdminDiagnosticsResponse,
|
||||
type AdminServicesStorageResponse,
|
||||
type AccountDeletionCompleteRequest,
|
||||
@@ -195,6 +201,11 @@ import {
|
||||
registrationFieldError,
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import {
|
||||
AdminAuditQueryError,
|
||||
listAdminOperationAudit,
|
||||
listPrivateContentAccessAudit,
|
||||
} from "./admin-audit.js";
|
||||
import type { AssetPreviewGrantService } from "./preview-grants.js";
|
||||
import type { RecentAssetService } from "./recent-assets.js";
|
||||
import type { AmapAdapter } from "./amap-adapter.js";
|
||||
@@ -749,11 +760,16 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
AdminLoginCompleteRequestSchema,
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminSessionResponseSchema,
|
||||
AdminAuditQuerySchema,
|
||||
AdminOperationAuditItemSchema,
|
||||
AdminOperationAuditResponseSchema,
|
||||
AdminGenerationRecordSchema,
|
||||
AdminGenerationListResponseSchema,
|
||||
PrivateContentNoticeAckRequestSchema,
|
||||
PrivateContentNoticeAckResponseSchema,
|
||||
PrivateContentPromptResponseSchema,
|
||||
PrivateContentAccessAuditItemSchema,
|
||||
PrivateContentAccessAuditResponseSchema,
|
||||
PrivateContentGenerationParamsSchema,
|
||||
AdminOverviewResponseSchema,
|
||||
AdminServicesResponseSchema,
|
||||
@@ -1684,6 +1700,70 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/audit/operations",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getAdminOperationAudit",
|
||||
querystring: Type.Ref(AdminAuditQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(AdminOperationAuditResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Null(),
|
||||
},
|
||||
tags: ["Admin Operations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration) return reply.code(503).send(null);
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||
if (!session) {
|
||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
}
|
||||
try {
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return listAdminOperationAudit(options.registration.database, request.query as AdminAuditQuery);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminAuditQueryError) return reply.code(400).send(null);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/audit/private-content",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getPrivateContentAccessAudit",
|
||||
querystring: Type.Ref(AdminAuditQuerySchema),
|
||||
response: {
|
||||
200: Type.Ref(PrivateContentAccessAuditResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Null(),
|
||||
},
|
||||
tags: ["Admin Operations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration) return reply.code(503).send(null);
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||
if (!session) {
|
||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
}
|
||||
try {
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return listPrivateContentAccessAudit(options.registration.database, request.query as AdminAuditQuery);
|
||||
} catch (error) {
|
||||
if (error instanceof AdminAuditQueryError) return reply.code(400).send(null);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/overview",
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import {
|
||||
auditRetentionMilliseconds,
|
||||
ensureAdminOperationAuditSchema,
|
||||
ensurePrivateAccessAuditSchema,
|
||||
isSafeAuditRef,
|
||||
@@ -281,6 +282,39 @@ export class RegistrationService {
|
||||
return { code, inviteId };
|
||||
}
|
||||
|
||||
createAdminInvite(input: { actorId: string; expiresAt: number; maxUses: number }) {
|
||||
if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) {
|
||||
throw new Error("Invite request is invalid.");
|
||||
}
|
||||
const code = this.options.inviteCodeGenerator();
|
||||
const inviteId = randomUUID();
|
||||
const now = this.options.clock();
|
||||
this.runImmediate("invite_create", () => {
|
||||
const admin = this.database.prepare(`
|
||||
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
||||
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||
`).get(input.actorId);
|
||||
if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
|
||||
this.database.prepare(`
|
||||
INSERT INTO invite_codes (
|
||||
invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at
|
||||
) VALUES (?, ?, ?, 0, ?, 'enabled', ?)
|
||||
`).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now);
|
||||
this.recordAdminAudit({
|
||||
actorRef: input.actorId,
|
||||
actorType: "super_admin",
|
||||
afterSummary: { max_uses: input.maxUses, status: "enabled" },
|
||||
beforeSummary: null,
|
||||
operationType: "invite_create",
|
||||
result: "succeeded",
|
||||
targetRef: inviteId,
|
||||
targetType: "invite",
|
||||
}, now);
|
||||
return { outcome: "committed", value: undefined };
|
||||
});
|
||||
return { code, inviteId };
|
||||
}
|
||||
|
||||
async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise<RegistrationSendResult> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const inviteCode = normalizeProfileValue(input.inviteCode, 160);
|
||||
@@ -1241,14 +1275,35 @@ export class RegistrationService {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
changeUserStatus(userId: string, status: "suspended" | "deleted") {
|
||||
changeUserStatus(userId: string, status: "suspended" | "deleted", actorId?: string) {
|
||||
const now = this.options.clock();
|
||||
this.runImmediate("session_revoke", () => {
|
||||
if (actorId) {
|
||||
const admin = this.database.prepare(`
|
||||
SELECT u.user_id FROM users u JOIN admin_access a ON a.user_id = u.user_id
|
||||
WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
|
||||
`).get(actorId);
|
||||
if (!admin) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
|
||||
}
|
||||
const before = this.database.prepare("SELECT status FROM users WHERE user_id = ? AND role = 'user'")
|
||||
.get(userId) as { status: "active" | "suspended" | "deleted" } | undefined;
|
||||
const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'")
|
||||
.run(status, userId);
|
||||
if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
|
||||
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
|
||||
.run(now, userId);
|
||||
if (actorId) {
|
||||
this.recordAdminAudit({
|
||||
actorRef: actorId,
|
||||
actorType: "super_admin",
|
||||
afterSummary: { status },
|
||||
beforeSummary: { status: before?.status ?? "unknown" },
|
||||
operationType: "user_status_change",
|
||||
result: "succeeded",
|
||||
targetRef: userId,
|
||||
targetType: "user_account",
|
||||
}, now);
|
||||
}
|
||||
return { outcome: "committed", value: undefined };
|
||||
});
|
||||
}
|
||||
@@ -1772,7 +1827,7 @@ export class RegistrationService {
|
||||
serializeAuditSummary(input.beforeSummary),
|
||||
serializeAuditSummary(input.afterSummary),
|
||||
now,
|
||||
now + 180 * 24 * 60 * 60 * 1_000,
|
||||
now + auditRetentionMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ import sharp, { type Metadata } from "sharp";
|
||||
|
||||
import type { StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
|
||||
|
||||
import {
|
||||
auditRetentionMilliseconds,
|
||||
isSafeAuditRef,
|
||||
isSafeAuditSummaryJson,
|
||||
serializeAuditSummary,
|
||||
} from "./audit-policy.js";
|
||||
import { ManagedStorage, type StagedManagedFile } from "./managed-storage.js";
|
||||
import { StickerReleaseError } from "./sticker-release-errors.js";
|
||||
import { classifyCapacity } from "./storage-policy.js";
|
||||
@@ -115,6 +121,12 @@ export class StickerReleaseService {
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.database.function("dada_audit_ref_is_safe", { deterministic: true }, isSafeAuditRef);
|
||||
this.database.function("dada_audit_summary_is_safe", { deterministic: true }, isSafeAuditSummaryJson);
|
||||
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_privacy_purge_subject", { deterministic: false }, () => "");
|
||||
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||
this.storage = input.storage;
|
||||
this.migrate();
|
||||
}
|
||||
@@ -234,6 +246,13 @@ export class StickerReleaseService {
|
||||
WHERE release_version = ? AND stable_id = ?
|
||||
`).run((input.enabled ?? existing.enabled === 1) ? 1 : 0, part, order, version, input.stableId);
|
||||
this.finalizeRelease(version, current, input.actorId);
|
||||
this.insertReleaseAudit({
|
||||
actorId: input.actorId,
|
||||
afterSummary: { enabled: input.enabled ?? existing.enabled === 1, order, part, stable_id: input.stableId },
|
||||
beforeSummary: { enabled: existing.enabled === 1, order: existing.order_index, part: existing.part, stable_id: input.stableId },
|
||||
operationType: "sticker_release_update",
|
||||
releaseVersion: version,
|
||||
});
|
||||
return version;
|
||||
});
|
||||
return { item: itemView(this.readItem(releaseVersion, input.stableId)!), release_version: releaseVersion };
|
||||
@@ -342,9 +361,36 @@ export class StickerReleaseService {
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(input.actorId, input.keyDigest, input.requestHash, releaseVersion, input.stableId, iso(this.clock()));
|
||||
this.finalizeRelease(releaseVersion, previous, input.actorId);
|
||||
this.insertReleaseAudit({
|
||||
actorId: input.actorId,
|
||||
afterSummary: { enabled: input.enabled, order: input.order, part: input.part, stable_id: input.stableId },
|
||||
beforeSummary: previous ? { release_version: previous } : null,
|
||||
operationType: "sticker_release_publish",
|
||||
releaseVersion,
|
||||
});
|
||||
return releaseVersion;
|
||||
}
|
||||
|
||||
private insertReleaseAudit(input: {
|
||||
actorId: string;
|
||||
afterSummary: Record<string, unknown>;
|
||||
beforeSummary: Record<string, unknown> | null;
|
||||
operationType: "sticker_release_publish" | "sticker_release_update";
|
||||
releaseVersion: string;
|
||||
}) {
|
||||
const occurredAt = this.clock();
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'super_admin', ?, ?, 'sticker_release', ?, 'succeeded', ?, ?, ?, ?)
|
||||
`).run(
|
||||
randomUUID(), input.actorId, input.operationType, input.releaseVersion,
|
||||
serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary),
|
||||
occurredAt, occurredAt + auditRetentionMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
private finalizeRelease(releaseVersion: string, previous: string | null, actorId: string) {
|
||||
const rows = this.database.prepare(`
|
||||
SELECT stable_id, part, order_index, original_sha256, thumbnail_sha256, enabled
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
.admin-audit-page {
|
||||
width: min(100% - 48px, 1440px);
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
color: #1a1a18;
|
||||
}
|
||||
|
||||
.admin-audit-heading {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 2px solid #1a1a18;
|
||||
}
|
||||
|
||||
.admin-audit-heading p,
|
||||
.admin-audit-heading h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-audit-heading p {
|
||||
color: #686861;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-audit-heading h2 {
|
||||
padding: 4px 0 12px;
|
||||
font-size: 28px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.admin-audit-heading time {
|
||||
padding-bottom: 14px;
|
||||
color: #686861;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-audit-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-top: 24px;
|
||||
border-bottom: 1px solid #a9a9a2;
|
||||
}
|
||||
|
||||
.admin-audit-tabs button {
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #4f4f49;
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-audit-tabs button[aria-selected="true"] {
|
||||
border-bottom-color: #1a1a18;
|
||||
color: #1a1a18;
|
||||
background: #f4df32;
|
||||
}
|
||||
|
||||
.admin-audit-failure {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 8px 12px;
|
||||
border-left: 4px solid #c92a24;
|
||||
background: #fff1ef;
|
||||
}
|
||||
|
||||
.admin-audit-failure button,
|
||||
.admin-audit-pagination button {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #1a1a18;
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-audit-status {
|
||||
margin: 0;
|
||||
padding: 48px 16px;
|
||||
color: #686861;
|
||||
}
|
||||
|
||||
.admin-audit-table-scroll {
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #a9a9a2;
|
||||
}
|
||||
|
||||
.admin-audit-page table {
|
||||
width: 100%;
|
||||
min-width: 1120px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-audit-page th,
|
||||
.admin-audit-page td {
|
||||
min-height: 40px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #d7d7d1;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-audit-page th {
|
||||
color: #55554f;
|
||||
background: #efefeb;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-audit-page th:nth-child(1) { width: 132px; }
|
||||
.admin-audit-page th:nth-child(2) { width: 210px; }
|
||||
.admin-audit-page th:nth-child(3) { width: 180px; }
|
||||
.admin-audit-page th:nth-child(5) { width: 100px; }
|
||||
.admin-audit-page th:nth-child(6) { width: 210px; }
|
||||
|
||||
.admin-audit-page td small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #686861;
|
||||
}
|
||||
|
||||
.admin-audit-page td strong {
|
||||
color: #16794b;
|
||||
}
|
||||
|
||||
.admin-audit-page td strong.is-failed {
|
||||
color: #c92a24;
|
||||
}
|
||||
|
||||
.admin-audit-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.admin-audit-retention {
|
||||
margin: 24px 0 0;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #d7d7d1;
|
||||
color: #686861;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-audit-page :focus-visible {
|
||||
outline: 2px solid #005fcc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-audit-page { width: calc(100% - 24px); }
|
||||
.admin-audit-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.admin-audit-heading time { padding-bottom: 12px; }
|
||||
.admin-audit-tabs { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.admin-audit-tabs button { min-width: 0; padding: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
AdminOperationAuditItem,
|
||||
AdminOperationAuditResponse,
|
||||
PrivateContentAccessAuditItem,
|
||||
PrivateContentAccessAuditResponse,
|
||||
} from "@dada/shared-contracts";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import "./admin-audit.css";
|
||||
|
||||
type AuditTab = "operations" | "private-content";
|
||||
|
||||
interface AuditPageState<Item> {
|
||||
failed: boolean;
|
||||
generatedAt: string | null;
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
const emptyState = <Item,>(): AuditPageState<Item> => ({
|
||||
failed: false,
|
||||
generatedAt: null,
|
||||
items: [],
|
||||
loading: false,
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function operationSummary(item: AdminOperationAuditItem) {
|
||||
if (item.after_summary) return item.after_summary;
|
||||
if (item.before_summary) return item.before_summary;
|
||||
return "无变更摘要";
|
||||
}
|
||||
|
||||
export function AdminAuditPage() {
|
||||
const [tab, setTab] = useState<AuditTab>("operations");
|
||||
const [operations, setOperations] = useState<AuditPageState<AdminOperationAuditItem>>(emptyState);
|
||||
const [privateAccess, setPrivateAccess] = useState<AuditPageState<PrivateContentAccessAuditItem>>(emptyState);
|
||||
|
||||
const loadOperations = useCallback(async (cursor?: string, append = false) => {
|
||||
setOperations((current) => ({ ...current, failed: false, loading: true }));
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: "50" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
const response = await fetch(`/api/v1/admin/audit/operations?${query}`, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("admin_operation_audit_unavailable");
|
||||
const body = await response.json() as AdminOperationAuditResponse;
|
||||
setOperations((current) => ({
|
||||
failed: false,
|
||||
generatedAt: body.generated_at,
|
||||
items: append ? [...current.items, ...body.items] : body.items,
|
||||
loading: false,
|
||||
nextCursor: body.next_cursor,
|
||||
}));
|
||||
} catch {
|
||||
setOperations((current) => ({ ...current, failed: true, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPrivateAccess = useCallback(async (cursor?: string, append = false) => {
|
||||
setPrivateAccess((current) => ({ ...current, failed: false, loading: true }));
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: "50" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
const response = await fetch(`/api/v1/admin/audit/private-content?${query}`, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("private_content_audit_unavailable");
|
||||
const body = await response.json() as PrivateContentAccessAuditResponse;
|
||||
setPrivateAccess((current) => ({
|
||||
failed: false,
|
||||
generatedAt: body.generated_at,
|
||||
items: append ? [...current.items, ...body.items] : body.items,
|
||||
loading: false,
|
||||
nextCursor: body.next_cursor,
|
||||
}));
|
||||
} catch {
|
||||
setPrivateAccess((current) => ({ ...current, failed: true, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadOperations(); }, [loadOperations]);
|
||||
|
||||
function selectTab(next: AuditTab) {
|
||||
setTab(next);
|
||||
if (next === "private-content" && !privateAccess.generatedAt && !privateAccess.loading) void loadPrivateAccess();
|
||||
}
|
||||
|
||||
const state = tab === "operations" ? operations : privateAccess;
|
||||
const reload = tab === "operations" ? loadOperations : loadPrivateAccess;
|
||||
|
||||
return (
|
||||
<main className="admin-audit-page" id="admin-main">
|
||||
<header className="admin-audit-heading">
|
||||
<div><p>IMMUTABLE / 180 DAYS</p><h2>审计</h2></div>
|
||||
{state.generatedAt ? <time dateTime={state.generatedAt}>读取于 {formatTime(state.generatedAt)}</time> : null}
|
||||
</header>
|
||||
|
||||
<div aria-label="审计类型" className="admin-audit-tabs" role="tablist">
|
||||
<button aria-controls="operation-audit-panel" aria-selected={tab === "operations"} id="operation-audit-tab" onClick={() => selectTab("operations")} role="tab" type="button">后台操作审计</button>
|
||||
<button aria-controls="private-audit-panel" aria-selected={tab === "private-content"} id="private-audit-tab" onClick={() => selectTab("private-content")} role="tab" type="button">私有内容访问审计</button>
|
||||
</div>
|
||||
|
||||
{state.failed ? (
|
||||
<div className="admin-audit-failure" role="alert">
|
||||
<span>审计记录暂时无法读取{state.generatedAt ? ",已保留上次结果" : ""}。</span>
|
||||
<button disabled={state.loading} onClick={() => void reload()} type="button">重试</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "operations" ? (
|
||||
<section aria-labelledby="operation-audit-tab" id="operation-audit-panel" role="tabpanel">
|
||||
{operations.loading && operations.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取后台操作审计</p> : null}
|
||||
{!operations.loading && !operations.failed && operations.items.length === 0 ? <p className="admin-audit-status">当前没有后台操作审计记录。</p> : null}
|
||||
{operations.items.length > 0 ? (
|
||||
<div className="admin-audit-table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>管理员</th><th>操作类型</th><th>对象安全摘要</th><th>结果</th><th>Operation ID</th></tr></thead>
|
||||
<tbody>{operations.items.map((item) => (
|
||||
<tr key={item.log_id}>
|
||||
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
||||
<td><code>{item.actor_ref}</code><small>{item.actor_type}</small></td>
|
||||
<td><code>{item.operation_type}</code></td>
|
||||
<td><code>{item.target_type}:{item.target_ref}</code><small>{operationSummary(item)}</small></td>
|
||||
<td><strong className={`is-${item.result}`}>{item.result}</strong></td>
|
||||
<td><code>{item.log_id}</code></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="private-audit-tab" id="private-audit-panel" role="tabpanel">
|
||||
{privateAccess.loading && privateAccess.items.length === 0 ? <p aria-live="polite" className="admin-audit-status">正在读取私有内容访问审计</p> : null}
|
||||
{!privateAccess.loading && !privateAccess.failed && privateAccess.items.length === 0 ? <p className="admin-audit-status">当前没有私有内容访问审计记录。</p> : null}
|
||||
{privateAccess.items.length > 0 ? (
|
||||
<div className="admin-audit-table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>管理员</th><th>安全目标标识</th><th>内容类型</th><th>到期时间</th><th>Access ID</th></tr></thead>
|
||||
<tbody>{privateAccess.items.map((item) => (
|
||||
<tr key={item.log_id}>
|
||||
<td><time dateTime={item.occurred_at}>{formatTime(item.occurred_at)}</time></td>
|
||||
<td><code>{item.actor_ref}</code></td>
|
||||
<td><code>{item.target_ref}</code></td>
|
||||
<td>{item.content_type}</td>
|
||||
<td><time dateTime={item.expires_at}>{formatTime(item.expires_at)}</time></td>
|
||||
<td><code>{item.log_id}</code></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{state.nextCursor ? (
|
||||
<div className="admin-audit-pagination">
|
||||
<button disabled={state.loading} onClick={() => void reload(state.nextCursor!, true)} type="button">{state.loading ? "正在读取" : "下一页"}</button>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="admin-audit-retention">记录保留 180 天。此页面不提供编辑、删除或清空能力。</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
|
||||
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminDiagnosticsResponse, AdminOperationAuditResponse, AdminOverviewResponse, AdminServicesResponse, AdminServicesStorageResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, PrivateContentAccessAuditResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -120,6 +120,13 @@ export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<
|
||||
return response.json() as Promise<AdminDiagnosticsResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminOperationAudit(options: ClientOptions = {}): Promise<AdminOperationAuditResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/operations`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<AdminOperationAuditResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
||||
@@ -208,6 +215,13 @@ export async function getMyCredits(options: ClientOptions = {}): Promise<CreditB
|
||||
return response.json() as Promise<CreditBalanceResponse>;
|
||||
}
|
||||
|
||||
export async function getPrivateContentAccessAudit(options: ClientOptions = {}): Promise<PrivateContentAccessAuditResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/audit/private-content`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<PrivateContentAccessAuditResponse>;
|
||||
}
|
||||
|
||||
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
|
||||
|
||||
@@ -50,6 +50,11 @@ export type AccountSettingsResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminAuditQuery = {
|
||||
"cursor"?: string;
|
||||
"limit"?: number;
|
||||
};
|
||||
|
||||
export type AdminAuthenticatedUser = {
|
||||
"role": "super_admin";
|
||||
"status": "active";
|
||||
@@ -112,6 +117,26 @@ export type AdminLoginSendRequest = {
|
||||
"email": string;
|
||||
};
|
||||
|
||||
export type AdminOperationAuditItem = {
|
||||
"actor_ref": string;
|
||||
"actor_type": "system" | "super_admin";
|
||||
"after_summary": string | null;
|
||||
"before_summary": string | null;
|
||||
"expires_at": string;
|
||||
"log_id": string;
|
||||
"occurred_at": string;
|
||||
"operation_type": string;
|
||||
"result": "succeeded" | "failed";
|
||||
"target_ref": string;
|
||||
"target_type": string;
|
||||
};
|
||||
|
||||
export type AdminOperationAuditResponse = {
|
||||
"generated_at": string;
|
||||
"items": Array<AdminOperationAuditItem>;
|
||||
"next_cursor": string | null;
|
||||
};
|
||||
|
||||
export type AdminOverviewResponse = {
|
||||
"asset_cleanup": {
|
||||
"pending_jobs": number;
|
||||
@@ -681,6 +706,21 @@ export type ModelRuntimeSseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type PrivateContentAccessAuditItem = {
|
||||
"actor_ref": string;
|
||||
"content_type": "image" | "prompt";
|
||||
"expires_at": string;
|
||||
"log_id": string;
|
||||
"occurred_at": string;
|
||||
"target_ref": string;
|
||||
};
|
||||
|
||||
export type PrivateContentAccessAuditResponse = {
|
||||
"generated_at": string;
|
||||
"items": Array<PrivateContentAccessAuditItem>;
|
||||
"next_cursor": string | null;
|
||||
};
|
||||
|
||||
export type PrivateContentGenerationParams = {
|
||||
"generationId": string;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AdminModelsPage } from "./admin-models.js";
|
||||
import { AdminAssetsPage } from "./admin-assets.js";
|
||||
import { AdminGenerationsPage } from "./admin-generations.js";
|
||||
import { AdminServicesStoragePage } from "./admin-services-storage.js";
|
||||
import { AdminAuditPage } from "./admin-audit.js";
|
||||
import { CreditsPage } from "./credits-page.js";
|
||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||
import { EditorPage } from "./editor-page.js";
|
||||
@@ -43,7 +44,7 @@ function renderAuthenticationEntry() {
|
||||
const adminPages: Record<string, { content: ReactNode; title: string }> = {
|
||||
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
|
||||
"/admin/assets": { content: <AdminAssetsPage />, title: "素材" },
|
||||
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" },
|
||||
"/admin/audit": { content: <AdminAuditPage />, title: "审计" },
|
||||
"/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
|
||||
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
||||
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
||||
|
||||
@@ -212,10 +212,19 @@ export class ProjectPurgeCleanup {
|
||||
transaction.immediate();
|
||||
completed += 1;
|
||||
} catch {
|
||||
this.database.prepare(`
|
||||
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
|
||||
WHERE cleanup_id = ?
|
||||
`).run(row.cleanup_id);
|
||||
const transaction = this.database.transaction(() => {
|
||||
this.database.prepare(`
|
||||
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
|
||||
WHERE cleanup_id = ?
|
||||
`).run(row.cleanup_id);
|
||||
if (row.managed_file_id && this.tableExists("asset_cleanup_request_items")) {
|
||||
const request = this.database.prepare(`
|
||||
SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ? LIMIT 1
|
||||
`).get(row.managed_file_id) as { request_id: string } | undefined;
|
||||
if (request) this.insertAssetCleanupFailureAudit(request.request_id, this.clock());
|
||||
}
|
||||
});
|
||||
transaction.immediate();
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -296,6 +305,19 @@ export class ProjectPurgeCleanup {
|
||||
);
|
||||
}
|
||||
|
||||
private insertAssetCleanupFailureAudit(requestId: string, occurredAt: number) {
|
||||
if (!this.tableExists("admin_operation_logs")) return;
|
||||
this.database.prepare(`
|
||||
INSERT INTO admin_operation_logs (
|
||||
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
|
||||
result, before_summary, after_summary, occurred_at, expires_at
|
||||
) VALUES (?, 'system', 'project_purge_worker', 'asset_cleanup_physical_failed', 'asset_cleanup', ?, 'failed', NULL, ?, ?, ?)
|
||||
`).run(
|
||||
randomUUID(), requestId, JSON.stringify({ failed_count: 1, status: "retry_pending" }), occurredAt,
|
||||
occurredAt + auditRetentionMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
private remeasureManagedCapacity() {
|
||||
const state = this.database.prepare(`
|
||||
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
|
||||
|
||||
Reference in New Issue
Block a user