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 干净
197 lines
6.7 KiB
Go
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]
|
|
}
|