Files
knowledge-governance/internal/platform/postgres/version_history.go
T
姚章浩 0974d8dfa2
build / build (push) Successful in 57s
feat(version-history): 「版本记录」页 —— 追踪远端 main 全部提交(只读 + 管理员手动同步)
按已确认的契约实现(口径见 docs/VERSION_HISTORY.md):

- **版本号口径(只给有证据的)**:注释 tag + tag 指向该提交 + 该提交内 CHANGELOG 顶部三者一致 ⇒ 正式版本;
  tag 之后的提交按 `<主>.<次>.<自该 tag 的提交数>` 显示开发号(与 scripts/version.py **同规则**);
  证据不足 ⇒ 「历史提交 · 短SHA」+ 原因码,**不补造版本号**。完整 SHA 是唯一身份、版本号不是唯一键
  (tag 之后两条并行分支各自的第 1 个提交都算 0.1.1 ⇒ 两条都保留,用短 SHA 区分)。
- **迁移 000046**:version_history_commits(提交记录,主键 repo_key+commit_sha)+ version_history_sync_runs
  (同步状态)。快照 = 一次同步:抓完并与 X-Total-Count 对账通过才切换;失败不切换快照、不清空历史、
  last_success_at 不动(页面显示"上次同步失败(原因)· 仍展示上次成功数据");「同步成功」≠「历史完整」。
- **只读客户端(匿名,不使用任何凭据)**:白名单 origin + 固定仓库、拒绝任意 URL 与跨主机重定向、
  响应上限 5MB / 超时 15s、仅 5xx/429 退避重试(最多 3 次);逐页抓到短页为止;每次全量扫 tag
  ⇒ 旧提交后补的 tag 会更新既有记录(不新增第二行)。
- **接口**(三个都登记进 api/openapi.yaml):GET /api/v1/build-info(viewer)、
  GET /api/v1/version-history(viewer;游标绑定快照,过期返回 409 cursor_expired;不返回伪造 total)、
  POST /api/v1/version-history/sync(admin;并发同步复用同一作业,靠 IdempotencyKey)。
- **「当前运行」只认精确匹配**:产物注入的完整 SHA 命中某条记录才标该条;开发构建显式标注"开发构建";
  没有可用标识 ⇒ "当前运行版本未知";**不拿最新 main / 最新 tag 顶替**,CI 成功不等于已部署。
- **前端**:一级菜单第 5 项(版本记录)+ 时间线页面(全部/正式/开发/历史四段筛选、正文展开、
  加载更多、760px 单列无横向溢出、提交正文与变更记录按纯文本渲染);无打 tag / 发布 / 部署入口。

新增配置(留空即未接线,页面降级):VERSION_HISTORY_GITEA_BASE_URL / _REPO / _ALLOWED_ORIGINS /
_HTTP_TIMEOUT / _MAX_PAGES。

验证:`make verify` 15/15 success(tests-go 578 通过 / 0 失败;tests-web 209 通过 / 0 失败);
新增 Go 用例 30(版本口径与图遍历 12、客户端守卫 6、同步编排 5、接口 7);前端新增 18(组件 10 + 客户端 8);
真实仓库匿名快照拉取(`VERSION_HISTORY_LIVE_BASE_URL=… go test -run TestLiveSyncAgainstRealRepository`):
抓到 113 条与 X-Total-Count 一致、v0.1.0 是注释 tag 且目标与 `git rev-parse v0.1.0^{commit}` 一致、
开发号逐个等于 `git rev-list --count v0.1.0..<sha>`(正式 1 / 开发 22 / 历史 90)。
2026-10-13 19:40:19 +08:00

316 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package postgres
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/versionhistory"
)
// 「版本记录」的持久化:提交记录 + 同步状态(migration 000046)。
//
// 事实口径(与 internal/governance、internal/versionhistory 一致):
// - commit_sha 是唯一身份;同一提交再次同步只更新自己那一行(后补 tag 靠这条生效);
// - 对外只服务**最近一次成功**的快照(snapshot_id = sync_runs.id);
// - 失败不切换快照、不清空历史。
const (
// VersionHistoryKeptSnapshots:保留最近几个成功快照供游标使用,更旧的清理。
VersionHistoryKeptSnapshots = 3
// VersionHistoryInFlightWindow:超过这个时长的 running 视为"陈旧"(进程被杀留下的),
// 不再挡住新的同步请求。
VersionHistoryInFlightWindow = 30 * time.Minute
versionHistoryDefaultLimit = 30
versionHistoryMaxLimit = 100
)
// ErrVersionHistoryCursorExpired:游标指向的快照已被清理 ⇒ 调用方返回 409 让页面刷新。
var ErrVersionHistoryCursorExpired = errors.New("version history cursor expired")
// BeginVersionHistorySync 开一次同步(status=running),返回 run id(同时就是快照 id)。
func (s *Store) BeginVersionHistorySync(ctx context.Context, repoKey, jobID, triggeredBy string, now time.Time) (int64, error) {
var id int64
err := s.db.QueryRow(ctx, `
INSERT INTO version_history_sync_runs (repo_key, job_id, status, started_at, triggered_by)
VALUES ($1, $2, 'running', $3, $4)
RETURNING id`, repoKey, jobID, now.UTC(), triggeredBy).Scan(&id)
if err != nil {
return 0, fmt.Errorf("开启版本记录同步:%w", err)
}
return id, nil
}
// VersionHistorySyncInFlight 是否有在跑的同步(陈旧 running 不算)。
func (s *Store) VersionHistorySyncInFlight(ctx context.Context, repoKey string, now time.Time) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM version_history_sync_runs
WHERE repo_key = $1 AND status IN ('pending', 'running') AND started_at > $2)`,
repoKey, now.UTC().Add(-VersionHistoryInFlightWindow)).Scan(&exists)
if err != nil {
return false, fmt.Errorf("查询版本记录同步状态:%w", err)
}
return exists, nil
}
// SaveVersionHistorySnapshot 写入本次快照的全部提交记录(同一提交只更新自己那一行),
// 并清理过旧的快照(**不动本次快照**)。
func (s *Store) SaveVersionHistorySnapshot(ctx context.Context, snapshotID int64, repoKey string,
records []governance.VersionHistoryCommit, expectedCount int, complete bool, now time.Time) error {
// expectedCount/complete 由 FinishVersionHistorySync 落库(这里只写记录行)。
_, _ = expectedCount, complete
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("写版本记录快照:%w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for _, record := range records {
parents := record.Parents
if parents == nil {
parents = []string{}
}
tags, err := json.Marshal(record.ReleaseTags)
if err != nil {
return fmt.Errorf("序列化 tag 证据:%w", err)
}
evidence, err := json.Marshal(record.VersionEvidence)
if err != nil {
return fmt.Errorf("序列化版本依据:%w", err)
}
var label *string
if record.VersionLabel != "" {
value := record.VersionLabel
label = &value
}
if _, err := tx.Exec(ctx, `
INSERT INTO version_history_commits (repo_key, commit_sha, parents, committed_at, author_display,
subject, body, version_label, version_state, record_kind, release_tags, release_notes,
commit_url, version_evidence, snapshot_id, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (repo_key, commit_sha) DO UPDATE SET
parents = EXCLUDED.parents, committed_at = EXCLUDED.committed_at,
author_display = EXCLUDED.author_display, subject = EXCLUDED.subject, body = EXCLUDED.body,
version_label = EXCLUDED.version_label, version_state = EXCLUDED.version_state,
record_kind = EXCLUDED.record_kind, release_tags = EXCLUDED.release_tags,
release_notes = EXCLUDED.release_notes, commit_url = EXCLUDED.commit_url,
version_evidence = EXCLUDED.version_evidence, snapshot_id = EXCLUDED.snapshot_id,
updated_at = EXCLUDED.updated_at`,
repoKey, record.CommitSHA, parents, record.CommittedAt.UTC(), record.AuthorDisplay,
record.Subject, record.Body, label, record.VersionState, record.RecordKind,
tags, record.ReleaseNotes, record.CommitURL, evidence, snapshotID, now.UTC()); err != nil {
return fmt.Errorf("写入版本记录 %s:%w", record.CommitSHA, err)
}
}
if _, err := tx.Exec(ctx, `
DELETE FROM version_history_commits
WHERE repo_key = $1 AND snapshot_id <> $2 AND snapshot_id NOT IN (
SELECT id FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT $3)`,
repoKey, snapshotID, VersionHistoryKeptSnapshots); err != nil {
return fmt.Errorf("清理过旧版本记录快照:%w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("提交版本记录快照:%w", err)
}
return nil
}
// FinishVersionHistorySync 标记同步成功(此时本次快照成为对外服务的事实)。
func (s *Store) FinishVersionHistorySync(ctx context.Context, runID int64, outcome versionhistory.SyncOutcome, now time.Time) error {
command, err := s.db.Exec(ctx, `
UPDATE version_history_sync_runs
SET status = 'completed', finished_at = $2, last_success_at = $2, complete = $3,
fetched_count = $4, expected_count = $5, source_head_sha = $6
WHERE id = $1`,
runID, now.UTC(), outcome.Complete, outcome.FetchedCount, outcome.ExpectedCount, outcome.SourceHeadSHA)
if err != nil {
return fmt.Errorf("完成版本记录同步:%w", err)
}
if command.RowsAffected() != 1 {
return fmt.Errorf("完成版本记录同步:run %d 不存在", runID)
}
return nil
}
// FailVersionHistorySync 标记同步失败并落错误码(**不切换对外快照**)。
func (s *Store) FailVersionHistorySync(ctx context.Context, runID int64, errorCode string, now time.Time) error {
_, err := s.db.Exec(ctx, `
UPDATE version_history_sync_runs
SET status = 'failed', finished_at = $2, error_code = $3
WHERE id = $1`, runID, now.UTC(), errorCode)
if err != nil {
return fmt.Errorf("标记版本记录同步失败:%w", err)
}
return nil
}
// ActiveVersionHistorySnapshot 返回对外快照(最近一次成功同步);没有 ⇒ 0。
func (s *Store) ActiveVersionHistorySnapshot(ctx context.Context, repoKey string) (int64, error) {
var id int64
err := s.db.QueryRow(ctx, `
SELECT id FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT 1`, repoKey).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("查询版本记录快照:%w", err)
}
return id, nil
}
// LoadVersionHistorySyncState 读同步状态:最新一次 run(任何终态)+ 最近一次成功时间。
func (s *Store) LoadVersionHistorySyncState(ctx context.Context, repoKey string) (governance.VersionHistorySyncRun, error) {
var run governance.VersionHistorySyncRun
var expected *int
err := s.db.QueryRow(ctx, `
SELECT id, job_id, status, source_head_sha, started_at, finished_at, complete,
fetched_count, expected_count, error_code, triggered_by
FROM version_history_sync_runs WHERE repo_key = $1 ORDER BY id DESC LIMIT 1`, repoKey).
Scan(&run.ID, &run.JobID, &run.Status, &run.SourceHeadSHA, &run.StartedAt, &run.FinishedAt,
&run.Complete, &run.FetchedCount, &expected, &run.ErrorCode, &run.TriggeredBy)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, fmt.Errorf("查询同步状态:%w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return governance.VersionHistorySyncRun{}, nil
}
run.ExpectedCount = expected
if snapshot, err := s.ActiveVersionHistorySnapshot(ctx, repoKey); err != nil {
return governance.VersionHistorySyncRun{}, err
} else {
run.ActiveSnapshotID = snapshot
}
var lastSuccess *time.Time
if err := s.db.QueryRow(ctx, `
SELECT last_success_at FROM version_history_sync_runs
WHERE repo_key = $1 AND status = 'completed' ORDER BY id DESC LIMIT 1`, repoKey).
Scan(&lastSuccess); err == nil && lastSuccess != nil {
run.LastSuccessAt = *lastSuccess
}
return run, nil
}
// VersionHistoryCursorBetween 偏移一页:绑定快照 + (committed_at, commit_sha) 组合游标。
func encodeVersionHistoryCursor(snapshotID int64, committedAt time.Time, sha string) string {
raw := fmt.Sprintf("%d|%s|%s", snapshotID, committedAt.UTC().Format(time.RFC3339Nano), sha)
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
func decodeVersionHistoryCursor(cursor string) (int64, time.Time, string, error) {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return 0, time.Time{}, "", fmt.Errorf("游标不是合法编码")
}
parts := strings.Split(string(decoded), "|")
if len(parts) != 3 {
return 0, time.Time{}, "", fmt.Errorf("游标结构不对")
}
var snapshotID int64
if _, err := fmt.Sscanf(parts[0], "%d", &snapshotID); err != nil || snapshotID <= 0 {
return 0, time.Time{}, "", fmt.Errorf("游标缺少快照")
}
committedAt, err := time.Parse(time.RFC3339Nano, parts[1])
if err != nil {
return 0, time.Time{}, "", fmt.Errorf("游标的提交时间不对")
}
if len(parts[2]) != 40 {
return 0, time.Time{}, "", fmt.Errorf("游标的提交标识不对")
}
return snapshotID, committedAt.UTC(), parts[2], nil
}
// LoadVersionHistoryPage 按游标读一页(只读当前快照;跨快照游标直接拒绝)。
func (s *Store) LoadVersionHistoryPage(ctx context.Context, repoKey, kind, cursor string, limit int) (
items []governance.VersionHistoryCommit, nextCursor string, hasMore bool, snapshotID int64, err error) {
if limit <= 0 {
limit = versionHistoryDefaultLimit
}
if limit > versionHistoryMaxLimit {
limit = versionHistoryMaxLimit
}
switch kind {
case "", "all", governance.RecordKindRelease, governance.RecordKindDevelopment, governance.RecordKindHistorical:
default:
return nil, "", false, 0, fmt.Errorf("未知的记录类型 %q", kind)
}
if kind == "" {
kind = "all"
}
active, err := s.ActiveVersionHistorySnapshot(ctx, repoKey)
if err != nil {
return nil, "", false, 0, err
}
var (
cursorAt *time.Time
cursorSHA *string
)
if cursor != "" {
cursorSnapshot, committedAt, sha, decodeErr := decodeVersionHistoryCursor(cursor)
if decodeErr != nil {
return nil, "", false, 0, fmt.Errorf("%w:%s", ErrVersionHistoryCursorExpired, decodeErr)
}
if cursorSnapshot != active {
return nil, "", false, active, ErrVersionHistoryCursorExpired
}
cursorAt, cursorSHA = &committedAt, &sha
}
if active == 0 {
return []governance.VersionHistoryCommit{}, "", false, 0, nil
}
rows, err := s.db.Query(ctx, `
SELECT commit_sha, parents, committed_at, author_display, subject, body, version_label,
version_state, record_kind, release_tags, release_notes, commit_url, version_evidence, snapshot_id
FROM version_history_commits
WHERE repo_key = $1 AND snapshot_id = $2 AND ($3 = 'all' OR record_kind = $3)
AND ($4::TIMESTAMPTZ IS NULL OR (committed_at, commit_sha) < ($4, $5))
ORDER BY committed_at DESC, commit_sha DESC
LIMIT $6`, repoKey, active, kind, cursorAt, cursorSHA, limit+1)
if err != nil {
return nil, "", false, active, fmt.Errorf("读取版本记录:%w", err)
}
defer rows.Close()
for rows.Next() {
var (
record governance.VersionHistoryCommit
label *string
tagsRaw []byte
evidRaw []byte
)
if err := rows.Scan(&record.CommitSHA, &record.Parents, &record.CommittedAt, &record.AuthorDisplay,
&record.Subject, &record.Body, &label, &record.VersionState, &record.RecordKind,
&tagsRaw, &record.ReleaseNotes, &record.CommitURL, &evidRaw, &record.SnapshotID); err != nil {
return nil, "", false, active, fmt.Errorf("解析版本记录:%w", err)
}
record.RepoKey = repoKey
if label != nil {
record.VersionLabel = *label
}
if len(tagsRaw) > 0 {
_ = json.Unmarshal(tagsRaw, &record.ReleaseTags)
}
if len(evidRaw) > 0 {
_ = json.Unmarshal(evidRaw, &record.VersionEvidence)
}
items = append(items, record)
}
if err := rows.Err(); err != nil {
return nil, "", false, active, fmt.Errorf("读取版本记录:%w", err)
}
if len(items) > limit {
items = items[:limit]
hasMore = true
last := items[len(items)-1]
nextCursor = encodeVersionHistoryCursor(active, last.CommittedAt, last.CommitSHA)
}
return items, nextCursor, hasMore, active, nil
}