Files
knowledge-governance/internal/httpapi/version_history.go
姚章浩 013ce4a24e
build / build (push) Successful in 1m3s
fix(version): 补全开发快照记录与同步交付闭环
收录 main、注释开发快照和完整运行 SHA;开发说明绑定原提交,补记 0.1.1–0.1.25。
进行中同步按仓库合并,完成后重新执行;启动入队,部署脚本读回版本证据。
开发快照独立于正式标签与 Release,既有冲突标签不移动。

已运行:整仓 make verify,Go 673 通过、前端 274 通过,92 个已声明可选跳过,必需跳过 0。
隔离 PostgreSQL 重复同步队列用例及 -race 通过;未更新现有 8088、未修改生产问答。
2026-10-18 18:11:49 +08:00

274 lines
11 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/version"
"knowledge-governance/internal/versionhistory"
)
// 「版本记录」页的服务面:**只读查询** + 管理员手动同步。
//
// 设计口径(见 docs/RELEASE_RUNBOOK.md 与 internal/governance/version_history.go):
// - 列表只读本库缓存(Gitea 不可达也能看),游标绑定快照 ⇒ 翻页不重不漏;
// - 同步只有一个入口,且只允许管理员触发;并发同步合并成同一个作业;
// - 「当前运行」只认构建产物注入的**完整 SHA 精确匹配**,拿不到就显示未知。
// 版本记录的作业类型(worker 侧注册同名 handler;值来自 versionhistory,避免两处写死)。
const VersionHistoryJobType = versionhistory.JobType
const (
versionHistoryDefaultPageSize = 30
versionHistoryMaxPageSize = 100
)
// VersionHistoryStore 是版本记录页需要的只读查询面(由 postgres.Store 实现)。
type VersionHistoryStore interface {
ActiveVersionHistorySnapshot(ctx context.Context, repoKey string) (int64, error)
LoadVersionHistoryPage(ctx context.Context, repoKey, kind, cursor string, limit int) (
[]governance.VersionHistoryCommit, string, bool, int64, error)
LoadVersionHistorySyncState(ctx context.Context, repoKey string) (governance.VersionHistorySyncRun, error)
VersionHistorySyncInFlight(ctx context.Context, repoKey string, now time.Time) (bool, error)
}
type versionHistoryTag struct {
Name string `json:"name"`
URL string `json:"url"`
}
type versionHistoryItem struct {
CommitSHA string `json:"commit_sha"`
ShortSHA string `json:"short_sha"`
Parents []string `json:"parents"`
CommittedAt string `json:"committed_at"`
AuthorDisplay string `json:"author_display"`
Subject string `json:"subject"`
Body string `json:"body"`
RecordKind string `json:"record_kind"`
VersionLabel string `json:"version_label"`
VersionState string `json:"version_state"`
ReleaseTags []versionHistoryTag `json:"release_tags"`
ReleaseNotes string `json:"release_notes"`
CommitURL string `json:"commit_url"`
VersionReason string `json:"version_reason"`
CurrentRunning bool `json:"is_current_running"`
}
type versionHistorySyncState struct {
Available bool `json:"available"`
Status string `json:"status"`
Complete bool `json:"complete"`
LastSuccessAt string `json:"last_success_at"`
FetchedCount int `json:"fetched_count"`
ExpectedCount *int `json:"expected_count"`
ErrorCode string `json:"error_code"`
SourceHeadSHA string `json:"source_head_sha"`
ActiveSnapshotID int64 `json:"active_snapshot_id"`
Repo string `json:"repo"`
}
type versionHistoryCurrentRunning struct {
State string `json:"state"`
CommitSHA string `json:"commit_sha"`
DisplayVersion string `json:"display_version"`
MatchedCommitSHA string `json:"matched_commit_sha"`
}
type versionHistoryResponse struct {
Items []versionHistoryItem `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
Sync versionHistorySyncState `json:"sync"`
CurrentRunning versionHistoryCurrentRunning `json:"current_running"`
}
func versionHistoryError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]string{"error": message, "code": code})
}
// currentRunning 判定「当前运行」:只在完整 SHA 精确命中时标记,否则明确说未知。
func currentRunning(items []governance.VersionHistoryCommit) (versionHistoryCurrentRunning, string) {
info := version.Snapshot()
if !info.IdentityAvailable || info.CommitSHA == nil {
return versionHistoryCurrentRunning{State: "unknown",
DisplayVersion: info.DisplayVersion}, ""
}
sha := *info.CommitSHA
for _, item := range items {
if item.CommitSHA == sha {
return versionHistoryCurrentRunning{State: "matched", CommitSHA: sha,
DisplayVersion: info.DisplayVersion, MatchedCommitSHA: sha}, sha
}
}
state := "unknown"
if info.BuildKind == "development" {
state = "development"
}
// 开发构建:产物带完整 SHA,但它不在已同步的提交里 ⇒ 说明"当前运行"与页面记录不重合
return versionHistoryCurrentRunning{State: state, CommitSHA: sha,
DisplayVersion: info.DisplayVersion}, ""
}
func versionHistoryList(w http.ResponseWriter, r *http.Request, config Config) {
if config.VersionHistory == nil || config.VersionHistoryRepo == "" {
writeJSON(w, http.StatusOK, versionHistoryResponse{Items: []versionHistoryItem{},
Sync: versionHistorySyncState{Available: false},
CurrentRunning: func() versionHistoryCurrentRunning {
running, _ := currentRunning(nil)
return running
}()})
return
}
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
if kind == "" {
kind = "all"
}
limit := versionHistoryDefaultPageSize
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 || parsed > versionHistoryMaxPageSize {
versionHistoryError(w, http.StatusBadRequest, "invalid_limit",
fmt.Sprintf("limit 必须是 1..%d 的整数", versionHistoryMaxPageSize))
return
}
limit = parsed
}
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
items, nextCursor, hasMore, _, err := config.VersionHistory.LoadVersionHistoryPage(
r.Context(), config.VersionHistoryRepo, kind, cursor, limit)
if err != nil {
if errors.Is(err, postgresstore.ErrVersionHistoryCursorExpired) {
versionHistoryError(w, http.StatusConflict, "cursor_expired",
"这次翻页依据的记录快照已被清理,请刷新页面重新加载")
return
}
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "读取版本记录失败")
return
}
if items == nil {
items = []governance.VersionHistoryCommit{}
}
state, err := config.VersionHistory.LoadVersionHistorySyncState(r.Context(), config.VersionHistoryRepo)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "读取同步状态失败")
return
}
running, matchedSHA := currentRunning(items)
payload := versionHistoryResponse{
Items: make([]versionHistoryItem, 0, len(items)),
NextCursor: nextCursor, HasMore: hasMore, CurrentRunning: running,
Sync: versionHistorySyncState{Available: true, Status: state.Status, Complete: state.Complete,
LastSuccessAt: versionHistoryTime(state.LastSuccessAt), FetchedCount: state.FetchedCount,
ExpectedCount: state.ExpectedCount, ErrorCode: state.ErrorCode,
SourceHeadSHA: state.SourceHeadSHA, ActiveSnapshotID: state.ActiveSnapshotID,
Repo: config.VersionHistoryRepo},
}
for _, item := range items {
record := versionHistoryItem{
CommitSHA: item.CommitSHA, ShortSHA: shortSHA(item.CommitSHA), Parents: item.Parents,
CommittedAt: versionHistoryTime(item.CommittedAt), AuthorDisplay: item.AuthorDisplay,
Subject: item.Subject, Body: item.Body, RecordKind: item.RecordKind,
VersionLabel: item.VersionLabel, VersionState: item.VersionState,
ReleaseNotes: item.ReleaseNotes, CommitURL: item.CommitURL,
VersionReason: item.VersionEvidence.Reason,
ReleaseTags: make([]versionHistoryTag, 0, len(item.ReleaseTags)),
CurrentRunning: matchedSHA != "" && item.CommitSHA == matchedSHA,
}
for _, tag := range item.ReleaseTags {
record.ReleaseTags = append(record.ReleaseTags, versionHistoryTag{Name: tag.Name, URL: tag.URL})
}
payload.Items = append(payload.Items, record)
}
writeJSON(w, http.StatusOK, payload)
}
func versionHistorySync(w http.ResponseWriter, r *http.Request, config Config) {
if config.VersionHistory == nil || config.VersionHistoryRepo == "" {
versionHistoryError(w, http.StatusServiceUnavailable, "version_history_unavailable",
"版本记录未接线:没有配置只读的仓库来源")
return
}
now := config.Now().UTC()
inFlight, err := config.VersionHistory.VersionHistorySyncInFlight(r.Context(), config.VersionHistoryRepo, now)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "查询同步状态失败")
return
}
if inFlight {
state, err := config.VersionHistory.LoadVersionHistorySyncState(r.Context(), config.VersionHistoryRepo)
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "查询同步状态失败")
return
}
// 已经在跑:不重复入队,明确告诉调用方复用现有任务
writeJSON(w, http.StatusOK, map[string]any{"job_id": state.JobID, "sync_run_id": state.ID,
"status": state.Status, "reused": true})
return
}
triggeredBy := ""
if identity, ok := sessionFrom(r.Context()); ok {
triggeredBy = identity.Username
}
payload, err := json.Marshal(map[string]string{"repo_key": config.VersionHistoryRepo, "triggered_by": triggeredBy})
if err != nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "构造同步任务失败")
return
}
if queue, ok := config.Store.(interface {
EnqueueVersionHistorySync(context.Context, string, string, string, time.Time) (*jobs.Job, error)
}); ok {
target := ""
info := version.Snapshot()
if info.IdentityAvailable && info.CommitSHA != nil {
target = *info.CommitSHA
}
job, e := queue.EnqueueVersionHistorySync(r.Context(), config.VersionHistoryRepo, triggeredBy, target, now)
if e != nil || job == nil {
versionHistoryError(w, 500, "version_history_unavailable", "入队同步任务失败")
return
}
writeJSON(w, 202, map[string]any{"job_id": job.ID, "status": job.State})
return
}
requestedID := fmt.Sprintf("version-history-sync-%d", now.UnixNano())
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{ID: requestedID,
Type: VersionHistoryJobType, Payload: payload, AvailableAt: now, MaxAttempts: 3,
IdempotencyKey: "version-history.sync:" + config.VersionHistoryRepo + ":" + requestedID}, now)
if err != nil || job == nil {
versionHistoryError(w, http.StatusInternalServerError, "version_history_unavailable", "入队同步任务失败")
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": job.ID, "status": job.State,
"reused": job.ID != requestedID})
}
// buildInfo:当前运行产物的构建身份(只读;与监控接口同级可见)。
func buildInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, version.Snapshot())
}
func versionHistoryTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func shortSHA(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}