Files
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

149 lines
4.8 KiB
Go

package postgres
import (
"context"
"fmt"
"strings"
"unicode"
"knowledge-governance/internal/governance"
)
func (s *Store) ListSourceAssets(ctx context.Context, filter governance.SourceAssetFilter) (governance.SourceAssetPage, error) {
var page governance.SourceAssetPage
if err := s.db.QueryRow(ctx, `
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE v.raw_record_summary->>'legacy_status' = 'completed'),
COUNT(*) FILTER (WHERE v.raw_record_summary->>'legacy_status' = 'content_alias'),
COUNT(*) FILTER (WHERE v.raw_record_summary->>'legacy_status' = 'over_limit')
FROM source_items i
JOIN source_item_versions v ON v.source_item_id = i.id AND v.revision = i.revision`).Scan(
&page.Summary.Total,
&page.Summary.Completed,
&page.Summary.ContentAlias,
&page.Summary.OverLimit,
); err != nil {
return governance.SourceAssetPage{}, fmt.Errorf("summarize source assets: %w", err)
}
rows, err := s.db.Query(ctx, `
SELECT
i.id,
i.title,
COALESCE(i.normalized_path, ''),
i.source_connection_id,
CASE
WHEN item_scope.source_item_id IS NOT NULL THEN 'file_override'
WHEN directory_scope.source_connection_id IS NOT NULL THEN 'directory_rule'
ELSE i.visibility_origin
END,
COALESCE(directory_scope.directory_path, ''),
COALESCE(p.ingest_state, ''),
COALESCE(NULLIF(p.last_error, ''), v.download_error, ''),
COALESCE(p.id, ''),
COALESCE(((p.processing_state IN ('failed','stalled') OR (p.ingest_state = 'failed' AND p.processing_state = 'pending'))
AND co.content_state = 'verified' AND co.local_spool_ref <> ''), false),
c.source_type,
c.name,
COALESCE(i.size_bytes, 0),
COALESCE(item_scope.visibility, directory_scope.visibility, i.visibility),
i.source_change,
v.content_state,
COALESCE(v.raw_record_summary->>'legacy_status', ''),
p.id IS NOT NULL,
COALESCE(p.processing_state, ''),
COALESCE(p.retrieval_state, ''),
GREATEST(i.updated_at, v.updated_at, COALESCE(p.updated_at, i.updated_at))
FROM source_items i
JOIN source_connections c ON c.id = i.source_connection_id
JOIN source_item_versions v ON v.source_item_id = i.id AND v.revision = i.revision
LEFT JOIN LATERAL (
SELECT id, ingest_state, processing_state, retrieval_state, last_error, updated_at
FROM test_publications
WHERE source_item_version_id = v.id
ORDER BY updated_at DESC, id
LIMIT 1
) p ON TRUE
LEFT JOIN content_objects co ON co.id = v.content_object_id
LEFT JOIN source_item_scope_overrides item_scope ON item_scope.source_item_id = i.id
LEFT JOIN LATERAL (
SELECT rule.source_connection_id, rule.directory_path, rule.visibility
FROM directory_scope_rules rule
WHERE rule.source_connection_id = i.source_connection_id
AND (
rule.directory_path = ''
OR i.normalized_path = rule.directory_path
OR LEFT(i.normalized_path, LENGTH(rule.directory_path) + 1) = rule.directory_path || '/'
)
ORDER BY LENGTH(rule.directory_path) DESC
LIMIT 1
) directory_scope ON TRUE
WHERE ($1 = '' OR v.raw_record_summary->>'legacy_status' = $1)
ORDER BY i.updated_at DESC, i.id
LIMIT $2 OFFSET $3`, filter.State, filter.Limit, filter.Offset)
if err != nil {
return governance.SourceAssetPage{}, fmt.Errorf("list source assets: %w", err)
}
defer rows.Close()
page.Items = make([]governance.SourceAsset, 0)
for rows.Next() {
var item governance.SourceAsset
var sourceType, visibility, sourceChange, contentState string
if err := rows.Scan(
&item.ID,
&item.Title,
&item.DirectoryPath,
&item.ConnectionID,
&item.VisibilityOrigin,
&item.ScopeRulePath,
&item.IngestState,
&item.LastError,
&item.PublicationID,
&item.CanRetry,
&sourceType,
&item.ConnectionName,
&item.SizeBytes,
&visibility,
&sourceChange,
&contentState,
&item.LegacyStatus,
&item.HasPublication,
&item.ProcessingState,
&item.RetrievalState,
&item.UpdatedAt,
); err != nil {
return governance.SourceAssetPage{}, fmt.Errorf("scan source asset: %w", err)
}
item.SourceType = governance.SourceType(sourceType)
item.DirectoryPath = safeDocumentDirectory(item.DirectoryPath)
item.Visibility = governance.Visibility(visibility)
item.SourceChange = governance.SourceChange(sourceChange)
item.ContentState = governance.ContentState(contentState)
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return governance.SourceAssetPage{}, fmt.Errorf("iterate source assets: %w", err)
}
return page, nil
}
// Expose only relative source labels, never host paths or signed URLs.
func safeDocumentDirectory(value string) string {
if strings.HasPrefix(value, "/") || strings.ContainsAny(value, ":\\") || len(value) > 2048 {
return ""
}
for _, part := range strings.Split(value, "/") {
if part == ".." || part == "." {
return ""
}
}
for _, r := range value {
if unicode.IsControl(r) {
return ""
}
}
return value
}