From 2de7a43286d37bbe03bcd3ac0e4158e53ca36b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 8 Oct 2026 17:00:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E7=94=9F=E4=BA=A7=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=94=AF=E6=8C=81=E9=A1=B5=E9=9D=A2=E9=80=9A=E9=81=93?= =?UTF-8?q?=EF=BC=88=E7=AE=A1=E7=90=86=E5=91=98=E4=BC=9A=E8=AF=9D=20+=20?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E7=A1=AE=E8=AE=A4=E8=AF=AD=E5=8F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面原本没有任何生产入口,授权/重排/回滚只能带 Bearer 调接口,实际还要 admin 会话。 现在三个端点支持两条通道,门禁语义不变: - 运维通道:Authorization: Bearer ,审计记 PRODUCTION_RELEASE_AUTHORIZER_REF - 页面通道:已认证 admin 会话 + 固定确认语句,审计记该管理员用户名 - 请求门禁 / 备份恢复门禁 / Release Worker 执行门禁三者不受影响 - 发布管理第 6–8 步加「授权发布 / 重排 / 回滚」按钮,点击需逐字输入确认语句, 仅 admin 可见;重排沿用原执行授权人,不改写历史 - openapi 与 P6 契约同步;测试:双通道各一条 + 非 admin/错短语拒绝 + 前端按钮与确认门禁 --- api/openapi.yaml | 10 +- cmd/release-channel-e2e/main.go | 2 +- cmd/release-worker/runtime.go | 2 +- docs/P6_RELEASE_WORKER_ACCEPTANCE.md | 7 +- internal/httpapi/production_action_test.go | 132 ++++++++++++++++++ internal/httpapi/server.go | 32 ++++- .../releaseadapter/v071/contract_inspector.go | 16 ++- .../v071/contract_inspector_test.go | 45 ++++++ web/src/App.test.tsx | 43 ++++++ web/src/App.tsx | 92 ++++++++++-- web/src/reviewRelease.test.ts | 27 ++++ web/src/reviewRelease.ts | 44 ++++++ 12 files changed, 429 insertions(+), 23 deletions(-) create mode 100644 internal/httpapi/production_action_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index b085012..1f93b2a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1031,8 +1031,8 @@ "Release" ], "operationId": "authorizeProductionRelease", - "summary": "受门禁保护的生产发布授权", - "description": "默认关闭。调用方不得提交审核人身份;服务端从受限配置读取固定授权身份。", + "summary": "受门禁保护的生产发布授权(页面按钮或运维脚本)", + "description": "默认关闭。调用方不得提交审核人身份。两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:publish-to-production。", "responses": { "202": { "$ref": "#/components/responses/OperationEnvelope" @@ -1154,7 +1154,8 @@ "Release" ], "operationId": "authorizeProductionRollback", - "summary": "受门禁保护的生产回滚授权", + "summary": "受门禁保护的生产回滚授权(页面按钮或运维脚本)", + "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:rollback-production-release。", "responses": { "202": { "$ref": "#/components/responses/OperationEnvelope" @@ -1193,7 +1194,8 @@ "Release" ], "operationId": "retryProductionRelease", - "summary": "受门禁保护地重试失败发布", + "summary": "受门禁保护地重排失败发布(页面按钮或运维脚本)", + "description": "两条通道:运维脚本用受限配置的 Bearer token(审计记固定授权人);页面按钮用管理员会话 + 固定确认语句(审计记该管理员)。请求门禁、备份恢复门禁与 Release Worker 执行门禁不变。 确认语句:retry-production-release。重排沿用原执行的授权人记录。", "responses": { "202": { "$ref": "#/components/responses/OperationEnvelope" diff --git a/cmd/release-channel-e2e/main.go b/cmd/release-channel-e2e/main.go index e1a45bd..bdacf18 100644 --- a/cmd/release-channel-e2e/main.go +++ b/cmd/release-channel-e2e/main.go @@ -159,7 +159,7 @@ func main() { fmt.Println("STEP target PG importer: FAIL", err) return } - sourceContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: sourcePool, Mapping: sourceSearch, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Source.Elasticsearch.Index}) + sourceContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: sourcePool, Mapping: sourceSearch, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Source.Elasticsearch.Index, ReadTestedBase: true}) if err != nil { fmt.Println("STEP source contract: FAIL", err) return diff --git a/cmd/release-worker/runtime.go b/cmd/release-worker/runtime.go index a6d37ec..7666877 100644 --- a/cmd/release-worker/runtime.go +++ b/cmd/release-worker/runtime.go @@ -117,7 +117,7 @@ func buildReleaseRoute(ctx context.Context, archiveRoot string, route releaseRou return cleanup(errors.New("create target object importer")) } - sourceContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: sourcePool, Mapping: sourceSearch, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Source.Elasticsearch.Index}) + sourceContract, err := v071.NewTargetContractInspector(v071.TargetContractInspectorConfig{DB: sourcePool, Mapping: sourceSearch, KnowledgeBaseID: route.KnowledgeBaseID, TenantID: route.TenantID, IndexName: route.Source.Elasticsearch.Index, ReadTestedBase: true}) if err != nil { return cleanup(errors.New("create source release contract inspector")) } diff --git a/docs/P6_RELEASE_WORKER_ACCEPTANCE.md b/docs/P6_RELEASE_WORKER_ACCEPTANCE.md index 4dcd85d..3c31864 100644 --- a/docs/P6_RELEASE_WORKER_ACCEPTANCE.md +++ b/docs/P6_RELEASE_WORKER_ACCEPTANCE.md @@ -83,7 +83,12 @@ PRODUCTION_RELEASE_AUTHORIZER_REF PRODUCTION_RELEASE_AUTH_TOKEN_FILE ``` -缺少固定 actor 或 token 文件时 API 拒绝启动。UI 不录入、不读取、不显示 token,并且没有“一键生产发布”按钮。 +缺少固定 actor 或 token 文件时 API 拒绝启动。**两条授权通道**(2026-10-08 起): + +- **运维通道**:`Authorization: Bearer `,审计记 `PRODUCTION_RELEASE_AUTHORIZER_REF`; +- **页面通道**:**管理员会话** + 请求体里的固定确认语句(发布管理「授权发布 / 重排 / 回滚」按钮),审计记该管理员用户名。 + +UI 仍然不录入、不读取、不显示 token,也没有"一键生产发布":每次动作都要在页面逐字输入确认语句。请求门禁、备份恢复门禁与执行门禁三者不受通道选择影响——放宽"谁能调"不等于放宽"能不能跑"。 固定确认语句: diff --git a/internal/httpapi/production_action_test.go b/internal/httpapi/production_action_test.go new file mode 100644 index 0000000..e6a577f --- /dev/null +++ b/internal/httpapi/production_action_test.go @@ -0,0 +1,132 @@ +package httpapi + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "knowledge-governance/internal/governance" +) + +// productionActionHandler builds a handler with both gates open and one +// configurable production execution result. +func productionActionHandler(t *testing.T) (http.Handler, *fakeJobStore) { + t.Helper() + now := time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC) + store := &fakeJobStore{authorizeReleaseResult: governance.ReleaseExecution{ + ID: "rx-safe", ReleaseBatchID: "rb-safe", Operation: governance.ReleaseOperationPublish, + State: governance.ReleaseExecutionPending, TargetEnvironment: "production", AuthorizedBy: "release-owner", + AuthorizationCode: "production_release_authorized", JobID: "job-safe", ItemCount: 1, CreatedAt: now, + }} + handler, err := NewHandler(Config{ + Store: store, Now: func() time.Time { return now }, + ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: true, + ProductionReleaseAuthorizerRef: "release-owner", ProductionReleaseAuthorizationToken: "safe-token", + }) + if err != nil { + t.Fatal(err) + } + return handler, store +} + +func authorizeProductionRequest(t *testing.T, identity *sessionIdentity) *http.Request { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/api/v1/release-batches/rb-safe/authorize-production", + bytes.NewBufferString(`{"confirmation":"publish-to-production"}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", "op-page-authorize") + if identity != nil { + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, *identity)) + } + return request +} + +// The page path: an ADMIN session plus the fixed confirmation phrase authorizes +// a production release without the Bearer token, and the audit names the +// operator who clicked. +func TestProductionActionAcceptsAdminSessionWithConfirmation(t *testing.T) { + handler, store := productionActionHandler(t) + response := httptest.NewRecorder() + handler.ServeHTTP(response, authorizeProductionRequest(t, &sessionIdentity{Username: "yaozhanghao", Role: roleAdmin})) + if response.Code != http.StatusAccepted { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if store.authorizeReleaseRequest.AuthorizedBy != "yaozhanghao" { + t.Fatalf("authorized_by=%q, want the page operator", store.authorizeReleaseRequest.AuthorizedBy) + } +} + +// The ops path is unchanged: a Bearer token authorizes and records the fixed +// configured authorizer. +func TestProductionActionAcceptsBearerTokenWithoutSession(t *testing.T) { + handler, store := productionActionHandler(t) + request := authorizeProductionRequest(t, nil) + request.Header.Set("Authorization", "Bearer safe-token") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + if store.authorizeReleaseRequest.AuthorizedBy != "release-owner" { + t.Fatalf("authorized_by=%q, want the configured authorizer", store.authorizeReleaseRequest.AuthorizedBy) + } +} + +// Neither a non-admin session nor a wrong token may authorize anything. +func TestProductionActionRejectsNonAdminAndUnsignedRequests(t *testing.T) { + tests := []struct { + name string + identity *sessionIdentity + token string + confirm string + }{ + {name: "engineer session", identity: &sessionIdentity{Username: "engineer", Role: roleEngineer}}, + {name: "viewer session", identity: &sessionIdentity{Username: "viewer", Role: roleViewer}}, + {name: "no credentials at all", identity: nil}, + {name: "wrong bearer token", identity: nil, token: "Bearer other-token", confirm: "publish-to-production"}, + {name: "admin session with wrong phrase", identity: &sessionIdentity{Username: "yaozhanghao", Role: roleAdmin}, confirm: "publish"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, store := productionActionHandler(t) + request := httptest.NewRequest(http.MethodPost, "/api/v1/release-batches/rb-safe/authorize-production", + bytes.NewBufferString(`{"confirmation":"`+tt.confirm+`"}`)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Idempotency-Key", "op-page-authorize") + if tt.identity != nil { + request = request.WithContext(context.WithValue(request.Context(), sessionKey{}, *tt.identity)) + } + if tt.token != "" { + request.Header.Set("Authorization", tt.token) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code == http.StatusAccepted || store.authorizeReleaseCalled { + t.Fatalf("status=%d called=%v body=%s", response.Code, store.authorizeReleaseCalled, response.Body.String()) + } + }) + } +} + +// The gates themselves are untouched: with the request gate closed the page path +// is blocked too, and the page must never be able to run a release on its own. +func TestProductionActionPagePathStillRespectsGates(t *testing.T) { + store := &fakeJobStore{} + handler, err := NewHandler(Config{Store: store, ProductionReleaseRequestsEnabled: true, + ProductionBackupRestoreVerified: false, ProductionReleaseAuthorizerRef: "release-owner", ProductionReleaseAuthorizationToken: "safe-token"}) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, authorizeProductionRequest(t, &sessionIdentity{Username: "yaozhanghao", Role: roleAdmin})) + if response.Code != http.StatusPreconditionFailed || store.authorizeReleaseCalled { + t.Fatalf("status=%d called=%v body=%s", response.Code, store.authorizeReleaseCalled, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "backup restore") { + t.Fatalf("body=%s", response.Body.String()) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 8c084e1..dfe6907 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -1654,7 +1654,8 @@ func authorizeProductionRelease(w http.ResponseWriter, r *http.Request, config C writeJSON(w, http.StatusPreconditionFailed, map[string]string{"error": "production backup restore verification is required"}) return } - if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) { + actor, releaseAuthorized := productionReleaseActor(r, config) + if !releaseAuthorized { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"}) return } @@ -1668,7 +1669,7 @@ func authorizeProductionRelease(w http.ResponseWriter, r *http.Request, config C return } execution, replayed, err := config.Store.AuthorizeReleaseBatchWithOperation(r.Context(), governance.AuthorizeReleaseBatchRequest{ - BatchID: r.PathValue("id"), AuthorizedBy: productionAuthorizer(r, config), + BatchID: r.PathValue("id"), AuthorizedBy: actor, AuthorizationCode: "production_release_authorized", Now: config.Now().UTC(), }, operationID) switch { @@ -1699,7 +1700,8 @@ func authorizeProductionRollback(w http.ResponseWriter, r *http.Request, config writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"}) return } - if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) { + actor, releaseAuthorized := productionReleaseActor(r, config) + if !releaseAuthorized { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"}) return } @@ -1713,7 +1715,7 @@ func authorizeProductionRollback(w http.ResponseWriter, r *http.Request, config return } execution, replayed, err := config.Store.AuthorizeReleaseRollbackWithOperation(r.Context(), governance.AuthorizeReleaseRollbackRequest{ - PriorExecutionID: r.PathValue("id"), AuthorizedBy: productionAuthorizer(r, config), + PriorExecutionID: r.PathValue("id"), AuthorizedBy: actor, AuthorizationCode: "production_rollback_authorized", Now: config.Now().UTC(), }, operationID) switch { @@ -1744,7 +1746,8 @@ func retryProductionRelease(w http.ResponseWriter, r *http.Request, config Confi writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"}) return } - if !productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) { + // 重排沿用原执行的授权人记录(改写它会篡改历史),因此这里只取授权结果。 + if _, releaseAuthorized := productionReleaseActor(r, config); !releaseAuthorized { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"}) return } @@ -1788,6 +1791,25 @@ func productionAuthorizer(r *http.Request, config Config) string { return config.ProductionReleaseAuthorizerRef } +// productionReleaseActor authorises a production action and names who performed +// it. Two sanctioned paths behind the same gates: +// - the deployment Bearer token (ops scripts) — recorded under the configured +// fixed authorizer ref; +// - an authenticated ADMIN session plus the fixed confirmation phrase (the +// page's 授权发布 / 重排 / 回滚 buttons) — recorded under that operator. +// +// The request gate, the backup-restore gate and the worker's execution gate stay +// independent of this choice: relaxing "who may call" must not relax "may it run". +func productionReleaseActor(r *http.Request, config Config) (string, bool) { + if productionReleaseAuthorized(r, config.ProductionReleaseAuthorizationToken) { + return config.ProductionReleaseAuthorizerRef, true + } + if identity, ok := sessionFrom(r.Context()); ok && identity.Role == roleAdmin { + return identity.Username, true + } + return "", false +} + func productionReleaseAuthorized(r *http.Request, token string) bool { provided := r.Header.Get("Authorization") expected := "Bearer " + token diff --git a/internal/releaseadapter/v071/contract_inspector.go b/internal/releaseadapter/v071/contract_inspector.go index 93c1290..ac55e6b 100644 --- a/internal/releaseadapter/v071/contract_inspector.go +++ b/internal/releaseadapter/v071/contract_inspector.go @@ -25,6 +25,12 @@ type TargetContractInspectorConfig struct { KnowledgeBaseID string TenantID uint64 IndexName string + // ReadTestedBase makes the inspector read the TESTED base (the source side + // of a release) instead of the 源库 → 目标库 mapped base. The exporter and + // the target share this inspector type, so the side must be explicit: with + // a mapping in play, "which base do I read" is not the same question on + // both ends. + ReadTestedBase bool } type TargetContractInspector struct { @@ -64,10 +70,14 @@ func (inspector *TargetContractInspector) Contract(ctx context.Context, item rel if item.TestKnowledgeBaseID != inspector.config.KnowledgeBaseID { return releasebundle.TargetContract{}, errors.New("release plan knowledge base does not match target contract") } - // The route is keyed by the TESTED base (where the content was verified); - // the contract is read for the base this release actually writes into — - // the 源库 → 目标库 mapping when the item carries one, else the same base. + // The route is keyed by the TESTED base (where the content was verified). The + // contract is read for the base this side actually owns: the source side reads + // the tested base (what the bundle is exported from), the target side reads the + // 源库 → 目标库 mapped base (what the release writes into). targetKnowledgeBaseID := item.TargetBase() + if inspector.config.ReadTestedBase { + targetKnowledgeBaseID = item.TestKnowledgeBaseID + } if !validSearchIdentifier(targetKnowledgeBaseID) { return releasebundle.TargetContract{}, errors.New("invalid release target knowledge base") } diff --git a/internal/releaseadapter/v071/contract_inspector_test.go b/internal/releaseadapter/v071/contract_inspector_test.go index 29e64fb..1c89223 100644 --- a/internal/releaseadapter/v071/contract_inspector_test.go +++ b/internal/releaseadapter/v071/contract_inspector_test.go @@ -45,13 +45,58 @@ func TestTargetContractInspectorRejectsMismatchedPlanKB(t *testing.T) { } } +// With a 源库 → 目标库 mapping the two sides must read DIFFERENT bases: the +// target reads the mapped one, the source keeps reading the tested one. Reading +// the mapped base on the source plane made a real release fail with +// "source WeKnora release contract mismatch" (the target base is unknown there). +func TestTargetContractInspectorSidesPickTheRightBase(t *testing.T) { + row := contractRow{values: []any{74, false, uint64(10000), "bge-m3", 1024, "elasticsearch", "chunks"}} + mapping := &mappingInspector{sha: strings.Repeat("a", 64)} + plan := releasePlanForArchive() + plan.TargetKnowledgeBaseID = "prod-kb" + + targetDB := &contractDB{row: row} + targetInspector, err := NewTargetContractInspector(TargetContractInspectorConfig{DB: targetDB, Mapping: mapping, KnowledgeBaseID: "kb-safe", TenantID: 10000, IndexName: "chunks"}) + if err != nil { + t.Fatal(err) + } + targetContract, err := targetInspector.Contract(context.Background(), plan) + if err != nil { + t.Fatal(err) + } + if targetContract.KnowledgeBaseID != "prod-kb" { + t.Fatalf("target contract kb=%q, want the mapped base", targetContract.KnowledgeBaseID) + } + if len(targetDB.args) != 1 || targetDB.args[0] != "prod-kb" { + t.Fatalf("target read base=%v, want prod-kb", targetDB.args) + } + + sourceDB := &contractDB{row: row} + sourceInspector, err := NewTargetContractInspector(TargetContractInspectorConfig{DB: sourceDB, Mapping: mapping, KnowledgeBaseID: "kb-safe", TenantID: 10000, IndexName: "chunks", ReadTestedBase: true}) + if err != nil { + t.Fatal(err) + } + sourceContract, err := sourceInspector.Contract(context.Background(), plan) + if err != nil { + t.Fatal(err) + } + if sourceContract.KnowledgeBaseID != "kb-safe" { + t.Fatalf("source contract kb=%q, want the tested base", sourceContract.KnowledgeBaseID) + } + if len(sourceDB.args) != 1 || sourceDB.args[0] != "kb-safe" { + t.Fatalf("source read base=%v, want kb-safe", sourceDB.args) + } +} + type contractDB struct { row contractRow query string + args []any } func (d *contractDB) QueryRow(_ context.Context, query string, args ...any) pgx.Row { d.query = query + d.args = args return d.row } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 10c1d62..3aeb226 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import App, { JobDashboard, KnowledgeInventory, ModulePage, ReleaseManagement, SourceConnections, WorkspaceShell, reviewGateLabel } from './App' +import { AuthContext } from './auth' import type { SourceAssetPage } from './assets' import type { Job } from './jobs' import type { ReleaseBatchDetail, ReleaseBatchPage, ReleaseExecutionDetail, ReleaseExecutionPage, ReleasePlan, ReviewCandidatePage } from './reviewRelease' @@ -482,3 +483,45 @@ describe('R4 rollback levels in the execution timeline', () => { expect(graded).toContain('本批后续已停止') }) }) + +// 生产动作(授权发布 / 重排 / 回滚):页面按钮只对管理员出现,且必须逐字输入固定确认语句。 +describe('ReleaseManagement production actions', () => { + const authValue = (role: 'admin' | 'engineer') => ({ identity: { id: `${role}-1`, username: role, display_name: role, role, must_change_password: false }, ready: true, login: async () => null, logout: async () => {}, changePassword: async () => null, signedOut: 0, notifySignedOut: () => {} }) + const now = '2026-10-08T08:00:00Z' + const batchPage: ReleaseBatchPage = { items: [{ id: 'rb-sealed', name: '单文档首发', state: 'sealed', policy_version: 'release-gate-v1', created_by: 'admin', sealed_by: 'admin', item_count: 1, created_at: now, sealed_at: now }], total: 1, limit: 20, offset: 0 } + const executionPage: ReleaseExecutionPage = { items: [{ id: 'rx-done', release_batch_id: 'rb-sealed', operation: 'publish', prior_execution_id: '', state: 'completed', target_environment: 'production', authorized_by: 'admin', job_id: 'job-1', item_count: 1, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 1, failed_item_count: 0, error_code: '', created_at: now, started_at: now, completed_at: now }, { id: 'rx-bad', release_batch_id: 'rb-sealed', operation: 'publish', prior_execution_id: '', state: 'failed', target_environment: 'production', authorized_by: 'admin', job_id: 'job-2', item_count: 1, batch_size: 50, current_batch: 1, total_batches: 1, completed_item_count: 0, failed_item_count: 1, error_code: 'release_item_failed', created_at: now, started_at: now, completed_at: now }], total: 2, limit: 20, offset: 0 } + const base = (extra: Record = {}) => + + it('offers 授权发布 / 重排 / 回滚 to admins only', () => { + const admin = renderToStaticMarkup({base()}) + expect(admin).toContain('授权发布') + expect(admin).toContain('重排') + expect(admin).toContain('回滚') + expect(admin).toContain('生产操作可执行(待确认)') + const engineer = renderToStaticMarkup({base()}) + expect(engineer).not.toContain('授权发布') + expect(engineer).not.toContain('') + expect(engineer).not.toContain('') + }) + + it('requires the exact confirmation phrase before the action can be submitted', () => { + const pending = { kind: 'authorize' as const, id: 'rb-sealed', label: '单文档首发' } + const wrong = renderToStaticMarkup({base({ pendingReleaseAction: pending, releaseActionPhrase: 'publish', onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })}) + expect(wrong).toContain('publish-to-production') + expect(wrong).toMatch(/ + + : null} + {props.releaseActionError ?
{props.releaseActionError}
: null} + {props.releaseActionMessage ?

{props.releaseActionMessage}

: 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) => )} + {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}
{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.error_code ? ` · ${releaseFailureMessage(item.error_code)}` : ''}{result.outcome}
})}{props.executionDetail.items.length === 0 ?
发布尚无逐项结果。
: null} :
点击“查看结果”核对每项知识。
}
: null} {props.executionChain ?

发布审计链

@@ -1154,6 +1192,11 @@ export default function App() { const [executionChain, setExecutionChain] = useState(null) const [executionLoading, setExecutionLoading] = useState(true) const [executionError, setExecutionError] = useState(null) + const [pendingReleaseAction, setPendingReleaseAction] = useState(null) + const [releaseActionPhrase, setReleaseActionPhrase] = useState('') + const [releaseActionBusy, setReleaseActionBusy] = useState(false) + const [releaseActionError, setReleaseActionError] = useState(null) + const [releaseActionMessage, setReleaseActionMessage] = useState(null) const [workspace, setWorkspace] = useState(() => { if (typeof window === 'undefined') return initialWorkspace try { @@ -1406,6 +1449,39 @@ export default function App() { } }, []) + // 页面上的生产动作:点按钮 → 逐字输入固定确认语句 → 提交。服务端同时校验 +// 管理员会话、请求门禁与确认语句;Bearer 通道留给运维脚本,行为不变。 + const handleStartReleaseAction = useCallback((kind: ProductionActionKind, id: string, label: string) => { + setPendingReleaseAction({ kind, id, label }) + setReleaseActionPhrase('') + setReleaseActionError(null) + setReleaseActionMessage(null) + }, []) + + const handleCancelReleaseAction = useCallback(() => { + setPendingReleaseAction(null) + setReleaseActionPhrase('') + setReleaseActionError(null) + }, []) + + const handleSubmitReleaseAction = useCallback(async () => { + if (!pendingReleaseAction) return + setReleaseActionBusy(true) + setReleaseActionError(null) + setReleaseActionMessage(null) + try { + 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('') + await loadReleaseExecutions() + } catch (cause) { + setReleaseActionError(cause instanceof Error ? cause.message : '生产操作失败') + } finally { + setReleaseActionBusy(false) + } + }, [pendingReleaseAction, releaseActionPhrase, loadReleaseExecutions]) + const handleSelectExecution = useCallback(async (executionID: string) => { setExecutionLoading(true) setExecutionError(null) @@ -1570,7 +1646,7 @@ export default function App() { executionLoading={executionLoading} executionError={executionError} onSelectExecution={(executionID) => void handleSelectExecution(executionID)} - onExecutionRefresh={() => void loadReleaseExecutions()} + 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/reviewRelease.test.ts b/web/src/reviewRelease.test.ts index 82e7f3d..05d0d42 100644 --- a/web/src/reviewRelease.test.ts +++ b/web/src/reviewRelease.test.ts @@ -10,6 +10,8 @@ import { fetchReleaseCapabilities, fetchReleaseExecution, fetchReleaseExecutions, + productionConfirmationPhrases, + runProductionAction, fetchReleasePlan, fetchReviewCandidates, parseReleaseBatchDetailResponse, @@ -192,3 +194,28 @@ describe('release audit chain (R6)', () => { await expect(fetchReleaseAuditChain('rx-1')).rejects.toThrow('HTTP 500') }) }) + +// 生产动作:三条路径 + 服务端原因回传(页面通道 = 管理员会话 + 固定确认语句)。 +describe('production actions', () => { + it('routes each action to its endpoint with the fixed phrase and an idempotency key', async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ data: { id: 'rx-1', state: 'pending', operation: 'publish', item_count: 1 } }), { status: 202 }))) + vi.stubGlobal('fetch', fetchMock) + await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).resolves.toEqual({ id: 'rx-1', state: 'pending', operation: 'publish', item_count: 1 }) + await runProductionAction('retry', 'rx-1', 'retry-production-release') + await runProductionAction('rollback', 'rx-1', 'rollback-production-release') + const urls = fetchMock.mock.calls.map((call) => call[0]) + expect(urls).toEqual([ + '/api/v1/release-batches/rb-1/authorize-production', + '/api/v1/release-executions/rx-1/retry', + '/api/v1/release-executions/rx-1/authorize-rollback', + ]) + expect(fetchMock.mock.calls[0][1].headers['Idempotency-Key']).toBeTruthy() + vi.unstubAllGlobals() + }) + it('surfaces the server reason and keeps the phrases fixed', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'production release authorization required' }), { status: 401 }))) + await expect(runProductionAction('authorize', 'rb-1', 'publish-to-production')).rejects.toThrow('production release authorization required') + expect(productionConfirmationPhrases).toEqual({ authorize: 'publish-to-production', retry: 'retry-production-release', rollback: 'rollback-production-release' }) + vi.unstubAllGlobals() + }) +}) diff --git a/web/src/reviewRelease.ts b/web/src/reviewRelease.ts index 62351d1..16d778a 100644 --- a/web/src/reviewRelease.ts +++ b/web/src/reviewRelease.ts @@ -530,3 +530,47 @@ export async function fetchReleaseAuditChain(executionID: string, signal?: Abort items: rows('items').map((row) => ({ publication_id: auditText(row, 'publication_id'), title: auditText(row, 'title'), action: auditText(row, 'action'), item_state: auditText(row, 'item_state'), review_id: auditText(row, 'review_id'), acceptance_run_id: auditText(row, 'acceptance_run_id'), source_item_id: auditText(row, 'source_item_id'), source_item_version_id: auditText(row, 'source_item_version_id'), production_revision: auditText(row, 'production_revision'), error_code: auditText(row, 'error_code') })), } } + +// ——— 生产动作(授权发布 / 重排 / 回滚)——— +// 页面通道:管理员会话 + 服务端逐字核对的固定确认语句。Bearer 通道仍留给运维脚本。 +export type ProductionActionKind = 'authorize' | 'retry' | 'rollback' + +export const productionConfirmationPhrases: Record = { + authorize: 'publish-to-production', + retry: 'retry-production-release', + rollback: 'rollback-production-release', +} + +export interface ProductionActionSummary { + id: string + state: string + operation: string + item_count: number +} + +const productionActionPaths: Record string> = { + authorize: (id) => `/api/v1/release-batches/${encodeURIComponent(id)}/authorize-production`, + retry: (id) => `/api/v1/release-executions/${encodeURIComponent(id)}/retry`, + rollback: (id) => `/api/v1/release-executions/${encodeURIComponent(id)}/authorize-rollback`, +} + +export async function runProductionAction(kind: ProductionActionKind, id: string, confirmation: string): Promise { + const response = await fetch(productionActionPaths[kind](id), { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': newOperationID() }, + body: JSON.stringify({ confirmation }), + }) + const payload: unknown = await response.json().catch(() => null) + if (!response.ok) { + const reason = isRecord(payload) && typeof payload.error === 'string' ? payload.error : `生产操作失败(HTTP ${response.status})` + throw new Error(reason) + } + if (!isRecord(payload) || !isRecord(payload.data)) throw new Error('生产操作响应无效') + const data = payload.data + return { + id: typeof data.id === 'string' ? data.id : id, + state: typeof data.state === 'string' ? data.state : '', + operation: typeof data.operation === 'string' ? data.operation : '', + item_count: typeof data.item_count === 'number' ? data.item_count : 0, + } +}