feat(settings): 汇总全部数据源配置操作审计
build / build (push) Successful in 1m13s

This commit is contained in:
姚章浩
2026-10-27 10:42:30 +08:00
parent f0b12412a1
commit 7448d130ec
17 changed files with 453 additions and 56 deletions
+15
View File
@@ -1707,6 +1707,21 @@
}
}
},
"/api/v1/source-connections/audit": {
"get": {
"tags": ["Source connections"], "operationId": "listSourceConfigurationAudit",
"summary": "全部来源的配置操作审计(含已移除连接)",
"parameters": [{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":100}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}}],
"responses": {
"200":{"$ref":"#/components/responses/Envelope"},
"400":{"$ref":"#/components/responses/BadRequest"},
"401":{"$ref":"#/components/responses/Unauthorized"},
"403":{"$ref":"#/components/responses/Forbidden"},
"500":{"$ref":"#/components/responses/InternalError"},
"501":{"description":"审计读取器未配置"}
}
}
},
"/api/v1/source-connections": {
"get": {
"tags": [
+3
View File
@@ -99,6 +99,9 @@ func isAuthEndpoint(method, path string) bool {
// classify maps a request to the minimum role required.
func classifyRole(method, path string) string {
if path == "/api/v1/source-connections/audit" {
return roleAdmin
}
// The member roster exposes account identities and roles: admin-only for
// every method, checked before the read shortcut below.
if strings.HasPrefix(path, "/api/v1/users") || strings.HasPrefix(path, "/api/v1/qa-ops/") {
+2 -2
View File
@@ -141,7 +141,7 @@ func registerDingtalkSpace(w http.ResponseWriter, r *http.Request, config Config
return
}
connectionID, err := store.RegisterDingtalkSpace(r.Context(), postgresstore.RegisterDingtalkSpaceInput{
RootKey: resolved.Key, Name: resolved.Name, SpaceID: spaceID, RootID: resolved.Root, Now: config.Now(),
RootKey: resolved.Key, Name: resolved.Name, SpaceID: spaceID, RootID: resolved.Root, Now: config.Now(), Actor: actorFor(r),
})
switch {
case errors.Is(err, postgresstore.ErrDingtalkSpaceNameTaken):
@@ -286,7 +286,7 @@ func registerSpaceThroughDWS(w http.ResponseWriter, r *http.Request, config Conf
return true, false
}
connectionID, err := store.RegisterDingtalkSpace(ctx, postgresstore.RegisterDingtalkSpaceInput{
RootKey: rootKey, Name: name, SpaceID: spaceID, RootID: rootID, Now: config.Now(),
RootKey: rootKey, Name: name, SpaceID: spaceID, RootID: rootID, Now: config.Now(), Actor: actorFor(r),
})
switch {
case errors.Is(err, postgresstore.ErrDingtalkSpaceNameTaken):
+1
View File
@@ -419,6 +419,7 @@ func NewHandler(config Config) (http.Handler, error) {
mux.HandleFunc("DELETE /api/v1/filename-filters", func(w http.ResponseWriter, r *http.Request) {
mutateFilenameFilter(w, r, config, true)
})
mux.HandleFunc("GET /api/v1/source-connections/audit", func(w http.ResponseWriter, r *http.Request) { listSourceAudit(w, r, config) })
mux.HandleFunc("GET /api/v1/source-connections", func(w http.ResponseWriter, r *http.Request) {
listSourceConnections(w, r, config.Store)
})
+45
View File
@@ -0,0 +1,45 @@
package httpapi
import (
"context"
postgresstore "knowledge-governance/internal/platform/postgres"
"net/http"
"strconv"
)
type sourceAuditReader interface {
ListSourceAudit(context.Context, int, int) (postgresstore.SourceAuditPage, error)
}
func listSourceAudit(w http.ResponseWriter, r *http.Request, config Config) {
if _, ok := requireSessionIdentity(w, r); !ok {
return
}
limit, offset := 100, 0
for key, target := range map[string]*int{"limit": &limit, "offset": &offset} {
if value := r.URL.Query().Get(key); value != "" {
n, err := strconv.Atoi(value)
if err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid pagination"})
return
}
*target = n
}
}
if limit < 1 || limit > 100 || offset < 0 {
writeJSON(w, 400, map[string]string{"error": "invalid pagination"})
return
}
reader, ok := config.Store.(sourceAuditReader)
if !ok {
writeJSON(w, 501, map[string]string{"error": "audit not configured"})
return
}
page, err := reader.ListSourceAudit(r.Context(), limit, offset)
if err != nil {
writeJSON(w, 500, map[string]string{"error": "无法读取数据源操作记录"})
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, 200, map[string]any{"data": page})
}
+12
View File
@@ -0,0 +1,12 @@
package httpapi
import (
"net/http"
"testing"
)
func TestSourceAuditRequiresAdmin(t *testing.T) {
if got := classifyRole(http.MethodGet, "/api/v1/source-connections/audit"); got != roleAdmin {
t.Fatalf("audit exposes actors: required role %s", got)
}
}
+6 -2
View File
@@ -36,6 +36,7 @@ type RegisterDingtalkSpaceInput struct {
SpaceID string // alidocs space id from the URL
RootID string // root dentry uuid
Now time.Time
Actor string
}
func (in RegisterDingtalkSpaceInput) ConnectionID() string {
@@ -59,6 +60,9 @@ func (in RegisterDingtalkSpaceInput) validate() error {
}
func (s *Store) RegisterDingtalkSpace(ctx context.Context, in RegisterDingtalkSpaceInput) (string, error) {
if strings.TrimSpace(in.Actor) == "" {
in.Actor = "dingtalk-registration"
}
if err := in.validate(); err != nil {
return "", err
}
@@ -98,7 +102,7 @@ func (s *Store) RegisterDingtalkSpace(ctx context.Context, in RegisterDingtalkSp
}
if _, err := s.db.Exec(ctx, `
UPDATE source_connections SET name = $2, updated_at = $3, updated_by = $4 WHERE id = $1`,
nameHolderID, archivedConnectionName(in.Name, nameHolderID), in.Now.UTC(), "dingtalk-registration"); err != nil {
nameHolderID, archivedConnectionName(in.Name, nameHolderID), in.Now.UTC(), in.Actor); err != nil {
return "", fmt.Errorf("free archived dingtalk name: %w", err)
}
}
@@ -117,7 +121,7 @@ SET name = EXCLUDED.name, base_url = EXCLUDED.base_url,
-- 目录快照与历史仍属于同一行。
archived_at = NULL, archived_by = '',
updated_at = EXCLUDED.updated_at, updated_by = EXCLUDED.updated_by`,
connectionID, in.Name, locator, in.Now.UTC(), "dingtalk-registration")
connectionID, in.Name, locator, in.Now.UTC(), in.Actor)
if err != nil {
if isUniqueViolation(err) {
return "", fmt.Errorf("dingtalk space registration: name %q already used by another connection", in.Name)
@@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS source_configuration_audit ON source_connections;
DROP FUNCTION IF EXISTS audit_source_configuration();
DROP TABLE IF EXISTS source_configuration_events;
@@ -0,0 +1,50 @@
-- Only safe configuration metadata is audited; no URLs or credential references.
CREATE TABLE source_configuration_events (
id BIGSERIAL PRIMARY KEY,
connection_id VARCHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
action VARCHAR(32) NOT NULL,
actor VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
detail TEXT NOT NULL
);
CREATE INDEX source_configuration_events_time ON source_configuration_events(created_at DESC,id DESC);
-- Evidence-backed history, explicitly labelled rather than invented user actions.
INSERT INTO source_configuration_events(connection_id,source_name,action,actor,created_at,detail)
SELECT connection_id,name,'historical_settings',created_by,activated_at,
'历史生效设置:' || CASE WHEN enabled THEN '自动刷新已启用' ELSE '自动刷新已暂停' END ||
';刷新周期(秒):' || COALESCE(schedule->>'refresh_interval_seconds','0') || '(0 为仅手动);来自已有修订记录'
FROM source_connection_revisions WHERE activated_at IS NOT NULL ORDER BY activated_at,id;
INSERT INTO source_configuration_events(connection_id,source_name,action,actor,created_at,detail)
SELECT id,name,'archived',archived_by,archived_at,'历史移除连接记录;源端文件与已入库知识保留'
FROM source_connections WHERE archived_at IS NOT NULL ORDER BY archived_at,id;
CREATE FUNCTION audit_source_configuration() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE event_action TEXT; event_detail TEXT; event_actor TEXT;
BEGIN
event_actor := COALESCE(NULLIF(NEW.updated_by,''),'system');
IF TG_OP='INSERT' THEN
event_action:='created'; event_detail:='添加数据源连接';
ELSIF OLD.archived_at IS NULL AND NEW.archived_at IS NOT NULL THEN
event_action:='archived'; event_detail:='移除连接;源端文件与已入库知识保留';
event_actor:=COALESCE(NULLIF(NEW.archived_by,''),event_actor);
ELSIF OLD.archived_at IS NOT NULL AND NEW.archived_at IS NULL THEN
event_action:='restored'; event_detail:='恢复数据源连接';
ELSIF ROW(OLD.name,OLD.enabled,OLD.schedule,OLD.base_url,OLD.secret_ref) IS NOT DISTINCT FROM
ROW(NEW.name,NEW.enabled,NEW.schedule,NEW.base_url,NEW.secret_ref) THEN
RETURN NEW;
ELSE
event_action:=CASE WHEN OLD.enabled AND NOT NEW.enabled THEN 'paused' WHEN NOT OLD.enabled AND NEW.enabled THEN 'resumed' ELSE 'settings_updated' END;
event_detail:='';
IF OLD.name IS DISTINCT FROM NEW.name THEN event_detail:='名称:' || OLD.name || ' → ' || NEW.name || ';'; END IF;
IF OLD.enabled IS DISTINCT FROM NEW.enabled THEN event_detail:=event_detail || CASE WHEN NEW.enabled THEN '恢复自动刷新;' ELSE '暂停自动刷新;' END; END IF;
IF OLD.schedule IS DISTINCT FROM NEW.schedule THEN event_detail:=event_detail || '刷新周期(秒):' || COALESCE(OLD.schedule->>'refresh_interval_seconds','0') || ' → ' || COALESCE(NEW.schedule->>'refresh_interval_seconds','0') || '(0 为仅手动);'; END IF;
IF OLD.base_url IS DISTINCT FROM NEW.base_url THEN event_detail:=event_detail || '修改连接地址;'; END IF;
IF OLD.secret_ref IS DISTINCT FROM NEW.secret_ref THEN event_detail:=event_detail || '修改凭据引用;'; END IF;
END IF;
INSERT INTO source_configuration_events(connection_id,source_name,action,actor,created_at,detail)
VALUES(NEW.id,NEW.name,event_action,event_actor,clock_timestamp(),event_detail);
RETURN NEW;
END $$;
CREATE TRIGGER source_configuration_audit AFTER INSERT OR UPDATE ON source_connections FOR EACH ROW EXECUTE FUNCTION audit_source_configuration();
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
if len(migrations) != 64 {
t.Fatalf("migration count = %d, want 64", len(migrations))
if len(migrations) != 65 {
t.Fatalf("migration count = %d, want 65", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
@@ -0,0 +1,38 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"time"
)
type SourceAuditItem struct {
ID string `json:"id"`
ConnectionID string `json:"connection_id"`
SourceName string `json:"source_name"`
Action string `json:"action"`
Actor string `json:"actor"`
CreatedAt time.Time `json:"created_at"`
Detail string `json:"detail"`
}
type SourceAuditPage struct {
Items []SourceAuditItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func (s *Store) ListSourceAudit(ctx context.Context, limit, offset int) (SourceAuditPage, error) {
if limit < 1 || limit > 100 || offset < 0 {
return SourceAuditPage{}, errors.New("invalid audit pagination")
}
var raw []byte
err := s.db.QueryRow(ctx, `SELECT jsonb_build_object('total',(SELECT count(*) FROM source_configuration_events),'limit',$1::int,'offset',$2::int,'items',COALESCE((SELECT jsonb_agg(to_jsonb(e) ORDER BY e.created_at DESC,e.sort_id DESC) FROM (SELECT id::text AS id,id AS sort_id,connection_id,source_name,action,actor,created_at,detail FROM source_configuration_events ORDER BY created_at DESC,id DESC LIMIT $1 OFFSET $2) e),'[]'::jsonb))`, limit, offset).Scan(&raw)
if err != nil {
return SourceAuditPage{}, err
}
var page SourceAuditPage
err = json.Unmarshal(raw, &page)
return page, err
}
@@ -0,0 +1,85 @@
package postgres
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"os"
"testing"
)
func TestSourceAuditPersistsOperations(t *testing.T) {
url := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
var exists bool
if err := pool.QueryRow(ctx, `SELECT to_regclass('source_configuration_events') IS NOT NULL`).Scan(&exists); err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("configuration operations have no persistent audit log")
}
_, err = pool.Exec(ctx, `INSERT INTO source_connections(id,source_type,name,enabled,updated_by) VALUES ('audit-test','dingtalk','测试知识库',true,'tester');
UPDATE source_connections SET enabled=false,updated_by='tester',updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET enabled=true,updated_by='tester',updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET schedule='{"refresh_interval_seconds":3600}',updated_by='tester',updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET archived_at=now(),archived_by='tester',enabled=false,updated_by='tester',updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET archived_at=archived_at,updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET archived_at=NULL,archived_by='',updated_by='tester',updated_at=now() WHERE id='audit-test';
UPDATE source_connections SET last_scheduled_at=now(),next_refresh_at=now(),updated_at=now() WHERE id='audit-test';`)
if err != nil {
t.Fatal(err)
}
var actions []string
if err := pool.QueryRow(ctx, `SELECT array_agg(action ORDER BY id) FROM source_configuration_events WHERE connection_id='audit-test'`).Scan(&actions); err != nil {
t.Fatal(err)
}
store := NewStore(pool)
page, err := store.ListSourceAudit(ctx, 2, 0)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Total < 6 || page.Items[0].Action != "restored" {
t.Fatalf("bad audit page %+v", page)
}
next, err := store.ListSourceAudit(ctx, 2, 2)
if err != nil || len(next.Items) != 2 || next.Items[0].ID == page.Items[0].ID {
t.Fatalf("bad second page %+v %v", next, err)
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, `UPDATE source_connections SET enabled=true WHERE id='audit-test'`); err != nil {
t.Fatal(err)
}
if err = tx.Rollback(ctx); err != nil {
t.Fatal(err)
}
after, err := store.ListSourceAudit(ctx, 100, 0)
if err != nil || after.Total != page.Total {
t.Fatalf("rolled back changes must not appear %+v %v", after, err)
}
want := []string{"created", "paused", "resumed", "settings_updated", "archived", "restored"}
if len(actions) != len(want) {
t.Fatalf("audit actions=%v want=%v", actions, want)
}
for i := range want {
if actions[i] != want[i] {
t.Fatalf("actions=%v", actions)
}
}
var wrong int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM source_configuration_events WHERE connection_id='audit-test' AND actor<>'tester'`).Scan(&wrong); err != nil || wrong != 0 {
t.Fatalf("incorrect actors %d %v", wrong, err)
}
}
+29
View File
@@ -61,6 +61,35 @@
padding: 8px 12px;
min-width: 180px;
}
.source-audit-scroll {
overflow-x: auto;
}
.source-audit-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
line-height: 1.6;
text-align: left;
}
.source-audit-table th,
.source-audit-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-base);
vertical-align: top;
}
.source-audit-table th {
color: var(--ink-mute);
font-weight: 500;
white-space: nowrap;
}
.source-audit-table td:first-child {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.source-audit-table td:last-child {
min-width: 220px;
overflow-wrap: anywhere;
}
.source-schedule-form > small {
color: var(--ink-mute);
font-size: 12px;
+3
View File
@@ -27,5 +27,8 @@ describe('source management separation', () => {
const html = renderToStaticMarkup(<SourceSettingsAudit connections={[row]} />)
expect(html).toContain('数据源设置审计')
expect(html).not.toContain('保存')
expect(html).not.toContain('<select')
expect(html).toContain('全部数据源')
expect(html).toContain('操作记录')
})
})
+71 -50
View File
@@ -2,7 +2,9 @@ import './SourceManagement.css'
import { useEffect, useState } from 'react'
import { archiveSourceConnection, fetchSourceConnectionList, fetchSourceSettingsRevisions } from './sourceConnections'
import { useOptionalAuth } from './auth'
import type { SourceConnectionListItem, SourceSettingsRevision } from './sourceConnections'
import type { SourceConnectionListItem } from './sourceConnections'
import { fetchSourceAudit, sourceAuditActions } from './sourceSettingsAudit'
import type { SourceAuditItem } from './sourceSettingsAudit'
import { configuredInterval, saveSourceRefresh } from './sourceRefreshPolicy'
const types: Record<string, string> = {
@@ -341,19 +343,28 @@ export function SourceRefreshPolicy({
}
export function SourceSettingsAudit({ connections }: { connections: SourceConnectionListItem[] }) {
const [id, setID] = useState(connections[0]?.id ?? '')
const [rows, setRows] = useState<SourceSettingsRevision[]>([])
const [rows, setRows] = useState<SourceAuditItem[]>([])
const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0)
const [retry, setRetry] = useState(0)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
setOffset(0)
setRows([])
setTotal(0)
}, [connections])
useEffect(() => {
const controller = new AbortController()
setRows([])
setError('')
if (!id) return
setLoading(true)
void fetchSourceSettingsRevisions(id, controller.signal)
.then((items) => {
if (!controller.signal.aborted) setRows(items)
setError('')
void fetchSourceAudit(offset, controller.signal)
.then((page) => {
if (controller.signal.aborted) return
setRows((previous) =>
offset === 0 ? page.items : [...new Map([...previous, ...page.items].map((row) => [row.id, row])).values()]
)
setTotal(page.total)
})
.catch((cause) => {
if (!controller.signal.aborted) setError(errorText(cause))
@@ -362,51 +373,61 @@ export function SourceSettingsAudit({ connections }: { connections: SourceConnec
if (!controller.signal.aborted) setLoading(false)
})
return () => controller.abort()
}, [id])
}, [offset, retry, connections])
return (
<section className="settings-panel wide">
<section className="settings-panel wide source-audit-panel" aria-label="数据源操作记录">
<h2>数据源设置审计</h2>
<label>
数据源
<select value={id} onChange={(event) => setID(event.target.value)}>
{connections.map((row) => (
<option key={row.id} value={row.id}>
{row.name}
</option>
))}
</select>
</label>
<p>全部数据源的配置操作记录,按时间倒序展示,包含已移除的连接。</p>
{rows.length ? (
<div className="source-audit-scroll">
<table className="source-audit-table">
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>数据源</th>
<th>操作</th>
<th>变更内容</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<time dateTime={row.created_at}>
{new Date(row.created_at).toLocaleString('zh-CN', { hour12: false })}
</time>
</td>
<td>{row.actor || '未记录'}</td>
<td>{row.source_name}</td>
<td>{sourceAuditActions[row.action] ?? '配置操作'}</td>
<td>{row.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{loading ? <p role="status">加载操作记录中…</p> : null}
{error ? (
<p role="alert" className="error-message">
{error}
{error}{' '}
<button className="secondary" onClick={() => setRetry((value) => value + 1)}>
重试
</button>
</p>
) : loading ? (
<p>加载中…</p>
) : !rows.length ? (
<p>暂无设置变更记录。</p>
) : (
<ul className="revision-list">
{rows.map((row) => (
<li key={row.id}>
<b>修订 {row.revision_number}</b>
<span>
{row.state === 'active'
? '当前生效'
: row.state === 'retired'
? '历史版本'
: row.state === 'validated'
? '待启用'
: '草稿或失败'}
</span>
<small>
{row.created_by} · {new Date(row.created_at).toLocaleString('zh-CN')} ·{' '}
{row.enabled ? '允许自动刷新' : '已暂停'} ·{' '}
{row.refresh_interval_seconds ? `周期 ${row.refresh_interval_seconds} 秒` : '仅手动'}
</small>
</li>
))}
</ul>
)}
) : null}
{!loading && !error && !rows.length ? <p>暂无数据源配置操作记录。</p> : null}
{rows.length ? (
<p>
已展示 {rows.length} / {total} 条记录
</p>
) : null}
{!loading && !error && rows.length < total ? (
<button className="secondary" onClick={() => setOffset(offset + 100)}>
加载更多
</button>
) : null}
</section>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchSourceAudit } from './sourceSettingsAudit'
afterEach(() => vi.unstubAllGlobals())
describe('all-source operation audit', () => {
it('requests paginated global history without a source selector', async () => {
const page = {
items: [
{
id: '1',
connection_id: 'removed',
source_name: '已移除来源',
action: 'archived',
actor: 'tester',
created_at: '2026-10-27T02:00:00Z',
detail: '移除连接',
},
],
total: 101,
limit: 100,
offset: 100,
}
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: page }) })
vi.stubGlobal('fetch', fetcher)
expect(await fetchSourceAudit(100)).toEqual(page)
expect(fetcher.mock.calls[0][0]).toBe('/api/v1/source-connections/audit?limit=100&offset=100')
})
it('does not disguise errors as empty history', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(fetchSourceAudit()).rejects.toThrow('无法读取')
})
it('rejects malformed history', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ data: { items: [], total: '0', offset: 0, limit: 100 } }) })
)
await expect(fetchSourceAudit()).rejects.toThrow('格式无效')
})
})
+48
View File
@@ -0,0 +1,48 @@
export type SourceAuditItem = {
id: string
connection_id: string
source_name: string
action: string
actor: string
created_at: string
detail: string
}
export type SourceAuditPage = { items: SourceAuditItem[]; total: number; limit: number; offset: number }
export async function fetchSourceAudit(offset = 0, signal?: AbortSignal): Promise<SourceAuditPage> {
const response = await fetch(`/api/v1/source-connections/audit?limit=100&offset=${offset}`, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) throw new Error('无法读取数据源操作记录,请重试')
const { data } = await response.json()
if (
!data ||
!Array.isArray(data.items) ||
!Number.isInteger(data.total) ||
data.total < 0 ||
data.offset !== offset ||
!Number.isInteger(data.limit) ||
data.limit < 1
)
throw new Error('数据源操作记录格式无效')
for (const item of data.items) {
if (
!item ||
['id', 'connection_id', 'source_name', 'action', 'actor', 'created_at', 'detail'].some(
(key) => typeof item[key] !== 'string'
) ||
!Number.isFinite(Date.parse(item.created_at))
)
throw new Error('数据源操作记录格式无效')
}
return data
}
export const sourceAuditActions: Record<string, string> = {
created: '添加连接',
settings_updated: '修改配置',
paused: '暂停自动刷新',
resumed: '恢复自动刷新',
archived: '移除连接',
restored: '恢复连接',
historical_settings: '历史设置记录',
}