@@ -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": [
|
||||
|
||||
@@ -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/") {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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('操作记录')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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('格式无效')
|
||||
})
|
||||
})
|
||||
@@ -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: '历史设置记录',
|
||||
}
|
||||
Reference in New Issue
Block a user