diff --git a/api/openapi.yaml b/api/openapi.yaml index e34bc59..ef4e0eb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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": [ diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index ef8279d..a9f4aec 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -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/") { diff --git a/internal/httpapi/dingtalk_session.go b/internal/httpapi/dingtalk_session.go index 877df4b..d86946d 100644 --- a/internal/httpapi/dingtalk_session.go +++ b/internal/httpapi/dingtalk_session.go @@ -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): diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a408736..550cdd5 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) }) diff --git a/internal/httpapi/source_audit.go b/internal/httpapi/source_audit.go new file mode 100644 index 0000000..3a60a18 --- /dev/null +++ b/internal/httpapi/source_audit.go @@ -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}) +} diff --git a/internal/httpapi/source_audit_test.go b/internal/httpapi/source_audit_test.go new file mode 100644 index 0000000..f78c7cc --- /dev/null +++ b/internal/httpapi/source_audit_test.go @@ -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) + } +} diff --git a/internal/platform/postgres/dingtalk_space.go b/internal/platform/postgres/dingtalk_space.go index 61fbbb8..a5debd8 100644 --- a/internal/platform/postgres/dingtalk_space.go +++ b/internal/platform/postgres/dingtalk_space.go @@ -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) diff --git a/internal/platform/postgres/migrations/000065_source_configuration_audit.down.sql b/internal/platform/postgres/migrations/000065_source_configuration_audit.down.sql new file mode 100644 index 0000000..f9f8564 --- /dev/null +++ b/internal/platform/postgres/migrations/000065_source_configuration_audit.down.sql @@ -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; diff --git a/internal/platform/postgres/migrations/000065_source_configuration_audit.up.sql b/internal/platform/postgres/migrations/000065_source_configuration_audit.up.sql new file mode 100644 index 0000000..d84ecf6 --- /dev/null +++ b/internal/platform/postgres/migrations/000065_source_configuration_audit.up.sql @@ -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(); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index 9c78216..5a2586c 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -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]) diff --git a/internal/platform/postgres/source_audit.go b/internal/platform/postgres/source_audit.go new file mode 100644 index 0000000..ea84dc6 --- /dev/null +++ b/internal/platform/postgres/source_audit.go @@ -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 +} diff --git a/internal/platform/postgres/source_audit_integration_test.go b/internal/platform/postgres/source_audit_integration_test.go new file mode 100644 index 0000000..83406e0 --- /dev/null +++ b/internal/platform/postgres/source_audit_integration_test.go @@ -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) + } +} diff --git a/web/src/SourceManagement.css b/web/src/SourceManagement.css index c62d777..6bde915 100644 --- a/web/src/SourceManagement.css +++ b/web/src/SourceManagement.css @@ -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; diff --git a/web/src/SourceManagement.test.tsx b/web/src/SourceManagement.test.tsx index 6ddea8a..6b53404 100644 --- a/web/src/SourceManagement.test.tsx +++ b/web/src/SourceManagement.test.tsx @@ -27,5 +27,8 @@ describe('source management separation', () => { const html = renderToStaticMarkup() expect(html).toContain('数据源设置审计') expect(html).not.toContain('保存') + expect(html).not.toContain(' = { @@ -341,19 +343,28 @@ export function SourceRefreshPolicy({ } export function SourceSettingsAudit({ connections }: { connections: SourceConnectionListItem[] }) { - const [id, setID] = useState(connections[0]?.id ?? '') - const [rows, setRows] = useState([]) + const [rows, setRows] = useState([]) + 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 ( -
+

数据源设置审计

- +

全部数据源的配置操作记录,按时间倒序展示,包含已移除的连接。

+ {rows.length ? ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
时间操作人数据源操作变更内容
+ + {row.actor || '未记录'}{row.source_name}{sourceAuditActions[row.action] ?? '配置操作'}{row.detail}
+
+ ) : null} + {loading ?

加载操作记录中…

: null} {error ? (

- {error} + {error}{' '} +

- ) : loading ? ( -

加载中…

- ) : !rows.length ? ( -

暂无设置变更记录。

- ) : ( -
    - {rows.map((row) => ( -
  • - 修订 {row.revision_number} - - {row.state === 'active' - ? '当前生效' - : row.state === 'retired' - ? '历史版本' - : row.state === 'validated' - ? '待启用' - : '草稿或失败'} - - - {row.created_by} · {new Date(row.created_at).toLocaleString('zh-CN')} ·{' '} - {row.enabled ? '允许自动刷新' : '已暂停'} ·{' '} - {row.refresh_interval_seconds ? `周期 ${row.refresh_interval_seconds} 秒` : '仅手动'} - -
  • - ))} -
- )} + ) : null} + {!loading && !error && !rows.length ?

暂无数据源配置操作记录。

: null} + {rows.length ? ( +

+ 已展示 {rows.length} / {total} 条记录 +

+ ) : null} + {!loading && !error && rows.length < total ? ( + + ) : null}
) } diff --git a/web/src/sourceSettingsAudit.test.ts b/web/src/sourceSettingsAudit.test.ts new file mode 100644 index 0000000..571f8aa --- /dev/null +++ b/web/src/sourceSettingsAudit.test.ts @@ -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('格式无效') + }) +}) diff --git a/web/src/sourceSettingsAudit.ts b/web/src/sourceSettingsAudit.ts new file mode 100644 index 0000000..73cf6cf --- /dev/null +++ b/web/src/sourceSettingsAudit.ts @@ -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 { + 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 = { + created: '添加连接', + settings_updated: '修改配置', + paused: '暂停自动刷新', + resumed: '恢复自动刷新', + archived: '移除连接', + restored: '恢复连接', + historical_settings: '历史设置记录', +}