From 0974d8dfa2aad5650eefbcee3edb365a8afd4868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Tue, 13 Oct 2026 19:40:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(version-history):=20=E3=80=8C=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=AE=B0=E5=BD=95=E3=80=8D=E9=A1=B5=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=BF=BD=E8=B8=AA=E8=BF=9C=E7=AB=AF=20main=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=8F=90=E4=BA=A4=EF=BC=88=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=20+=20=E7=AE=A1=E7=90=86=E5=91=98=E6=89=8B=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按已确认的契约实现(口径见 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..`(正式 1 / 开发 22 / 历史 90)。 --- .env.example | 9 + CHANGELOG.md | 14 + api/openapi.yaml | 100 + cmd/api/main.go | 11 +- cmd/worker/main.go | 37 + docs/VERSION_HISTORY.md | 80 + internal/appconfig/config.go | 62 +- internal/governance/version_history.go | 268 ++ internal/governance/version_history_test.go | 101 + internal/httpapi/auth.go | 5 + internal/httpapi/server.go | 16 + internal/httpapi/server_test.go | 26 +- internal/httpapi/version_history.go | 257 ++ internal/httpapi/version_history_test.go | 286 ++ .../000046_version_history.down.sql | 4 + .../migrations/000046_version_history.up.sql | 56 + internal/platform/postgres/migrations_test.go | 14 +- internal/platform/postgres/version_history.go | 315 ++ internal/version/buildinfo.go | 35 + internal/versionhistory/gitea.go | 337 +++ internal/versionhistory/gitea_test.go | 214 ++ internal/versionhistory/helpers.go | 36 + internal/versionhistory/live_test.go | 168 + internal/versionhistory/rules.go | 190 ++ internal/versionhistory/rules_test.go | 195 ++ internal/versionhistory/sync.go | 279 ++ internal/versionhistory/sync_test.go | 251 ++ scripts/test-scope.json | 5 + web/src/App.tsx | 2689 +++++++++++++---- web/src/VersionHistory.test.tsx | 209 ++ web/src/VersionHistory.tsx | 285 ++ web/src/versionHistory.css | 171 ++ web/src/versionHistory.test.ts | 140 + web/src/versionHistory.ts | 194 ++ web/src/workspace.ts | 13 +- 35 files changed, 6413 insertions(+), 659 deletions(-) create mode 100644 docs/VERSION_HISTORY.md create mode 100644 internal/governance/version_history.go create mode 100644 internal/governance/version_history_test.go create mode 100644 internal/httpapi/version_history.go create mode 100644 internal/httpapi/version_history_test.go create mode 100644 internal/platform/postgres/migrations/000046_version_history.down.sql create mode 100644 internal/platform/postgres/migrations/000046_version_history.up.sql create mode 100644 internal/platform/postgres/version_history.go create mode 100644 internal/version/buildinfo.go create mode 100644 internal/versionhistory/gitea.go create mode 100644 internal/versionhistory/gitea_test.go create mode 100644 internal/versionhistory/helpers.go create mode 100644 internal/versionhistory/live_test.go create mode 100644 internal/versionhistory/rules.go create mode 100644 internal/versionhistory/rules_test.go create mode 100644 internal/versionhistory/sync.go create mode 100644 internal/versionhistory/sync_test.go create mode 100644 web/src/VersionHistory.test.tsx create mode 100644 web/src/VersionHistory.tsx create mode 100644 web/src/versionHistory.css create mode 100644 web/src/versionHistory.test.ts create mode 100644 web/src/versionHistory.ts diff --git a/.env.example b/.env.example index 2f4c13f..7a01b8a 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,12 @@ TEST_WEKNORA_POLL_MAX=60s TEST_WEKNORA_STALLED_AFTER=30m TEST_WEKNORA_MAX_UPLOAD_BYTES=52428800 TEST_ACCEPTANCE_POLICY_VERSION=structural-v1 + +# 「版本记录」页:**匿名只读**远端仓库的提交与 tag(**不使用任何凭据**)。 +# 留空即"未接线"(页面降级展示、同步按钮不可用)。只允许下面批准的 origin 与固定仓库, +# 不接受任何调用方传入的 URL;`VERSION_HISTORY_REPO` 必须是 owner/name。 +VERSION_HISTORY_GITEA_BASE_URL= +VERSION_HISTORY_REPO=Weknora/knowledge-governance +VERSION_HISTORY_ALLOWED_ORIGINS= +VERSION_HISTORY_HTTP_TIMEOUT=15s +VERSION_HISTORY_MAX_PAGES=200 diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfcdfd..d0af1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [Unreleased] + +### Added + +- **「版本记录」页**(一级菜单第 5 项):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。 +- 新增只读接口 `GET /api/v1/build-info`(当前运行产物的构建身份)、`GET /api/v1/version-history`(快照游标分页,不返回伪造的 total)、`POST /api/v1/version-history/sync`(仅管理员手动同步,并发同步复用同一作业)。 +- 新增迁移 `000046_version_history`(提交记录 + 同步状态):`commit_sha` 是唯一身份,**版本号不是唯一键** —— 两个提交算出同一个开发号时各占一行,用 SHA 区分。 +- 「当前运行」只认运行产物注入的**完整 SHA 精确匹配**:匹配到就标该条;开发构建显式标注"开发构建";拿不到标识就显示"当前运行版本未知",**不拿最新 main 或最新 tag 顶替**;CI 成功不等于已部署。 + +### Security + +- 版本记录同步**匿名只读**远端仓库(**不使用任何凭据**),来源限配置里批准的 origin 与固定仓库:拒绝任意 URL、拒绝跨主机重定向,单个响应设大小上限与超时、5xx/429 才有退避重试。 +- 提交正文与变更记录一律按**纯文本**渲染(不产生 HTML/脚本注入面);页面不含打 tag / 发布 / 部署入口;同步动作仅管理员可用,服务端再次判角色。 + > **版本号的唯一来源就是本文件顶部那条已发布版本。** 不放 `VERSION` 文件、不把版本写进源码常量。 > > **正式版本**用**注释 tag** `vX.Y.Z`(由人批准后创建):CI 只**核验**「tag 的版本 == 它指到那个提交里的 diff --git a/api/openapi.yaml b/api/openapi.yaml index 27abaaf..2114dbb 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -44,6 +44,106 @@ } ], "paths": { + "/api/v1/build-info": { + "get": { + "tags": [ + "Runtime" + ], + "operationId": "getBuildInfo", + "summary": "读取当前运行产物的构建身份(正式版本/构建类型/完整提交 SHA);拿不到注入就明确返回不可用", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/version-history": { + "get": { + "tags": [ + "Runtime" + ], + "operationId": "listVersionHistory", + "summary": "按快照游标分页列出远端 main 可达的提交、版本号证据与「当前运行」判据", + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "release", + "development", + "historical" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "409": { + "description": "游标指向的记录快照已被清理,需要刷新后重新加载" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v1/version-history/sync": { + "post": { + "tags": [ + "Runtime" + ], + "operationId": "syncVersionHistory", + "summary": "手动同步一次版本记录(仅管理员;并发同步复用同一个作业)", + "responses": { + "200": { + "$ref": "#/components/responses/Envelope" + }, + "202": { + "description": "已入队同步任务" + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/v1/backup-schedule": { "get": { "tags": [ diff --git a/cmd/api/main.go b/cmd/api/main.go index 097fe1e..d1dc260 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -120,6 +120,11 @@ func run(ctx context.Context, getenv func(string) string) error { TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP, ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP, }) + // 「版本记录」:只读查询面在 API 侧接线;真正的抓取在 worker 侧(未配置来源即未接线)。 + versionHistoryStore, versionHistoryRepo := httpapi.VersionHistoryStore(nil), "" + if config.VersionHistoryGiteaBaseURL != "" { + versionHistoryStore, versionHistoryRepo = postgresstore.NewStore(pool), config.VersionHistoryRepo + } handler, err := httpapi.NewHandler(httpapi.Config{ Store: postgresstore.NewStore(pool), Monitor: monitorProvider, @@ -137,8 +142,10 @@ func run(ctx context.Context, getenv func(string) string) error { ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef, ProductionReleaseAuthorizationToken: releaseAuthorizationToken, // 数据源发现(系统设置 → 数据源配置):用与 worker 同一份 dws 状态做只读列举。 - DingtalkSources: dingtalkSourceLister{}, - BackupSchedule: postgresstore.NewStore(pool), + DingtalkSources: dingtalkSourceLister{}, + BackupSchedule: postgresstore.NewStore(pool), + VersionHistory: versionHistoryStore, + VersionHistoryRepo: versionHistoryRepo, BackupHost: backuphost.New(backuphost.Config{ SSHConfig: config.BackupHostSSHConfig, Target: config.BackupHostTarget, diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 3cf7815..ab27e35 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "os" @@ -16,6 +17,7 @@ import ( "knowledge-governance/internal/jobs" postgresstore "knowledge-governance/internal/platform/postgres" "knowledge-governance/internal/platform/weknora" + "knowledge-governance/internal/versionhistory" artifactfetch "knowledge-governance/internal/workflows/artifactfetch" catalogrefresh "knowledge-governance/internal/workflows/catalogrefresh" "knowledge-governance/internal/workflows/sourcescan" @@ -110,6 +112,41 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st return nil, fmt.Errorf("create artifact fetch handler: %w", err) } handlers["artifact.fetch"] = artifactHandler.Handle + if config.VersionHistoryGiteaBaseURL != "" { + // 「版本记录」同步:匿名只读远端仓库的提交/tag,写本库快照。未配置来源就不注册, + // 界面上的同步按钮也会显示"未接线",不会入队一个没人处理的作业。 + client, err := versionhistory.NewGiteaClient(versionhistory.GiteaConfig{ + BaseURL: config.VersionHistoryGiteaBaseURL, + AllowedBaseURLs: config.VersionHistoryAllowedOrigins, + Repo: config.VersionHistoryRepo, + Timeout: config.VersionHistoryHTTPTimeout, + }) + if err != nil { + return nil, fmt.Errorf("create version history client: %w", err) + } + syncer, err := versionhistory.NewSyncer(versionhistory.SyncerConfig{Source: client, Store: store, + RepoKey: config.VersionHistoryRepo, MaxPages: config.VersionHistoryMaxPages}) + if err != nil { + return nil, fmt.Errorf("create version history syncer: %w", err) + } + handlers[versionhistory.JobType] = func(ctx context.Context, job jobs.Job) error { + triggeredBy := "" + var payload struct { + TriggeredBy string `json:"triggered_by"` + } + if err := json.Unmarshal(job.Payload, &payload); err == nil { + triggeredBy = payload.TriggeredBy + } + run, err := syncer.Sync(ctx, job.ID, triggeredBy) + if err != nil { + log.Printf("version history sync failed job=%s run=%d code=%s: %v", job.ID, run.ID, run.ErrorCode, err) + return err + } + log.Printf("version history sync completed job=%s run=%d fetched=%d expected=%v complete=%v", + job.ID, run.ID, run.FetchedCount, run.ExpectedCount, run.Complete) + return nil + } + } if config.PPScanEnabled { scanner, err := sourcescan.NewFilePPScanner(sourcescan.FilePPScannerConfig{ AllowedRoot: config.PPScanAllowedRoot, diff --git a/docs/VERSION_HISTORY.md b/docs/VERSION_HISTORY.md new file mode 100644 index 0000000..9454da9 --- /dev/null +++ b/docs/VERSION_HISTORY.md @@ -0,0 +1,80 @@ +# 版本记录(全部 main 提交时间线) + +「版本记录」是平台一级菜单的只读页面:追踪**远端 main 可达的每一个提交**(含合并提交与已并入 main +的侧分支提交),按提交时间倒序展示,并标注它对应哪一版、有没有正式 tag、是不是当前正在跑的那份产物。 + +## 一、版本号口径(只给有证据的号) + +| 判定 | 条件(全部可机器核验) | 页面显示 | +|---|---|---| +| **正式版本** | ① 有 tag `vX.Y.Z` 指向该提交;② 该 tag 是**注释 tag**(Gitea `/git/refs/tags` 的 `object.type == "tag"`);③ 该提交内 `CHANGELOG.md` 顶部版本 == `X.Y.Z` | `0.1.0` + `[正式版本]`,正文取该提交内的变更记录 | +| **开发版本** | 该提交内 CHANGELOG 顶部有版本号,且存在 `v<该版本>` tag、能算出「自该 tag 以来的提交数」n | `<主>.<次>.` + `[开发版本]`(与 `scripts/version.py` **同规则**:n=0 时等于版本号本身) | +| **历史提交** | 证据不足(轻量 tag / tag 与提交内记录不一致 / 找不到基线 / 提交图不完整) | `历史提交 · <短SHA>` + 具体原因,**不补造版本号** | + +- **完整 SHA 是唯一身份**,版本号不是唯一键:两个提交算出同一个号(例如 tag 之后两个并行分支各自第 1 个提交都是 `0.1.1`)时**两条都保留**,用短 SHA 区分。 +- 提交数按提交图算(等价 `git rev-list --count ..`),**不执行历史提交里的任何脚本**;提交图不完整(检出被截断)时拒绝给号,原因码 `shallow_or_incomplete_graph`。 + +## 二、数据与同步 + +- 两张表(迁移 `000046_version_history`):`version_history_commits`(提交记录,主键 `repo_key + commit_sha`)、`version_history_sync_runs`(同步状态)。 +- **快照 = 一次同步**(`snapshot_id = sync_runs.id`):抓取期间旧快照继续对外可见;抓完并**与 `X-Total-Count` 对账通过**后才成为事实。失败**不切换快照**、不清空历史,`last_success_at` 保持不动 ⇒ 页面显示"上次同步失败(原因)· 仍展示上次成功的数据(时间)"。 +- **同步成功 ≠ 历史完整**:`complete=false` 时页面显示"部分历史(N/M)"。 +- 抓取方式:`GET /repos/{owner}/{repo}/commits?limit=50&page=N` 逐页到短页为止(本仓 113 条 = 3 页);**每次同步都全量扫 tag**,因此"旧提交后补的 tag"会更新到既有记录(不新增第二行)。 +- 游标绑定快照(`base64(snapshot_id|committed_at|sha)`),排序 `committed_at DESC, commit_sha DESC` ⇒ 翻页确定性、不重不漏;游标指向的快照被清理后返回 `409 cursor_expired`,页面提示刷新。 +- 只保留最近 **3** 个成功快照供游标使用(`VersionHistoryKeptSnapshots`)。 + +## 三、当前运行(只认精确匹配) + +- `GET /api/v1/build-info` 回运行产物由 `-ldflags -X` 注入的身份:`release_version / build_kind / commit_sha(完整 40 位)/ display_version / built_at / identity_available`。 +- 页面判据:完整 SHA **精确命中**某条记录 ⇒ 标该条;`build_kind=development` 且命中不了 ⇒ 顶部显示"开发构建 `<号>`(短SHA)",**不标任何条目**;没有可用标识 ⇒ "当前运行版本未知"。 +- **绝不**用"最新 main"或"最新 tag"顶替当前运行;**CI 成功不等于已部署**。 + +## 四、配置(匿名只读,不使用凭据) + +```text +VERSION_HISTORY_GITEA_BASE_URL=http://192.168.50.223:3000 # 留空 = 未接线(页面降级,同步按钮不可用) +VERSION_HISTORY_REPO=Weknora/knowledge-governance # 固定 owner/name +VERSION_HISTORY_ALLOWED_ORIGINS= # 逗号分隔白名单;留空 = 只允许上面那个 origin +VERSION_HISTORY_HTTP_TIMEOUT=15s +VERSION_HISTORY_MAX_PAGES=200 +``` + +安全边界:来源必须落在白名单 origin 与固定仓库内(**拒绝任意 URL**)、拒绝跨主机重定向、单响应大小上限 5MB、 +超时 15s、仅 5xx/429 退避重试(最多 3 次);**请求不携带任何凭据**;提交正文与变更记录一律按**纯文本**渲染。 + +## 五、接口与权限 + +| 接口 | 最小角色 | 说明 | +|---|---|---| +| `GET /api/v1/build-info` | viewer | 当前运行产物的构建身份(只回身份字段) | +| `GET /api/v1/version-history` | viewer | 游标分页列表;带 `sync` 状态与 `current_running` 判定 | +| `POST /api/v1/version-history/sync` | **admin** | 手动同步一次;并发同步**复用同一个作业**(`IdempotencyKey`) | + +三个接口都登记在 `api/openapi.yaml`(契约测试会核对)。 + +## 六、同步触发 + +初版只有**管理员手动同步**(页面上「同步记录」按钮,仅管理员可见);**不默认新增定时调度**。 + +## 七、验证 + +```bash +# 单元与规则(含分页去重、对账失败、不可达保留旧快照、后补 tag 升级、同号不同 SHA) +go test ./internal/versionhistory/ ./internal/governance/ ./internal/httpapi/ -count=1 + +# 真实仓库交叉核对(**匿名**;未设变量即按设计跳过,理由在 scripts/test-scope.json) +VERSION_HISTORY_LIVE_BASE_URL=http://192.168.50.223:3000 \ + go test ./internal/versionhistory/ -run TestLiveSyncAgainstRealRepository -count=1 -v + +# 全量门禁 +make verify +``` + +真实仓库交叉核对会断言:抓到条数 == `X-Total-Count`、SHA 不重复、`vX.Y.Z` 是注释 tag 且目标与 +`git rev-parse vX.Y.Z^{commit}` 一致、开发号与本地 `git rev-list --count vX.Y.Z..` 逐个相等。 + +## 八、不做什么 + +- 不提供打 tag / 发布 / 部署入口;同步不改远端任何东西(只读 GET)。 +- 不用最新 tag 给历史提交重新编号,不给证据不足的提交编版本号。 +- 不把「软件版本」与「知识发布批次」混在一起(发布批次在「发布管理」)。 \ No newline at end of file diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index d64c4e2..3b9b4f7 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -61,6 +61,14 @@ type Config struct { BackupHostTarget string BackupHostScriptDir string BackupHostRoot string + // 版本记录(「版本记录」页):**匿名只读**远端仓库的提交与 tag。来源留空即"未接线" + // (页面降级展示,接口返回可用性=false)。只允许配置里批准的 origin 与仓库, + // 不接受任何调用方传入的 URL;本功能**不使用凭据**。 + VersionHistoryGiteaBaseURL string + VersionHistoryRepo string + VersionHistoryAllowedOrigins []string + VersionHistoryHTTPTimeout time.Duration + VersionHistoryMaxPages int } func Load(getenv func(string) string) (Config, error) { @@ -98,10 +106,15 @@ func Load(getenv func(string) string) (Config, error) { TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"), TestAutoReviewPolicy: valueOrDefault(getenv("TEST_AUTO_REVIEW_POLICY"), string(governance.AutoReviewInternal)), // 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。 - BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")), - BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")), - BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"), - BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"), + BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")), + BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")), + BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"), + BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"), + VersionHistoryGiteaBaseURL: strings.TrimSpace(getenv("VERSION_HISTORY_GITEA_BASE_URL")), + VersionHistoryRepo: valueOrDefault(strings.TrimSpace(getenv("VERSION_HISTORY_REPO")), "Weknora/knowledge-governance"), + VersionHistoryAllowedOrigins: splitOrigins(getenv("VERSION_HISTORY_ALLOWED_ORIGINS")), + VersionHistoryHTTPTimeout: 15 * time.Second, + VersionHistoryMaxPages: valueOrDefaultInt(getenv("VERSION_HISTORY_MAX_PAGES"), 200), } if config.DatabaseURL == "" { return Config{}, errors.New("DATABASE_URL is required") @@ -183,6 +196,26 @@ func Load(getenv func(string) string) (Config, error) { if config.WorkerLivenessStaleAfter, err = durationOrDefault(getenv("WORKER_LIVENESS_STALE_AFTER"), config.WorkerLivenessStaleAfter); err != nil { return Config{}, fmt.Errorf("WORKER_LIVENESS_STALE_AFTER: %w", err) } + if config.VersionHistoryHTTPTimeout, err = durationOrDefault(getenv("VERSION_HISTORY_HTTP_TIMEOUT"), config.VersionHistoryHTTPTimeout); err != nil { + return Config{}, fmt.Errorf("VERSION_HISTORY_HTTP_TIMEOUT: %w", err) + } + // 版本记录来源:给了 origin 就必须是干净的 http(s) origin;白名单留空则默认只许自己。 + if config.VersionHistoryGiteaBaseURL != "" { + if err := validateHTTPBaseURL(config.VersionHistoryGiteaBaseURL); err != nil { + return Config{}, fmt.Errorf("VERSION_HISTORY_GITEA_BASE_URL: %w", err) + } + if len(config.VersionHistoryAllowedOrigins) == 0 { + config.VersionHistoryAllowedOrigins = []string{config.VersionHistoryGiteaBaseURL} + } + for _, origin := range config.VersionHistoryAllowedOrigins { + if err := validateHTTPBaseURL(origin); err != nil { + return Config{}, fmt.Errorf("VERSION_HISTORY_ALLOWED_ORIGINS: %w", err) + } + } + if strings.Count(config.VersionHistoryRepo, "/") != 1 { + return Config{}, errors.New("VERSION_HISTORY_REPO 必须是 owner/name 形式") + } + } if config.TestWeKnoraHTTPTimeout, err = durationOrDefault(getenv("TEST_WEKNORA_HTTP_TIMEOUT"), config.TestWeKnoraHTTPTimeout); err != nil { return Config{}, fmt.Errorf("TEST_WEKNORA_HTTP_TIMEOUT: %w", err) } @@ -279,6 +312,27 @@ func validateHTTPBaseURL(value string) error { return nil } +// splitOrigins 解析逗号分隔的 origin 白名单(空项丢弃)。 +func splitOrigins(value string) []string { + parts := strings.Split(value, ",") + origins := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + origins = append(origins, trimmed) + } + } + return origins +} + +// valueOrDefaultInt 解析正整数(非法/缺省都退回默认值)。 +func valueOrDefaultInt(value string, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + func valueOrDefault(value, fallback string) string { if value == "" { return fallback diff --git a/internal/governance/version_history.go b/internal/governance/version_history.go new file mode 100644 index 0000000..524e9f4 --- /dev/null +++ b/internal/governance/version_history.go @@ -0,0 +1,268 @@ +package governance + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// 版本记录(「版本记录」页)的领域类型与**版本号口径**。 +// +// 口径(主人 2026-10-13 定:只给有证据的版本号): +// - 正式版本:存在**注释 tag** `vX.Y.Z` 指向该提交,且该提交内 CHANGELOG 顶部版本 == X.Y.Z; +// - 开发版本:该提交位于某个正式 tag 之后,且能算出「自该 tag 以来的提交数」⇒ `<主>.<次>.`; +// - 历史提交:证据不足 ⇒ 不下发数字版本号,只给原因(**不补造**)。 +// +// 不变量:**完整 SHA 是唯一身份**,版本号不是唯一键;两个提交算出同一个号时两条都保留。 +const ( + VersionStateVerified = "verified" + VersionStateUnknown = "unknown" + + RecordKindRelease = "release" + RecordKindDevelopment = "development" + RecordKindHistorical = "historical" + + // 原因码:页面据此显示「版本号未能核验」的具体理由。 + ReasonAnnotatedTagMatch = "annotated_tag_and_changelog_top_match" + ReasonLightweightTag = "lightweight_tag" + ReasonChangelogTopMissing = "no_changelog_top" + ReasonChangelogMismatch = "changelog_top_mismatch" + ReasonNoReleaseTag = "no_release_tag_for_commit" + ReasonNoBaseline = "no_baseline" + // ReasonIncompleteGraph:提交图不完整(检出被截断)⇒ 提交数算不准 ⇒ 宁可不给号。 + ReasonIncompleteGraph = "shallow_or_incomplete_graph" +) + +// versionHistorySemver 只认三段十进制、无前导零(与 scripts/check_changelog.py 同口径)。 +var versionHistorySemver = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`) + +// releasedHeading 匹配 CHANGELOG 里的已发布版本标题 `## [x.y.z] - …`。 +var releasedHeading = regexp.MustCompile(`(?m)^##\s*\[(\d+\.\d+\.\d+)\]\s*$|^##\s*\[(\d+\.\d+\.\d+)\]\s*-`) + +// VersionTagEvidence 是一个指向某提交的 tag 及其可核验属性。 +type VersionTagEvidence struct { + Name string `json:"name"` + TagSHA string `json:"sha,omitempty"` + Annotated bool `json:"annotated"` + URL string `json:"url,omitempty"` +} + +// VersionEvidence 是版本号判定的完整依据(进库的 jsonb,也直接给页面看)。 +type VersionEvidence struct { + State string `json:"state"` + Reason string `json:"reason"` + Tags []VersionTagEvidence `json:"tags,omitempty"` + ChangelogTop string `json:"changelog_top,omitempty"` + BaseTag string `json:"base_tag,omitempty"` + CommitsSinceBase *int `json:"commits_since_base,omitempty"` +} + +// VersionHistoryCommit 是一条提交记录(版本记录页的列表项)。 +type VersionHistoryCommit struct { + RepoKey string + CommitSHA string + Parents []string + CommittedAt time.Time + AuthorDisplay string + Subject string + Body string + VersionLabel string + VersionState string + RecordKind string + ReleaseTags []VersionTagEvidence + ReleaseNotes string + CommitURL string + VersionEvidence VersionEvidence + SnapshotID int64 +} + +// VersionHistorySyncRun 是一次同步的状态(「同步成功」≠「历史完整」)。 +type VersionHistorySyncRun struct { + ID int64 + JobID string + Status string + SourceHeadSHA string + StartedAt time.Time + FinishedAt time.Time + LastSuccessAt time.Time + Complete bool + FetchedCount int + ExpectedCount *int + ErrorCode string + ActiveSnapshotID int64 + TriggeredBy string +} + +// CurrentRunningIdentity 是「当前运行」的判据结果:只认精确匹配,**不猜**。 +type CurrentRunningIdentity struct { + State string `json:"state"` // matched | development | unknown + CommitSHA string `json:"commit_sha,omitempty"` + DisplayVersion string `json:"display_version,omitempty"` + MatchedCommitSHA string `json:"matched_commit_sha,omitempty"` +} + +// VersionHistoryPage 是列表接口的返回体。 +type VersionHistoryPage struct { + Items []VersionHistoryCommit + NextCursor string + HasMore bool + Sync VersionHistorySyncRun + CurrentRunning CurrentRunningIdentity +} + +// SemverValid 判断是否为合规三段的正式版本号。 +func SemverValid(value string) bool { return versionHistorySemver.MatchString(value) } + +// CompareSemver 按主/次/修订比较;任一非法 ⇒ 0(调用方应先校验)。 +func CompareSemver(left, right string) int { + lp, ok := semverParts(left) + if !ok { + return 0 + } + rp, ok := semverParts(right) + if !ok { + return 0 + } + for i := 0; i < 3; i++ { + if lp[i] != rp[i] { + if lp[i] < rp[i] { + return -1 + } + return 1 + } + } + return 0 +} + +func semverParts(value string) ([3]int, bool) { + var out [3]int + if !versionHistorySemver.MatchString(value) { + return out, false + } + for i, part := range strings.Split(value, ".") { + n, err := strconv.Atoi(part) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} + +// DecideVersion 按证据判定一个提交的版本号口径。 +// +// changelogTop 是该提交内 CHANGELOG 的顶部已发布版本(读不到就空); +// tagsAtCommit 是所有指向该提交的 tag(含注释标记);baseTag/commitsSinceBase +// 来自提交图遍历(最近的正式 tag 与该 tag 以来的提交数),没有就传空/nil。 +func DecideVersion(changelogTop string, tagsAtCommit []VersionTagEvidence, baseTag string, commitsSinceBase *int) (label, state, kind string, evidence VersionEvidence) { + evidence = VersionEvidence{State: VersionStateUnknown, Reason: ReasonNoReleaseTag, + Tags: tagsAtCommit, ChangelogTop: changelogTop, BaseTag: baseTag, CommitsSinceBase: commitsSinceBase} + + // ① 正式版本:注释 tag + 版本名合法 + 提交内 CHANGELOG 顶部一致。 + annotated := make([]VersionTagEvidence, 0, len(tagsAtCommit)) + semverTags := make([]VersionTagEvidence, 0, len(tagsAtCommit)) + for _, tag := range tagsAtCommit { + if tag.Annotated { + annotated = append(annotated, tag) + } + if SemverValid(strings.TrimPrefix(tag.Name, "v")) { + semverTags = append(semverTags, tag) + } + } + sort.Slice(semverTags, func(i, j int) bool { return semverTags[i].Name < semverTags[j].Name }) + for _, tag := range annotated { + version := strings.TrimPrefix(tag.Name, "v") + if !SemverValid(version) { + continue + } + if changelogTop != "" && changelogTop != version { + continue // 该 tag 不合规(版本与提交内记录不一致)⇒ 继续找别的 + } + if changelogTop == "" { + evidence.Reason = ReasonChangelogTopMissing + continue + } + return version, VersionStateVerified, RecordKindRelease, VersionEvidence{ + State: VersionStateVerified, Reason: ReasonAnnotatedTagMatch, Tags: tagsAtCommit, + ChangelogTop: changelogTop, BaseTag: "v" + version, CommitsSinceBase: commitsSinceBase} + } + + // ② 开发版本:基线 tag 存在且能算出「自该 tag 以来的提交数」。 + // 口径与 scripts/version.py **完全一致**:基线 = `v<提交内 CHANGELOG 顶部版本>`, + // 号 = `<主>.<次>.`(n=0 时正好等于该版本号本身);两个提交算出同一个号时两条都保留。 + if SemverValid(changelogTop) && baseTag != "" && commitsSinceBase != nil && *commitsSinceBase >= 0 { + parts, _ := semverParts(changelogTop) + label := fmt.Sprintf("%d.%d.%d", parts[0], parts[1], *commitsSinceBase) + return label, VersionStateVerified, RecordKindDevelopment, VersionEvidence{ + State: VersionStateVerified, Reason: "counted_commits_since_base_tag", Tags: tagsAtCommit, + ChangelogTop: changelogTop, BaseTag: baseTag, CommitsSinceBase: commitsSinceBase} + } + + // ③ 历史提交:证据不足 ⇒ 不给号,只给原因(不补造)。 + annotatedSemver := 0 + for _, tag := range annotated { + if SemverValid(strings.TrimPrefix(tag.Name, "v")) { + annotatedSemver++ + } + } + switch { + case len(semverTags) > 0 && annotatedSemver == 0: + evidence.Reason = ReasonLightweightTag + case changelogTop == "": + evidence.Reason = ReasonChangelogTopMissing + case annotatedSemver > 0: + evidence.Reason = ReasonChangelogMismatch + case baseTag == "": + evidence.Reason = ReasonNoBaseline + default: + evidence.Reason = ReasonNoReleaseTag + } + return "", VersionStateUnknown, RecordKindHistorical, evidence +} + +// ExtractReleaseNotes 从提交内的 CHANGELOG 文本里取某个已发布版本的整段正文。 +// +// 边界口径与 scripts/check_changelog.py 一致:**任意二级标题都是边界**(否则 +// `## 能力基线(未发布)` 这类标题会把后文算进上一节)。 +func ExtractReleaseNotes(changelog, version string) string { + if changelog == "" || !SemverValid(version) { + return "" + } + lines := strings.Split(changelog, "\n") + start := -1 + for i, line := range lines { + if m := releasedHeading.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + if m[1] == version || m[2] == version { + start = i + break + } + } + } + if start < 0 { + return "" + } + var body []string + for _, line := range lines[start+1:] { + if strings.HasPrefix(line, "## ") { + break + } + body = append(body, line) + } + return strings.TrimSpace(strings.Join(body, "\n")) +} + +// ChangelogTopVersion 取 CHANGELOG 顶部的已发布版本;没有 ⇒ 空串。 +func ChangelogTopVersion(changelog string) string { + for _, line := range strings.Split(changelog, "\n") { + if m := releasedHeading.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + if m[1] != "" { + return m[1] + } + return m[2] + } + } + return "" +} diff --git a/internal/governance/version_history_test.go b/internal/governance/version_history_test.go new file mode 100644 index 0000000..b6449ed --- /dev/null +++ b/internal/governance/version_history_test.go @@ -0,0 +1,101 @@ +package governance + +import "testing" + +func tag(name string, annotated bool) VersionTagEvidence { + return VersionTagEvidence{Name: name, TagSHA: "tag-" + name, Annotated: annotated} +} + +func intPtr(value int) *int { return &value } + +func TestDecideVersionReleaseRequiresAnnotatedTagAndChangelogTop(t *testing.T) { + cases := []struct { + name string + changelogTop string + tags []VersionTagEvidence + wantLabel string + wantKind string + wantReason string + }{ + {"注释 tag + 顶部一致 ⇒ 正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1.0", true)}, + "0.1.0", RecordKindRelease, ReasonAnnotatedTagMatch}, + {"轻量 tag 指向该提交 ⇒ 不算正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1.0", false)}, + "", RecordKindHistorical, ReasonLightweightTag}, + {"有版本但无 tag ⇒ 不算正式版本", "0.1.0", nil, "", RecordKindHistorical, ReasonNoBaseline}, + {"注释 tag 与提交内顶部不一致 ⇒ 不算正式版本", "0.1.1", []VersionTagEvidence{tag("v0.1.0", true)}, + "", RecordKindHistorical, ReasonChangelogMismatch}, + {"非三段版本名的 tag ⇒ 不算正式版本", "0.1.0", []VersionTagEvidence{tag("v0.1", true)}, + "", RecordKindHistorical, ReasonNoBaseline}, + } + for _, tc := range cases { + label, state, kind, evidence := DecideVersion(tc.changelogTop, tc.tags, "", nil) + if label != tc.wantLabel || kind != tc.wantKind { + t.Fatalf("%s:label=%q kind=%q,期望 %q/%q(reason=%s)", tc.name, label, kind, tc.wantLabel, tc.wantKind, evidence.Reason) + } + if evidence.Reason != tc.wantReason { + t.Fatalf("%s:reason=%q,期望 %q", tc.name, evidence.Reason, tc.wantReason) + } + if tc.wantKind == RecordKindHistorical && state != VersionStateUnknown { + t.Fatalf("%s:历史提交必须是 unknown,得到 %q", tc.name, state) + } + } +} + +func TestDecideVersionDevelopmentUsesBaseTagCount(t *testing.T) { + label, state, kind, evidence := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(12)) + if label != "0.1.12" || kind != RecordKindDevelopment || state != VersionStateVerified { + t.Fatalf("开发号计算错:%q/%q/%q", label, kind, state) + } + if evidence.BaseTag != "v0.1.0" || evidence.CommitsSinceBase == nil || *evidence.CommitsSinceBase != 12 { + t.Fatalf("依据不完整:%+v", evidence) + } + // 恰在发布提交上(0 个提交)⇒ 号就是该版本号 + label, _, kind, _ = DecideVersion("0.1.0", nil, "v0.1.0", intPtr(0)) + if label != "0.1.0" || kind != RecordKindDevelopment { + t.Fatalf("0 个提交时应等于版本号本身,得到 %q/%q", label, kind) + } + // 图不完整 ⇒ 不给号 + label, _, kind, _ = DecideVersion("0.1.0", nil, "", nil) + if label != "" || kind != RecordKindHistorical { + t.Fatalf("无基线时必须不下发号,得到 %q/%q", label, kind) + } +} + +func TestDecideVersionKeepsSameNumberForDifferentCommits(t *testing.T) { + // 两个提交算出同一个号:判定层不合并、不去重(唯一身份是 SHA) + first, _, _, _ := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(3)) + second, _, _, _ := DecideVersion("0.1.0", nil, "v0.1.0", intPtr(3)) + if first != second || first != "0.1.3" { + t.Fatalf("同号应各自保留:%q / %q", first, second) + } +} + +func TestChangelogTopAndReleaseNotes(t *testing.T) { + changelog := "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n" + + "## [0.2.0] - 2026-10-13\n\n### Changed\n\n- **破坏性**:接口下线\n\n" + + "## [0.1.0] - 2026-10-01\n\n### Added\n\n- 旧\n" + if top := ChangelogTopVersion(changelog); top != "0.2.0" { + t.Fatalf("顶部版本 = %q,期望 0.2.0", top) + } + notes := ExtractReleaseNotes(changelog, "0.2.0") + if notes != "### Changed\n\n- **破坏性**:接口下线" { + t.Fatalf("0.2.0 段落 = %q", notes) + } + if ExtractReleaseNotes(changelog, "9.9.9") != "" { + t.Fatal("不存在的版本必须返回空,不许猜") + } + // 不带方括号的二级标题也是边界(与 check_changelog 同口径) + boundary := "# Changelog\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- 甲\n\n## 能力基线(未发布)\n\n- 乙\n" + if notes := ExtractReleaseNotes(boundary, "0.1.0"); notes != "### Added\n\n- 甲" { + t.Fatalf("边界算错:%q", notes) + } +} + +func TestSemverHelpers(t *testing.T) { + if !SemverValid("0.1.0") || SemverValid("01.1.0") || SemverValid("0.1") || SemverValid("v0.1.0") { + t.Fatal("三段无前导零校验不对") + } + if CompareSemver("0.10.0", "0.9.0") != 1 || CompareSemver("1.0.0", "1.0.0") != 0 || CompareSemver("0.1.0", "0.2.0") != -1 { + t.Fatal("版本比较不对") + } +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index b67f2a7..49f5ecd 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -139,6 +139,11 @@ func classifyRole(method, path string) string { if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet { return roleAdmin } + // 版本记录的同步会拉起一次对远端仓库的抓取(消耗网络与数据库写入), + // 与"谁能改生产绑定"同级:读任何角色可看,同步只能管理员。 + if strings.HasPrefix(path, "/api/v1/version-history") && method != http.MethodGet { + return roleAdmin + } // Everything else that mutates (review, batch, seal, confirm, start, retry, // route visibility, disable) is engineer work; viewers cannot POST at all. return roleEngineer diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 862cc5a..0630d17 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -111,6 +111,10 @@ type Config struct { // enumerates the production-side bases the operator can bind. // BackupSchedule 是「系统设置 → 存储与备份」的计划存储;BackupHost 负责在目标库 // 所在主机上读回/下发计划并触发一次整库备份(未接线时页面降级展示)。 + // VersionHistory 是「版本记录」页的只读查询面;VersionHistoryRepo 是固定的仓库标识 + // (owner/name)。两者都给才接线,否则页面降级为"未接线"(不报 500)。 + VersionHistory VersionHistoryStore + VersionHistoryRepo string BackupSchedule BackupScheduleStore BackupHost BackupHost BackupRuns *BackupRunTracker @@ -318,6 +322,18 @@ func NewHandler(config Config) (http.Handler, error) { mux.HandleFunc("GET /api/v1/monitor/overview", func(w http.ResponseWriter, r *http.Request) { monitorOverview(w, r, config.Monitor) }) + // 「版本记录」:当前运行的构建身份(只读,与监控同级可见) + mux.HandleFunc("GET /api/v1/build-info", func(w http.ResponseWriter, r *http.Request) { + buildInfo(w, r) + }) + // 全部 main 提交时间线(读本库缓存,不直接打 Gitea) + mux.HandleFunc("GET /api/v1/version-history", func(w http.ResponseWriter, r *http.Request) { + versionHistoryList(w, r, config) + }) + // 手动同步一次(仅管理员;并发同步合并成同一个作业) + mux.HandleFunc("POST /api/v1/version-history/sync", func(w http.ResponseWriter, r *http.Request) { + versionHistorySync(w, r, config) + }) mux.HandleFunc("GET /api/v1/monitor/knowledge-bases/{id}/files", func(w http.ResponseWriter, r *http.Request) { monitorFiles(w, r, config.Monitor) }) diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index c757d99..3122788 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -1489,16 +1489,19 @@ func TestHandlerRejectsCatalogMutationsWithoutOperationID(t *testing.T) { } type fakeJobStore struct { - createSettingsConnectionID string - createSettingsDraft postgresstore.SourceSettingsDraft - createSettingsOperationID string - createSettingsErr error - createSettingsReplayed bool - activateSettingsConnectionID string - activateSettingsRevisionID string - activateSettingsOperationID string - activateSettingsReplayed bool - activateSettingsErr error + createSettingsConnectionID string + createSettingsDraft postgresstore.SourceSettingsDraft + createSettingsOperationID string + createSettingsErr error + createSettingsReplayed bool + activateSettingsConnectionID string + activateSettingsRevisionID string + activateSettingsOperationID string + activateSettingsReplayed bool + activateSettingsErr error + // enqueueEchoRequestedID:幂等"首次入队"语义 —— 返回调用方请求的那个 id + // (默认 false 时返回 enqueueResult,即"复用已有任务"的路径)。 + enqueueEchoRequestedID bool listSettingsConnectionID string listSettingsErr error settingsRevisions []governance.SourceConnectionRevision @@ -1867,6 +1870,9 @@ func (s *fakeJobStore) ActivateUISettingsWithOperation(_ context.Context, revisi func (s *fakeJobStore) Enqueue(_ context.Context, request jobs.EnqueueRequest, _ time.Time) (*jobs.Job, error) { s.enqueued = request + if s.enqueueEchoRequestedID { + return &jobs.Job{ID: request.ID, Type: request.Type, State: jobs.StatePending}, nil + } return s.enqueueResult, nil } diff --git a/internal/httpapi/version_history.go b/internal/httpapi/version_history.go new file mode 100644 index 0000000..ffa48d5 --- /dev/null +++ b/internal/httpapi/version_history.go @@ -0,0 +1,257 @@ +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 + } + 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}, 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 +} diff --git a/internal/httpapi/version_history_test.go b/internal/httpapi/version_history_test.go new file mode 100644 index 0000000..82b5bad --- /dev/null +++ b/internal/httpapi/version_history_test.go @@ -0,0 +1,286 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "knowledge-governance/internal/governance" + "knowledge-governance/internal/jobs" + postgresstore "knowledge-governance/internal/platform/postgres" + "knowledge-governance/internal/version" +) + +type fakeVersionHistoryStore struct { + items []governance.VersionHistoryCommit + nextCursor string + hasMore bool + snapshot int64 + pageErr error + state governance.VersionHistorySyncRun + stateErr error + inFlight bool + inFlightErr error + lastKind string + lastCursor string + lastLimit int +} + +func (f *fakeVersionHistoryStore) ActiveVersionHistorySnapshot(context.Context, string) (int64, error) { + return f.snapshot, nil +} + +func (f *fakeVersionHistoryStore) LoadVersionHistoryPage(_ context.Context, _, kind, cursor string, limit int) ( + []governance.VersionHistoryCommit, string, bool, int64, error) { + f.lastKind, f.lastCursor, f.lastLimit = kind, cursor, limit + if f.pageErr != nil { + return nil, "", false, f.snapshot, f.pageErr + } + return f.items, f.nextCursor, f.hasMore, f.snapshot, nil +} + +func (f *fakeVersionHistoryStore) LoadVersionHistorySyncState(context.Context, string) (governance.VersionHistorySyncRun, error) { + return f.state, f.stateErr +} + +func (f *fakeVersionHistoryStore) VersionHistorySyncInFlight(context.Context, string, time.Time) (bool, error) { + return f.inFlight, f.inFlightErr +} + +func decodeVersionHistoryBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("响应不是 JSON:%v(%s)", err, recorder.Body.String()) + } + return payload +} + +// 注入的完整 SHA 决定「当前运行」;不注入就只能是未知 —— 绝不拿最新版本顶替。 +func withInjectedIdentity(t *testing.T, sha, kind string) { + t.Helper() + previousSHA, previousKind, previousRelease := version.CommitSHA, version.BuildKind, version.ReleaseVersion + version.CommitSHA, version.BuildKind, version.ReleaseVersion = sha, kind, "0.1.0" + t.Cleanup(func() { + version.CommitSHA, version.BuildKind, version.ReleaseVersion = previousSHA, previousKind, previousRelease + }) +} + +func TestBuildInfoReportsIdentityAvailability(t *testing.T) { + sha := strings.Repeat("a", 40) + withInjectedIdentity(t, sha, "development") + recorder := httptest.NewRecorder() + buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("状态码 = %d", recorder.Code) + } + payload := decodeVersionHistoryBody(t, recorder) + if payload["identity_available"] != true || payload["commit_sha"] != sha { + t.Fatalf("完整注入时应给出身份:%v", payload) + } + if payload["build_kind"] != "development" { + t.Fatalf("构建类型 = %v", payload["build_kind"]) + } + + withInjectedIdentity(t, "unknown", "release") + recorder = httptest.NewRecorder() + buildInfo(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/build-info", nil)) + payload = decodeVersionHistoryBody(t, recorder) + if payload["identity_available"] != false || payload["commit_sha"] != nil || payload["display_version"] != "dev" { + t.Fatalf("身份不可用时必须明确说未知:%v", payload) + } +} + +func TestVersionHistoryListUnconfiguredStaysReadable(t *testing.T) { + withInjectedIdentity(t, "unknown", "development") + recorder := httptest.NewRecorder() + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), Config{}) + if recorder.Code != http.StatusOK { + t.Fatalf("未接线也必须 200(页面降级展示):%d", recorder.Code) + } + payload := decodeVersionHistoryBody(t, recorder) + sync, _ := payload["sync"].(map[string]any) + if sync["available"] != false { + t.Fatalf("未接线时 sync.available 必须为 false:%v", sync) + } + items, _ := payload["items"].([]any) + if len(items) != 0 { + t.Fatalf("未接线时不该有记录:%v", items) + } + running, _ := payload["current_running"].(map[string]any) + if running["state"] != "unknown" { + t.Fatalf("拿不到身份必须显示未知:%v", running) + } +} + +func TestVersionHistoryListMarksOnlyExactMatchAndKeepsSameNumberApart(t *testing.T) { + sha := strings.Repeat("b", 40) + other := strings.Repeat("c", 40) + withInjectedIdentity(t, sha, "development") + store := &fakeVersionHistoryStore{snapshot: 7, hasMore: true, nextCursor: "cursor-1", + items: []governance.VersionHistoryCommit{ + {CommitSHA: sha, CommittedAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC), + Subject: "feat: 当前运行的这条", RecordKind: governance.RecordKindDevelopment, + VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified, + VersionEvidence: governance.VersionEvidence{Reason: "counted_commits_since_base_tag"}}, + {CommitSHA: other, CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC), + Subject: "feat: 同号的另一条", RecordKind: governance.RecordKindDevelopment, + VersionLabel: "0.1.3", VersionState: governance.VersionStateVerified}, + }} + config := Config{VersionHistory: store, VersionHistoryRepo: "WeknorA/knowledge-governance"} + recorder := httptest.NewRecorder() + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?kind=development&limit=2", nil), config) + if recorder.Code != http.StatusOK { + t.Fatalf("状态码 = %d", recorder.Code) + } + if store.lastKind != "development" || store.lastLimit != 2 { + t.Fatalf("查询参数没透传:kind=%s limit=%d", store.lastKind, store.lastLimit) + } + payload := decodeVersionHistoryBody(t, recorder) + items, _ := payload["items"].([]any) + if len(items) != 2 { + t.Fatalf("记录数 = %d", len(items)) + } + flagged := 0 + for _, raw := range items { + item, _ := raw.(map[string]any) + if item["is_current_running"] == true { + flagged++ + if item["commit_sha"] != sha { + t.Fatalf("只允许精确匹配的那条被标记:%v", item) + } + } + // 同号不同 SHA:两条都在,且都带自己的短 SHA + if item["version_label"] != "0.1.3" || item["short_sha"] == "" { + t.Fatalf("同号的记录应各自保留并以短 SHA 区分:%v", item) + } + } + if flagged != 1 { + t.Fatalf("被标记为当前运行的条数 = %d", flagged) + } + if payload["has_more"] != true || payload["next_cursor"] != "cursor-1" { + t.Fatalf("分页游标没透传:%v", payload) + } + running, _ := payload["current_running"].(map[string]any) + if running["state"] != "matched" || running["matched_commit_sha"] != sha { + t.Fatalf("当前运行判定不对:%v", running) + } +} + +func TestVersionHistoryListDevelopmentBuildNotInRecordsIsNotMarked(t *testing.T) { + withInjectedIdentity(t, strings.Repeat("d", 40), "development") + store := &fakeVersionHistoryStore{snapshot: 3, items: []governance.VersionHistoryCommit{ + {CommitSHA: strings.Repeat("e", 40), VersionLabel: "0.1.5", RecordKind: governance.RecordKindDevelopment}}} + config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"} + recorder := httptest.NewRecorder() + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config) + payload := decodeVersionHistoryBody(t, recorder) + items, _ := payload["items"].([]any) + for _, raw := range items { + item, _ := raw.(map[string]any) + if item["is_current_running"] == true { + t.Fatalf("产物 SHA 不在记录里就不得标记任何条目:%v", item) + } + } + running, _ := payload["current_running"].(map[string]any) + if running["state"] != "development" || running["commit_sha"] == "" { + t.Fatalf("开发构建要显式标为开发构建,而不是标成某条记录:%v", running) + } +} + +func TestVersionHistoryListRejectsBadLimitAndExpiredCursor(t *testing.T) { + withInjectedIdentity(t, "unknown", "development") + store := &fakeVersionHistoryStore{snapshot: 5} + config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"} + recorder := httptest.NewRecorder() + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?limit=999", nil), config) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("limit 越界应 400,得到 %d", recorder.Code) + } + if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "invalid_limit" { + t.Fatalf("错误码 = %v", code) + } + + store.pageErr = postgresstore.ErrVersionHistoryCursorExpired + recorder = httptest.NewRecorder() + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history?cursor=stale", nil), config) + if recorder.Code != http.StatusConflict { + t.Fatalf("游标过期应 409,得到 %d", recorder.Code) + } + if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "cursor_expired" { + t.Fatalf("错误码 = %v", code) + } +} + +func TestVersionHistorySyncRequiresAdminAndEnqueuesOnce(t *testing.T) { + // 角色:读任何角色可看,同步只能管理员 + if got := classifyRole(http.MethodGet, "/api/v1/version-history"); got != roleViewer { + t.Fatalf("GET 的最小角色 = %q", got) + } + if got := classifyRole(http.MethodPost, "/api/v1/version-history/sync"); got != roleAdmin { + t.Fatalf("同步的最小角色 = %q,必须是 %q", got, roleAdmin) + } + + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + queue := &fakeJobStore{enqueueEchoRequestedID: true} + store := &fakeVersionHistoryStore{snapshot: 9} + config := Config{Store: queue, VersionHistory: store, VersionHistoryRepo: "Weknora/knowledge-governance", + Now: func() time.Time { return now }} + + recorder := httptest.NewRecorder() + versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config) + if recorder.Code != http.StatusAccepted { + t.Fatalf("首次同步应 202,得到 %d(%s)", recorder.Code, recorder.Body.String()) + } + if queue.enqueued.Type != VersionHistoryJobType || queue.enqueued.IdempotencyKey != "version-history.sync:Weknora/knowledge-governance" { + t.Fatalf("入队参数不对:%+v", queue.enqueued) + } + if queue.enqueued.MaxAttempts != 3 || queue.enqueued.AvailableAt != now { + t.Fatalf("入队参数不对:%+v", queue.enqueued) + } + payload := decodeVersionHistoryBody(t, recorder) + if payload["reused"] != false { + t.Fatalf("首次不该标成复用:%v", payload) + } + + // 已经在跑:不重复入队,明确返回已有任务 + store.inFlight = true + store.state = governance.VersionHistorySyncRun{ID: 11, JobID: "job-running", Status: "running"} + queue.enqueued = jobs.EnqueueRequest{} + recorder = httptest.NewRecorder() + versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), config) + if recorder.Code != http.StatusOK { + t.Fatalf("并发同步应 200 + 复用已有任务,得到 %d", recorder.Code) + } + payload = decodeVersionHistoryBody(t, recorder) + if payload["reused"] != true || payload["job_id"] != "job-running" { + t.Fatalf("应复用已有任务:%v", payload) + } + if queue.enqueued.Type != "" { + t.Fatalf("已在跑时不得再次入队:%+v", queue.enqueued) + } +} + +func TestVersionHistorySyncUnavailableWhenNotWired(t *testing.T) { + recorder := httptest.NewRecorder() + versionHistorySync(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/version-history/sync", nil), Config{}) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("未接线应 503,得到 %d", recorder.Code) + } + if code := decodeVersionHistoryBody(t, recorder)["code"]; code != "version_history_unavailable" { + t.Fatalf("错误码 = %v", code) + } + // 未接线时页面仍然可读(只是没有记录) + store := &fakeVersionHistoryStore{stateErr: errors.New("boom")} + recorder = httptest.NewRecorder() + config := Config{VersionHistory: store, VersionHistoryRepo: "o/r"} + versionHistoryList(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/version-history", nil), config) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("查询异常应 500,得到 %d", recorder.Code) + } +} diff --git a/internal/platform/postgres/migrations/000046_version_history.down.sql b/internal/platform/postgres/migrations/000046_version_history.down.sql new file mode 100644 index 0000000..23be4d7 --- /dev/null +++ b/internal/platform/postgres/migrations/000046_version_history.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS version_history_sync_runs_recent_idx; +DROP INDEX IF EXISTS version_history_commits_page_idx; +DROP TABLE IF EXISTS version_history_sync_runs; +DROP TABLE IF EXISTS version_history_commits; diff --git a/internal/platform/postgres/migrations/000046_version_history.up.sql b/internal/platform/postgres/migrations/000046_version_history.up.sql new file mode 100644 index 0000000..48bad78 --- /dev/null +++ b/internal/platform/postgres/migrations/000046_version_history.up.sql @@ -0,0 +1,56 @@ +-- 「版本记录」页的数据:**远端 main 可达的每一个提交**(含合并提交与已并入 main 的侧分支提交), +-- 以及每次同步的状态。 +-- +-- 口径(见 docs/RELEASE_RUNBOOK.md 与 internal/governance/version_history.go): +-- * commit_sha(完整 40 位)是**唯一身份**;version_label **不是唯一键** —— 两个提交算出同一个 +-- 开发号时各占一行,用 SHA 区分。 +-- * 证据不足一律 version_state='unknown' + record_kind='historical',**不补造版本号**。 +-- * 快照 = 一次同步(snapshot_id = version_history_sync_runs.id);对外只服务**最近一次成功**的快照 +-- ⇒ 抓取过程中旧数据继续可读,新快照抓完并计数对账通过后才成为事实。 +CREATE TABLE version_history_commits ( + repo_key TEXT NOT NULL, + commit_sha CHAR(40) NOT NULL, + parents TEXT[] NOT NULL DEFAULT '{}', + committed_at TIMESTAMPTZ NOT NULL, + author_display TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + version_label TEXT, + version_state TEXT NOT NULL, + record_kind TEXT NOT NULL, + release_tags JSONB NOT NULL DEFAULT '[]'::JSONB, + release_notes TEXT NOT NULL DEFAULT '', + commit_url TEXT NOT NULL DEFAULT '', + version_evidence JSONB NOT NULL DEFAULT '{}'::JSONB, + snapshot_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (repo_key, commit_sha), + CONSTRAINT version_history_commits_kind CHECK (record_kind IN ('release', 'development', 'historical')), + CONSTRAINT version_history_commits_state CHECK (version_state IN ('verified', 'unknown')), + CONSTRAINT version_history_commits_sha CHECK (commit_sha ~ '^[0-9a-f]{40}$') +); + +-- 分页排序 (committed_at DESC, commit_sha DESC) 的覆盖索引:游标翻页不重不漏。 +CREATE INDEX version_history_commits_page_idx + ON version_history_commits (repo_key, snapshot_id, committed_at DESC, commit_sha DESC); + +CREATE TABLE version_history_sync_runs ( + id BIGSERIAL PRIMARY KEY, + repo_key TEXT NOT NULL, + job_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + source_head_sha TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + last_success_at TIMESTAMPTZ, + complete BOOLEAN NOT NULL DEFAULT FALSE, + fetched_count INTEGER NOT NULL DEFAULT 0, + expected_count INTEGER, + error_code TEXT NOT NULL DEFAULT '', + triggered_by TEXT NOT NULL DEFAULT '', + CONSTRAINT version_history_sync_runs_status + CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) +); + +CREATE INDEX version_history_sync_runs_recent_idx + ON version_history_sync_runs (repo_key, id DESC); diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go index be69e91..837166d 100644 --- a/internal/platform/postgres/migrations_test.go +++ b/internal/platform/postgres/migrations_test.go @@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if err != nil { t.Fatalf("loadMigrations() error = %v", err) } - if len(migrations) != 45 { - t.Fatalf("migration count = %d, want 45", len(migrations)) + if len(migrations) != 46 { + t.Fatalf("migration count = %d, want 46", len(migrations)) } if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") { t.Fatalf("migration 1 = %#v", migrations[0]) @@ -115,6 +115,16 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) { if migrations[44].Version != 45 || !strings.Contains(migrations[44].UpSQL, "CREATE TABLE backup_schedule") { t.Fatalf("migration 45 = %#v", migrations[44]) } + // 000046:「版本记录」的提交记录与同步状态;唯一身份是完整 commit_sha, + // 版本号不是唯一键;证据不足只落 historical(不许编版本号)。 + if migrations[45].Version != 46 || !strings.Contains(migrations[45].UpSQL, "CREATE TABLE version_history_commits") || + !strings.Contains(migrations[45].UpSQL, "CREATE TABLE version_history_sync_runs") || + !strings.Contains(migrations[45].UpSQL, "'release', 'development', 'historical'") { + t.Fatalf("migration 46 = %#v", migrations[45]) + } + if !strings.Contains(migrations[45].DownSQL, "DROP TABLE IF EXISTS version_history_commits") { + t.Fatalf("migration 46 down = %#v", migrations[45].DownSQL) + } } func TestProcessingConfirmationMigrationSeparatesPlanningFromUpload(t *testing.T) { diff --git a/internal/platform/postgres/version_history.go b/internal/platform/postgres/version_history.go new file mode 100644 index 0000000..511c530 --- /dev/null +++ b/internal/platform/postgres/version_history.go @@ -0,0 +1,315 @@ +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 +} diff --git a/internal/version/buildinfo.go b/internal/version/buildinfo.go new file mode 100644 index 0000000..55e0cbe --- /dev/null +++ b/internal/version/buildinfo.go @@ -0,0 +1,35 @@ +package version + +// 构建身份(「版本记录」页的"当前运行"判据)。**只回身份字段**:不含主机、配置或任何凭据。 +// +// 关键点:只有注入进来的 **完整 40 位 SHA** 才算可用身份 —— 页面据此做精确匹配; +// 拿不到就明确返回 identity_available=false,由页面显示"当前运行版本未知",**绝不猜**。 +type BuildInfo struct { + ReleaseVersion string `json:"release_version"` + BuildKind string `json:"build_kind"` + CommitSHA *string `json:"commit_sha"` + DisplayVersion string `json:"display_version"` + BuiltAt string `json:"built_at"` + IdentityAvailable bool `json:"identity_available"` +} + +// Snapshot 读当前运行产物的构建身份。 +func Snapshot() BuildInfo { + info := BuildInfo{ReleaseVersion: ReleaseVersion, BuildKind: BuildKind, BuiltAt: BuildTime} + if IsRelease() { + info.BuildKind = "release" + } else { + info.BuildKind = "development" + } + if fullSHA.MatchString(CommitSHA) { + sha := CommitSHA + info.CommitSHA = &sha + info.DisplayVersion = DisplayVersion() + info.IdentityAvailable = true + return info + } + // 身份不可用:明确说"未知",不回上一正式版本号,也不假装是某个提交 + info.DisplayVersion = "dev" + info.IdentityAvailable = false + return info +} diff --git a/internal/versionhistory/gitea.go b/internal/versionhistory/gitea.go new file mode 100644 index 0000000..fa50ef3 --- /dev/null +++ b/internal/versionhistory/gitea.go @@ -0,0 +1,337 @@ +package versionhistory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// GiteaConfig 是只读客户端的配置。**只用匿名请求**:本客户端不会携带任何凭据, +// 也不接受调用方传入任意 URL —— base 必须在白名单里,仓库只允许配置的那一个。 +type GiteaConfig struct { + BaseURL string + AllowedBaseURLs []string + Repo string + HTTPClient *http.Client + Timeout time.Duration + MaxResponseBytes int64 + MaxRetries int + Sleep func(time.Duration) + PageSize int +} + +// GiteaClient 只读地访问 Gitea 的提交、tag 与仓库内文件。 +type GiteaClient struct { + baseURL *url.URL + repo string + client *http.Client + maxBytes int64 + maxRetries int + sleep func(time.Duration) + pageSize int +} + +const ( + giteaDefaultTimeout = 15 * time.Second + giteaDefaultMaxBytes = 5 << 20 // 单个响应上限 5MB + giteaDefaultRetries = 3 + giteaDefaultPageSize = 50 // Gitea 的 commits/tags 上限就是 50 + giteaCommitURLSuffix = "/commit/" +) + +// NewGiteaClient 校验并构造客户端。校验失败一律返回错误(宁可不接线)。 +func NewGiteaClient(config GiteaConfig) (*GiteaClient, error) { + base, err := url.Parse(strings.TrimSpace(config.BaseURL)) + if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") { + return nil, errors.New("版本记录:Gitea base URL 非法(必须是 http(s) 绝对地址)") + } + allowed := false + for _, candidate := range config.AllowedBaseURLs { + parsed, perr := url.Parse(strings.TrimSpace(candidate)) + if perr == nil && parsed.Host != "" && parsed.Scheme == base.Scheme && parsed.Host == base.Host { + allowed = true + break + } + } + if !allowed { + return nil, fmt.Errorf("版本记录:Gitea base URL %q 不在白名单里(拒绝任意 URL 请求)", config.BaseURL) + } + repo := strings.Trim(strings.TrimSpace(config.Repo), "/") + if strings.Count(repo, "/") != 1 || strings.ContainsAny(repo, " \t?#") { + return nil, errors.New("版本记录:仓库必须是 owner/name 形式") + } + maxBytes := config.MaxResponseBytes + if maxBytes <= 0 { + maxBytes = giteaDefaultMaxBytes + } + timeout := config.Timeout + if timeout <= 0 { + timeout = giteaDefaultTimeout + } + retries := config.MaxRetries + if retries <= 0 { + retries = giteaDefaultRetries + } + pageSize := config.PageSize + if pageSize <= 0 || pageSize > giteaDefaultPageSize { + pageSize = giteaDefaultPageSize + } + client := config.HTTPClient + if client == nil { + client = &http.Client{Timeout: timeout} + } + // 只允许回到同一个 host:重定向到别处一律拒绝(防 SSRF)。 + sameHost := *client + sameHost.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != base.Scheme || req.URL.Host != base.Host { + return newGiteaError(KindRedirect, 0, "版本记录:拒绝重定向到其它主机", nil) + } + return nil + } + sleep := config.Sleep + if sleep == nil { + sleep = time.Sleep + } + return &GiteaClient{baseURL: base, repo: repo, client: &sameHost, maxBytes: maxBytes, + maxRetries: retries, sleep: sleep, pageSize: pageSize}, nil +} + +// PageSize 是抓取页大小(外层据此做完整性对账)。 +func (c *GiteaClient) PageSize() int { return c.pageSize } + +// CommitURLBase 给记录页拼提交链接用(白名单 origin + 固定仓库,不接受外部传入)。 +func (c *GiteaClient) CommitURLBase() string { + return strings.TrimRight(c.baseURL.String(), "/") + "/" + c.repo + giteaCommitURLSuffix +} + +func (c *GiteaClient) apiURL(path string, query url.Values) string { + target := *c.baseURL + target.Path = strings.TrimRight(c.baseURL.Path, "/") + "/api/v1/repos/" + c.repo + path + target.RawQuery = query.Encode() + return target.String() +} + +// get 发一次匿名 GET(**不设 Authorization 头**),带超时、响应上限与退避重试。 +func (c *GiteaClient) get(ctx context.Context, rawURL string) ([]byte, http.Header, error) { + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + c.sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, nil, newGiteaError(KindParse, 0, "版本记录:构造请求失败", err) + } + request.Header.Set("Accept", "application/json") + response, err := c.client.Do(request) + if err != nil { + lastErr = newGiteaError(KindUnreachable, 0, "版本记录:Gitea 不可达", err) + continue + } + body, readErr := io.ReadAll(io.LimitReader(response.Body, c.maxBytes+1)) + closeErr := response.Body.Close() + if readErr != nil { + lastErr = newGiteaError(KindUnreachable, 0, "版本记录:读取响应失败", readErr) + continue + } + if closeErr != nil { + lastErr = newGiteaError(KindUnreachable, 0, "版本记录:关闭响应失败", closeErr) + continue + } + if int64(len(body)) > c.maxBytes { + return nil, nil, newGiteaError(KindTooLarge, response.StatusCode, + fmt.Sprintf("版本记录:响应超过 %d 字节上限(已丢弃)", c.maxBytes), nil) + } + switch { + case response.StatusCode == http.StatusOK: + return body, response.Header, nil + case response.StatusCode == http.StatusNotFound: + return nil, response.Header, errGiteaNotFound + case response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500: + lastErr = newGiteaError(KindStatus, response.StatusCode, "版本记录:Gitea 返回可重试错误", nil) + continue + default: + return nil, response.Header, newGiteaError(KindStatus, response.StatusCode, "版本记录:Gitea 返回错误", nil) + } + } + if lastErr == nil { + lastErr = newGiteaError(KindUnreachable, 0, "版本记录:Gitea 请求失败", nil) + } + return nil, nil, lastErr +} + +var errGiteaNotFound = newGiteaError(KindNotFound, http.StatusNotFound, "版本记录:Gitea 上没有该对象", nil) + +// ListCommits 读一页提交;同时返回 X-Total-Count(用于完整性对账,不做假 total)。 +func (c *GiteaClient) ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) { + if limit <= 0 || limit > giteaDefaultPageSize { + limit = giteaDefaultPageSize + } + query := url.Values{} + query.Set("limit", fmt.Sprintf("%d", limit)) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("stat", "false") + query.Set("verification", "false") + body, header, err := c.get(ctx, c.apiURL("/commits", query)) + if err != nil { + return nil, 0, err + } + var payload []struct { + SHA string `json:"sha"` + Parents []struct { + SHA string `json:"sha"` + } `json:"parents"` + Created string `json:"created"` + Commit struct { + Message string `json:"message"` + Author struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"author"` + Committer struct { + Name string `json:"name"` + Date string `json:"date"` + } `json:"committer"` + } `json:"commit"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, 0, newGiteaError(KindParse, 0, "版本记录:提交响应无法解析", err) + } + total := 0 + if raw := header.Get("X-Total-Count"); raw != "" { + if _, err := fmt.Sscanf(raw, "%d", &total); err != nil { + total = 0 + } + } + commits := make([]RawCommit, 0, len(payload)) + for _, item := range payload { + if !fullSHA.MatchString(item.SHA) { + continue + } + parents := make([]string, 0, len(item.Parents)) + for _, parent := range item.Parents { + parents = append(parents, parent.SHA) + } + subject, body := splitMessage(item.Commit.Message) + when := parseGiteaTime(item.Commit.Committer.Date) + if when.IsZero() { + when = parseGiteaTime(item.Commit.Author.Date) + } + if when.IsZero() { + when = parseGiteaTime(item.Created) + } + author := item.Commit.Author.Name + if author == "" { + author = item.Commit.Committer.Name + } + commits = append(commits, RawCommit{SHA: item.SHA, Parents: parents, CommittedAt: when, + AuthorDisplay: author, Subject: subject, Body: body}) + } + return commits, total, nil +} + +// BranchHeadSHA 读分支 HEAD 的完整 SHA(同步记录里的 source_head_sha 用它)。 +func (c *GiteaClient) BranchHeadSHA(ctx context.Context, branch string) (string, error) { + if strings.ContainsAny(branch, " /?#") || branch == "" { + return "", newGiteaError(KindParse, 0, "版本记录:分支名非法", nil) + } + body, _, err := c.get(ctx, c.apiURL("/branches/"+branch, url.Values{})) + if err != nil { + return "", err + } + var payload struct { + Commit struct { + ID string `json:"id"` + } `json:"commit"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "", newGiteaError(KindParse, 0, "版本记录:分支响应无法解析", err) + } + if !fullSHA.MatchString(payload.Commit.ID) { + return "", newGiteaError(KindParse, 0, "版本记录:分支 HEAD 不是完整 SHA", nil) + } + return payload.Commit.ID, nil +} + +// ListTags 读全部 tag,并逐个核验它是不是**注释 tag** 以及它指向哪个提交。 +// 轻量 tag(object.type == "commit")不会被当作正式版本的证据。 +func (c *GiteaClient) ListTags(ctx context.Context) ([]RawTag, error) { + type ref struct { + Ref string `json:"ref"` + Object struct { + Type string `json:"type"` + SHA string `json:"sha"` + } `json:"object"` + } + refs := make([]ref, 0) + for page := 1; page <= 40; page++ { + query := url.Values{} + query.Set("limit", "50") + query.Set("page", fmt.Sprintf("%d", page)) + body, _, err := c.get(ctx, c.apiURL("/git/refs/tags", query)) + if err != nil { + return nil, err + } + var pageRefs []ref + if err := json.Unmarshal(body, &pageRefs); err != nil { + return nil, newGiteaError(KindParse, 0, "版本记录:tag 列表无法解析", err) + } + refs = append(refs, pageRefs...) + // 短页 = 最后一页(也防住"服务端忽略 page 参数"导致的死循环) + if len(pageRefs) < 50 { + break + } + } + tags := make([]RawTag, 0, len(refs)) + for _, item := range refs { + name := strings.TrimPrefix(item.Ref, "refs/tags/") + if name == "" { + continue + } + tag := RawTag{Name: name, TargetSHA: item.Object.SHA} + if item.Object.Type == "tag" { + tag.Annotated = true + tag.TagSHA = item.Object.SHA + // 注释 tag:解引用到真正的提交(tag 对象里的 object.sha) + body, _, err := c.get(ctx, c.apiURL("/git/tags/"+item.Object.SHA, url.Values{})) + if err != nil { + return nil, err + } + var annotated struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := json.Unmarshal(body, &annotated); err != nil { + return nil, newGiteaError(KindParse, 0, "版本记录:注释 tag 无法解析", err) + } + if !fullSHA.MatchString(annotated.Object.SHA) { + continue + } + tag.TargetSHA = annotated.Object.SHA + } + tags = append(tags, tag) + } + return tags, nil +} + +// ReadFile 读某个提交内的文件(用于取该提交的 CHANGELOG.md)。 +func (c *GiteaClient) ReadFile(ctx context.Context, sha, path string) (string, error) { + if !fullSHA.MatchString(sha) { + return "", newGiteaError(KindParse, 0, "版本记录:提交必须是完整 40 位 SHA", nil) + } + if strings.Contains(path, "..") || strings.HasPrefix(path, "/") { + return "", newGiteaError(KindParse, 0, "版本记录:文件路径非法", nil) + } + body, _, err := c.get(ctx, c.apiURL("/raw/"+path, url.Values{"ref": []string{sha}})) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/versionhistory/gitea_test.go b/internal/versionhistory/gitea_test.go new file mode 100644 index 0000000..896905b --- /dev/null +++ b/internal/versionhistory/gitea_test.go @@ -0,0 +1,214 @@ +package versionhistory + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const ( + shaA = "1111111111111111111111111111111111111111" + shaB = "2222222222222222222222222222222222222222" + shaT = "3333333333333333333333333333333333333333" // 注释 tag 对象 +) + +func fakeGitea(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} + +func validConfig(base string) GiteaConfig { + return GiteaConfig{BaseURL: base, AllowedBaseURLs: []string{base}, Repo: "Weknora/knowledge-governance", + Sleep: func(time.Duration) {}, MaxRetries: 2, PageSize: 50} +} + +func TestGiteaClientRejectsNonWhitelistedOriginAndBadRepo(t *testing.T) { + if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://evil.example.com", + AllowedBaseURLs: []string{"http://192.168.50.223:3000"}, Repo: "a/b"}); err == nil { + t.Fatal("白名单外的 origin 必须拒绝") + } + if _, err := NewGiteaClient(GiteaConfig{BaseURL: "ftp://host", AllowedBaseURLs: []string{"ftp://host"}, + Repo: "a/b"}); err == nil { + t.Fatal("非 http(s) 必须拒绝") + } + if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://host", AllowedBaseURLs: []string{"http://host"}, + Repo: "a/b/c"}); err == nil { + t.Fatal("仓库必须是 owner/name") + } + if _, err := NewGiteaClient(GiteaConfig{BaseURL: "http://host", AllowedBaseURLs: []string{"http://host"}, + Repo: "a/b?x=1"}); err == nil { + t.Fatal("仓库名带查询串必须拒绝") + } +} + +func TestGiteaClientListCommitsParsesFieldsAndSendsNoCredentials(t *testing.T) { + var sawAuth string + server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + sawAuth = r.Header.Get("Authorization") + if got := r.URL.Path; got != "/api/v1/repos/Weknora/knowledge-governance/commits" { + t.Errorf("路径 = %s", got) + } + if r.URL.Query().Get("limit") != "50" || r.URL.Query().Get("page") != "1" { + t.Errorf("分页参数 = %s", r.URL.RawQuery) + } + w.Header().Set("X-Total-Count", "111") + fmt.Fprintf(w, `[{"sha":%q,"parents":[{"sha":%q}],"created":"2026-10-13T09:12:00Z", + "commit":{"message":"feat: 标题\n\n正文一行\n第二行","author":{"name":"姚章浩","date":"2026-10-13T09:11:00Z"}, + "committer":{"name":"姚章浩","date":"2026-10-13T09:12:00Z"}}}]`, shaB, shaA) + }) + client, err := NewGiteaClient(validConfig(server.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + commits, total, err := client.ListCommits(context.Background(), 1, 50) + if err != nil { + t.Fatalf("ListCommits:%v", err) + } + if sawAuth != "" { + t.Fatalf("匿名只读不得携带任何凭据,实际带了 Authorization=%q", sawAuth) + } + if total != 111 || len(commits) != 1 { + t.Fatalf("total=%d len=%d", total, len(commits)) + } + got := commits[0] + if got.SHA != shaB || len(got.Parents) != 1 || got.Parents[0] != shaA { + t.Fatalf("提交字段解析错:%+v", got) + } + if got.Subject != "feat: 标题" || got.Body != "正文一行\n第二行" { + t.Fatalf("标题/正文拆分错:%q / %q", got.Subject, got.Body) + } + if !got.CommittedAt.Equal(time.Date(2026, 10, 13, 9, 12, 0, 0, time.UTC)) { + t.Fatalf("提交时间解析错:%s", got.CommittedAt) + } + if !strings.HasSuffix(client.CommitURLBase(), "/Weknora/knowledge-governance/commit/") { + t.Fatalf("提交链接基址错:%s", client.CommitURLBase()) + } +} + +func TestGiteaClientRetriesTransientFailuresThenGivesUp(t *testing.T) { + attempts := 0 + server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts < 3 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("X-Total-Count", "0") + fmt.Fprint(w, "[]") + }) + client, err := NewGiteaClient(validConfig(server.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + if _, _, err := client.ListCommits(context.Background(), 1, 50); err != nil { + t.Fatalf("5xx 应退避重试后成功:%v", err) + } + if attempts != 3 { + t.Fatalf("重试次数 = %d,期望 3", attempts) + } + + alwaysFail := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + client2, err := NewGiteaClient(validConfig(alwaysFail.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + if _, _, err := client2.ListCommits(context.Background(), 1, 50); err == nil { + t.Fatal("持续 5xx 必须报错,不能返回空数据") + } +} + +func TestGiteaClientRejectsOversizedResponseAndCrossHostRedirect(t *testing.T) { + big := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Total-Count", "1") + fmt.Fprintf(w, "[{\"sha\":%q,\"commit\":{\"message\":%q}}]", shaA, strings.Repeat("x", 4096)) + }) + config := validConfig(big.URL) + config.MaxResponseBytes = 512 + client, err := NewGiteaClient(config) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + if _, _, err := client.ListCommits(context.Background(), 1, 50); err == nil { + t.Fatal("超过响应上限必须报错(丢弃)") + } + + elsewhere := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "[]") }) + redirector := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, elsewhere.URL+"/api/v1/repos/Weknora/knowledge-governance/commits", http.StatusFound) + }) + client2, err := NewGiteaClient(validConfig(redirector.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + if _, _, err := client2.ListCommits(context.Background(), 1, 50); err == nil { + t.Fatal("跨主机重定向必须拒绝") + } +} + +func TestGiteaClientListTagsDetectsAnnotatedAndLightweight(t *testing.T) { + server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/refs/tags": + fmt.Fprintf(w, `[{"ref":"refs/tags/v0.1.0","object":{"type":"tag","sha":%q}}, + {"ref":"refs/tags/light","object":{"type":"commit","sha":%q}}]`, shaT, shaA) + case r.URL.Path == "/api/v1/repos/Weknora/knowledge-governance/git/tags/"+shaT: + fmt.Fprintf(w, `{"tag":"v0.1.0","object":{"type":"commit","sha":%q}}`, shaB) + default: + t.Errorf("未预期的路径 %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) + client, err := NewGiteaClient(validConfig(server.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + tags, err := client.ListTags(context.Background()) + if err != nil { + t.Fatalf("ListTags:%v", err) + } + if len(tags) != 2 { + t.Fatalf("tag 数 = %d", len(tags)) + } + annotated := tags[0] + if annotated.Name != "v0.1.0" || !annotated.Annotated || annotated.TagSHA != shaT || annotated.TargetSHA != shaB { + t.Fatalf("注释 tag 应解引用到提交:%+v", annotated) + } + lightweight := tags[1] + if lightweight.Annotated || lightweight.TargetSHA != shaA { + t.Fatalf("轻量 tag 不能被当成注释 tag:%+v", lightweight) + } +} + +func TestGiteaClientReadFileGuards(t *testing.T) { + server := fakeGitea(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("ref") != shaA { + t.Errorf("ref = %s", r.URL.Query().Get("ref")) + } + fmt.Fprint(w, "# Changelog\n") + }) + client, err := NewGiteaClient(validConfig(server.URL)) + if err != nil { + t.Fatalf("构造客户端失败:%v", err) + } + text, err := client.ReadFile(context.Background(), shaA, "CHANGELOG.md") + if err != nil || !strings.Contains(text, "Changelog") { + t.Fatalf("读文件失败:%v / %q", err, text) + } + if _, err := client.ReadFile(context.Background(), "abc", "CHANGELOG.md"); err == nil { + t.Fatal("短 SHA 必须拒绝") + } + if _, err := client.ReadFile(context.Background(), shaA, "../../etc/passwd"); err == nil { + t.Fatal("路径穿越必须拒绝") + } + if _, err := client.ReadFile(context.Background(), shaA, "/etc/passwd"); err == nil { + t.Fatal("绝对路径必须拒绝") + } +} diff --git a/internal/versionhistory/helpers.go b/internal/versionhistory/helpers.go new file mode 100644 index 0000000..c0b68de --- /dev/null +++ b/internal/versionhistory/helpers.go @@ -0,0 +1,36 @@ +package versionhistory + +import ( + "regexp" + "strings" + "time" +) + +// fullSHA:只认完整 40 位小写十六进制 —— 版本记录的唯一身份。 +var fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// splitMessage 把提交信息拆成标题(首行)与正文(其余);不编造摘要。 +func splitMessage(message string) (subject, body string) { + message = strings.ReplaceAll(message, "\r\n", "\n") + trimmed := strings.TrimRight(message, "\n") + if strings.TrimSpace(trimmed) == "" { + return "", "" + } + if index := strings.Index(trimmed, "\n"); index >= 0 { + return strings.TrimSpace(trimmed[:index]), strings.TrimSpace(trimmed[index+1:]) + } + return strings.TrimSpace(trimmed), "" +} + +// parseGiteaTime 解析 Gitea 的时间字段;解析不了返回零值(调用方按"时间未知"处理)。 +func parseGiteaTime(value string) time.Time { + if strings.TrimSpace(value) == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05Z0700"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.UTC() + } + } + return time.Time{} +} diff --git a/internal/versionhistory/live_test.go b/internal/versionhistory/live_test.go new file mode 100644 index 0000000..b18b308 --- /dev/null +++ b/internal/versionhistory/live_test.go @@ -0,0 +1,168 @@ +package versionhistory + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +// 真实仓库的只读同步用例:**匿名抓取**内部可达的 Gitea,并把算出的版本号与本地 git 交叉核对 +// (`git rev-list --count v0.1.0..`)—— 两套实现(Go 图遍历 vs git)必须给出同一个号。 +// +// 未设 VERSION_HISTORY_LIVE_BASE_URL 即按设计 SKIP(已在 scripts/test-scope.json 声明理由); +// 本用例**不读任何凭据**、只读远端仓库,不写任何东西。 +func TestLiveSyncAgainstRealRepository(t *testing.T) { + base := strings.TrimSpace(os.Getenv("VERSION_HISTORY_LIVE_BASE_URL")) + if base == "" { + t.Skip("VERSION_HISTORY_LIVE_BASE_URL 未设置:跳过真实仓库的只读同步用例") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("本地没有 git:无法与 git 交叉核对") + } + repo := strings.TrimSpace(os.Getenv("VERSION_HISTORY_LIVE_REPO")) + if repo == "" { + repo = "Weknora/knowledge-governance" + } + client, err := NewGiteaClient(GiteaConfig{BaseURL: base, AllowedBaseURLs: []string{base}, Repo: repo}) + if err != nil { + t.Fatalf("构造只读客户端:%v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + commits, total := []RawCommit{}, 0 + for page := 1; page <= 10; page++ { + pageCommits, pageTotal, err := client.ListCommits(ctx, page, 50) + if err != nil { + t.Fatalf("第 %d 页抓取失败:%v", page, err) + } + if pageTotal > 0 { + total = pageTotal + } + commits = append(commits, pageCommits...) + if len(pageCommits) < 50 { + break + } + } + seen := map[string]bool{} + for _, commit := range commits { + if seen[commit.SHA] { + t.Fatalf("分页出现重复提交 %s", commit.SHA) + } + seen[commit.SHA] = true + } + if total == 0 || len(commits) != total { + t.Fatalf("分页与 X-Total-Count 不一致:抓到 %d,远端宣称 %d", len(commits), total) + } + tags, err := client.ListTags(ctx) + if err != nil { + t.Fatalf("读 tag:%v", err) + } + + // 与本地 git 交叉核对:注释 tag 指向的提交必须与 `git rev-parse` 一致 + var releaseTag RawTag + foundRelease := false + for _, tag := range tags { + if tag.Name == "v0.1.0" { + releaseTag, foundRelease = tag, true + } + } + if !foundRelease || !releaseTag.Annotated { + t.Fatalf("真实仓库里应有注释 tag v0.1.0,实际:%+v", tags) + } + if local := localGit(t, "rev-parse", "v0.1.0^{commit}"); local != releaseTag.TargetSHA { + t.Fatalf("tag 目标与本地 git 不一致:接口 %s,本地 %s", releaseTag.TargetSHA, local) + } + + derived := DeriveVersions(DeriveInput{RepoKey: repo, CommitURLBase: client.CommitURLBase(), + Commits: commits, Tags: tags, + Changelog: func(sha string) (string, error) { return client.ReadFile(ctx, sha, "CHANGELOG.md") }}) + if !derived.GraphComplete { + t.Fatalf("真实仓库的提交图应当完整,缺父提交 %v", derived.MissingParents) + } + + releases, development, historical := 0, 0, 0 + for _, record := range derived.Records { + switch record.RecordKind { + case "release": + releases++ + if record.CommitSHA == releaseTag.TargetSHA { + if record.VersionLabel != "0.1.0" || record.ReleaseNotes == "" { + t.Fatalf("正式版本记录不完整:%+v", record) + } + } + case "development": + development++ + case "historical": + historical++ + if record.VersionLabel != "" { + t.Fatalf("历史提交不许有版本号:%+v", record) + } + } + } + if releases != 1 { + t.Fatalf("正式版本条数 = %d,期望 1(只有 v0.1.0)", releases) + } + + // 交叉核对开发号:Go 的图遍历 vs 本地 git 的 rev-list --count + checked := 0 + for _, record := range derived.Records { + if record.RecordKind != "development" || checked >= 8 { + continue + } + if _, err := exec.Command("git", "cat-file", "-e", record.CommitSHA).Output(); err != nil { + continue // 本地没有这个提交(远端有新提交)⇒ 跳过交叉核对 + } + count := localGit(t, "rev-list", "--count", "v0.1.0.."+record.CommitSHA) + want := "0.1." + count + if record.VersionLabel != want { + t.Fatalf("提交 %s 的号 = %q,git 算出来是 %q", record.CommitSHA[:12], record.VersionLabel, want) + } + checked++ + } + if checked == 0 { + t.Fatal("没有交叉核对任何开发号 —— 证据不足") + } + + head := derived.Records[0] + t.Logf("真实仓库 %s:远端 main 可达 %d 条提交(对账一致)|正式 %d|开发 %d|历史 %d|tag %d 个(注释 %v 个)", + repo, len(commits), releases, development, historical, len(tags), countAnnotated(tags)) + for index, record := range derived.Records { + if index >= 6 { + break + } + label := record.VersionLabel + if label == "" { + label = "历史提交 · " + record.CommitSHA[:12] + } + t.Logf(" %s|%s|%s|%s", label, record.CommitSHA[:12], record.RecordKind, record.Subject) + } + if head.VersionLabel == "" && head.RecordKind != "historical" { + t.Fatalf("HEAD 记录异常:%+v", head) + } + if want := "0.1." + localGit(t, "rev-list", "--count", "v0.1.0.."+head.CommitSHA); head.VersionLabel != want { + t.Fatalf("HEAD 的号 = %q,git 算出来是 %q", head.VersionLabel, want) + } +} + +func countAnnotated(tags []RawTag) int { + annotated := 0 + for _, tag := range tags { + if tag.Annotated { + annotated++ + } + } + return annotated +} + +func localGit(t *testing.T, args ...string) string { + t.Helper() + output, err := exec.Command("git", args...).Output() + if err != nil { + t.Fatalf("本地 git %s 失败:%v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(output)) +} diff --git a/internal/versionhistory/rules.go b/internal/versionhistory/rules.go new file mode 100644 index 0000000..bc9e2fd --- /dev/null +++ b/internal/versionhistory/rules.go @@ -0,0 +1,190 @@ +// Package versionhistory 把「远端 main 可达的提交」+「tag 证据」+「提交内 CHANGELOG」 +// 换算成版本记录页要的提交记录。 +// +// 它**只读、纯计算**:不发网络请求、不写库、不执行任何历史提交里的脚本。版本号口径 +// 委托给 internal/governance.DecideVersion,提交数按提交图算(等价 `git rev-list --count ..`), +// 与 scripts/version.py 同规则 —— 两处算出的号必须一致。 +package versionhistory + +import ( + "sort" + "time" + + "knowledge-governance/internal/governance" +) + +// RawCommit 是一条从 Gitea 读回的提交(只保留展示与判版本所需的字段)。 +type RawCommit struct { + SHA string + Parents []string + CommittedAt time.Time + AuthorDisplay string + Subject string + Body string +} + +// RawTag 是一个 tag 及其可核验属性;Annotated=false 表示轻量 tag(不算正式版本的证据)。 +type RawTag struct { + Name string + TagSHA string + TargetSHA string + Annotated bool +} + +// ChangelogSource 读「某个提交内的 CHANGELOG.md」;读不到返回错误(调用方按"无记录"处理)。 +type ChangelogSource func(sha string) (string, error) + +// DeriveInput 是一次换算的全部输入。 +type DeriveInput struct { + RepoKey string + CommitURLBase string // 形如 "///commit/",由配置拼好,不接受外部传入 + Commits []RawCommit + Tags []RawTag + Changelog ChangelogSource + SnapshotID int64 +} + +// DeriveResult 是换算结果。 +type DeriveResult struct { + Records []governance.VersionHistoryCommit + GraphComplete bool + MissingParents []string +} + +// DeriveVersions 逐提交判定版本号(含合并提交与侧分支提交;不展示任何未推送的本地提交, +// 因为输入本身就是远端 main 可达集合)。 +func DeriveVersions(in DeriveInput) DeriveResult { + bySHA := make(map[string]RawCommit, len(in.Commits)) + for _, commit := range in.Commits { + bySHA[commit.SHA] = commit + } + missing := map[string]bool{} + ancestors := make(map[string]map[string]struct{}, len(in.Commits)) + var walk func(sha string) map[string]struct{} + walk = func(sha string) map[string]struct{} { + if set, ok := ancestors[sha]; ok { + return set + } + set := map[string]struct{}{sha: {}} + ancestors[sha] = set // 先占位,DAG 不会有环 + commit, ok := bySHA[sha] + if !ok { + return set + } + for _, parent := range commit.Parents { + if _, known := bySHA[parent]; !known { + missing[parent] = true + continue + } + for item := range walk(parent) { + set[item] = struct{}{} + } + } + return set + } + for _, commit := range in.Commits { + walk(commit.SHA) + } + + tagsByTarget := map[string][]RawTag{} + tagByName := map[string]RawTag{} + for _, tag := range in.Tags { + tagsByTarget[tag.TargetSHA] = append(tagsByTarget[tag.TargetSHA], tag) + if _, exists := tagByName[tag.Name]; !exists { + tagByName[tag.Name] = tag + } + } + + changelogCache := map[string]string{} + readChangelog := func(sha string) string { + if text, ok := changelogCache[sha]; ok { + return text + } + text := "" + if in.Changelog != nil { + if loaded, err := in.Changelog(sha); err == nil { + text = loaded + } + } + changelogCache[sha] = text + return text + } + + graphComplete := len(missing) == 0 + result := DeriveResult{GraphComplete: graphComplete} + for sha := range missing { + result.MissingParents = append(result.MissingParents, sha) + } + sort.Strings(result.MissingParents) + + records := make([]governance.VersionHistoryCommit, 0, len(in.Commits)) + for _, commit := range in.Commits { + changelog := readChangelog(commit.SHA) + top := governance.ChangelogTopVersion(changelog) + + baseTagName := "" + var sinceBase *int + if governance.SemverValid(top) { + candidate := "v" + top + if base, exists := tagByName[candidate]; exists { + if !graphComplete { + // tag 存在但图不完整 ⇒ 不给号(宁可没号,也不报错号) + baseTagName = candidate + } else if baseSet, ok := ancestors[base.TargetSHA]; ok { + if own, ok2 := ancestors[commit.SHA]; ok2 { + count := 0 + for item := range own { + if _, inBase := baseSet[item]; !inBase { + count++ + } + } + baseTagName = candidate + sinceBase = &count + } + } + } + } + + evidenceTags := make([]governance.VersionTagEvidence, 0, len(tagsByTarget[commit.SHA])) + for _, tag := range tagsByTarget[commit.SHA] { + evidenceTags = append(evidenceTags, governance.VersionTagEvidence{ + Name: tag.Name, TagSHA: tag.TagSHA, Annotated: tag.Annotated}) + } + sort.Slice(evidenceTags, func(i, j int) bool { return evidenceTags[i].Name < evidenceTags[j].Name }) + + label, state, kind, evidence := governance.DecideVersion(top, evidenceTags, baseTagName, sinceBase) + if label == "" && !graphComplete { + evidence.Reason = governance.ReasonIncompleteGraph + } + record := governance.VersionHistoryCommit{ + RepoKey: in.RepoKey, + CommitSHA: commit.SHA, + Parents: commit.Parents, + CommittedAt: commit.CommittedAt, + AuthorDisplay: commit.AuthorDisplay, + Subject: commit.Subject, + Body: commit.Body, + VersionLabel: label, + VersionState: state, + RecordKind: kind, + ReleaseTags: evidenceTags, + CommitURL: in.CommitURLBase + commit.SHA, + VersionEvidence: evidence, + SnapshotID: in.SnapshotID, + } + if kind == governance.RecordKindRelease { + record.ReleaseNotes = governance.ExtractReleaseNotes(changelog, label) + } + records = append(records, record) + } + + // 最新在上;同一提交时间用 SHA 兜底 ⇒ 排序确定,分页不重不漏。 + sort.SliceStable(records, func(i, j int) bool { + if !records[i].CommittedAt.Equal(records[j].CommittedAt) { + return records[i].CommittedAt.After(records[j].CommittedAt) + } + return records[i].CommitSHA > records[j].CommitSHA + }) + result.Records = records + return result +} diff --git a/internal/versionhistory/rules_test.go b/internal/versionhistory/rules_test.go new file mode 100644 index 0000000..715f6f7 --- /dev/null +++ b/internal/versionhistory/rules_test.go @@ -0,0 +1,195 @@ +package versionhistory + +import ( + "testing" + "time" + + "knowledge-governance/internal/governance" +) + +func at(minute int) time.Time { + return time.Date(2026, 10, 13, 10, minute, 0, 0, time.UTC) +} + +func commit(sha string, parents []string, minute int) RawCommit { + return RawCommit{SHA: sha, Parents: parents, CommittedAt: at(minute), + AuthorDisplay: "姚章浩", Subject: "feat: " + sha, Body: "body " + sha} +} + +func changelogWith(top string) string { + if top == "" { + return "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 早期提交,还没有已发布版本\n" + } + return "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n" + + "## [" + top + "] - 2026-10-01\n\n### Added\n\n- 首次发布\n" +} + +func readerFor(topBySHA map[string]string) ChangelogSource { + return func(sha string) (string, error) { return changelogWith(topBySHA[sha]), nil } +} + +func find(t *testing.T, records []governance.VersionHistoryCommit, sha string) governance.VersionHistoryCommit { + t.Helper() + for _, record := range records { + if record.CommitSHA == sha { + return record + } + } + t.Fatalf("记录里没有提交 %s", sha) + return governance.VersionHistoryCommit{} +} + +func TestDeriveVersionsLinearHistoryWithAnnotatedTag(t *testing.T) { + commits := []RawCommit{ + commit("A", nil, 1), commit("B", []string{"A"}, 2), + commit("C", []string{"B"}, 3), commit("D", []string{"C"}, 4), + } + tags := []RawTag{{Name: "v0.1.0", TagSHA: "tagobj", TargetSHA: "A", Annotated: true}} + top := map[string]string{"A": "0.1.0", "B": "0.1.0", "C": "0.1.0", "D": "0.1.0"} + + result := DeriveVersions(DeriveInput{RepoKey: "Weknora/knowledge-governance", + CommitURLBase: "http://gitea/commit/", Commits: commits, Tags: tags, Changelog: readerFor(top)}) + if !result.GraphComplete { + t.Fatal("线性历史应判为图完整") + } + if len(result.Records) != 4 { + t.Fatalf("记录数 = %d,期望 4", len(result.Records)) + } + // 最新在上 + if result.Records[0].CommitSHA != "D" || result.Records[3].CommitSHA != "A" { + t.Fatalf("排序不对:%s…%s", result.Records[0].CommitSHA, result.Records[3].CommitSHA) + } + for sha, want := range map[string]string{"A": "0.1.0", "B": "0.1.1", "C": "0.1.2", "D": "0.1.3"} { + record := find(t, result.Records, sha) + if record.VersionLabel != want { + t.Fatalf("%s 的号 = %q,期望 %q(原因 %s)", sha, record.VersionLabel, want, record.VersionEvidence.Reason) + } + if record.CommitURL != "http://gitea/commit/"+sha { + t.Fatalf("%s 的提交链接 = %q", sha, record.CommitURL) + } + } + release := find(t, result.Records, "A") + if release.RecordKind != governance.RecordKindRelease || release.VersionState != governance.VersionStateVerified { + t.Fatalf("tag 指向的提交应是正式版本:%+v", release) + } + if release.ReleaseNotes == "" || len(release.ReleaseTags) != 1 { + t.Fatalf("正式版本必须带 tag 与提交内 CHANGELOG 正文:%+v", release) + } + development := find(t, result.Records, "B") + if development.RecordKind != governance.RecordKindDevelopment || development.ReleaseNotes != "" { + t.Fatalf("开发提交不该带发布正文:%+v", development) + } +} + +func TestDeriveVersionsKeepsMergeAndSideBranchCommitsAndSameNumberDifferentSHA(t *testing.T) { + commits := []RawCommit{ + commit("A", nil, 1), commit("B", []string{"A"}, 2), commit("C", []string{"A"}, 3), + commit("M", []string{"B", "C"}, 4), commit("D", []string{"M"}, 5), + } + tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}} + top := map[string]string{"A": "0.1.0", "B": "0.1.0", "C": "0.1.0", "M": "0.1.0", "D": "0.1.0"} + + result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags, Changelog: readerFor(top)}) + for _, sha := range []string{"B", "C", "M", "D"} { + if record := find(t, result.Records, sha); record.VersionLabel == "" { + t.Fatalf("合并/侧分支提交 %s 必须能查到:%+v", sha, record) + } + } + if merge := find(t, result.Records, "M"); len(merge.Parents) != 2 || merge.VersionLabel != "0.1.3" { + t.Fatalf("合并提交应保留双父并按图算号:%+v", merge) + } + if side := find(t, result.Records, "C"); side.VersionLabel != "0.1.1" { + t.Fatalf("侧分支提交按同一基线算:%+v", side) + } + // 同号不同 SHA:B 与 C 都是 0.1.1,两条都要在(唯一身份是 SHA) + same := 0 + for _, record := range result.Records { + if record.VersionLabel == "0.1.1" { + same++ + } + } + if same != 2 { + t.Fatalf("同号不同 SHA 应保留两条,实际 %d", same) + } + if find(t, result.Records, "B").CommitSHA == find(t, result.Records, "C").CommitSHA { + t.Fatal("同号两条必须靠 SHA 区分") + } +} + +func TestDeriveVersionsIncompleteGraphNeverInventsNumbers(t *testing.T) { + // 只拿到 B、C,父提交 A 不在集合里(等价浅克隆/检出截断) + commits := []RawCommit{commit("B", []string{"A"}, 2), commit("C", []string{"B"}, 3)} + tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}} + top := map[string]string{"B": "0.1.0", "C": "0.1.0"} + + result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags, Changelog: readerFor(top)}) + if result.GraphComplete { + t.Fatal("缺父提交必须判为图不完整") + } + if len(result.MissingParents) != 1 || result.MissingParents[0] != "A" { + t.Fatalf("缺失父提交 = %v", result.MissingParents) + } + for _, record := range result.Records { + if record.VersionLabel != "" { + t.Fatalf("图不完整时不许给号:%+v", record) + } + if record.VersionEvidence.Reason != governance.ReasonIncompleteGraph { + t.Fatalf("原因码应为 %s,得到 %s", governance.ReasonIncompleteGraph, record.VersionEvidence.Reason) + } + if record.RecordKind != governance.RecordKindHistorical { + t.Fatalf("应为历史提交:%+v", record) + } + } +} + +func TestDeriveVersionsLightweightTagIsNotARelease(t *testing.T) { + commits := []RawCommit{commit("A", nil, 1)} + tags := []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: false}} // 轻量 tag + result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Tags: tags, + Changelog: readerFor(map[string]string{"A": "0.1.0"})}) + record := find(t, result.Records, "A") + if record.RecordKind == governance.RecordKindRelease { + t.Fatalf("轻量 tag 不得标成正式版本:%+v", record) + } + if len(record.ReleaseTags) != 1 || record.ReleaseTags[0].Annotated { + t.Fatalf("tag 证据要原样保留:%+v", record.ReleaseTags) + } +} + +func TestDeriveVersionsPicksUpTagAddedAfterwards(t *testing.T) { + commits := []RawCommit{commit("A", nil, 1), commit("B", []string{"A"}, 2)} + changelog := readerFor(map[string]string{"A": "0.1.0", "B": "0.1.0"}) + before := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Changelog: changelog}) + if find(t, before.Records, "B").RecordKind != governance.RecordKindHistorical { + t.Fatalf("还没有 tag 时 B 只能是历史提交:%+v", find(t, before.Records, "B")) + } + // 旧提交后补的注释 tag:再算一次就该升级成正式版本相关记录(不新增第二行) + after := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits, Changelog: changelog, + Tags: []RawTag{{Name: "v0.1.0", TargetSHA: "A", Annotated: true}}}) + if find(t, after.Records, "A").RecordKind != governance.RecordKindRelease { + t.Fatalf("后补的注释 tag 必须让 A 变成正式版本:%+v", find(t, after.Records, "A")) + } + if len(after.Records) != len(before.Records) { + t.Fatalf("后补 tag 不得新增记录:%d → %d", len(before.Records), len(after.Records)) + } + if find(t, after.Records, "B").VersionLabel != "0.1.1" { + t.Fatalf("B 应拿到开发号:%+v", find(t, after.Records, "B")) + } +} + +func TestDeriveVersionsSortsDeterministicallyForPaging(t *testing.T) { + sameTime := at(9) + commits := []RawCommit{ + {SHA: "aaa", CommittedAt: sameTime}, {SHA: "bbb", CommittedAt: sameTime}, + {SHA: "ccc", CommittedAt: sameTime}, {SHA: "ddd", CommittedAt: sameTime}, + } + result := DeriveVersions(DeriveInput{RepoKey: "repo", Commits: commits}) + order := []string{result.Records[0].CommitSHA, result.Records[1].CommitSHA, + result.Records[2].CommitSHA, result.Records[3].CommitSHA} + want := []string{"ddd", "ccc", "bbb", "aaa"} + for i := range want { + if order[i] != want[i] { + t.Fatalf("同一提交时间的排序必须确定:%v", order) + } + } +} diff --git a/internal/versionhistory/sync.go b/internal/versionhistory/sync.go new file mode 100644 index 0000000..9433a4c --- /dev/null +++ b/internal/versionhistory/sync.go @@ -0,0 +1,279 @@ +package versionhistory + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "knowledge-governance/internal/governance" +) + +// 同步作业的错误码:页面与同步记录直接展示,**不暴露内部细节**。 +const ( + ErrorCodeUnreachable = "gitea_unreachable" + ErrorCodeStatus = "gitea_status" + ErrorCodeTooLarge = "gitea_response_too_large" + ErrorCodeNotFound = "gitea_not_found" + ErrorCodeParse = "gitea_parse" + ErrorCodeRedirect = "gitea_redirect" + ErrorCodeCountMismatch = "count_mismatch" + ErrorCodeIncompletePages = "incomplete_history" + ErrorCodeUnexpected = "unexpected" +) + +// ErrorKind 是客户端错误的机器可读类别(同步器据此给 error_code,不去猜字符串)。 +type ErrorKind string + +const ( + KindUnreachable ErrorKind = "unreachable" + KindStatus ErrorKind = "status" + KindTooLarge ErrorKind = "too_large" + KindNotFound ErrorKind = "not_found" + KindParse ErrorKind = "parse" + KindRedirect ErrorKind = "redirect" +) + +// GiteaError 携带类别与 HTTP 状态码。 +type GiteaError struct { + Kind ErrorKind + Status int + Message string + Wrapped error +} + +func (e *GiteaError) Error() string { + if e.Status > 0 { + return fmt.Sprintf("%s(HTTP %d)", e.Message, e.Status) + } + return e.Message +} + +func (e *GiteaError) Unwrap() error { return e.Wrapped } + +func newGiteaError(kind ErrorKind, status int, message string, wrapped error) *GiteaError { + return &GiteaError{Kind: kind, Status: status, Message: message, Wrapped: wrapped} +} + +// errorCode 把任意错误翻成同步错误码;未识别的错误归为 unexpected(不静默吞掉)。 +func errorCode(err error) string { + var gitea *GiteaError + if errors.As(err, &gitea) { + switch gitea.Kind { + case KindUnreachable: + return ErrorCodeUnreachable + case KindStatus: + if gitea.Status > 0 { + return fmt.Sprintf("%s_%d", ErrorCodeStatus, gitea.Status) + } + return ErrorCodeStatus + case KindTooLarge: + return ErrorCodeTooLarge + case KindNotFound: + return ErrorCodeNotFound + case KindParse: + return ErrorCodeParse + case KindRedirect: + return ErrorCodeRedirect + } + } + var typed interface{ ErrorCode() string } + if errors.As(err, &typed) { + return typed.ErrorCode() + } + return ErrorCodeUnexpected +} + +// codeError 让同步器自己的失败也带机器可读码。 +type codeError struct { + code string + message string +} + +func (e codeError) Error() string { return e.message } +func (e codeError) ErrorCode() string { return e.code } + +// JobType 是同步作业的类型名:HTTP 侧入队与 worker 侧注册必须用同一个值。 +const JobType = "versionhistory.sync" + +// Source 是版本记录同步需要的只读数据源。 +type Source interface { + PageSize() int + CommitURLBase() string + ListCommits(ctx context.Context, page, limit int) ([]RawCommit, int, error) + ListTags(ctx context.Context) ([]RawTag, error) + ReadFile(ctx context.Context, sha, path string) (string, error) + // BranchHeadSHA 读分支 HEAD;读不到不算致命(退回"提交列表里最新的那条")。 + BranchHeadSHA(ctx context.Context, branch string) (string, error) +} + +// SyncStore 是同步器需要的持久化能力。 +type SyncStore interface { + BeginVersionHistorySync(ctx context.Context, repoKey, jobID, triggeredBy string, now time.Time) (int64, error) + SaveVersionHistorySnapshot(ctx context.Context, snapshotID int64, repoKey string, + records []governance.VersionHistoryCommit, expected int, complete bool, now time.Time) error + FinishVersionHistorySync(ctx context.Context, runID int64, outcome SyncOutcome, now time.Time) error + FailVersionHistorySync(ctx context.Context, runID int64, errorCode string, now time.Time) error +} + +// SyncOutcome 是一次成功同步的结果(「同步成功」与「历史完整」分开表达)。 +type SyncOutcome struct { + SourceHeadSHA string + FetchedCount int + ExpectedCount int + Complete bool +} + +// SyncerConfig 装配同步器。 +type SyncerConfig struct { + Source Source + Store SyncStore + RepoKey string + Changelog string // 提交内的变更记录文件路径,默认 CHANGELOG.md + Now func() time.Time + MaxPages int +} + +// Syncer 抓取远端 main 可达的全部提交并换算版本记录。 +type Syncer struct { + source Source + store SyncStore + repoKey string + changelog string + now func() time.Time + maxPages int +} + +const syncerDefaultMaxPages = 200 + +// NewSyncer 校验依赖;缺一即拒绝装配(不允许"半接线"跑起来)。 +func NewSyncer(config SyncerConfig) (*Syncer, error) { + if config.Source == nil || config.Store == nil { + return nil, errors.New("版本记录同步器:数据源与存储都必需") + } + if strings.TrimSpace(config.RepoKey) == "" { + return nil, errors.New("版本记录同步器:仓库标识必需") + } + changelog := config.Changelog + if changelog == "" { + changelog = "CHANGELOG.md" + } + now := config.Now + if now == nil { + now = time.Now + } + maxPages := config.MaxPages + if maxPages <= 0 { + maxPages = syncerDefaultMaxPages + } + return &Syncer{source: config.Source, store: config.Store, repoKey: config.RepoKey, + changelog: changelog, now: now, maxPages: maxPages}, nil +} + +// Sync 跑一次同步。任何失败都会把这次 run 标成 failed 并带上错误码; +// **失败绝不切换对外快照**,页面继续看上一次成功的数据。 +func (s *Syncer) Sync(ctx context.Context, jobID, triggeredBy string) (governance.VersionHistorySyncRun, error) { + now := s.now().UTC() + runID, err := s.store.BeginVersionHistorySync(ctx, s.repoKey, jobID, triggeredBy, now) + if err != nil { + return governance.VersionHistorySyncRun{}, fmt.Errorf("版本记录:开始同步失败:%w", err) + } + fail := func(cause error) (governance.VersionHistorySyncRun, error) { + code := errorCode(cause) + if markErr := s.store.FailVersionHistorySync(ctx, runID, code, s.now().UTC()); markErr != nil { + return governance.VersionHistorySyncRun{}, fmt.Errorf("版本记录:标记同步失败时又出错:%w", markErr) + } + return governance.VersionHistorySyncRun{ID: runID, Status: "failed", ErrorCode: code}, + fmt.Errorf("版本记录:同步失败(%s):%w", code, cause) + } + + commits, total, err := s.fetchAllCommits(ctx) + if err != nil { + return fail(err) + } + if total > 0 && len(commits) != total { + return fail(codeError{code: ErrorCodeCountMismatch, + message: fmt.Sprintf("版本记录:提交数与远端宣称不一致(抓到 %d,远端 X-Total-Count=%d)", len(commits), total)}) + } + tags, err := s.source.ListTags(ctx) + if err != nil { + return fail(err) + } + + derived := DeriveVersions(DeriveInput{RepoKey: s.repoKey, CommitURLBase: s.source.CommitURLBase(), + Commits: commits, Tags: tags, SnapshotID: runID, + Changelog: func(sha string) (string, error) { + return s.source.ReadFile(ctx, sha, s.changelog) + }}) + + if err := s.store.SaveVersionHistorySnapshot(ctx, runID, s.repoKey, derived.Records, total, true, s.now().UTC()); err != nil { + return fail(err) + } + sourceHead := "" + if head, err := s.source.BranchHeadSHA(ctx, "main"); err == nil { + sourceHead = head + } else { + sourceHead = headSHA(commits) // 读分支失败不致命:退回提交列表最新一条 + } + outcome := SyncOutcome{SourceHeadSHA: sourceHead, FetchedCount: len(commits), + ExpectedCount: total, Complete: derived.GraphComplete && (total == 0 || len(commits) == total)} + if err := s.store.FinishVersionHistorySync(ctx, runID, outcome, s.now().UTC()); err != nil { + return fail(err) + } + return governance.VersionHistorySyncRun{ID: runID, Status: "completed", SourceHeadSHA: outcome.SourceHeadSHA, + FetchedCount: outcome.FetchedCount, ExpectedCount: &total, Complete: outcome.Complete, + LastSuccessAt: s.now().UTC(), StartedAt: now}, nil +} + +// fetchAllCommits 逐页抓取并按 SHA 去重;短页即终止(也防住服务端忽略 page 参数)。 +func (s *Syncer) fetchAllCommits(ctx context.Context) ([]RawCommit, int, error) { + pageSize := s.source.PageSize() + if pageSize <= 0 { + pageSize = giteaDefaultPageSize + } + seen := map[string]bool{} + commits := make([]RawCommit, 0, pageSize) + total := 0 + for page := 1; page <= s.maxPages; page++ { + pageCommits, pageTotal, err := s.source.ListCommits(ctx, page, pageSize) + if err != nil { + return nil, 0, err + } + if pageTotal > 0 { + total = pageTotal + } + for _, commit := range pageCommits { + if seen[commit.SHA] { + continue + } + seen[commit.SHA] = true + commits = append(commits, commit) + } + if len(pageCommits) < pageSize { + return commits, total, nil + } + if total > 0 && len(commits) >= total { + return commits, total, nil + } + } + return nil, 0, codeError{code: ErrorCodeIncompletePages, + message: fmt.Sprintf("版本记录:分页超过 %d 页仍未取完(疑似服务端分页异常)", s.maxPages)} +} + +// headSHA 取远端 main 的 HEAD(提交列表按时间倒序,最新一条即 HEAD;取不到就空)。 +func headSHA(commits []RawCommit) string { + sorted := make([]RawCommit, len(commits)) + copy(sorted, commits) + sort.Slice(sorted, func(i, j int) bool { + if !sorted[i].CommittedAt.Equal(sorted[j].CommittedAt) { + return sorted[i].CommittedAt.After(sorted[j].CommittedAt) + } + return sorted[i].SHA > sorted[j].SHA + }) + if len(sorted) == 0 { + return "" + } + return sorted[0].SHA +} diff --git a/internal/versionhistory/sync_test.go b/internal/versionhistory/sync_test.go new file mode 100644 index 0000000..4e2ca4e --- /dev/null +++ b/internal/versionhistory/sync_test.go @@ -0,0 +1,251 @@ +package versionhistory + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "knowledge-governance/internal/governance" +) + +type fakeSource struct { + commits []RawCommit + total int + pageSize int + tags []RawTag + changelog map[string]string + head string + headErr error + commitsErr error + listCalls int +} + +func (f *fakeSource) PageSize() int { return f.pageSize } +func (f *fakeSource) CommitURLBase() string { + return "http://gitea.test/Weknora/knowledge-governance/commit/" +} + +func (f *fakeSource) ListCommits(_ context.Context, page, limit int) ([]RawCommit, int, error) { + f.listCalls++ + if f.commitsErr != nil { + return nil, 0, f.commitsErr + } + start := (page - 1) * limit + if start >= len(f.commits) { + return nil, f.total, nil + } + end := start + limit + if end > len(f.commits) { + end = len(f.commits) + } + return f.commits[start:end], f.total, nil +} + +func (f *fakeSource) ListTags(context.Context) ([]RawTag, error) { return f.tags, nil } + +func (f *fakeSource) ReadFile(_ context.Context, sha, _ string) (string, error) { + if text, ok := f.changelog[sha]; ok { + return text, nil + } + return "", fmt.Errorf("no changelog for %s", sha) +} + +func (f *fakeSource) BranchHeadSHA(context.Context, string) (string, error) { + if f.headErr != nil { + return "", f.headErr + } + return f.head, nil +} + +type savedSnapshot struct { + snapshotID int64 + records []governance.VersionHistoryCommit + expected int + complete bool +} + +type fakeStore struct { + beginCalls int + beginErr error + saved []savedSnapshot + finished []SyncOutcome + failed []string + activeSnapshot int64 + lastSuccess time.Time + saveErr error +} + +func (f *fakeStore) BeginVersionHistorySync(_ context.Context, _, _, _ string, _ time.Time) (int64, error) { + f.beginCalls++ + if f.beginErr != nil { + return 0, f.beginErr + } + return int64(f.beginCalls), nil +} + +func (f *fakeStore) SaveVersionHistorySnapshot(_ context.Context, snapshotID int64, _ string, + records []governance.VersionHistoryCommit, expected int, complete bool, _ time.Time) error { + if f.saveErr != nil { + return f.saveErr + } + f.saved = append(f.saved, savedSnapshot{snapshotID: snapshotID, records: records, + expected: expected, complete: complete}) + if complete { + f.activeSnapshot = snapshotID + } + return nil +} + +func (f *fakeStore) FinishVersionHistorySync(_ context.Context, _ int64, outcome SyncOutcome, now time.Time) error { + f.finished = append(f.finished, outcome) + f.lastSuccess = now + return nil +} + +func (f *fakeStore) FailVersionHistorySync(_ context.Context, _ int64, code string, _ time.Time) error { + f.failed = append(f.failed, code) + return nil +} + +func linearCommits(count int) []RawCommit { + commits := make([]RawCommit, 0, count) + for index := 0; index < count; index++ { + sha := fmt.Sprintf("%040x", index+1) + parents := []string{} + if index > 0 { + parents = append(parents, fmt.Sprintf("%040x", index)) + } + commits = append(commits, RawCommit{SHA: sha, Parents: parents, + CommittedAt: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC).Add(time.Duration(index) * time.Minute), + AuthorDisplay: "姚章浩", Subject: fmt.Sprintf("feat: 第 %d 条", index)}) + } + return commits +} + +func newTestSyncer(t *testing.T, source Source, store SyncStore) *Syncer { + t.Helper() + syncer, err := NewSyncer(SyncerConfig{Source: source, Store: store, RepoKey: "Weknora/knowledge-governance", + Now: func() time.Time { return time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) }}) + if err != nil { + t.Fatalf("装配同步器:%v", err) + } + return syncer +} + +func TestSyncPullsEveryPageAndReconcilesTotal(t *testing.T) { + commits := linearCommits(111) + source := &fakeSource{commits: commits, total: 111, pageSize: 50, head: commits[110].SHA, + changelog: map[string]string{}} + store := &fakeStore{} + run, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-1", "admin") + if err != nil { + t.Fatalf("同步失败:%v", err) + } + if source.listCalls != 3 { + t.Fatalf("应抓 3 页,实际 %d 次", source.listCalls) + } + if len(store.saved) != 1 || len(store.saved[0].records) != 111 { + t.Fatalf("落库记录数 = %d", len(store.saved[0].records)) + } + seen := map[string]bool{} + for _, record := range store.saved[0].records { + if seen[record.CommitSHA] { + t.Fatalf("落库出现重复提交 %s", record.CommitSHA) + } + seen[record.CommitSHA] = true + } + if !run.Complete || run.FetchedCount != 111 || run.SourceHeadSHA != commits[110].SHA { + t.Fatalf("同步结果不完整:%+v", run) + } + if store.activeSnapshot != 1 || len(store.finished) != 1 || len(store.failed) != 0 { + t.Fatalf("快照切换/完成记录不对:%+v", store) + } +} + +func TestSyncFailsLoudlyWhenCountDoesNotMatch(t *testing.T) { + commits := linearCommits(100) // 远端宣称 121,实际只给 100 + source := &fakeSource{commits: commits, total: 121, pageSize: 50, head: commits[99].SHA} + store := &fakeStore{activeSnapshot: 7} + _, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-2", "admin") + if err == nil { + t.Fatal("数量不一致必须报错,不能宣称完整") + } + if len(store.failed) != 1 || store.failed[0] != ErrorCodeCountMismatch { + t.Fatalf("错误码 = %v,期望 %s", store.failed, ErrorCodeCountMismatch) + } + if len(store.saved) != 0 || store.activeSnapshot != 7 { + t.Fatalf("失败时不得写快照、不得切换对外快照:%+v", store) + } +} + +func TestSyncKeepsPreviousSnapshotWhenGiteaIsUnreachable(t *testing.T) { + source := &fakeSource{pageSize: 50, commitsErr: newGiteaError(KindUnreachable, 0, "不可达", nil)} + store := &fakeStore{activeSnapshot: 3, lastSuccess: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)} + _, err := newTestSyncer(t, source, store).Sync(context.Background(), "job-3", "admin") + if err == nil { + t.Fatal("不可达必须报错") + } + if len(store.failed) != 1 || store.failed[0] != ErrorCodeUnreachable { + t.Fatalf("错误码 = %v", store.failed) + } + if store.activeSnapshot != 3 || !store.lastSuccess.Equal(time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("失败不得动既有快照与最后成功时间:%+v", store) + } +} + +func TestSyncIsIdempotentAndPicksUpTagsAddedAfterwards(t *testing.T) { + commits := linearCommits(3) + changelog := map[string]string{} + for _, commit := range commits { + changelog[commit.SHA] = "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 新\n\n## [0.1.0] - 2026-10-01\n\n### Added\n\n- 首次发布\n" + } + source := &fakeSource{commits: commits, total: 3, pageSize: 50, head: commits[2].SHA, changelog: changelog} + store := &fakeStore{} + syncer := newTestSyncer(t, source, store) + if _, err := syncer.Sync(context.Background(), "job-4", "admin"); err != nil { + t.Fatalf("首次同步:%v", err) + } + first := store.saved[0].records + if len(first) != 3 { + t.Fatalf("记录数 = %d", len(first)) + } + // 还没有 tag ⇒ 全都是历史提交(不给号) + for _, record := range first { + if record.VersionLabel != "" || record.RecordKind != governance.RecordKindHistorical { + t.Fatalf("无 tag 时不应给号:%+v", record) + } + } + // 旧提交后补一个注释 tag ⇒ 再同步不新增记录,只把 A 升级成正式版本,B/C 拿到开发号 + source.tags = []RawTag{{Name: "v0.1.0", TagSHA: strings.Repeat("a", 40), TargetSHA: commits[0].SHA, Annotated: true}} + if _, err := syncer.Sync(context.Background(), "job-5", "admin"); err != nil { + t.Fatalf("二次同步:%v", err) + } + second := store.saved[1].records + if len(second) != len(first) { + t.Fatalf("后补 tag 不得新增记录:%d → %d", len(first), len(second)) + } + bySHA := map[string]governance.VersionHistoryCommit{} + for _, record := range second { + bySHA[record.CommitSHA] = record + } + if got := bySHA[commits[0].SHA]; got.RecordKind != governance.RecordKindRelease || got.VersionLabel != "0.1.0" { + t.Fatalf("tag 指向的提交应升级为正式版本:%+v", got) + } + if got := bySHA[commits[1].SHA]; got.VersionLabel != "0.1.1" || got.RecordKind != governance.RecordKindDevelopment { + t.Fatalf("tag 之后的提交应拿到开发号:%+v", got) + } + if got := bySHA[commits[2].SHA]; got.VersionLabel != "0.1.2" { + t.Fatalf("第二个提交应是 0.1.2:%+v", got) + } +} + +func TestSyncerRequiresDependencies(t *testing.T) { + if _, err := NewSyncer(SyncerConfig{RepoKey: "a/b"}); err == nil { + t.Fatal("缺数据源/存储必须拒绝装配") + } + if _, err := NewSyncer(SyncerConfig{Source: &fakeSource{}, Store: &fakeStore{}}); err == nil { + t.Fatal("缺仓库标识必须拒绝装配") + } +} diff --git a/scripts/test-scope.json b/scripts/test-scope.json index 02043c4..7172361 100644 --- a/scripts/test-scope.json +++ b/scripts/test-scope.json @@ -53,6 +53,11 @@ "package": "knowledge-governance/internal/version", "test": "TestReleaseBuildRequiresEvidence", "reason": "构建身份未注入时按设计 SKIP(本地跑全量测试不该被迫先构建);**它的强制点在 contracts 项**:那里带 -ldflags 跑它并断言真的 PASS、且出现 SKIP 就判红" + }, + { + "package": "knowledge-governance/internal/versionhistory", + "test": "TestLiveSyncAgainstRealRepository", + "reason": "真实仓库的只读同步用例需要内部可达的 Gitea(**匿名**,不读凭据);无环境即跳过(补验路径:设 VERSION_HISTORY_LIVE_BASE_URL 后单跑这条)" } ] } diff --git a/web/src/App.tsx b/web/src/App.tsx index b5aace1..b2e3488 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { TopBarUser, useOptionalAuth } from './auth' import { DocumentWorkbench } from './DocumentWorkbench' import { SourceCatalogBrowser } from './SourceCatalogBrowser' import { WeKnoraMonitor } from './WeKnoraMonitor' +import { VersionHistory } from './VersionHistory' import { SourceLinkManager } from './SourceLinkManager' import { SystemSettings } from './SystemSettings' import { WorkspaceContextTabsBar, WorkspaceContextTabsSetter } from './workspaceContext' @@ -12,9 +13,40 @@ import { fetchSourceAssets } from './assets' import type { LegacyStatus, SourceAssetPage } from './assets' import { fetchJobs } from './jobs' import type { Job, JobState } from './jobs' -import { confirmReleaseChangeSet, createReleaseBatch, fetchLatestReleaseChangeSet, fetchReleaseAuditChain, fetchReleaseBatch, fetchReleaseBatches, fetchReleaseCapabilities, fetchReleaseExecution, fetchReleaseExecutions, fetchReleasePlan, fetchReviewCandidates, generateReleaseChangeSet, productionConfirmationPhrases, reviewPublication, runProductionAction, sealReleaseBatch, reviewPublicationsBatch , runPublishNow} from './reviewRelease' +import { + confirmReleaseChangeSet, + createReleaseBatch, + fetchLatestReleaseChangeSet, + fetchReleaseAuditChain, + fetchReleaseBatch, + fetchReleaseBatches, + fetchReleaseCapabilities, + fetchReleaseExecution, + fetchReleaseExecutions, + fetchReleasePlan, + fetchReviewCandidates, + generateReleaseChangeSet, + productionConfirmationPhrases, + reviewPublication, + runProductionAction, + sealReleaseBatch, + reviewPublicationsBatch, + runPublishNow, +} from './reviewRelease' import type { ProductionActionKind } from './reviewRelease' -import type { ReleaseAuditChain, ReleaseBatchDetail, ReleaseBatchPage, ReleaseCapabilities, ReleaseChangeSetDetail, ReleaseExecutionDetail, ReleaseExecutionPage, ReleasePlan, ReleasePlanItem, ReviewCandidatePage, Visibility } from './reviewRelease' +import type { + ReleaseAuditChain, + ReleaseBatchDetail, + ReleaseBatchPage, + ReleaseCapabilities, + ReleaseChangeSetDetail, + ReleaseExecutionDetail, + ReleaseExecutionPage, + ReleasePlan, + ReleasePlanItem, + ReviewCandidatePage, + Visibility, +} from './reviewRelease' import { fetchSourceConnections, fetchSourceScanDetail, fetchSourceScans, triggerSourceScan } from './sourceConnections' import type { SourceConnectionPage, SourceScanDetail, SourceScanPage } from './sourceConnections' import { fetchTestPublications, retryPublication } from './testPublications' @@ -29,6 +61,7 @@ const modules: ReadonlyArray<{ id: ModuleId; label: string; icon: string }> = [ { id: 'tasks', label: '解析监控', icon: '↻' }, { id: 'releases', label: '发布管理', icon: '⇧' }, { id: 'settings', label: '系统设置', icon: '⚙' }, + { id: 'versions', label: '版本记录', icon: '⌥' }, ] interface WorkspaceShellProps { @@ -46,101 +79,104 @@ export function WorkspaceShell(props: WorkspaceShellProps) { return ( -
- - {navigationOpen ? ( - + ))} + +
+
+ + {navigationOpen ? ( -
- 知识治理 - - {activeModule?.label ?? '工作台'} -
-
- {props.workspace.openTabs.map((tabId) => { - const module = modules.find((item) => item.id === tabId) - if (!module) return null - const active = props.workspace.activeTab === tabId - return ( -
- - {tabId !== 'dashboard' ? ( + aria-label="关闭功能导航" + onClick={() => setNavigationOpen(false)} + /> + ) : null} +
+
+ +
+ 知识治理 + + {activeModule?.label ?? '工作台'} +
+
+ {props.workspace.openTabs.map((tabId) => { + const module = modules.find((item) => item.id === tabId) + if (!module) return null + const active = props.workspace.activeTab === tabId + return ( +
- ) : null} -
- ) - })} -
- - -
-
{props.children}
-
-
+ {tabId !== 'dashboard' ? ( + + ) : null} +
+ ) + })} +
+ + + +
{props.children}
+ +
) } @@ -228,7 +264,13 @@ interface ModulePageProps extends JobDashboardProps { releaseActionBusy?: boolean releaseActionError?: string | null releaseActionMessage?: string | null - onStartReleaseAction?: (kind: ProductionActionKind, id: string, label: string, ids?: string[], deleteIDs?: string[]) => void + onStartReleaseAction?: ( + kind: ProductionActionKind, + id: string, + label: string, + ids?: string[], + deleteIDs?: string[] + ) => void onReleaseActionPhrase?: (value: string) => void onSubmitReleaseAction?: () => void onCancelReleaseAction?: () => void @@ -244,9 +286,17 @@ export interface PendingReleaseAction { deletePublicationIDs?: string[] } -const emptyAssetPage: SourceAssetPage = { items: [], summary: { total: 0, completed: 0, content_alias: 0, over_limit: 0 }, limit: 50, offset: 0 } +const emptyAssetPage: SourceAssetPage = { + items: [], + summary: { total: 0, completed: 0, content_alias: 0, over_limit: 0 }, + limit: 50, + offset: 0, +} -const emptyConnectionPage: SourceConnectionPage = { items: [], summary: { total: 0, enabled: 0, disabled: 0, healthy: 0, degraded: 0, unhealthy: 0, unknown: 0 } } +const emptyConnectionPage: SourceConnectionPage = { + items: [], + summary: { total: 0, enabled: 0, disabled: 0, healthy: 0, degraded: 0, unhealthy: 0, unknown: 0 }, +} const emptyScanPage: SourceScanPage = { items: [], total: 0, limit: 20, offset: 0 } @@ -256,21 +306,183 @@ const emptyReviewPage: ReviewCandidatePage = { items: [], total: 0, limit: 20, o const emptyBatchPage: ReleaseBatchPage = { items: [], total: 0, limit: 20, offset: 0 } const emptyExecutionPage: ReleaseExecutionPage = { items: [], total: 0, limit: 20, offset: 0 } -const disabledReleaseCapabilities: ReleaseCapabilities = { production_release_requests_enabled: false, production_backup_restore_verified: false, test_auto_review_policy: '' } +const disabledReleaseCapabilities: ReleaseCapabilities = { + production_release_requests_enabled: false, + production_backup_restore_verified: false, + test_auto_review_policy: '', +} const emptyReleasePlan: ReleasePlan = { items: [], summary: { add: 0, update: 0, delete: 0, blocked: 0, unchanged: 0 } } -export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connectionPage = emptyConnectionPage, connectionLoading = false, connectionError = null, onConnectionRefresh = () => undefined, scanningConnectionId = null, onTriggerSourceScan = () => undefined, scanPage = emptyScanPage, scanDetail = null, scanLoading = false, scanError = null, selectedScanId = null, onSelectScan = () => undefined, assetPage = emptyAssetPage, assetLoading = false, assetError = null, assetState = '', onAssetStateChange = () => undefined, onAssetPageChange = () => undefined, onAssetRefresh = () => undefined, publicationPage = emptyPublicationPage, publicationLoading = false, publicationError = null, publicationState = '', retryingId = null, onPublicationStateChange = () => undefined, onPublicationPageChange = () => undefined, onPublicationRefresh = () => undefined, onRetry = () => undefined, reviewPage = emptyReviewPage, reviewLoading = false, reviewError = null, reviewingId = null, batchPage = emptyBatchPage, batchDetail = null, batchLoading = false, batchError = null, mutatingBatchId = null, onReview = () => undefined, onBatchReview = () => undefined, reviewBatchMessage = '', onReviewRefresh = () => undefined, onCreateBatch = () => undefined, onSealBatch = () => undefined, onSelectBatch = () => undefined, onBatchRefresh = () => undefined, releaseCapabilities = disabledReleaseCapabilities, releasePlan = emptyReleasePlan, releaseChangeSet = null, changeSetBusy = false, changeSetError = null, onGenerateChangeSet = () => undefined, onConfirmChangeSet = () => undefined, selectedDeleteIDs = [], onToggleDelete = () => undefined, executionPage = emptyExecutionPage, executionDetail = null, executionChain = null, executionLoading = false, executionError = null, onSelectExecution = () => undefined, onExecutionRefresh = () => undefined, pendingReleaseAction = null, releaseActionPhrase = '', releaseActionBusy = false, releaseActionError = null, releaseActionMessage = null, onStartReleaseAction = () => undefined, onReleaseActionPhrase = () => undefined, onSubmitReleaseAction = () => undefined, onCancelReleaseAction = () => undefined }: ModulePageProps) { +export function ModulePage({ + moduleId, + jobs, + loading, + error, + onRefresh, + connectionPage = emptyConnectionPage, + connectionLoading = false, + connectionError = null, + onConnectionRefresh = () => undefined, + scanningConnectionId = null, + onTriggerSourceScan = () => undefined, + scanPage = emptyScanPage, + scanDetail = null, + scanLoading = false, + scanError = null, + selectedScanId = null, + onSelectScan = () => undefined, + assetPage = emptyAssetPage, + assetLoading = false, + assetError = null, + assetState = '', + onAssetStateChange = () => undefined, + onAssetPageChange = () => undefined, + onAssetRefresh = () => undefined, + publicationPage = emptyPublicationPage, + publicationLoading = false, + publicationError = null, + publicationState = '', + retryingId = null, + onPublicationStateChange = () => undefined, + onPublicationPageChange = () => undefined, + onPublicationRefresh = () => undefined, + onRetry = () => undefined, + reviewPage = emptyReviewPage, + reviewLoading = false, + reviewError = null, + reviewingId = null, + batchPage = emptyBatchPage, + batchDetail = null, + batchLoading = false, + batchError = null, + mutatingBatchId = null, + onReview = () => undefined, + onBatchReview = () => undefined, + reviewBatchMessage = '', + onReviewRefresh = () => undefined, + onCreateBatch = () => undefined, + onSealBatch = () => undefined, + onSelectBatch = () => undefined, + onBatchRefresh = () => undefined, + releaseCapabilities = disabledReleaseCapabilities, + releasePlan = emptyReleasePlan, + releaseChangeSet = null, + changeSetBusy = false, + changeSetError = null, + onGenerateChangeSet = () => undefined, + onConfirmChangeSet = () => undefined, + selectedDeleteIDs = [], + onToggleDelete = () => undefined, + executionPage = emptyExecutionPage, + executionDetail = null, + executionChain = null, + executionLoading = false, + executionError = null, + onSelectExecution = () => undefined, + onExecutionRefresh = () => undefined, + pendingReleaseAction = null, + releaseActionPhrase = '', + releaseActionBusy = false, + releaseActionError = null, + releaseActionMessage = null, + onStartReleaseAction = () => undefined, + onReleaseActionPhrase = () => undefined, + onSubmitReleaseAction = () => undefined, + onCancelReleaseAction = () => undefined, +}: ModulePageProps) { if (moduleId === 'tasks') { - return + return ( + + ) } if (moduleId === 'knowledge') { - return + return ( + + ) } if (moduleId === 'sources') { - return + return ( + + ) } if (moduleId === 'releases') { - return + return ( + + ) } if (moduleId === 'settings') { return @@ -295,7 +507,11 @@ export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connecti {loading ? '刷新中…' : '刷新数据'} - {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null}
@@ -306,7 +522,15 @@ export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connecti
运行安全 - PP历史来源清单已接管;生产写入:{releaseCapabilities.production_release_requests_enabled ? (releaseCapabilities.production_backup_restore_verified ? '已开启' : '已开启(快照恢复未验证,仍被阻断)') : '关闭'};测试 / 生产 WeKnora 连接状态以「系统设置 → 服务配置」的实时探测为准。 + + PP历史来源清单已接管;生产写入: + {releaseCapabilities.production_release_requests_enabled + ? releaseCapabilities.production_backup_restore_verified + ? '已开启' + : '已开启(快照恢复未验证,仍被阻断)' + : '关闭'} + ;测试 / 生产 WeKnora 连接状态以「系统设置 → 服务配置」的实时探测为准。 +
Release Worker 默认关闭
@@ -350,9 +574,9 @@ interface KnowledgeInventoryProps { } const legacyStatusLabels: Record = { - completed: '历史已同步', - content_alias: '内容别名', - over_limit: '超过旧同步上限', + completed: '历史已同步', + content_alias: '内容别名', + over_limit: '超过旧同步上限', } const sourceTypeLabels: Record = { @@ -397,7 +621,20 @@ const sourceScanChangeLabels: Record = { unavailable: '来源不可用', } -export function SourceConnections({ page, loading, error, scanningConnectionId, scanPage, scanDetail, scanLoading, scanError, selectedScanId, onRefresh, onTriggerScan, onSelectScan }: SourceConnectionsProps) { +export function SourceConnections({ + page, + loading, + error, + scanningConnectionId, + scanPage, + scanDetail, + scanLoading, + scanError, + selectedScanId, + onRefresh, + onTriggerScan, + onSelectScan, +}: SourceConnectionsProps) { const attention = page.summary.degraded + page.summary.unhealthy const scannableConnectionID = page.items.find((item) => item.source_type === 'pp')?.id ?? '' return ( @@ -408,9 +645,15 @@ export function SourceConnections({ page, loading, error, scanningConnectionId,

来源连接

查看来源是否可读、多久检查一次。日常文件操作请进入“来源与目录”。

- + - {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null}
@@ -420,67 +663,188 @@ export function SourceConnections({ page, loading, error, scanningConnectionId,
-

来源连接

PP检查的是本机已保存快照,不是实时拉取网站。检查不会修改来源,也不会自动上传或删除文件。

+
+

来源连接

+

PP检查的是本机已保存快照,不是实时拉取网站。检查不会修改来源,也不会自动上传或删除文件。

+
来源只读
{!loading && !error && page.items.length === 0 ?
尚无来源连接。
: null} {page.items.length > 0 ? ( -
- {page.items.map((item) => { - const activeScan = item.last_scan?.state === 'pending' || item.last_scan?.state === 'running' - return - - - - - - - - - - })} -
连接类型状态调度健康对象数最近扫描操作
{item.name.startsWith('PP legacy ') ? 'PP公开文档' : item.name}{sourceTypeLabels[item.source_type] ?? item.source_type}{item.enabled ? '已启用' : '已停用'}{item.schedule_configured ? '已配置' : '未配置'}{sourceHealthLabels[item.health_status] ?? item.health_status}{item.item_count}{item.last_scan ?
- 最近扫描 · {sourceScanLabels[item.last_scan.state] ?? item.last_scan.state} - 观察 {item.last_scan.observed_items} · 未变化 {item.last_scan.unchanged_items} - 新增 {item.last_scan.added_items} · 更新 {item.last_scan.updated_items} · 缺失 {item.last_scan.missing_items} - 超过上限 {item.last_scan.over_limit_items} · 待上传 {item.last_scan.planned_uploads} - {item.last_scan.error_code ? {item.last_scan.error_code} : null} -
: '尚未扫描'}
{item.source_type === 'pp' ? : '暂未支持检查'}
+
+ + + + + + + + + + + + + + + {page.items.map((item) => { + const activeScan = item.last_scan?.state === 'pending' || item.last_scan?.state === 'running' + return ( + + + + + + + + + + + ) + })} + +
连接类型状态调度健康对象数最近扫描操作
+ {item.name.startsWith('PP legacy ') ? 'PP公开文档' : item.name} + {sourceTypeLabels[item.source_type] ?? item.source_type} + + {item.enabled ? '已启用' : '已停用'} + + {item.schedule_configured ? '已配置' : '未配置'} + + {sourceHealthLabels[item.health_status] ?? item.health_status} + + {item.item_count} + {item.last_scan ? ( +
+ 最近扫描 · {sourceScanLabels[item.last_scan.state] ?? item.last_scan.state} + + 观察 {item.last_scan.observed_items} · 未变化 {item.last_scan.unchanged_items} + + + 新增 {item.last_scan.added_items} · 更新 {item.last_scan.updated_items} · 缺失{' '} + {item.last_scan.missing_items} + + + 超过上限 {item.last_scan.over_limit_items} · 待上传 {item.last_scan.planned_uploads} + + {item.last_scan.error_code ? {item.last_scan.error_code} : null} +
+ ) : ( + '尚未扫描' + )} +
+ {item.source_type === 'pp' ? ( + + ) : ( + '暂未支持检查' + )} +
+
) : null}
-
查看检查历史与审计记录 +
+ 查看检查历史与审计记录
-

扫描历史与变化记录

展示安全聚合和治理侧变化标识;不展示来源路径、修订值或快照指纹。

+
+

扫描历史与变化记录

+

展示安全聚合和治理侧变化标识;不展示来源路径、修订值或快照指纹。

+
审计只读
- {scanError ?
{scanError}
: null} - {!scanLoading && !scanError && scanPage.items.length === 0 ?
当前来源尚无扫描历史。
: null} - {scanPage.items.length > 0 ?
-
- {scanPage.items.map((scan) => - - - - - - - )} -
扫描状态观察变化上限阻断操作
{scan.id}{sourceScanLabels[scan.state] ?? scan.state}{scan.observed_items}新增 {scan.added_items} · 更新 {scan.updated_items} · 缺失 {scan.missing_items}{scan.over_limit_items}
-
-

变化记录

- {scanLoading ?
加载扫描记录中…
: null} - {!scanLoading && !scanDetail ?
选择一条扫描查看变化。
: null} - {!scanLoading && scanDetail?.changes.length === 0 ?
本次扫描未发现新增、更新或不可用变化。
: null} - {!scanLoading && scanDetail && scanDetail.changes.length > 0 ?
- {scanDetail.changes.map((change) => - - - - - )} -
类型治理资产大小记录时间
{sourceScanChangeLabels[change.change_type] ?? change.change_type}{change.source_item_id ?? change.id}{change.observed_size_bytes === null ? '—' : formatBytes(change.observed_size_bytes)}{formatTime(change.created_at)}
: null} + {scanError ? ( +
+ {scanError}
-
: null} + ) : null} + {!scanLoading && !scanError && scanPage.items.length === 0 ? ( +
当前来源尚无扫描历史。
+ ) : null} + {scanPage.items.length > 0 ? ( +
+
+ + + + + + + + + + + + + {scanPage.items.map((scan) => ( + + + + + + + + + ))} + +
扫描状态观察变化上限阻断操作
+ {scan.id} + + + + {sourceScanLabels[scan.state] ?? scan.state} + + {scan.observed_items} + 新增 {scan.added_items} · 更新 {scan.updated_items} · 缺失 {scan.missing_items} + {scan.over_limit_items} + +
+
+
+

变化记录

+ {scanLoading ?
加载扫描记录中…
: null} + {!scanLoading && !scanDetail ?
选择一条扫描查看变化。
: null} + {!scanLoading && scanDetail?.changes.length === 0 ? ( +
本次扫描未发现新增、更新或不可用变化。
+ ) : null} + {!scanLoading && scanDetail && scanDetail.changes.length > 0 ? ( +
+ + + + + + + + + + + {scanDetail.changes.map((change) => ( + + + + + + + ))} + +
类型治理资产大小记录时间
{sourceScanChangeLabels[change.change_type] ?? change.change_type} + {change.source_item_id ?? change.id} + {change.observed_size_bytes === null ? '—' : formatBytes(change.observed_size_bytes)}{formatTime(change.created_at)}
+
+ ) : null} +
+
+ ) : null}
) @@ -493,7 +857,15 @@ function sourceHealthClass(status: string) { return 'pending' } -export function KnowledgeInventory({ page, loading, error, state, onStateChange, onPageChange, onRefresh }: KnowledgeInventoryProps) { +export function KnowledgeInventory({ + page, + loading, + error, + state, + onStateChange, + onPageChange, + onRefresh, +}: KnowledgeInventoryProps) { return (
@@ -502,9 +874,15 @@ export function KnowledgeInventory({ page, loading, error, state, onStateChange,

知识资产

统一展示各类治理来源;清单只读,不会修改或重新上传来源文件。

- +
- {error ?
{error}
: null} + {error ? ( +
+ {error} +
+ ) : null}
@@ -513,8 +891,12 @@ export function KnowledgeInventory({ page, loading, error, state, onStateChange,
-

统一来源清单

按当前治理版本分页读取,超限项保持阻断。

-
: null} + - {pendingItems.length > 0 ? <> +
+
+ {productionStatus} + + {props.releaseCapabilities.production_backup_restore_verified + ? '发布前会创建可恢复快照;失败自动按快照回滚。' + : '生产发布已阻断:先完成生产快照并通过恢复验证。'} + +
+ + {props.releaseCapabilities.production_release_requests_enabled + ? '修改生产内容:待确认' + : '修改生产内容:关闭'} + +
+ 查看技术状态 +

{`生产请求门禁${props.releaseCapabilities.production_release_requests_enabled ? '已开启' : '已关闭'};快照恢复验证${props.releaseCapabilities.production_backup_restore_verified ? '已通过' : '未通过'}。`}

+
+
+ + {props.releaseActionError ? ( +
+ {props.releaseActionError} +
+ ) : null} + {props.releaseActionMessage ? ( +

+ {props.releaseActionMessage} +

+ ) : null} + +
-

待你审核({pendingItems.length})

先确认测试结果与发布范围,再批准或驳回。可批准的多条可一次批。

+
+

+ 待发布({publishReady.length} + {props.selectedDeleteIDs.length ? ` + 下架 ${props.selectedDeleteIDs.length}` : ''}) +

+

已批准、可直接发布到生产的知识。点单条后面的按钮即可发布。

+ {autoReviewNote(props.releaseCapabilities.test_auto_review_policy) ? ( +

+ {autoReviewNote(props.releaseCapabilities.test_auto_review_policy)} +

+ ) : null} +
- - - + + + {props.reviewBatchMessage ? {props.reviewBatchMessage} : null}
- {batchBlocker ?
{`为什么按钮不能点:${batchBlocker}`}
: null} -
- {pendingItems.map((item) => { - const visibility = visibilityByID[item.publication_id] ?? (item.decided_visibility || item.current_visibility) - return - - - - - - - - })} -
选择知识测试结果能否继续发布范围操作
toggleSelected(item.publication_id)} />{item.title}{sourceTypeLabels[item.source_type] ?? item.source_type}{item.knowledge_exists === 'passed' && item.chunks_present === 'passed' ? '基础测试通过' : '基础测试未通过'}{item.retrieval_hit === 'manual_required' ? '搜索效果需人工确认' : `搜索测试${acceptanceLabels[item.retrieval_hit]}`}{reviewGateLabel(item)}
- : null} + {props.reviewError ? ( +
+ {props.reviewError} +
+ ) : null} + {!props.reviewLoading && publishReady.length === 0 ? ( +
+ 没有待发布的已批准知识。到「来源与目录」标注后,在「解析监控」处理完成即可发布。 +
+ ) : null} + {publishReady.length > 0 ? ( +
+ + + + + + + + + + + + + {publishReady.map((item) => ( + + + + + + + + + ))} + +
选择知识目标测试审核操作
+ toggleSelected(item.publication_id)} + /> + + {item.title} + {sourceTypeLabels[item.source_type] ?? item.source_type} + {item.current_visibility === 'internal' ? '对内知识库' : '对外知识库'} + {item.knowledge_exists === 'passed' && item.chunks_present === 'passed' ? '通过' : '未通过'} + + 已批准 + {item.reviewer_ref ? {item.reviewer_ref} : null} + +
+ + +
+
+
+ ) : null} - {rejectedItems.length > 0 ?
已驳回({rejectedItems.length})
    {rejectedItems.map((item) =>
  • {item.title}
  • )}
: null} -
+ {pendingItems.length > 0 ? ( + <> +
+
+

待你审核({pendingItems.length})

+

先确认测试结果与发布范围,再批准或驳回。可批准的多条可一次批。

+
+
+ + + +
+
+ {batchBlocker ? ( +
+ {`为什么按钮不能点:${batchBlocker}`} +
+ ) : null} +
+ + + + + + + + + + + + + {pendingItems.map((item) => { + const visibility = + visibilityByID[item.publication_id] ?? (item.decided_visibility || item.current_visibility) + return ( + + + + + + + + + ) + })} + +
选择知识测试结果能否继续发布范围操作
+ toggleSelected(item.publication_id)} + /> + + {item.title} + {sourceTypeLabels[item.source_type] ?? item.source_type} + + + {item.knowledge_exists === 'passed' && item.chunks_present === 'passed' + ? '基础测试通过' + : '基础测试未通过'} + + + {item.retrieval_hit === 'manual_required' + ? '搜索效果需人工确认' + : `搜索测试${acceptanceLabels[item.retrieval_hit]}`} + + + + {reviewGateLabel(item)} + + + + +
+ + +
+
+
+ + ) : null} -
-

发布记录

最近 20 条生产发布与回滚。生产确认、发布验证与回滚都由 Release Worker 完成,这里只读展示结果。

{props.releaseCapabilities.production_release_requests_enabled ? '生产操作可执行(待确认)' : '生产操作已锁定'}Release Worker 默认关闭
- {props.pendingReleaseAction ?
-

{releaseActionLabels[props.pendingReleaseAction.kind]}「{props.pendingReleaseAction.label}」—— 请输入固定确认语句 {productionConfirmationPhrases[props.pendingReleaseAction.kind]} 后执行。

- props.onReleaseActionPhrase?.(event.target.value)} placeholder={productionConfirmationPhrases[props.pendingReleaseAction.kind]} aria-label="生产动作确认语句" /> - - -
: null} - {props.executionError ?
{props.executionError}
: null} - {!props.executionLoading && !props.executionError && props.executionPage.items.length === 0 ?
还没有发布记录。发布一条知识后,这里会出现执行进度与逐项结果。
: null} - {props.executionPage.items.length > 0 ?
- {props.executionPage.items.map((execution) => )} -
开始时间操作状态批次进度完成进度授权人操作
{formatTime(execution.created_at)}{execution.operation === 'publish' ? '发布' : '回滚'}{executionLabels[execution.state]}{executionStateDetail(execution, props.executionPage.items) ? {executionStateDetail(execution, props.executionPage.items)} : null}{execution.error_code ? {releaseFailureMessage(execution.error_code)} : null}{execution.total_batches > 0 ? `批次 ${execution.current_batch} / ${execution.total_batches}` : '等待开始'}每批最多 {execution.batch_size} 项{execution.completed_item_count}/{execution.item_count}{execution.failed_item_count ? 失败 {execution.failed_item_count} : null}{execution.authorized_by}
{isAdmin && execution.state === 'failed' ? : null}{isAdmin && execution.operation === 'publish' && execution.state === 'completed' ? : null}

每项知识的结果

{props.executionDetail ? <>{props.executionDetail.items.map((item) => { const result = releaseItemResult(item); return
{item.title}{result.detail}{item.target_knowledge_base_id ? ` · 目标库 ${item.target_knowledge_base_id.slice(0, 8)}…` : ''}{item.error_code ? ` · ${releaseFailureMessage(item.error_code)}` : ''}{result.outcome}
})}{props.executionDetail.items.length === 0 ?
发布尚无逐项结果。
: null} :
点击“查看结果”核对每项知识。
}
: null} - {props.executionChain ?
-

发布审计链

-
批次{props.executionChain.release_batch_name || '未绑定批次'}{`创建 ${props.executionChain.created_by || '—'} · 锁定 ${props.executionChain.sealed_by || '—'} · 授权 ${props.executionChain.authorized_by || '—'}`}
- {props.executionChain.release_change_set_id ?
发布计划集{props.executionChain.release_change_set_name}{`${props.executionChain.release_change_set_state === 'confirmed' ? '已确认' : props.executionChain.release_change_set_state} · 确认人 ${props.executionChain.release_change_set_confirmed_by || '—'} · 摘要 ${props.executionChain.manifest_sha256.slice(0, 8)}…${props.executionChain.manifest_sha256.slice(-6)}`}
: null} - {props.executionChain.snapshots.length ?
来源快照{props.executionChain.snapshots.map((snap) => `${snap.source_name}(${snap.file_count})`).join('、')}
: null} - {props.executionChain.rollback_execution_id ?
补偿回滚已关联回滚执行
: null} -
: null} -
- -
- 更多 · 发布计划、下架与批次清单 -
-

发布计划

来源与目录 → 测试知识库 → 生产知识库。来源标注是最终 Truth Source。

按知识级别同步
-
新增 {props.releasePlan.summary.add}更新 {props.releasePlan.summary.update}删除 {props.releasePlan.summary.delete}阻断 {props.releasePlan.summary.blocked}未变化 {props.releasePlan.summary.unchanged}
-
- - - - {`筛选 ${planFiltered.length} / 全部 ${props.releasePlan.items.length}`} -
- {planPageItems.length ?
{planPageItems.map((item, index) => )}
选择知识目标计划动作测试状态生产状态能否发布
{item.action === 'delete' && item.can_release ? { props.onToggleDelete(item.publication_id); setDeleteConfirmed(false) }} /> : '—'}{item.title}{item.target_scope === 'internal' ? '对内知识库' : '对外知识库'}{actionLabel(item)}{item.test_state === 'ready' ? '已通过' : item.test_state === 'not_required' ? '无需测试' : '未就绪'}{item.production_state === 'present' ? '已存在' : '不存在'}{item.can_release ? item.action === 'delete' ? '将从生产检索中移除' : '可加入发布批次' : item.blocker === 'source_unavailable' ? '来源当前不可用,保留生产知识' : '测试未就绪,当前阻断'}
: planFiltered.length ?
当前筛选没有发布变更。
:
来源、测试与生产状态一致,当前没有发布变更。
} - {planPageCount > 1 ?
{`第 ${planSafePage + 1} / ${planPageCount} 页 · 每页 ${planPageSize} 条`}
: null} -
-

冻结发布计划集

建批时自动冻结当前计划;同一份计划复用同一版本。

{changeSet ? (changeSet.state === 'confirmed' ? `v${changeSet.number} · 已确认` : `v${changeSet.number} · 待确认`) : '尚未生成'}
- {changeSet ?

{`清单 ${changeSet.item_count} 项(新增${changeSet.add_count}·更新${changeSet.update_count}·删除${changeSet.delete_count}·阻断${changeSet.blocked_count})· 摘要 ${changeSet.manifest_sha256.slice(0, 8)}…`}

: null} - {changeSetError ?

{changeSetError}

: null} -
{changeSet ? 建批时自动冻结当前计划,无需手动确认冻结 : 建批时会自动冻结当前发布计划}
-
- -
已选 {selectedCount} 项{props.selectedDeleteIDs.length > 0 && !deleteConfirmed ? '(删除待确认)' : ''}
-
创建批次还需要确认所选发布动作至少 1 项动作通过确认填写批次名称
+ {rejectedItems.length > 0 ? ( +
+ 已驳回({rejectedItems.length}) +
    + {rejectedItems.map((item) => ( +
  • + {item.title} + +
  • + ))} +
+
+ ) : null}
-
-

批次清单

建批即锁定;锁定 ≠ 上线。日常发布直接在上面「待发布」里点按钮;已锁定的批次也可以在这里逐条做生产确认。

锁定清单 ≠ 上线
- {props.batchError ?
{props.batchError}
: null} - {props.batchPage.items.length === 0 ?
还没有批次。用上面的发布计划表创建,或直接在「待发布」里一键发布。
:
- {props.batchPage.items.map((batch) => )} -
批次名称状态知识数量内容状态操作
{batch.name}{batchLabels[batch.state]}{batch.item_count}{batch.state === 'sealed' ? '内容已锁定' : '仍可检查调整'}
{batch.state === 'draft' ? : null}{batch.state === 'sealed' && isAdmin ? : null}

本批次包含

{props.batchDetail ? <>{props.batchDetail.items.map((item) =>
{item.title}{item.action === 'delete' ? '删除生产知识' : '新增或更新生产知识'}{item.decided_visibility === 'internal' ? '仅内部员工' : '允许外部用户'}
)}{props.batchDetail.items.length === 0 ?
批次没有知识。
: null} :
点击“查看内容”核对本次发布清单。
}
} + +
+
+
+

发布记录

+

最近 20 条生产发布与回滚。生产确认、发布验证与回滚都由 Release Worker 完成,这里只读展示结果。

+
+ + {props.releaseCapabilities.production_release_requests_enabled + ? '生产操作可执行(待确认)' + : '生产操作已锁定'} + + Release Worker 默认关闭 +
+ {props.pendingReleaseAction ? ( +
+

+ {releaseActionLabels[props.pendingReleaseAction.kind]}「{props.pendingReleaseAction.label}」—— + 请输入固定确认语句 {productionConfirmationPhrases[props.pendingReleaseAction.kind]} 后执行。 +

+ props.onReleaseActionPhrase?.(event.target.value)} + placeholder={productionConfirmationPhrases[props.pendingReleaseAction.kind]} + aria-label="生产动作确认语句" + /> + + +
+ ) : null} + {props.executionError ? ( +
+ {props.executionError} +
+ ) : null} + {!props.executionLoading && !props.executionError && props.executionPage.items.length === 0 ? ( +
还没有发布记录。发布一条知识后,这里会出现执行进度与逐项结果。
+ ) : null} + {props.executionPage.items.length > 0 ? ( +
+
+ + + + + + + + + + + + + + {props.executionPage.items.map((execution) => ( + + + + + + + + + + ))} + +
开始时间操作状态批次进度完成进度授权人操作
{formatTime(execution.created_at)}{execution.operation === 'publish' ? '发布' : '回滚'} + {executionLabels[execution.state]} + {executionStateDetail(execution, props.executionPage.items) ? ( + {executionStateDetail(execution, props.executionPage.items)} + ) : null} + {execution.error_code ? {releaseFailureMessage(execution.error_code)} : null} + + {execution.total_batches > 0 + ? `批次 ${execution.current_batch} / ${execution.total_batches}` + : '等待开始'} + 每批最多 {execution.batch_size} 项 + + {execution.completed_item_count}/{execution.item_count} + {execution.failed_item_count ? 失败 {execution.failed_item_count} : null} + {execution.authorized_by} +
+ + {isAdmin && execution.state === 'failed' ? ( + + ) : null} + {isAdmin && execution.operation === 'publish' && execution.state === 'completed' ? ( + + ) : null} +
+
+
+
+

每项知识的结果

+ {props.executionDetail ? ( + <> + {props.executionDetail.items.map((item) => { + const result = releaseItemResult(item) + return ( +
+ + {item.title} + + {result.detail} + {item.target_knowledge_base_id + ? ` · 目标库 ${item.target_knowledge_base_id.slice(0, 8)}…` + : ''} + {item.error_code ? ` · ${releaseFailureMessage(item.error_code)}` : ''} + + + + {result.outcome} + +
+ ) + })} + {props.executionDetail.items.length === 0 ? ( +
发布尚无逐项结果。
+ ) : null} + + ) : ( +
点击“查看结果”核对每项知识。
+ )} +
+
+ ) : null} + {props.executionChain ? ( +
+

发布审计链

+
+ 批次 + {props.executionChain.release_batch_name || '未绑定批次'} + {`创建 ${props.executionChain.created_by || '—'} · 锁定 ${props.executionChain.sealed_by || '—'} · 授权 ${props.executionChain.authorized_by || '—'}`} +
+ {props.executionChain.release_change_set_id ? ( +
+ 发布计划集 + {props.executionChain.release_change_set_name} + {`${props.executionChain.release_change_set_state === 'confirmed' ? '已确认' : props.executionChain.release_change_set_state} · 确认人 ${props.executionChain.release_change_set_confirmed_by || '—'} · 摘要 ${props.executionChain.manifest_sha256.slice(0, 8)}…${props.executionChain.manifest_sha256.slice(-6)}`} +
+ ) : null} + {props.executionChain.snapshots.length ? ( +
+ 来源快照 + + {props.executionChain.snapshots.map((snap) => `${snap.source_name}(${snap.file_count})`).join('、')} + +
+ ) : null} + {props.executionChain.rollback_execution_id ? ( +
+ 补偿回滚 + 已关联回滚执行 +
+ ) : null} +
+ ) : null}
-
-
+ +
+ 更多 · 发布计划、下架与批次清单 +
+
+
+

发布计划

+

来源与目录 → 测试知识库 → 生产知识库。来源标注是最终 Truth Source。

+
+ 按知识级别同步 +
+
+ 新增 {props.releasePlan.summary.add} + 更新 {props.releasePlan.summary.update} + 删除 {props.releasePlan.summary.delete} + 阻断 {props.releasePlan.summary.blocked} + 未变化 {props.releasePlan.summary.unchanged} +
+
+ + + + {`筛选 ${planFiltered.length} / 全部 ${props.releasePlan.items.length}`} +
+ {planPageItems.length ? ( +
+ + + + + + + + + + + + + + {planPageItems.map((item, index) => ( + + + + + + + + + + ))} + +
选择知识目标计划动作测试状态生产状态能否发布
+ {item.action === 'delete' && item.can_release ? ( + { + props.onToggleDelete(item.publication_id) + setDeleteConfirmed(false) + }} + /> + ) : ( + '—' + )} + + {item.title} + {item.target_scope === 'internal' ? '对内知识库' : '对外知识库'}{actionLabel(item)} + {item.test_state === 'ready' + ? '已通过' + : item.test_state === 'not_required' + ? '无需测试' + : '未就绪'} + {item.production_state === 'present' ? '已存在' : '不存在'} + {item.can_release + ? item.action === 'delete' + ? '将从生产检索中移除' + : '可加入发布批次' + : item.blocker === 'source_unavailable' + ? '来源当前不可用,保留生产知识' + : '测试未就绪,当前阻断'} +
+
+ ) : planFiltered.length ? ( +
当前筛选没有发布变更。
+ ) : ( +
来源、测试与生产状态一致,当前没有发布变更。
+ )} + {planPageCount > 1 ? ( +
+ + {`第 ${planSafePage + 1} / ${planPageCount} 页 · 每页 ${planPageSize} 条`} + +
+ ) : null} +
+
+
+

冻结发布计划集

+

建批时自动冻结当前计划;同一份计划复用同一版本。

+
+ + {changeSet + ? changeSet.state === 'confirmed' + ? `v${changeSet.number} · 已确认` + : `v${changeSet.number} · 待确认` + : '尚未生成'} + +
+ {changeSet ? ( +

{`清单 ${changeSet.item_count} 项(新增${changeSet.add_count}·更新${changeSet.update_count}·删除${changeSet.delete_count}·阻断${changeSet.blocked_count})· 摘要 ${changeSet.manifest_sha256.slice(0, 8)}…`}

+ ) : null} + {changeSetError ?

{changeSetError}

: null} +
+ {changeSet ? ( + 建批时自动冻结当前计划,无需手动确认冻结 + ) : ( + 建批时会自动冻结当前发布计划 + )} +
+
+ +
+ + + 已选 {selectedCount} 项{props.selectedDeleteIDs.length > 0 && !deleteConfirmed ? '(删除待确认)' : ''} + + +
+
+ 创建批次还需要 + + 确认所选发布动作 + + 至少 1 项动作通过确认 + 填写批次名称 +
+
+
+
+
+

批次清单

+

+ 建批即锁定;锁定 ≠ 上线。日常发布直接在上面「待发布」里点按钮;已锁定的批次也可以在这里逐条做生产确认。 +

+
+ 锁定清单 ≠ 上线 +
+ {props.batchError ? ( +
+ {props.batchError} +
+ ) : null} + {props.batchPage.items.length === 0 ? ( +
还没有批次。用上面的发布计划表创建,或直接在「待发布」里一键发布。
+ ) : ( +
+
+ + + + + + + + + + + + {props.batchPage.items.map((batch) => ( + + + + + + + + ))} + +
批次名称状态知识数量内容状态操作
+ {batch.name} + + + {batchLabels[batch.state]} + + {batch.item_count}{batch.state === 'sealed' ? '内容已锁定' : '仍可检查调整'} +
+ + {batch.state === 'draft' ? ( + + ) : null} + {batch.state === 'sealed' && isAdmin ? ( + + ) : null} +
+
+
+
+

本批次包含

+ {props.batchDetail ? ( + <> + {props.batchDetail.items.map((item) => ( +
+ + {item.title} + {item.action === 'delete' ? '删除生产知识' : '新增或更新生产知识'} + + {item.decided_visibility === 'internal' ? '仅内部员工' : '允许外部用户'} +
+ ))} + {props.batchDetail.items.length === 0 ?
批次没有知识。
: null} + + ) : ( +
点击“查看内容”核对本次发布清单。
+ )} +
+
+ )} +
+
+ + ) } -function TestPublications({ page, loading, error, state, retryingId, onStateChange, onPageChange, onRefresh, onRetry }: TestPublicationsProps) { +function TestPublications({ + page, + loading, + error, + state, + retryingId, + onStateChange, + onPageChange, + onRefresh, + onRetry, +}: TestPublicationsProps) { const [view, setView] = useState<'overview' | 'internal' | 'external' | 'failed'>('overview') - const visibleItems = page.items.filter((item) => view === 'internal' ? item.target_scope === 'internal' : view === 'external' ? item.target_scope === 'external' : view === 'failed' ? item.processing_state === 'failed' || item.processing_state === 'stalled' : true) + const visibleItems = page.items.filter((item) => + view === 'internal' + ? item.target_scope === 'internal' + : view === 'external' + ? item.target_scope === 'external' + : view === 'failed' + ? item.processing_state === 'failed' || item.processing_state === 'stalled' + : true + ) const internalCount = page.items.filter((item) => item.target_scope === 'internal').length const externalCount = page.items.filter((item) => item.target_scope === 'external').length - const processingCount = page.items.filter((item) => item.processing_state === 'processing' || item.processing_state === 'finalizing').length - const problemCount = page.items.filter((item) => item.processing_state === 'failed' || item.processing_state === 'stalled').length + const processingCount = page.items.filter( + (item) => item.processing_state === 'processing' || item.processing_state === 'finalizing' + ).length + const problemCount = page.items.filter( + (item) => item.processing_state === 'failed' || item.processing_state === 'stalled' + ).length return (
@@ -1086,15 +2337,43 @@ function TestPublications({ page, loading, error, state, retryingId, onStateChan

解析监控

查看测试知识库中每份资料的上传、解析和验收状态。

- +
- - {error ?
{error}
: null} -
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ + + + +
-

{view === 'failed' ? '失败与停滞文件' : '解析队列'}

当前筛选共 {visibleItems.length} 条;仅返回治理安全投影,不含来源地址或凭据。

-
- {!loading && !error && visibleItems.length === 0 ?
当前筛选没有测试发布记录。
: null} - {visibleItems.length > 0 ? ( -
- {visibleItems.map((item) => ( - - - - - - - - - - ))} -
发布标题处理状态重试次数错误码更新时间操作
{item.id}{item.title}{processingLabels[item.processing_state]}{item.retry_count}{item.last_error || '—'}{formatTime(item.updated_at)}{item.can_retry ? : '—'}
+ {!loading && !error && visibleItems.length === 0 ? ( +
当前筛选没有测试发布记录。
) : null} -
第 {Math.floor(page.offset / page.limit) + 1} 页
- - -
+ {visibleItems.length > 0 ? ( +
+ + + + + + + + + + + + + + {visibleItems.map((item) => ( + + + + + + + + + + ))} + +
发布标题处理状态重试次数错误码更新时间操作
+ {item.id} + {item.title} + + {processingLabels[item.processing_state]} + + {item.retry_count}{item.last_error || '—'}{formatTime(item.updated_at)} + {item.can_retry ? ( + + ) : ( + '—' + )} +
+
+ ) : null} +
+ 第 {Math.floor(page.offset / page.limit) + 1} 页 +
+ + +
+
) @@ -1217,18 +2542,21 @@ export default function App() { } }, []) - const loadAssets = useCallback(async (signal?: AbortSignal) => { - setAssetLoading(true) - setAssetError(null) - try { - setAssetPage(await fetchSourceAssets({ state: assetState, limit: 50, offset: assetOffset }, signal)) - } catch (cause) { - if (cause instanceof DOMException && cause.name === 'AbortError') return - setAssetError(cause instanceof Error ? cause.message : '加载知识资产失败') - } finally { - setAssetLoading(false) - } - }, [assetOffset, assetState]) + const loadAssets = useCallback( + async (signal?: AbortSignal) => { + setAssetLoading(true) + setAssetError(null) + try { + setAssetPage(await fetchSourceAssets({ state: assetState, limit: 50, offset: assetOffset }, signal)) + } catch (cause) { + if (cause instanceof DOMException && cause.name === 'AbortError') return + setAssetError(cause instanceof Error ? cause.message : '加载知识资产失败') + } finally { + setAssetLoading(false) + } + }, + [assetOffset, assetState] + ) const loadConnections = useCallback(async (signal?: AbortSignal) => { setConnectionLoading(true) @@ -1273,43 +2601,57 @@ export default function App() { } }, []) - const handleTriggerSourceScan = useCallback(async (connectionID: string) => { - setScanningConnectionId(connectionID) - setConnectionError(null) - try { - await triggerSourceScan(connectionID) - await loadConnections() - } catch (cause) { - setConnectionError(cause instanceof Error ? cause.message : '触发来源扫描失败') - } finally { - setScanningConnectionId(null) - } - }, [loadConnections]) + const handleTriggerSourceScan = useCallback( + async (connectionID: string) => { + setScanningConnectionId(connectionID) + setConnectionError(null) + try { + await triggerSourceScan(connectionID) + await loadConnections() + } catch (cause) { + setConnectionError(cause instanceof Error ? cause.message : '触发来源扫描失败') + } finally { + setScanningConnectionId(null) + } + }, + [loadConnections] + ) - const loadPublications = useCallback(async (signal?: AbortSignal) => { - setPublicationLoading(true) - setPublicationError(null) - try { - setPublicationPage(await fetchTestPublications({ processing_state: publicationState, limit: 50, offset: publicationOffset }, signal)) - } catch (cause) { - if (cause instanceof DOMException && cause.name === 'AbortError') return - setPublicationError(cause instanceof Error ? cause.message : '加载测试发布失败') - } finally { - setPublicationLoading(false) - } - }, [publicationOffset, publicationState]) + const loadPublications = useCallback( + async (signal?: AbortSignal) => { + setPublicationLoading(true) + setPublicationError(null) + try { + setPublicationPage( + await fetchTestPublications( + { processing_state: publicationState, limit: 50, offset: publicationOffset }, + signal + ) + ) + } catch (cause) { + if (cause instanceof DOMException && cause.name === 'AbortError') return + setPublicationError(cause instanceof Error ? cause.message : '加载测试发布失败') + } finally { + setPublicationLoading(false) + } + }, + [publicationOffset, publicationState] + ) - const handleRetry = useCallback(async (id: string) => { - setRetryingId(id) - try { - await retryPublication(id) - await loadPublications() - } catch (cause) { - setPublicationError(cause instanceof Error ? cause.message : '重试失败') - } finally { - setRetryingId(null) - } - }, [loadPublications]) + const handleRetry = useCallback( + async (id: string) => { + setRetryingId(id) + try { + await retryPublication(id) + await loadPublications() + } catch (cause) { + setPublicationError(cause instanceof Error ? cause.message : '重试失败') + } finally { + setRetryingId(null) + } + }, + [loadPublications] + ) const loadReviews = useCallback(async (signal?: AbortSignal) => { setReviewLoading(true) @@ -1339,99 +2681,124 @@ export default function App() { } }, []) - const handleReview = useCallback(async (publicationID: string, decision: 'approved' | 'rejected', visibility: Visibility, decisionCode: string) => { - setReviewingId(publicationID) - setReviewError(null) - try { - await reviewPublication(publicationID, decision, visibility, decisionCode) - await loadReviews() - } catch (cause) { - setReviewError(cause instanceof Error ? cause.message : '提交审核决定失败') - } finally { - setReviewingId(null) - } - }, [loadReviews]) + const handleReview = useCallback( + async (publicationID: string, decision: 'approved' | 'rejected', visibility: Visibility, decisionCode: string) => { + setReviewingId(publicationID) + setReviewError(null) + try { + await reviewPublication(publicationID, decision, visibility, decisionCode) + await loadReviews() + } catch (cause) { + setReviewError(cause instanceof Error ? cause.message : '提交审核决定失败') + } finally { + setReviewingId(null) + } + }, + [loadReviews] + ) const reloadChangeSet = useCallback(async () => { setReleaseChangeSet(await fetchLatestReleaseChangeSet().catch(() => null)) }, []) - const handleGenerateChangeSet = useCallback(async (name: string) => { - setChangeSetBusy(true) - setChangeSetError(null) - try { - await generateReleaseChangeSet(name) - await reloadChangeSet() - } catch (cause) { - setChangeSetError(cause instanceof Error ? cause.message : '生成发布计划集失败') - } finally { - setChangeSetBusy(false) - } - }, [reloadChangeSet]) + const handleGenerateChangeSet = useCallback( + async (name: string) => { + setChangeSetBusy(true) + setChangeSetError(null) + try { + await generateReleaseChangeSet(name) + await reloadChangeSet() + } catch (cause) { + setChangeSetError(cause instanceof Error ? cause.message : '生成发布计划集失败') + } finally { + setChangeSetBusy(false) + } + }, + [reloadChangeSet] + ) - const handleConfirmChangeSet = useCallback(async (changeSetID: string) => { - setChangeSetBusy(true) - setChangeSetError(null) - try { - await confirmReleaseChangeSet(changeSetID) - await reloadChangeSet() - } catch (cause) { - setChangeSetError(cause instanceof Error ? cause.message : '确认发布计划集失败') - } finally { - setChangeSetBusy(false) - } - }, [reloadChangeSet]) + const handleConfirmChangeSet = useCallback( + async (changeSetID: string) => { + setChangeSetBusy(true) + setChangeSetError(null) + try { + await confirmReleaseChangeSet(changeSetID) + await reloadChangeSet() + } catch (cause) { + setChangeSetError(cause instanceof Error ? cause.message : '确认发布计划集失败') + } finally { + setChangeSetBusy(false) + } + }, + [reloadChangeSet] + ) const [reviewMessage, setReviewMessage] = useState('') // P0-3 批量审核:逐条其实都由服务端处理,这里只管调用与如实回报。 - const handleBatchReview = useCallback(async (publicationIDs: string[], decision: 'approved' | 'rejected', visibility: Visibility, decisionCode: string) => { - setReviewingId('batch') - setBatchError(null) - try { - const result = await reviewPublicationsBatch(publicationIDs, decision, visibility, decisionCode) - const label = decision === 'approved' ? '批准' : '驳回' - setReviewMessage(result.skipped > 0 - ? `已${label} ${result.decided} 项;${result.skipped} 项当时不可审核(${result.skipped_ids.join('、')})` - : `已${label} ${result.decided} 项`) - await loadReviews() - } catch (cause) { - setBatchError(cause instanceof Error ? cause.message : '批量审核失败') - } finally { - setReviewingId(null) - } - }, [loadReviews]) + const handleBatchReview = useCallback( + async ( + publicationIDs: string[], + decision: 'approved' | 'rejected', + visibility: Visibility, + decisionCode: string + ) => { + setReviewingId('batch') + setBatchError(null) + try { + const result = await reviewPublicationsBatch(publicationIDs, decision, visibility, decisionCode) + const label = decision === 'approved' ? '批准' : '驳回' + setReviewMessage( + result.skipped > 0 + ? `已${label} ${result.decided} 项;${result.skipped} 项当时不可审核(${result.skipped_ids.join('、')})` + : `已${label} ${result.decided} 项` + ) + await loadReviews() + } catch (cause) { + setBatchError(cause instanceof Error ? cause.message : '批量审核失败') + } finally { + setReviewingId(null) + } + }, + [loadReviews] + ) - const handleCreateBatch = useCallback(async (name: string, publicationIDs: string[], deletePublicationIDs: string[]) => { - setMutatingBatchId('creating') - setBatchError(null) - try { - // 不传版本 id:服务端在"建批这一下"冻结当前计划(同摘要复用同一版本), - // 操作员因此不必先「生成新版本」再「确认冻结」。 - // seal=true:建批即锁定(P0-3),草稿态不再单独占一步。 - const batch = await createReleaseBatch(name, publicationIDs, deletePublicationIDs, '', true) - setSelectedDeleteIDs([]) - await loadBatches() - setBatchDetail(await fetchReleaseBatch(batch.id)) - } catch (cause) { - setBatchError(cause instanceof Error ? cause.message : '创建封批失败') - } finally { - setMutatingBatchId(null) - } - }, [loadBatches, releaseChangeSet]) + const handleCreateBatch = useCallback( + async (name: string, publicationIDs: string[], deletePublicationIDs: string[]) => { + setMutatingBatchId('creating') + setBatchError(null) + try { + // 不传版本 id:服务端在"建批这一下"冻结当前计划(同摘要复用同一版本), + // 操作员因此不必先「生成新版本」再「确认冻结」。 + // seal=true:建批即锁定(P0-3),草稿态不再单独占一步。 + const batch = await createReleaseBatch(name, publicationIDs, deletePublicationIDs, '', true) + setSelectedDeleteIDs([]) + await loadBatches() + setBatchDetail(await fetchReleaseBatch(batch.id)) + } catch (cause) { + setBatchError(cause instanceof Error ? cause.message : '创建封批失败') + } finally { + setMutatingBatchId(null) + } + }, + [loadBatches, releaseChangeSet] + ) - const handleSealBatch = useCallback(async (batchID: string) => { - setMutatingBatchId(batchID) - setBatchError(null) - try { - await sealReleaseBatch(batchID) - await loadBatches() - setBatchDetail(await fetchReleaseBatch(batchID)) - } catch (cause) { - setBatchError(cause instanceof Error ? cause.message : '封批失败') - } finally { - setMutatingBatchId(null) - } - }, [loadBatches]) + const handleSealBatch = useCallback( + async (batchID: string) => { + setMutatingBatchId(batchID) + setBatchError(null) + try { + await sealReleaseBatch(batchID) + await loadBatches() + setBatchDetail(await fetchReleaseBatch(batchID)) + } catch (cause) { + setBatchError(cause instanceof Error ? cause.message : '封批失败') + } finally { + setMutatingBatchId(null) + } + }, + [loadBatches] + ) const handleSelectBatch = useCallback(async (batchID: string) => { setBatchLoading(true) @@ -1459,7 +2826,13 @@ export default function App() { setExecutionPage(page) setReleasePlan(plan) setReleaseChangeSet(changeSet) - setSelectedDeleteIDs((current) => current.filter((publicationID) => plan.items.some((item) => item.publication_id === publicationID && item.action === 'delete' && item.can_release))) + setSelectedDeleteIDs((current) => + current.filter((publicationID) => + plan.items.some( + (item) => item.publication_id === publicationID && item.action === 'delete' && item.can_release + ) + ) + ) setExecutionDetail(page.items[0] ? await fetchReleaseExecution(page.items[0].id, signal) : null) } catch (cause) { if (cause instanceof DOMException && cause.name === 'AbortError') return @@ -1470,13 +2843,16 @@ export default function App() { }, []) // 页面上的生产动作:点按钮 → 逐字输入固定确认语句 → 提交。服务端同时校验 -// 管理员会话、请求门禁与确认语句;Bearer 通道留给运维脚本,行为不变。 - const handleStartReleaseAction = useCallback((kind: ProductionActionKind, id: string, label: string, ids?: string[], deleteIDs?: string[]) => { - setPendingReleaseAction({ kind, id, label, publicationIDs: ids, deletePublicationIDs: deleteIDs }) - setReleaseActionPhrase('') - setReleaseActionError(null) - setReleaseActionMessage(null) - }, []) + // 管理员会话、请求门禁与确认语句;Bearer 通道留给运维脚本,行为不变。 + const handleStartReleaseAction = useCallback( + (kind: ProductionActionKind, id: string, label: string, ids?: string[], deleteIDs?: string[]) => { + setPendingReleaseAction({ kind, id, label, publicationIDs: ids, deletePublicationIDs: deleteIDs }) + setReleaseActionPhrase('') + setReleaseActionError(null) + setReleaseActionMessage(null) + }, + [] + ) const handleCancelReleaseAction = useCallback(() => { setPendingReleaseAction(null) @@ -1493,11 +2869,24 @@ export default function App() { if (pendingReleaseAction.kind === 'publish_now') { // 名称留空由服务端兜底;这里给一个可读的默认名(确定性内容,重放指纹一致)。 const defaultName = `立即发布 · ${new Date().toLocaleDateString('zh-CN')}` - const result = await runPublishNow(pendingReleaseAction.publicationIDs ?? [], pendingReleaseAction.deletePublicationIDs ?? [], defaultName, releaseActionPhrase.trim()) - setReleaseActionMessage(`已受理发布:批次 ${result.batch_name || result.batch_id} · 执行 ${result.execution_id}`) + const result = await runPublishNow( + pendingReleaseAction.publicationIDs ?? [], + pendingReleaseAction.deletePublicationIDs ?? [], + defaultName, + releaseActionPhrase.trim() + ) + setReleaseActionMessage( + `已受理发布:批次 ${result.batch_name || result.batch_id} · 执行 ${result.execution_id}` + ) } else { - const summary = await runProductionAction(pendingReleaseAction.kind, pendingReleaseAction.id, releaseActionPhrase.trim()) - setReleaseActionMessage(`${releaseActionLabels[pendingReleaseAction.kind]}已受理:执行 ${summary.id}(${summary.state || 'pending'}) · ${summary.item_count} 项`) + const summary = await runProductionAction( + pendingReleaseAction.kind, + pendingReleaseAction.id, + releaseActionPhrase.trim() + ) + setReleaseActionMessage( + `${releaseActionLabels[pendingReleaseAction.kind]}已受理:执行 ${summary.id}(${summary.state || 'pending'}) · ${summary.item_count} 项` + ) } setPendingReleaseAction(null) setReleaseActionPhrase('') @@ -1513,7 +2902,10 @@ export default function App() { setExecutionLoading(true) setExecutionError(null) try { - const [detail, chain] = await Promise.all([fetchReleaseExecution(executionID), fetchReleaseAuditChain(executionID)]) + const [detail, chain] = await Promise.all([ + fetchReleaseExecution(executionID), + fetchReleaseAuditChain(executionID), + ]) setExecutionDetail(detail) setExecutionChain(chain) } catch (cause) { @@ -1554,7 +2946,10 @@ export default function App() { }, [loadSourceScans, ppConnectionID, ppLastScanID, ppLastScanState]) useEffect(() => { - if (!connectionPage.items.some((item) => item.last_scan?.state === 'pending' || item.last_scan?.state === 'running')) return + if ( + !connectionPage.items.some((item) => item.last_scan?.state === 'pending' || item.last_scan?.state === 'running') + ) + return const timer = window.setTimeout(() => void loadConnections(), 1000) return () => window.clearTimeout(timer) }, [connectionPage, loadConnections]) @@ -1597,86 +2992,116 @@ export default function App() { onOpen={(moduleId) => setWorkspace((current) => openModule(current, moduleId))} onClose={(moduleId) => setWorkspace((current) => closeModule(current, moduleId))} > - {workspace.activeTab === 'dashboard' ? : workspace.activeTab === 'tasks' ? : workspace.activeTab === 'sources' ? : workspace.activeTab === 'settings' ? : workspace.activeTab === 'knowledge' ? : void load()} - connectionPage={connectionPage} - connectionLoading={connectionLoading} - connectionError={connectionError} - onConnectionRefresh={() => { - void loadConnections() - if (ppConnectionID) void loadSourceScans(ppConnectionID) - }} - scanningConnectionId={scanningConnectionId} - onTriggerSourceScan={(id) => void handleTriggerSourceScan(id)} - scanPage={scanPage} - scanDetail={scanDetail} - scanLoading={scanLoading} - scanError={scanError} - selectedScanId={selectedScanId} - onSelectScan={(connectionID, scanID) => void handleSelectScan(connectionID, scanID)} - assetPage={assetPage} - assetLoading={assetLoading} - assetError={assetError} - assetState={assetState} - onAssetStateChange={(state) => { - setAssetState(state) - setAssetOffset(0) - }} - onAssetPageChange={setAssetOffset} - onAssetRefresh={() => void loadAssets()} - publicationPage={publicationPage} - publicationLoading={publicationLoading} - publicationError={publicationError} - publicationState={publicationState} - retryingId={retryingId} - onPublicationStateChange={(state) => { - setPublicationState(state) - setPublicationOffset(0) - }} - onPublicationPageChange={setPublicationOffset} - onPublicationRefresh={() => void loadPublications()} - onRetry={(id) => void handleRetry(id)} - reviewPage={reviewPage} - reviewLoading={reviewLoading} - reviewError={reviewError} - reviewingId={reviewingId} - batchPage={batchPage} - batchDetail={batchDetail} - batchLoading={batchLoading} - batchError={batchError} - mutatingBatchId={mutatingBatchId} - onReview={(publicationID, decision, visibility, decisionCode) => void handleReview(publicationID, decision, visibility, decisionCode)} - onBatchReview={handleBatchReview} - reviewBatchMessage={reviewMessage} - onReviewRefresh={() => void loadReviews()} - onCreateBatch={(name, publicationIDs, deletePublicationIDs) => void handleCreateBatch(name, publicationIDs, deletePublicationIDs)} - onSealBatch={(batchID) => void handleSealBatch(batchID)} - onSelectBatch={(batchID) => void handleSelectBatch(batchID)} - onBatchRefresh={() => void loadBatches()} - releaseCapabilities={releaseCapabilities} - releasePlan={releasePlan} - releaseChangeSet={releaseChangeSet} - changeSetBusy={changeSetBusy} - changeSetError={changeSetError} - onGenerateChangeSet={handleGenerateChangeSet} - onConfirmChangeSet={handleConfirmChangeSet} - selectedDeleteIDs={selectedDeleteIDs} - onToggleDelete={(publicationID) => setSelectedDeleteIDs((current) => current.includes(publicationID) ? current.filter((id) => id !== publicationID) : [...current, publicationID])} - executionPage={executionPage} - executionDetail={executionDetail} - executionChain={executionChain} - executionLoading={executionLoading} - executionError={executionError} - onSelectExecution={(executionID) => void handleSelectExecution(executionID)} - onExecutionRefresh={() => void loadReleaseExecutions()} pendingReleaseAction={pendingReleaseAction} releaseActionPhrase={releaseActionPhrase} releaseActionBusy={releaseActionBusy} releaseActionError={releaseActionError} releaseActionMessage={releaseActionMessage} onStartReleaseAction={handleStartReleaseAction} onReleaseActionPhrase={setReleaseActionPhrase} onSubmitReleaseAction={() => void handleSubmitReleaseAction()} onCancelReleaseAction={handleCancelReleaseAction} - />} + {workspace.activeTab === 'dashboard' ? ( + + ) : workspace.activeTab === 'tasks' ? ( + + ) : workspace.activeTab === 'sources' ? ( + + ) : workspace.activeTab === 'settings' ? ( + + ) : workspace.activeTab === 'knowledge' ? ( + + ) : workspace.activeTab === 'versions' ? ( + + ) : ( + void load()} + connectionPage={connectionPage} + connectionLoading={connectionLoading} + connectionError={connectionError} + onConnectionRefresh={() => { + void loadConnections() + if (ppConnectionID) void loadSourceScans(ppConnectionID) + }} + scanningConnectionId={scanningConnectionId} + onTriggerSourceScan={(id) => void handleTriggerSourceScan(id)} + scanPage={scanPage} + scanDetail={scanDetail} + scanLoading={scanLoading} + scanError={scanError} + selectedScanId={selectedScanId} + onSelectScan={(connectionID, scanID) => void handleSelectScan(connectionID, scanID)} + assetPage={assetPage} + assetLoading={assetLoading} + assetError={assetError} + assetState={assetState} + onAssetStateChange={(state) => { + setAssetState(state) + setAssetOffset(0) + }} + onAssetPageChange={setAssetOffset} + onAssetRefresh={() => void loadAssets()} + publicationPage={publicationPage} + publicationLoading={publicationLoading} + publicationError={publicationError} + publicationState={publicationState} + retryingId={retryingId} + onPublicationStateChange={(state) => { + setPublicationState(state) + setPublicationOffset(0) + }} + onPublicationPageChange={setPublicationOffset} + onPublicationRefresh={() => void loadPublications()} + onRetry={(id) => void handleRetry(id)} + reviewPage={reviewPage} + reviewLoading={reviewLoading} + reviewError={reviewError} + reviewingId={reviewingId} + batchPage={batchPage} + batchDetail={batchDetail} + batchLoading={batchLoading} + batchError={batchError} + mutatingBatchId={mutatingBatchId} + onReview={(publicationID, decision, visibility, decisionCode) => + void handleReview(publicationID, decision, visibility, decisionCode) + } + onBatchReview={handleBatchReview} + reviewBatchMessage={reviewMessage} + onReviewRefresh={() => void loadReviews()} + onCreateBatch={(name, publicationIDs, deletePublicationIDs) => + void handleCreateBatch(name, publicationIDs, deletePublicationIDs) + } + onSealBatch={(batchID) => void handleSealBatch(batchID)} + onSelectBatch={(batchID) => void handleSelectBatch(batchID)} + onBatchRefresh={() => void loadBatches()} + releaseCapabilities={releaseCapabilities} + releasePlan={releasePlan} + releaseChangeSet={releaseChangeSet} + changeSetBusy={changeSetBusy} + changeSetError={changeSetError} + onGenerateChangeSet={handleGenerateChangeSet} + onConfirmChangeSet={handleConfirmChangeSet} + selectedDeleteIDs={selectedDeleteIDs} + onToggleDelete={(publicationID) => + setSelectedDeleteIDs((current) => + current.includes(publicationID) + ? current.filter((id) => id !== publicationID) + : [...current, publicationID] + ) + } + executionPage={executionPage} + executionDetail={executionDetail} + executionChain={executionChain} + executionLoading={executionLoading} + executionError={executionError} + onSelectExecution={(executionID) => void handleSelectExecution(executionID)} + onExecutionRefresh={() => void loadReleaseExecutions()} + pendingReleaseAction={pendingReleaseAction} + releaseActionPhrase={releaseActionPhrase} + releaseActionBusy={releaseActionBusy} + releaseActionError={releaseActionError} + releaseActionMessage={releaseActionMessage} + onStartReleaseAction={handleStartReleaseAction} + onReleaseActionPhrase={setReleaseActionPhrase} + onSubmitReleaseAction={() => void handleSubmitReleaseAction()} + onCancelReleaseAction={handleCancelReleaseAction} + /> + )} ) } diff --git a/web/src/VersionHistory.test.tsx b/web/src/VersionHistory.test.tsx new file mode 100644 index 0000000..09d717d --- /dev/null +++ b/web/src/VersionHistory.test.tsx @@ -0,0 +1,209 @@ +// @ts-expect-error The test runner provides Node; the browser bundle intentionally omits @types/node. +import { readFileSync } from 'node:fs' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { VersionHistoryView } from './VersionHistory' +import type { VersionHistoryItem, VersionHistoryPage } from './versionHistory' + +const releaseItem: VersionHistoryItem = { + commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e', + short_sha: '6de5d5236380', + parents: ['97f4b296613e23889997dd14004cd23f6c3a2c3f'], + committed_at: '2026-10-13T16:45:56+08:00', + author_display: '姚章浩', + subject: 'feat(version): 开发号随提交自动递增 + 首个正式版本 0.1.0', + body: '正文第一行\n正文第二行', + record_kind: 'release', + version_label: '0.1.0', + version_state: 'verified', + release_tags: [{ name: 'v0.1.0', url: 'https://gitea.example/Weknora/knowledge-governance/src/tag/v0.1.0' }], + release_notes: '### Added\n\n- 首个正式版本', + commit_url: 'https://gitea.example/Weknora/knowledge-governance/commit/6de5d5236380ad619fe35e4bd20f49b2d967903e', + version_reason: 'annotated_tag_and_changelog_top_match', + is_current_running: true, +} + +const developmentItem: VersionHistoryItem = { + ...releaseItem, + commit_sha: '950c1b429bc814d9fa7c2c1449f999a05537af21', + short_sha: '950c1b429bc8', + subject: 'feat(release): 抬档门禁', + body: '', + record_kind: 'development', + version_label: '0.1.1', + version_state: 'verified', + release_tags: [], + release_notes: '', + is_current_running: false, +} + +const historicalItem: VersionHistoryItem = { + ...releaseItem, + commit_sha: 'e1c5f68c0d8a4f2b9c3d5e7a1b2c3d4e5f607182', + short_sha: 'e1c5f68c0d8a', + subject: 'chore(deploy): 部署与备份脚本', + body: '', + record_kind: 'historical', + version_label: '', + version_state: 'unknown', + release_tags: [], + release_notes: '', + version_reason: 'no_baseline', + is_current_running: false, + commit_url: 'https://gitea.example/Weknora/knowledge-governance/commit/e1c5f68c0d8a4f2b9c3d5e7a1b2c3d4e5f607182', +} + +const page: VersionHistoryPage = { + items: [releaseItem, developmentItem, historicalItem], + next_cursor: 'cursor-2', + has_more: true, + sync: { + available: true, + status: 'completed', + complete: true, + last_success_at: '2026-10-13T20:00:00+08:00', + fetched_count: 111, + expected_count: 111, + error_code: '', + source_head_sha: 'ad9c5d20d467…', + active_snapshot_id: 12, + repo: 'Weknora/knowledge-governance', + }, + current_running: { + state: 'matched', + commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e', + display_version: '0.1.0', + matched_commit_sha: '6de5d5236380ad619fe35e4bd20f49b2d967903e', + }, +} + +const render = (over: Partial[0]> = {}) => + renderToStaticMarkup( + + ) + +describe('VersionHistoryView', () => { + it('lists commits newest-first with kind badges, short sha, times and links', () => { + const html = render() + expect(html).toContain('版本记录') + expect(html).toContain('正式版本') + expect(html).toContain('开发版本') + expect(html).toContain('历史提交') + expect(html).toContain('0.1.0') + expect(html).toContain('0.1.1') + expect(html).toContain('950c1b429bc8') + expect(html).toContain('查看提交') + expect(html).toContain('查看 tag v0.1.0') + expect(html.indexOf('0.1.0')).toBeLessThan(html.indexOf('0.1.1')) + }) + + it('marks only the exact-match commit as current running', () => { + const html = render() + const flags = html.match(/当前运行/g) ?? [] + // 顶部一句「当前运行:…」+ 恰好一个条目徽标 + expect(flags.length).toBe(2) + expect(html).toContain('已匹配到下面的提交') + }) + + it('never labels a commit when the running identity is unknown', () => { + const html = render({ + page: { + ...page, + current_running: { state: 'unknown', commit_sha: '', display_version: 'dev', matched_commit_sha: '' }, + }, + }) + expect(html).toContain('当前运行版本未知') + expect(html).not.toContain('当前运行') + expect(html).not.toContain('已匹配到下面的提交') + }) + + it('does not invent a version number for unverified history commits', () => { + const html = render() + const start = html.indexOf('历史提交 · e1c5f68c0d8a') + expect(start).toBeGreaterThan(-1) + expect(html).toContain('版本号未能核验:找不到可核验的发布基线') + expect(html).not.toMatch(/历史提交 · e1c5f68c0d8a[\s\S]{0,200}?\d+\.\d+\.\d+[\s\S]{0,40}?版本号未能核验/) + }) + + it('keeps two commits that resolve to the same number as separate entries', () => { + const sameNumber: VersionHistoryItem = { + ...developmentItem, + commit_sha: 'c'.repeat(40), + short_sha: 'cccccccccccc', + subject: '另一条同号提交', + } + const html = render({ page: { ...page, items: [developmentItem, sameNumber] } }) + expect(html).toContain('950c1b429bc8') + expect(html).toContain('cccccccccccc') + expect((html.match(/0\.1\.1/g) ?? []).length).toBe(2) + }) + + it('shows an honest empty state and never offers tag/publish/deploy actions', () => { + const html = render({ page: { ...page, items: [], has_more: false } }) + expect(html).toContain('暂无记录') + expect(html).not.toContain('打 tag') + expect(html).not.toContain('发布') + expect(html).not.toContain('部署') + // 非管理员看不到同步入口 + expect(html).not.toContain('同步记录') + }) + + it('exposes the admin sync entry only for admins and reflects sync failures', () => { + const html = render({ canSync: true }) + expect(html).toContain('同步记录') + const failed = render({ + canSync: true, + page: { + ...page, + sync: { ...page.sync, status: 'failed', complete: false, error_code: 'gitea_unreachable' }, + }, + }) + expect(failed).toContain('上次同步失败(gitea_unreachable)') + expect(failed).toContain('仍展示上次成功的数据') + expect(failed).toContain('同步失败') + }) + + it('degrades to "not wired" instead of failing when the source is unconfigured', () => { + const html = render({ + page: { ...page, items: [], sync: { ...page.sync, available: false, status: '' } }, + }) + expect(html).toContain('未接线') + expect(html).not.toContain('同步记录') + }) + + it('keeps mobile single-column rules so nothing overflows horizontally', () => { + const css = readFileSync(new URL('./versionHistory.css', import.meta.url), 'utf8') + expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*?\.version-history[\s\S]*?overflow-wrap:\s*anywhere/) + expect(css).toMatch( + /@media \(max-width: 760px\)[\s\S]*?\.version-history-entry-head\s*\{\s*flex-direction:\s*column/ + ) + }) + + it('renders commit bodies as text, never as markup', () => { + const html = render({ + expanded: developmentItem.commit_sha, + page: { + ...page, + items: [{ ...developmentItem, body: '', release_notes: '' }], + }, + }) + expect(html).not.toContain('x') + expect(html).toContain('<img src=x') + }) +}) diff --git a/web/src/VersionHistory.tsx b/web/src/VersionHistory.tsx new file mode 100644 index 0000000..36a8a60 --- /dev/null +++ b/web/src/VersionHistory.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + fetchVersionHistory, + fetchBuildInfo, + reasonLabel, + syncVersionHistory, + type BuildInfo, + type VersionHistoryItem, + type VersionHistoryPage, +} from './versionHistory' +import { useOptionalAuth } from './auth' +import './versionHistory.css' + +// 「版本记录」页:追踪**所有已进入远端 main 的提交**,按提交时间倒序。 +// 只读:不提供打 tag / 发布 / 部署按钮;版本号只按证据展示,证据不足就说"历史提交"。 +const FILTERS: ReadonlyArray<{ id: string; label: string }> = [ + { id: 'all', label: '全部' }, + { id: 'release', label: '正式' }, + { id: 'development', label: '开发' }, + { id: 'historical', label: '历史' }, +] + +const stamp = (value: string) => (value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—') +const kindLabel: Record = { release: '正式版本', development: '开发版本', historical: '历史提交' } + +const runningText = (running: VersionHistoryPage['current_running']) => { + if (running.state === 'matched') + return `当前运行:${running.display_version}(${running.commit_sha.slice(0, 12)},已匹配到下面的提交)` + if (running.state === 'development') { + return `当前运行:开发构建 ${running.display_version}(${running.commit_sha.slice(0, 12)})—— 这份产物不在已同步的提交里` + } + return '当前运行版本未知(产物没有注入可核验的提交标识)' +} + +const syncText = (sync: VersionHistoryPage['sync']) => { + if (!sync.available) return '未接线:还没有配置只读的仓库来源' + if (!sync.status) return '尚未同步过' + if (sync.status === 'running' || sync.status === 'pending') return `同步中(${sync.status})` + if (sync.status === 'failed') { + return `上次同步失败(${sync.error_code || '未知原因'})· 仍展示上次成功的数据(${stamp(sync.last_success_at)})` + } + if (!sync.complete) { + return `上次同步:部分历史(${sync.fetched_count}/${sync.expected_count ?? '?'})· ${stamp(sync.last_success_at)}` + } + return `最后同步:${stamp(sync.last_success_at)}(${sync.fetched_count} 条提交)` +} + +interface ViewProps { + page: VersionHistoryPage | null + build: BuildInfo | null + filter: string + loading: boolean + loadingMore: boolean + error: string | null + notice: string | null + expanded: string | null + canSync: boolean + onFilter: (id: string) => void + onLoadMore: () => void + onSync: () => void + onExpand: (sha: string | null) => void +} + +export function VersionHistoryView(props: ViewProps) { + const { page, build } = props + const items = page?.items ?? [] + return ( +
+
+
+

版本记录

+

+ {page + ? runningText(page.current_running) + : build + ? `当前运行:${build.identity_available ? build.display_version : '未知'}` + : '当前运行版本未知'} +

+

+ {page ? syncText(page.sync) : '正在读取同步状态…'} + {page?.sync.status === 'running' || page?.sync.status === 'pending' ? ( + 同步中 + ) : null} + {page && page.sync.status === 'failed' ? ( + 同步失败 + ) : null} +

+
+ {props.canSync ? ( + + ) : null} +
+ + {props.error ? ( +
+ {props.error} +
+ ) : null} + {props.notice ?
{props.notice}
: null} + + + + {items.length === 0 && !props.loading ? ( +
+ {page?.sync.available + ? '暂无记录(先同步一次,或该筛选下没有条目)' + : '版本记录未接线:等待配置只读的仓库来源'} +
+ ) : null} + +
    + {items.map((item) => ( + props.onExpand(props.expanded === item.commit_sha ? null : item.commit_sha)} + /> + ))} +
+ + {props.loading ?
正在读取版本记录…
: null} + {page?.has_more ? ( + + ) : null} +
+ ) +} + +function TimelineEntry({ + item, + currentMatchedSHA, + expanded, + onExpand, +}: { + item: VersionHistoryItem + currentMatchedSHA: string + expanded: boolean + onExpand: () => void +}) { + const isRelease = item.record_kind === 'release' && item.version_state === 'verified' + const heading = isRelease ? item.version_label : item.version_label || `历史提交 · ${item.short_sha}` + const note = isRelease ? item.release_notes : '' + const hasDetail = Boolean(item.body) || Boolean(note) + return ( +
  • +
    + {heading} + {stamp(item.committed_at)} +
    +
    + {kindLabel[item.record_kind] ?? '历史提交'} + {item.short_sha} + {item.commit_sha && item.commit_sha === currentMatchedSHA ? ( + 当前运行 + ) : null} + {item.author_display} +
    +

    {item.subject}

    + {!item.version_label && item.record_kind === 'historical' ? ( +

    版本号未能核验:{reasonLabel(item.version_reason)}

    + ) : null} + {hasDetail ? ( + <> + + {expanded ? ( +
    + {item.record_kind === 'release' && note ?
    {note}
    : null} + {item.body ?
    {item.body}
    : null} +
    + ) : null} + + ) : null} +
    + {item.commit_url ? ( + + 查看提交 + + ) : null} + {item.release_tags.map((tag) => ( + + 查看 tag {tag.name} + + ))} +
    +
  • + ) +} + +export function VersionHistory() { + const auth = useOptionalAuth() + const [page, setPage] = useState(null) + const [build, setBuild] = useState(null) + const [filter, setFilter] = useState('all') + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [expanded, setExpanded] = useState(null) + const cursor = useRef('') + + const load = useCallback(async (kind: string, append: boolean) => { + append ? setLoadingMore(true) : setLoading(true) + try { + const next = await fetchVersionHistory({ kind, cursor: append ? cursor.current : '' }) + cursor.current = next.next_cursor + setPage((current) => (append && current ? { ...next, items: [...current.items, ...next.items] } : next)) + setError(null) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '加载版本记录失败') + } finally { + append ? setLoadingMore(false) : setLoading(false) + } + }, []) + + useEffect(() => { + void load(filter, false) + }, [filter, load]) + + useEffect(() => { + let cancelled = false + void fetchBuildInfo() + .then((value) => { + if (!cancelled) setBuild(value) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, []) + + // 只有管理员能看到同步入口;其他人只能看(服务端也会再判一次角色) + const canSync = auth?.identity?.role === 'admin' + const onSync = useCallback(async () => { + setNotice(null) + try { + const accepted = await syncVersionHistory() + setNotice(accepted.reused ? '已有同步任务在跑,本次复用它。' : '已提交同步任务,稍后刷新即可看到新记录。') + await load(filter, false) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '触发同步失败') + } + }, [filter, load]) + + const viewProps = useMemo( + () => ({ + page, + build, + filter, + loading, + loadingMore, + error, + notice, + expanded, + canSync, + onFilter: setFilter, + onLoadMore: () => void load(filter, true), + onSync: () => void onSync(), + onExpand: setExpanded, + }), + [page, build, filter, loading, loadingMore, error, notice, expanded, canSync, load, onSync] + ) + + return +} diff --git a/web/src/versionHistory.css b/web/src/versionHistory.css new file mode 100644 index 0000000..b406b0d --- /dev/null +++ b/web/src/versionHistory.css @@ -0,0 +1,171 @@ +/* 「版本记录」页:只读时间线。窄屏(≤760px,与工作台既有断点一致)单列、无横向溢出。 */ +.version-history { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.version-history-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.version-history-header h2 { + margin: 0 0 4px; + font-size: 18px; +} +.version-history-header p { + margin: 0; + color: var(--muted, #666); + font-size: 13px; +} +.version-history-sync-line { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px !important; +} +.version-history-notice { + padding: 8px 10px; + border-radius: 6px; + background: rgba(64, 158, 255, 0.12); + font-size: 13px; +} +.version-history-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.version-history-filters button.active { + font-weight: 600; + border-color: currentColor; +} +.version-history-timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} +.version-history-entry { + border-left: 3px solid rgba(127, 127, 127, 0.35); + padding: 10px 12px; + background: rgba(127, 127, 127, 0.06); + border-radius: 6px; + min-width: 0; +} +.version-history-entry.release { + border-left-color: #2f9e44; +} +.version-history-entry.development { + border-left-color: #1c7ed6; +} +.version-history-entry-head { + display: flex; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} +.version-history-entry-head strong { + font-size: 15px; +} +.version-history-entry-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + font-size: 12px; + color: var(--muted, #666); +} +.version-history-badge { + padding: 1px 6px; + border-radius: 999px; + border: 1px solid currentColor; + font-size: 12px; +} +.version-history-badge.release { + color: #2f9e44; +} +.version-history-badge.development { + color: #1c7ed6; +} +.version-history-badge.historical { + color: #868e96; +} +.version-history-badge.current { + color: #e8590c; +} +.version-history-badge.pending { + color: #1c7ed6; +} +.version-history-badge.warn { + color: #c92a2a; +} +.version-history-sha { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + overflow-wrap: anywhere; +} +.version-history-subject { + margin: 6px 0 0; + overflow-wrap: anywhere; +} +.version-history-reason { + margin: 4px 0 0; + font-size: 12px; + color: #c92a2a; + overflow-wrap: anywhere; +} +.version-history-toggle { + margin-top: 6px; +} +.version-history-detail pre { + margin: 6px 0 0; + padding: 8px; + max-height: 320px; + overflow: auto; + background: rgba(127, 127, 127, 0.1); + border-radius: 6px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; +} +.version-history-links { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 6px; + font-size: 13px; +} + +@media (max-width: 760px) { + .version-history-header { + flex-direction: column; + align-items: stretch; + } + .version-history-entry { + padding: 8px 10px; + } + .version-history-entry-head { + flex-direction: column; + gap: 2px; + } + /* 窄屏单列:正文/标题/hash 一律换行,不出现横向滚动 */ + .version-history, + .version-history-timeline, + .version-history-entry, + .version-history-subject, + .version-history-sha, + .version-history-reason { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + } + .version-history-detail pre { + max-height: none; + } +} diff --git a/web/src/versionHistory.test.ts b/web/src/versionHistory.test.ts new file mode 100644 index 0000000..2684a2f --- /dev/null +++ b/web/src/versionHistory.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchBuildInfo, fetchVersionHistory, reasonLabel, syncVersionHistory } from './versionHistory' + +const jsonResponse = (body: unknown, status = 200) => + Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) } as Response) + +afterEach(() => vi.unstubAllGlobals()) + +const pageBody = { + items: [ + { + commit_sha: 'a'.repeat(40), + short_sha: 'aaaaaaaaaaaa', + parents: [], + committed_at: '2026-10-13T10:00:00Z', + author_display: '姚章浩', + subject: 'feat: 甲', + body: '正文', + record_kind: 'development', + version_label: '0.1.2', + version_state: 'verified', + release_tags: [], + release_notes: '', + commit_url: 'https://gitea.example/commit/aaa', + version_reason: 'counted_commits_since_base_tag', + is_current_running: false, + }, + ], + next_cursor: 'next-cursor', + has_more: true, + sync: { + available: true, + status: 'completed', + complete: true, + last_success_at: '2026-10-13T12:00:00Z', + fetched_count: 111, + expected_count: 111, + error_code: '', + source_head_sha: 'a'.repeat(40), + active_snapshot_id: 3, + repo: 'Weknora/knowledge-governance', + }, + current_running: { + state: 'development', + commit_sha: 'b'.repeat(40), + display_version: '0.1.2', + matched_commit_sha: '', + }, +} + +describe('versionHistory client', () => { + it('loads a page, keeps the cursor and reads sync/current-running facts', async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(pageBody)) + vi.stubGlobal('fetch', fetchMock) + const page = await fetchVersionHistory({ kind: 'all', cursor: '' }) + expect(fetchMock).toHaveBeenCalledOnce() + const url = String(fetchMock.mock.calls[0][0]) + expect(url).toContain('/api/v1/version-history?') + expect(url).not.toContain('kind=all') + expect(url).toContain('limit=30') + expect(page.items[0].version_label).toBe('0.1.2') + expect(page.next_cursor).toBe('next-cursor') + expect(page.has_more).toBe(true) + expect(page.sync.expected_count).toBe(111) + expect(page.current_running.state).toBe('development') + }) + + it('passes the kind filter and cursor through', async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ ...pageBody, items: [], has_more: false }) + ) + vi.stubGlobal('fetch', fetchMock) + await fetchVersionHistory({ kind: 'historical', cursor: 'c-1', limit: 50 }) + const url = String(fetchMock.mock.calls[0][0]) + expect(url).toContain('kind=historical') + expect(url).toContain('cursor=c-1') + expect(url).toContain('limit=50') + }) + + it('explains an expired cursor instead of showing a raw error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse({ code: 'cursor_expired' }, 409)) + ) + await expect(fetchVersionHistory({ kind: 'all', cursor: 'stale' })).rejects.toThrow('已被清理') + }) + + it('rejects malformed payloads instead of rendering half-truths', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse({ items: 'nope' })) + ) + await expect(fetchVersionHistory({ kind: 'all', cursor: '' })).rejects.toThrow('响应无效') + }) + + it('posts the sync request and reports reuse', async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ job_id: 'job-1', status: 'running', reused: true }) + ) + vi.stubGlobal('fetch', fetchMock) + const accepted = await syncVersionHistory() + expect(String(fetchMock.mock.calls[0][0])).toBe('/api/v1/version-history/sync') + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'POST' }) + expect(accepted.reused).toBe(true) + }) + + it('says admin-only when the sync is refused', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse({ code: 'forbidden_role' }, 403)) + ) + await expect(syncVersionHistory()).rejects.toThrow('只有管理员') + }) + + it('never fabricates a build identity when injection is missing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + jsonResponse({ + release_version: '', + build_kind: 'development', + commit_sha: null, + display_version: 'dev', + built_at: '', + identity_available: false, + }) + ) + ) + const info = await fetchBuildInfo() + expect(info.commit_sha).toBeNull() + expect(info.identity_available).toBe(false) + }) + + it('maps every evidence reason to a human explanation', () => { + expect(reasonLabel('lightweight_tag')).toContain('轻量 tag') + expect(reasonLabel('no_baseline')).toContain('发布基线') + expect(reasonLabel('shallow_or_incomplete_graph')).toContain('提交图不完整') + expect(reasonLabel('something_new')).toBe('版本号未能核验') + }) +}) diff --git a/web/src/versionHistory.ts b/web/src/versionHistory.ts new file mode 100644 index 0000000..40922cd --- /dev/null +++ b/web/src/versionHistory.ts @@ -0,0 +1,194 @@ +// 「版本记录」页的客户端:只读查询 + 管理员手动同步。 +// 页面读的是本库缓存(Gitea 不可达也能看),同步动作只允许管理员触发。 +export type VersionRecordKind = 'release' | 'development' | 'historical' +export type VersionState = 'verified' | 'unknown' +export type CurrentRunningState = 'matched' | 'development' | 'unknown' + +export interface VersionHistoryTag { + name: string + url: string +} + +export interface VersionHistoryItem { + commit_sha: string + short_sha: string + parents: string[] + committed_at: string + author_display: string + subject: string + body: string + record_kind: VersionRecordKind + version_label: string + version_state: VersionState + release_tags: VersionHistoryTag[] + release_notes: string + commit_url: string + version_reason: string + is_current_running: boolean +} + +export interface VersionHistorySyncState { + available: boolean + status: string + complete: boolean + last_success_at: string + fetched_count: number + expected_count: number | null + error_code: string + source_head_sha: string + active_snapshot_id: number + repo: string +} + +export interface VersionHistoryCurrentRunning { + state: CurrentRunningState + commit_sha: string + display_version: string + matched_commit_sha: string +} + +export interface VersionHistoryPage { + items: VersionHistoryItem[] + next_cursor: string + has_more: boolean + sync: VersionHistorySyncState + current_running: VersionHistoryCurrentRunning +} + +export interface BuildInfo { + release_version: string + build_kind: string + commit_sha: string | null + display_version: string + built_at: string + identity_available: boolean +} + +export interface SyncAccepted { + job_id: string + status: string + reused: boolean +} + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null +const text = (value: unknown): string => (typeof value === 'string' ? value : '') +const bool = (value: unknown): boolean => value === true +const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0) + +// 「版本号未能核验」的原因码 → 页面文案(不补造版本号,只解释为什么没有号)。 +export const reasonLabels: Record = { + annotated_tag_and_changelog_top_match: '注释 tag 与提交内版本记录一致', + counted_commits_since_base_tag: '按发布基线数出来的开发号', + lightweight_tag: '该提交上的 tag 是轻量 tag,不作为正式版本证据', + no_changelog_top: '该提交内没有已发布版本的变更记录', + changelog_top_mismatch: 'tag 版本与该提交内的变更记录不一致', + no_release_tag_for_commit: '没有指向该提交的正式 tag', + no_baseline: '找不到可核验的发布基线(该版本没有对应的 tag)', + shallow_or_incomplete_graph: '提交图不完整(检出被截断),提交数算不准', +} + +export const reasonLabel = (reason: string): string => reasonLabels[reason] ?? '版本号未能核验' + +const parseItem = (value: unknown): VersionHistoryItem => { + if (!record(value)) throw new Error('版本记录条目无效') + return { + commit_sha: text(value.commit_sha), + short_sha: text(value.short_sha), + parents: Array.isArray(value.parents) + ? value.parents.filter((item): item is string => typeof item === 'string') + : [], + committed_at: text(value.committed_at), + author_display: text(value.author_display), + subject: text(value.subject), + body: text(value.body), + record_kind: (text(value.record_kind) || 'historical') as VersionRecordKind, + version_label: text(value.version_label), + version_state: (text(value.version_state) || 'unknown') as VersionState, + release_tags: Array.isArray(value.release_tags) + ? value.release_tags.filter(record).map((tag) => ({ name: text(tag.name), url: text(tag.url) })) + : [], + release_notes: text(value.release_notes), + commit_url: text(value.commit_url), + version_reason: text(value.version_reason), + is_current_running: bool(value.is_current_running), + } +} + +export async function fetchVersionHistory( + params: { kind: string; cursor: string; limit?: number }, + signal?: AbortSignal +): Promise { + const query = new URLSearchParams() + if (params.kind && params.kind !== 'all') query.set('kind', params.kind) + if (params.cursor) query.set('cursor', params.cursor) + query.set('limit', String(params.limit ?? 30)) + const response = await fetch(`/api/v1/version-history?${query.toString()}`, { + headers: { Accept: 'application/json' }, + signal, + }) + const value = (await response.json().catch(() => null)) as unknown + if (!response.ok) { + const code = record(value) ? text(value.code) : '' + if (response.status === 409 || code === 'cursor_expired') { + throw new Error('这次翻页依据的记录快照已被清理,请刷新页面重新加载。') + } + throw new Error(`加载版本记录失败(HTTP ${response.status})`) + } + if (!record(value) || !Array.isArray(value.items) || !record(value.sync) || !record(value.current_running)) { + throw new Error('版本记录响应无效') + } + const sync = value.sync + const running = value.current_running + const expected = typeof sync.expected_count === 'number' ? sync.expected_count : null + return { + items: value.items.map(parseItem), + next_cursor: text(value.next_cursor), + has_more: bool(value.has_more), + sync: { + available: bool(sync.available), + status: text(sync.status), + complete: bool(sync.complete), + last_success_at: text(sync.last_success_at), + fetched_count: count(sync.fetched_count), + expected_count: expected, + error_code: text(sync.error_code), + source_head_sha: text(sync.source_head_sha), + active_snapshot_id: count(sync.active_snapshot_id), + repo: text(sync.repo), + }, + current_running: { + state: (text(running.state) || 'unknown') as CurrentRunningState, + commit_sha: text(running.commit_sha), + display_version: text(running.display_version), + matched_commit_sha: text(running.matched_commit_sha), + }, + } +} + +export async function syncVersionHistory(): Promise { + const response = await fetch('/api/v1/version-history/sync', { + method: 'POST', + headers: { Accept: 'application/json' }, + }) + const value = (await response.json().catch(() => null)) as unknown + if (!response.ok) { + throw new Error(response.status === 403 ? '只有管理员能触发同步。' : `触发同步失败(HTTP ${response.status})`) + } + if (!record(value)) throw new Error('同步响应无效') + return { job_id: text(value.job_id), status: text(value.status), reused: bool(value.reused) } +} + +export async function fetchBuildInfo(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/build-info', { headers: { Accept: 'application/json' }, signal }) + if (!response.ok) throw new Error(`读取当前运行版本失败(HTTP ${response.status})`) + const value = (await response.json()) as unknown + if (!record(value)) throw new Error('构建身份响应无效') + return { + release_version: text(value.release_version), + build_kind: text(value.build_kind), + commit_sha: typeof value.commit_sha === 'string' ? value.commit_sha : null, + display_version: text(value.display_version), + built_at: text(value.built_at), + identity_available: bool(value.identity_available), + } +} diff --git a/web/src/workspace.ts b/web/src/workspace.ts index c321a1e..a8c9320 100644 --- a/web/src/workspace.ts +++ b/web/src/workspace.ts @@ -1,16 +1,11 @@ -export type ModuleId = 'dashboard' | 'sources' | 'knowledge' | 'tasks' | 'releases' | 'settings' +export type ModuleId = 'dashboard' | 'sources' | 'knowledge' | 'tasks' | 'releases' | 'settings' | 'versions' export interface WorkspaceState { openTabs: ModuleId[] activeTab: ModuleId } -const moduleIds = new Set([ - 'dashboard', - 'tasks', - 'releases', - 'settings', -]) +const moduleIds = new Set(['dashboard', 'tasks', 'releases', 'settings']) export const initialWorkspace: WorkspaceState = { openTabs: ['dashboard'], @@ -45,10 +40,10 @@ export function restoreWorkspace(raw: string | null): WorkspaceState { const saved = JSON.parse(raw) as { openTabs?: unknown; activeTab?: unknown } if (!Array.isArray(saved.openTabs)) return initialWorkspace const restoredTabs = saved.openTabs.filter( - (value): value is ModuleId => typeof value === 'string' && moduleIds.has(value as ModuleId), + (value): value is ModuleId => typeof value === 'string' && moduleIds.has(value as ModuleId) ) const openTabs = ['dashboard', ...restoredTabs.filter((tab) => tab !== 'dashboard')].filter( - (tab, index, tabs) => tabs.indexOf(tab) === index, + (tab, index, tabs) => tabs.indexOf(tab) === index ) as ModuleId[] const activeTab = typeof saved.activeTab === 'string' &&