dcdc8d1ddd
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环): - 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口) - 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入 parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截 - 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过): * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id) * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配 * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数 * 回滚 job payload 支持 prior_execution_id(strict 解码) - 三通道发布+回滚演练实测:publish 4/4 verified,检索命中, rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false - 前端 A·Data-console 风格全站统一,120 用例通过 - 门禁:go vet / go test 全包通过,gofmt 干净
229 lines
9.9 KiB
Go
229 lines
9.9 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"knowledge-governance/internal/governance"
|
|
)
|
|
|
|
func seedP3Fixture(t *testing.T, pool *pgxpool.Pool) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
_, seedErr := pool.Exec(ctx, `
|
|
INSERT INTO source_connections(id,source_type,name,enabled) VALUES('conn-p3','alist','P3 来源',true);
|
|
INSERT INTO content_objects(id,sha256,size_bytes,local_spool_ref,content_state) VALUES
|
|
('co-p3-a',repeat('a',64),10,'verified/a.pdf','verified'),
|
|
('co-p3-b',repeat('b',64),12,'verified/b.pdf','verified'),
|
|
('co-p3-c',repeat('c',64),20,'verified/c.pdf','verified');
|
|
INSERT INTO source_items(id,source_connection_id,stable_external_id,canonical_uri_hash,title,item_type,revision,visibility,visibility_origin)
|
|
VALUES('item-a','conn-p3','ext-a',repeat('d',64),'新增.pdf','document','r1','internal','manual'),
|
|
('item-b','conn-p3','ext-b',repeat('e',64),'更新.pdf','document','r2','internal','manual'),
|
|
('item-c','conn-p3','ext-c',repeat('f',64),'删除.pdf','document','r3','internal','manual'),
|
|
('item-d','conn-p3','ext-d',repeat('9',64),'未变.pdf','document','r1','internal','manual'),
|
|
('item-e','conn-p3','ext-e',repeat('8',64),'内部专属.pdf','document','r1','internal','manual');
|
|
INSERT INTO source_item_versions(id,source_item_id,revision,content_object_id,content_state) VALUES
|
|
('version-a','item-a','r1','co-p3-a','verified'),
|
|
('version-b','item-b','r2','co-p3-b','verified'),
|
|
('version-c','item-c','r3','co-p3-c','verified'),
|
|
('version-d','item-d','r1','co-p3-a','verified'),
|
|
('version-e','item-e','r1','co-p3-a','verified');
|
|
INSERT INTO source_catalog_snapshots(id,source_connection_id,snapshot_sha256,source_label,node_count,folder_count,file_count,observed_at)
|
|
VALUES('p3-old','conn-p3',repeat('1',64),'P3 来源',1,1,0,'2026-09-11T00:00:00Z'),
|
|
('p3-new','conn-p3',repeat('2',64),'P3 来源',6,1,5,'2026-09-12T00:00:00Z');
|
|
INSERT INTO source_catalog_nodes(snapshot_id,node_id,parent_node_id,node_type,name,relative_path,source_locator_hash,source_item_id,size_bytes,content_sha256)
|
|
VALUES
|
|
('p3-new','root',NULL,'folder','P3 来源','',repeat('3',64),NULL,NULL,NULL),
|
|
('p3-new','n-a','root','file','新增.pdf','新增.pdf',repeat('a',64),'item-a',10,repeat('a',64)),
|
|
('p3-new','n-b','root','file','更新.pdf','更新.pdf',repeat('b',64),'item-b',12,repeat('b',64)),
|
|
('p3-new','n-d','root','file','未变.pdf','未变.pdf',repeat('c',64),'item-d',10,repeat('a',64)),
|
|
('p3-new','n-e','root','file','内部专属.pdf','内部专属.pdf',repeat('e',64),'item-e',10,repeat('a',64));
|
|
INSERT INTO test_publications(id,source_item_version_id,test_kb_id,target_scope,ingest_state,processing_state,retrieval_state,knowledge_id,created_at,updated_at)
|
|
VALUES('tp-p3-c','version-c','kb-internal','internal','uploaded','completed','enabled','kn-c',NOW(),NOW());
|
|
INSERT INTO source_catalog_change_sets(id,source_connection_id,from_snapshot_id,to_snapshot_id,created_at)
|
|
VALUES('cs-p3','conn-p3','p3-old','p3-new',NOW());
|
|
INSERT INTO source_catalog_change_items(change_set_id,source_locator_hash,action,title,old_path,new_path,new_scope,new_source_item_id,old_source_item_id,created_at)
|
|
VALUES
|
|
('cs-p3',repeat('a',64),'add','新增.pdf','','新增.pdf','internal','item-a',NULL,NOW()),
|
|
('cs-p3',repeat('b',64),'update','更新.pdf','更新.pdf','更新.pdf','internal','item-b',NULL,NOW()),
|
|
('cs-p3',repeat('c',64),'delete','删除.pdf','删除.pdf','','',NULL,'item-c',NOW()),
|
|
('cs-p3',repeat('d',64),'no_change','未变.pdf','未变.pdf','未变.pdf','internal','item-d',NULL,NOW()),
|
|
('cs-p3',repeat('e',64),'add','内部专属.pdf','','内部专属.pdf','blocked','item-e',NULL,NOW());`)
|
|
if seedErr != nil {
|
|
t.Fatal(seedErr)
|
|
}
|
|
return "cs-p3"
|
|
}
|
|
|
|
func p3Seqs(t *testing.T, pool *pgxpool.Pool, changeSetID string) []int64 {
|
|
t.Helper()
|
|
rows, err := pool.Query(context.Background(), `SELECT item_seq FROM source_catalog_change_items WHERE change_set_id=$1 ORDER BY item_seq`, changeSetID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var seqs []int64
|
|
for rows.Next() {
|
|
var seq int64
|
|
if err := rows.Scan(&seq); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seqs = append(seqs, seq)
|
|
}
|
|
if len(seqs) != 5 {
|
|
t.Fatalf("expected 5 change items, got %d", len(seqs))
|
|
}
|
|
return seqs
|
|
}
|
|
|
|
func TestP3ConfirmExecutesSelectedChangeItems(t *testing.T) {
|
|
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := seedP3Fixture(t, pool)
|
|
store := NewStore(pool)
|
|
now := time.Date(2026, 9, 12, 3, 0, 0, 0, time.UTC)
|
|
|
|
// Jobs are zero before confirmation.
|
|
var jobsBefore int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs`).Scan(&jobsBefore); err != nil || jobsBefore != 0 {
|
|
t.Fatalf("jobs before=%d err=%v", jobsBefore, err)
|
|
}
|
|
|
|
// Partial selection: add, update, delete, no_change, blocked-delete.
|
|
result, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
|
|
ChangeSetID: id, ItemSeqs: p3Seqs(t, pool, "cs-p3"),
|
|
OperatorRef: "engineer-1", Reason: "确认P3差异执行", Now: now,
|
|
InternalTargetKBID: "kb-internal", ExternalTargetKBID: "kb-external",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ConfirmedItems != 4 || result.UploadsCreated != 2 || result.RemovalsRequested != 1 || result.NoChangeIgnored != 1 || result.SkippedNoArtifact != 0 || result.SkippedNoTarget != 0 {
|
|
t.Fatalf("result=%#v", result)
|
|
}
|
|
// blocked add became a removal request too (desired state blocked → disable path)
|
|
var uploads, removals int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.remove'`).Scan(&removals); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uploads != 2 || removals != 1 {
|
|
t.Fatalf("uploads=%d removals=%d", uploads, removals)
|
|
}
|
|
// Upload payloads carry the ChangeSet and item identity (P5 will make it a constraint).
|
|
var payloadOK bool
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*)=2 FROM jobs WHERE type='test.upload' AND payload->>'change_set_id'='cs-p3'`).Scan(&payloadOK); err != nil || !payloadOK {
|
|
t.Fatalf("upload provenance missing ok=%v err=%v", payloadOK, err)
|
|
}
|
|
// Publications record their change provenance.
|
|
var provenance int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM test_publications WHERE change_set_id='cs-p3'`).Scan(&provenance); err != nil || provenance != 2 {
|
|
t.Fatalf("publication provenance=%d err=%v", provenance, err)
|
|
}
|
|
// Confirmations bind to the change set.
|
|
var confirmations int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM processing_confirmations WHERE change_set_id='cs-p3'`).Scan(&confirmations); err != nil || confirmations != 2 {
|
|
t.Fatalf("confirmations=%d err=%v", confirmations, err)
|
|
}
|
|
// Lifecycle moved to confirmed.
|
|
var state string
|
|
if err := pool.QueryRow(ctx, `SELECT state FROM source_catalog_change_sets WHERE id='cs-p3'`).Scan(&state); err != nil || state != "confirmed" {
|
|
t.Fatalf("state=%q err=%v", state, err)
|
|
}
|
|
|
|
// Re-confirming the same items replays stored counts; no new jobs or rows.
|
|
replaySeqs := p3Seqs(t, pool, "cs-p3")[:1]
|
|
replay, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
|
|
ChangeSetID: id, ItemSeqs: replaySeqs,
|
|
OperatorRef: "engineer-2", Reason: "重复确认", Now: now.Add(time.Minute),
|
|
InternalTargetKBID: "kb-internal",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if replay.UploadsCreated != 2 || replay.RemovalsRequested != 1 {
|
|
t.Fatalf("replay=%#v", replay)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil || uploads != 2 {
|
|
t.Fatalf("replay created uploads=%d err=%v", uploads, err)
|
|
}
|
|
|
|
// Unknown locator belongs to nobody: rejected before any enqueue.
|
|
if _, err := store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
|
|
ChangeSetID: id, ItemSeqs: []int64{999999},
|
|
OperatorRef: "engineer-2", Reason: "越界确认", Now: now, InternalTargetKBID: "kb-internal",
|
|
}); err == nil {
|
|
t.Fatal("unknown item selection accepted")
|
|
}
|
|
}
|
|
|
|
func TestP3ConcurrentConfirmationsEnqueueOnce(t *testing.T) {
|
|
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
if err := ApplyMigrations(ctx, pool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := seedP3Fixture(t, pool)
|
|
store := NewStore(pool)
|
|
now := time.Date(2026, 9, 12, 4, 0, 0, 0, time.UTC)
|
|
|
|
const workers = 8
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, workers)
|
|
for index := 0; index < workers; index++ {
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
_, errs[index] = store.ConfirmCatalogChangeSet(ctx, governance.ConfirmCatalogChangeSetRequest{
|
|
ChangeSetID: id,
|
|
ItemSeqs: p3Seqs(t, pool, "cs-p3"),
|
|
OperatorRef: "engineer-1", Reason: "并发确认", Now: now.Add(time.Duration(index) * time.Second),
|
|
InternalTargetKBID: "kb-internal", ExternalTargetKBID: "kb-external",
|
|
})
|
|
}(index)
|
|
}
|
|
wg.Wait()
|
|
for index, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("worker %d err=%v", index, err)
|
|
}
|
|
}
|
|
// Exactly one enqueue per publication regardless of concurrent confirmations
|
|
// (FOR UPDATE on the set serializes; later workers replay from stored counts).
|
|
var uploads, removals int
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.upload'`).Scan(&uploads); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM jobs WHERE type='test.remove'`).Scan(&removals); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uploads != 2 || removals != 1 {
|
|
t.Fatalf("concurrent enqueue: uploads=%d removals=%d", uploads, removals)
|
|
}
|
|
}
|