feat: implement sensitive operation audit retention (TASK-WP6-04)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s

This commit is contained in:
suyx
2026-08-04 11:51:09 +08:00
parent c589b8bb4e
commit 1c46311e05
21 changed files with 1670 additions and 11 deletions
+164
View File
@@ -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; }
}
+181
View File
@@ -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>
);
}
+15 -1
View File
@@ -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 ?? {} });
+40
View File
@@ -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;
};
+2 -1
View File
@@ -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: "模型" },