Files
knowledge-governance/internal/platform/postgres/manual_publication.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(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 干净
2026-09-30 10:54:06 +08:00

197 lines
6.7 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
var ErrInvalidManualPublicationSeed = errors.New("invalid manual publication seed")
type ManualPublicationSeedRequest struct {
TestKBID string
Title string
SHA256 string
SizeBytes int64
MIME string
ValidatedFormat string
LocalSpoolRef string
Now time.Time
}
type ManualPublicationSeedResult struct {
PublicationID string
JobID string
}
const seedManualPublicationSQL = `
WITH ensured_connection AS (
INSERT INTO source_connections (
id, source_type, name, base_url, secret_ref, enabled,
schedule, last_health, created_at, updated_at
) VALUES ($3, 'manual', '本地手工验收', '', '', FALSE,
'{}'::jsonb, '{}'::jsonb, $2, $2)
ON CONFLICT (id) DO UPDATE
SET updated_at = source_connections.updated_at
RETURNING id
), ensured_content AS (
INSERT INTO content_objects (
id, sha256, size_bytes, mime, validated_format,
local_spool_ref, content_state,
first_discovered_at, last_discovered_at, created_at, updated_at
) VALUES ($10, $11, $8, $9, $12, $13, 'verified', $2, $2, $2, $2)
ON CONFLICT (sha256) DO UPDATE
SET last_discovered_at = EXCLUDED.last_discovered_at,
updated_at = EXCLUDED.updated_at,
local_spool_ref = CASE
WHEN content_objects.local_spool_ref = '' THEN EXCLUDED.local_spool_ref
ELSE content_objects.local_spool_ref
END
WHERE content_objects.size_bytes = EXCLUDED.size_bytes
RETURNING id
), ensured_item AS (
INSERT INTO source_items (
id, source_connection_id, stable_external_id, canonical_uri_hash,
normalized_path, title, item_type, size_bytes, mime, revision,
visibility, visibility_origin, source_change, created_at, updated_at,
identity_confidence
)
SELECT $4, id, $5, $6, '', $7, 'document', $8, $9, $15,
'internal', 'manual', 'added', $2, $2, 'content_hash'
FROM ensured_connection
ON CONFLICT (source_connection_id, stable_external_id) DO UPDATE
SET updated_at = source_items.updated_at,
identity_confidence = CASE
WHEN source_items.identity_confidence='stable_id' THEN source_items.identity_confidence
ELSE EXCLUDED.identity_confidence
END
RETURNING id
), ensured_version AS (
INSERT INTO source_item_versions (
id, source_item_id, revision, content_object_id, content_state,
download_error, raw_record_summary, created_at, updated_at
)
SELECT $14, ensured_item.id, $15, ensured_content.id, 'verified', '',
'{"origin":"manual_e2e"}'::jsonb, $2, $2
FROM ensured_item CROSS JOIN ensured_content
ON CONFLICT (source_item_id, revision) DO UPDATE
SET updated_at = source_item_versions.updated_at
RETURNING id
), ensured_publication AS (
INSERT INTO test_publications (
id, source_item_version_id, test_kb_id,
ingest_state, processing_state, retrieval_state,
created_at, updated_at
)
SELECT $16, id, $17, 'pending', 'pending', 'unpublished', $2, $2
FROM ensured_version
ON CONFLICT (source_item_version_id, test_kb_id) DO UPDATE
SET updated_at = test_publications.updated_at
RETURNING id
), ensured_job AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $18, 'test.upload', $1::jsonb, 'pending', 10, $2,
5, $19, $2, $2
FROM ensured_publication
ON CONFLICT (idempotency_key) DO UPDATE
SET idempotency_key = jobs.idempotency_key
RETURNING id
)
SELECT ensured_publication.id, ensured_job.id
FROM ensured_publication CROSS JOIN ensured_job
`
func (s *Store) SeedManualPublication(ctx context.Context, request ManualPublicationSeedRequest) (ManualPublicationSeedResult, error) {
if !validManualPublicationSeed(request) {
return ManualPublicationSeedResult{}, ErrInvalidManualPublicationSeed
}
identity := request.SHA256 + ":" + request.TestKBID
connectionID := "conn-manual-local"
sourceItemID := deterministicGovernanceID("si-", request.SHA256)
contentID := deterministicGovernanceID("co-", request.SHA256)
versionID := deterministicGovernanceID("sv-", request.SHA256)
publicationID := deterministicGovernanceID("tp-", identity)
jobID := deterministicGovernanceID("job-", "test.upload:"+publicationID)
stableExternalID := "manual-e2e:" + request.SHA256
canonicalDigest := sha256.Sum256([]byte(stableExternalID))
revision := "sha256:" + request.SHA256
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
}{PublicationID: publicationID})
if err != nil {
return ManualPublicationSeedResult{}, errors.New("encode manual publication upload job")
}
idempotencyKey := "test-upload:" + publicationID
result := ManualPublicationSeedResult{}
err = s.db.QueryRow(
ctx,
seedManualPublicationSQL,
payload,
request.Now,
connectionID,
sourceItemID,
stableExternalID,
hex.EncodeToString(canonicalDigest[:]),
request.Title,
request.SizeBytes,
request.MIME,
contentID,
request.SHA256,
request.ValidatedFormat,
request.LocalSpoolRef,
versionID,
revision,
publicationID,
request.TestKBID,
jobID,
idempotencyKey,
).Scan(&result.PublicationID, &result.JobID)
if errors.Is(err, pgx.ErrNoRows) {
return ManualPublicationSeedResult{}, ErrPublicationStateConflict
}
if err != nil {
return ManualPublicationSeedResult{}, fmt.Errorf("seed manual test publication: %w", err)
}
return result, nil
}
func validManualPublicationSeed(request ManualPublicationSeedRequest) bool {
if !governance.ValidSHA256(request.SHA256) || request.SizeBytes <= 0 || request.Now.IsZero() {
return false
}
if len(request.TestKBID) == 0 || len(request.TestKBID) > 255 || strings.ContainsAny(request.TestKBID, "/\\\x00\t\r\n") {
return false
}
if strings.TrimSpace(request.Title) == "" || len(request.Title) > 512 || strings.ContainsRune(request.Title, '\x00') {
return false
}
if request.MIME == "" || len(request.MIME) > 128 || strings.ContainsAny(request.MIME, "\x00\r\n") || !validErrorCode(request.ValidatedFormat) {
return false
}
clean := path.Clean(request.LocalSpoolRef)
return len(request.LocalSpoolRef) <= 512 && clean == request.LocalSpoolRef && clean != "." && !strings.HasPrefix(clean, "/") && !strings.HasPrefix(clean, "../")
}
func deterministicGovernanceID(prefix string, parts ...string) string {
digest := sha256.New()
for _, part := range parts {
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(part))
}
return prefix + hex.EncodeToString(digest.Sum(nil))[:32]
}