Files
姚章浩 2c7265efc4
build / build (push) Successful in 58s
feat(sources): 数据源配置收敛成 dws 导入 + PP 导入;「去掉来源」= 归档下架(不删数据)
主人要求:去掉入口,统一成 dws 导入与 PP 导入;其他来源连数据一起下架,之后用 dws 重新获取。

界面(web):
- 「数据源配置」只剩两块:**dws 导入(钉钉)**(按当前钉钉账号列出可访问的知识库与钉盘空间)
  与 **PP 导入**(PP 资料下载中心)。上一步加的「显示 AList/钉钉知识库来源」折叠开关删除。
- 来源卡片新增管理员可见的「去掉来源」(带确认弹窗,说明是归档下架、数据保留可恢复)。
- 侧栏文案改成"只留两个入口";发现面板标题改为「dws 导入(钉钉)」。

后端(归档语义,可逆):
- 迁移 000047:source_connections 增 archived_at / archived_by + 只覆盖未归档行的部分索引。
- store:ArchiveSourceConnection / RestoreSourceConnection(幂等;归档同时停用并清空
  next_refresh_at,避免被手工改回后立刻带着旧调度跑)。列表、目录、计划刷新(claimDue)、
  手动扫描(triggerBlock/inspect)四类查询一律排除归档行。
- 注册路径(dws 重新获取):RegisterDingtalkSpace 的 ON CONFLICT 清掉归档标记 ——
  重新登记同一个空间**原地复活同一行**,不产生重复,历史快照仍属于同一行。
- HTTP:POST /api/v1/source-connections/{id}/archive|restore(管理员专属,写意图头
  source-archive / source-restore + Idempotency-Key;404 区分"不存在"与"已是目标状态");
  openapi 同步两条契约。

验证:
- 集成测试(真 postgres):归档从列表消失但 source_items 一行不少、重复归档不漂移归档时间与人、
  未知 ID 报 ErrSourceConnectionNotFound、归档来源不能再被扫描、恢复后回到列表但保持停用;
  dws 重新登记同一空间复活同一行。
- httpapi:写意图/幂等键门禁、200/403/404/500、错误体不回显内部细节、classifyRole 管理员专属。
- 迁移护栏:000047 结构 + 四类 SQL 必须含 archived_at is null(漏一条就是入口没去掉)。
- make verify 15/15。
2026-10-14 12:36:32 +08:00

439 lines
16 KiB
Go

package postgres
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
ppconnector "knowledge-governance/internal/connectors/pp"
"knowledge-governance/internal/governance"
)
var (
ErrSourceConnectionNotScannable = errors.New("source connection is not scannable")
ErrSourceScanAlreadyActive = errors.New("source scan already active")
ErrSourceScanStateConflict = errors.New("source scan state conflict")
ErrSourceScanNotFound = errors.New("source scan not found")
)
const triggerPPSourceScanSQL = `
WITH target AS (
SELECT id, source_type
FROM source_connections
WHERE id = $1 AND source_type = 'pp' AND archived_at IS NULL
), active AS (
SELECT 1
FROM source_scans
WHERE source_connection_id = $1
AND state IN ('pending', 'running')
), created_scan AS (
INSERT INTO source_scans (
id, source_connection_id, source_type, state, created_at, updated_at
)
SELECT $2, target.id, target.source_type, 'pending', $5, $5
FROM target
WHERE NOT EXISTS (SELECT 1 FROM active)
RETURNING id, source_connection_id, source_type, state, created_at
), created_job AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $4, 'source.scan.pp', $3::jsonb, 'pending', 20, $5,
3, 'source-scan:' || created_scan.id, $5, $5
FROM created_scan
RETURNING id
)
SELECT created_scan.id, created_scan.source_connection_id,
created_scan.source_type, created_scan.state, created_scan.created_at
FROM created_scan CROSS JOIN created_job
`
const inspectPPSourceScanBlockSQL = `
SELECT connection.source_type,
EXISTS (
SELECT 1 FROM source_scans AS scan
WHERE scan.source_connection_id = connection.id
AND scan.state IN ('pending', 'running')
)
FROM source_connections AS connection
WHERE connection.id = $1 AND connection.archived_at IS NULL
`
const startSourceScanSQL = `
WITH transitioned AS (
UPDATE source_scans
SET state = 'running',
started_at = COALESCE(started_at, $2),
updated_at = $2
WHERE id = $1 AND state IN ('pending', 'running')
RETURNING id, source_connection_id, source_type, state, created_at
), already_completed AS (
SELECT id, source_connection_id, source_type, state, created_at
FROM source_scans
WHERE id = $1 AND state = 'completed'
)
SELECT id, source_connection_id, source_type, state, created_at
FROM transitioned
UNION ALL
SELECT id, source_connection_id, source_type, state, created_at
FROM already_completed
LIMIT 1
`
const completeSourceScanSQL = `
WITH completed AS (
UPDATE source_scans
SET state = 'completed', snapshot_sha256 = $2,
observed_items = $3, unchanged_items = $4,
added_items = $5, updated_items = $6,
over_limit_items = $7, missing_items = $8,
planned_uploads = $9, error_code = '',
completed_at = $10, updated_at = $10
WHERE id = $1 AND state = 'running'
RETURNING source_connection_id
), inserted_changes AS (
INSERT INTO source_scan_changes (
id, source_scan_id, source_connection_id, stable_external_id,
source_item_id, change_type, previous_revision,
observed_revision, observed_size_bytes, created_at
)
SELECT change.id, $1, completed.source_connection_id,
change.stable_external_id, item.id, change.change_type,
change.previous_revision, change.observed_revision,
change.observed_size_bytes, $10
FROM completed
CROSS JOIN jsonb_to_recordset($11::jsonb) AS change(
id TEXT, stable_external_id TEXT, change_type TEXT,
previous_revision TEXT, observed_revision TEXT,
observed_size_bytes BIGINT
)
LEFT JOIN source_items AS item
ON item.source_connection_id = completed.source_connection_id
AND item.stable_external_id = change.stable_external_id
ON CONFLICT (source_scan_id, stable_external_id) DO NOTHING
RETURNING id
), healthy AS (
UPDATE source_connections AS connection
SET last_health = jsonb_build_object(
'status', 'healthy', 'checked_at', $10, 'scan_id', $1
),
updated_at = $10
FROM completed
WHERE connection.id = completed.source_connection_id
RETURNING connection.id
)
SELECT (SELECT COUNT(*)::INTEGER FROM completed),
(SELECT COUNT(*)::INTEGER FROM inserted_changes),
(SELECT COUNT(*)::INTEGER FROM healthy)
`
const failSourceScanSQL = `
WITH failed AS (
UPDATE source_scans
SET state = 'failed', error_code = $2,
completed_at = $3, updated_at = $3
WHERE id = $1 AND state = 'running'
RETURNING source_connection_id
), unhealthy AS (
UPDATE source_connections AS connection
SET last_health = jsonb_build_object(
'status', 'unhealthy', 'checked_at', $3,
'scan_id', $1, 'error_code', $2
),
updated_at = $3
FROM failed
WHERE connection.id = failed.source_connection_id
RETURNING connection.id
)
SELECT (SELECT COUNT(*)::INTEGER FROM failed),
(SELECT COUNT(*)::INTEGER FROM unhealthy)
`
func (s *Store) TriggerPPSourceScan(ctx context.Context, connectionID string, now time.Time) (governance.SourceScan, error) {
if strings.TrimSpace(connectionID) == "" || len(connectionID) > 64 || now.IsZero() {
return governance.SourceScan{}, ErrSourceConnectionNotScannable
}
scanID, err := newOpaqueID("scan-")
if err != nil {
return governance.SourceScan{}, err
}
jobID, err := newOpaqueID("job-")
if err != nil {
return governance.SourceScan{}, err
}
payload, err := json.Marshal(struct {
ScanID string `json:"scan_id"`
}{ScanID: scanID})
if err != nil {
return governance.SourceScan{}, errors.New("encode source scan job")
}
scan, err := scanSourceScan(s.db.QueryRow(ctx, triggerPPSourceScanSQL, connectionID, scanID, payload, jobID, now))
if errors.Is(err, pgx.ErrNoRows) {
var sourceType string
var active bool
inspectErr := s.db.QueryRow(ctx, inspectPPSourceScanBlockSQL, connectionID).Scan(&sourceType, &active)
if inspectErr != nil && !errors.Is(inspectErr, pgx.ErrNoRows) {
return governance.SourceScan{}, fmt.Errorf("inspect blocked PP source scan: %w", inspectErr)
}
if inspectErr == nil && sourceType == string(governance.SourceTypePP) && active {
return governance.SourceScan{}, ErrSourceScanAlreadyActive
}
return governance.SourceScan{}, ErrSourceConnectionNotScannable
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.SourceScan{}, ErrSourceScanAlreadyActive
}
if err != nil {
return governance.SourceScan{}, fmt.Errorf("trigger PP source scan: %w", err)
}
return scan, nil
}
func (s *Store) ListSourceScans(ctx context.Context, connectionID string, limit, offset int) (governance.SourceScanPage, error) {
rows, err := s.db.Query(ctx, `
SELECT id, state, observed_items, unchanged_items, added_items,
updated_items, over_limit_items, missing_items, planned_uploads,
error_code, created_at, completed_at, COUNT(*) OVER()
FROM source_scans
WHERE source_connection_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3`, connectionID, limit, offset)
if err != nil {
return governance.SourceScanPage{}, fmt.Errorf("query source scans: %w", err)
}
defer rows.Close()
page := governance.SourceScanPage{Items: []governance.SourceScanSummaryView{}, Limit: limit, Offset: offset}
for rows.Next() {
var item governance.SourceScanSummaryView
var state string
if err := rows.Scan(&item.ID, &state, &item.ObservedItems, &item.UnchangedItems, &item.AddedItems,
&item.UpdatedItems, &item.OverLimitItems, &item.MissingItems, &item.PlannedUploads,
&item.ErrorCode, &item.CreatedAt, &item.CompletedAt, &page.Total); err != nil {
return governance.SourceScanPage{}, fmt.Errorf("scan source scan summary: %w", err)
}
item.State = governance.SourceScanState(state)
if !item.State.Valid() {
return governance.SourceScanPage{}, errors.New("invalid source scan summary state")
}
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return governance.SourceScanPage{}, fmt.Errorf("iterate source scans: %w", err)
}
return page, nil
}
func (s *Store) GetSourceScanDetail(ctx context.Context, connectionID, scanID string) (governance.SourceScanDetailView, error) {
var detail governance.SourceScanDetailView
var state string
err := s.db.QueryRow(ctx, `
SELECT id, state, observed_items, unchanged_items, added_items,
updated_items, over_limit_items, missing_items, planned_uploads,
error_code, created_at, completed_at
FROM source_scans
WHERE source_connection_id = $1 AND id = $2`, connectionID, scanID).Scan(
&detail.ID, &state, &detail.ObservedItems, &detail.UnchangedItems, &detail.AddedItems,
&detail.UpdatedItems, &detail.OverLimitItems, &detail.MissingItems, &detail.PlannedUploads,
&detail.ErrorCode, &detail.CreatedAt, &detail.CompletedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return governance.SourceScanDetailView{}, ErrSourceScanNotFound
}
if err != nil {
return governance.SourceScanDetailView{}, fmt.Errorf("query source scan detail: %w", err)
}
detail.State = governance.SourceScanState(state)
if !detail.State.Valid() {
return governance.SourceScanDetailView{}, errors.New("invalid source scan detail state")
}
rows, err := s.db.Query(ctx, `
SELECT id, source_item_id, change_type, observed_size_bytes, created_at
FROM source_scan_changes
WHERE source_connection_id = $1 AND source_scan_id = $2
ORDER BY created_at, id`, connectionID, scanID)
if err != nil {
return governance.SourceScanDetailView{}, fmt.Errorf("query source scan changes: %w", err)
}
defer rows.Close()
detail.Changes = []governance.SourceScanChangeView{}
for rows.Next() {
var change governance.SourceScanChangeView
var changeType string
if err := rows.Scan(&change.ID, &change.SourceItemID, &changeType, &change.ObservedSizeBytes, &change.CreatedAt); err != nil {
return governance.SourceScanDetailView{}, fmt.Errorf("scan source scan change: %w", err)
}
change.ChangeType = governance.SourceScanChangeType(changeType)
switch change.ChangeType {
case governance.SourceScanAdded, governance.SourceScanUpdated, governance.SourceScanUnavailable:
default:
return governance.SourceScanDetailView{}, errors.New("invalid source scan change type")
}
detail.Changes = append(detail.Changes, change)
}
if err := rows.Err(); err != nil {
return governance.SourceScanDetailView{}, fmt.Errorf("iterate source scan changes: %w", err)
}
return detail, nil
}
func (s *Store) StartSourceScan(ctx context.Context, scanID string, now time.Time) (governance.SourceScan, error) {
scan, err := scanSourceScan(s.db.QueryRow(ctx, startSourceScanSQL, scanID, now))
if errors.Is(err, pgx.ErrNoRows) {
return governance.SourceScan{}, ErrSourceScanStateConflict
}
if err != nil {
return governance.SourceScan{}, fmt.Errorf("start source scan: %w", err)
}
return scan, nil
}
func (s *Store) LoadPPCurrentItems(ctx context.Context, connectionID string) (map[string]ppconnector.CurrentItem, error) {
rows, err := s.db.Query(ctx, `
SELECT stable_external_id, revision
FROM source_items
WHERE source_connection_id = $1
ORDER BY stable_external_id`, connectionID)
if err != nil {
return nil, fmt.Errorf("query current PP items: %w", err)
}
defer rows.Close()
items := make(map[string]ppconnector.CurrentItem)
for rows.Next() {
var sourceID string
var item ppconnector.CurrentItem
if err := rows.Scan(&sourceID, &item.Revision); err != nil {
return nil, fmt.Errorf("scan current PP item: %w", err)
}
items[sourceID] = item
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate current PP items: %w", err)
}
return items, nil
}
func (s *Store) CompleteSourceScan(ctx context.Context, scanID string, result governance.SourceScanResult, now time.Time) error {
if !validSourceScanResult(result) || strings.TrimSpace(scanID) == "" || now.IsZero() {
return ErrSourceScanStateConflict
}
type encodedChange struct {
ID string `json:"id"`
StableExternalID string `json:"stable_external_id"`
ChangeType governance.SourceScanChangeType `json:"change_type"`
PreviousRevision string `json:"previous_revision"`
ObservedRevision string `json:"observed_revision"`
ObservedSizeBytes *int64 `json:"observed_size_bytes"`
}
changes := make([]encodedChange, 0, len(result.Changes))
for _, change := range result.Changes {
changes = append(changes, encodedChange{
ID: deterministicGovernanceID("chg-", scanID, change.StableExternalID),
StableExternalID: change.StableExternalID, ChangeType: change.ChangeType,
PreviousRevision: change.PreviousRevision, ObservedRevision: change.ObservedRevision,
ObservedSizeBytes: change.ObservedSizeBytes,
})
}
payload, err := json.Marshal(changes)
if err != nil {
return errors.New("encode source scan changes")
}
var completed, inserted, health int
err = s.db.QueryRow(ctx, completeSourceScanSQL,
scanID, result.SnapshotSHA256, result.ObservedItems, result.UnchangedItems,
result.AddedItems, result.UpdatedItems, result.OverLimitItems,
result.MissingItems, result.PlannedUploads, now, payload,
).Scan(&completed, &inserted, &health)
if err != nil {
return fmt.Errorf("complete source scan: %w", err)
}
if completed != 1 || inserted != len(changes) || health != 1 {
return ErrSourceScanStateConflict
}
return nil
}
func (s *Store) FailSourceScan(ctx context.Context, scanID, code string, now time.Time) error {
if strings.TrimSpace(scanID) == "" || now.IsZero() || code != "pp_snapshot_invalid" && code != "source_type_unsupported" {
return ErrSourceScanStateConflict
}
var failed, health int
if err := s.db.QueryRow(ctx, failSourceScanSQL, scanID, code, now).Scan(&failed, &health); err != nil {
return fmt.Errorf("fail source scan: %w", err)
}
if failed != 1 || health != 1 {
return ErrSourceScanStateConflict
}
return nil
}
func validSourceScanResult(result governance.SourceScanResult) bool {
if !governance.ValidSHA256(result.SnapshotSHA256) || result.ObservedItems < 0 || result.UnchangedItems < 0 ||
result.AddedItems < 0 || result.UpdatedItems < 0 || result.OverLimitItems < 0 || result.MissingItems < 0 ||
result.PlannedUploads < 0 || result.ObservedItems != result.UnchangedItems+result.AddedItems+result.UpdatedItems+result.OverLimitItems ||
result.PlannedUploads != result.UpdatedItems || len(result.Changes) != result.AddedItems+result.UpdatedItems+result.MissingItems {
return false
}
seen := make(map[string]struct{}, len(result.Changes))
for _, change := range result.Changes {
if change.StableExternalID == "" || len(change.StableExternalID) > 512 || len(change.PreviousRevision) > 512 || len(change.ObservedRevision) > 512 {
return false
}
if _, exists := seen[change.StableExternalID]; exists {
return false
}
seen[change.StableExternalID] = struct{}{}
switch change.ChangeType {
case governance.SourceScanAdded:
if change.PreviousRevision != "" || change.ObservedRevision == "" || change.ObservedSizeBytes == nil {
return false
}
case governance.SourceScanUpdated:
if change.PreviousRevision == "" || change.ObservedRevision == "" || change.ObservedSizeBytes == nil {
return false
}
case governance.SourceScanUnavailable:
if change.PreviousRevision == "" || change.ObservedRevision != "" || change.ObservedSizeBytes != nil {
return false
}
default:
return false
}
if change.ObservedSizeBytes != nil && *change.ObservedSizeBytes < 0 {
return false
}
}
return true
}
func scanSourceScan(row rowScanner) (governance.SourceScan, error) {
var scan governance.SourceScan
var sourceType, state string
if err := row.Scan(&scan.ID, &scan.SourceConnectionID, &sourceType, &state, &scan.CreatedAt); err != nil {
return governance.SourceScan{}, err
}
scan.SourceType = governance.SourceType(sourceType)
scan.State = governance.SourceScanState(state)
if !scan.SourceType.Valid() || !scan.State.Valid() {
return governance.SourceScan{}, errors.New("invalid source scan row")
}
return scan, nil
}
func newOpaqueID(prefix string) (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", fmt.Errorf("generate opaque ID: %w", err)
}
return prefix + hex.EncodeToString(value[:]), nil
}