0974d8dfa2
build / build (push) Successful in 57s
按已确认的契约实现(口径见 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)。
316 lines
12 KiB
Go
316 lines
12 KiB
Go
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
|
||
}
|