feat(release): 生产授权支持页面通道(管理员会话 + 固定确认语句)
页面原本没有任何生产入口,授权/重排/回滚只能带 Bearer 调接口,实际还要 admin 会话。 现在三个端点支持两条通道,门禁语义不变: - 运维通道:Authorization: Bearer <token>,审计记 PRODUCTION_RELEASE_AUTHORIZER_REF - 页面通道:已认证 admin 会话 + 固定确认语句,审计记该管理员用户名 - 请求门禁 / 备份恢复门禁 / Release Worker 执行门禁三者不受影响 - 发布管理第 6–8 步加「授权发布 / 重排 / 回滚」按钮,点击需逐字输入确认语句, 仅 admin 可见;重排沿用原执行授权人,不改写历史 - openapi 与 P6 契约同步;测试:双通道各一条 + 非 admin/错短语拒绝 + 前端按钮与确认门禁
This commit is contained in:
+6
-4
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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 <token>`,审计记 `PRODUCTION_RELEASE_AUTHORIZER_REF`;
|
||||
- **页面通道**:**管理员会话** + 请求体里的固定确认语句(发布管理「授权发布 / 重排 / 回滚」按钮),审计记该管理员用户名。
|
||||
|
||||
UI 仍然不录入、不读取、不显示 token,也没有"一键生产发布":每次动作都要在页面逐字输入确认语句。请求门禁、备份恢复门禁与执行门禁三者不受通道选择影响——放宽"谁能调"不等于放宽"能不能跑"。
|
||||
|
||||
固定确认语句:
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) => <ReleaseManagement
|
||||
reviewPage={{ items: [], total: 0, limit: 20, offset: 0 }} reviewLoading={false} reviewError={null} reviewingId={null}
|
||||
batchPage={batchPage} batchDetail={null} batchLoading={false} batchError={null} mutatingBatchId={null}
|
||||
onReview={vi.fn()} onReviewRefresh={vi.fn()} onCreateBatch={vi.fn()} onSealBatch={vi.fn()} onSelectBatch={vi.fn()} onBatchRefresh={vi.fn()}
|
||||
releaseCapabilities={{ production_release_requests_enabled: true, production_backup_restore_verified: true }}
|
||||
executionPage={executionPage} executionDetail={null} executionLoading={false} executionError={null} onSelectExecution={vi.fn()} onExecutionRefresh={vi.fn()}
|
||||
releasePlan={{ items: [], summary: { add: 0, update: 0, delete: 0, blocked: 0, unchanged: 0 } }}
|
||||
selectedDeleteIDs={[]} onToggleDelete={vi.fn()} {...extra} />
|
||||
|
||||
it('offers 授权发布 / 重排 / 回滚 to admins only', () => {
|
||||
const admin = renderToStaticMarkup(<AuthContext.Provider value={authValue('admin')}>{base()}</AuthContext.Provider>)
|
||||
expect(admin).toContain('授权发布')
|
||||
expect(admin).toContain('重排')
|
||||
expect(admin).toContain('回滚')
|
||||
expect(admin).toContain('生产操作可执行(待确认)')
|
||||
const engineer = renderToStaticMarkup(<AuthContext.Provider value={authValue('engineer')}>{base()}</AuthContext.Provider>)
|
||||
expect(engineer).not.toContain('授权发布')
|
||||
expect(engineer).not.toContain('<button type="button">回滚</button>')
|
||||
expect(engineer).not.toContain('<button type="button">重排</button>')
|
||||
})
|
||||
|
||||
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(<AuthContext.Provider value={authValue('admin')}>{base({ pendingReleaseAction: pending, releaseActionPhrase: 'publish', onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })}</AuthContext.Provider>)
|
||||
expect(wrong).toContain('publish-to-production')
|
||||
expect(wrong).toMatch(/<button type="button" disabled=""[^>]*>确认执行<\/button>/)
|
||||
const right = renderToStaticMarkup(<AuthContext.Provider value={authValue('admin')}>{base({ pendingReleaseAction: pending, releaseActionPhrase: 'publish-to-production', onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })}</AuthContext.Provider>)
|
||||
expect(right).toMatch(/<button type="button">确认执行<\/button>/)
|
||||
})
|
||||
|
||||
it('surfaces the server reason when the action is refused', () => {
|
||||
const html = renderToStaticMarkup(<AuthContext.Provider value={authValue('admin')}>{base({ pendingReleaseAction: null, releaseActionError: 'production release authorization required', releaseActionMessage: null, onStartReleaseAction: vi.fn(), onReleaseActionPhrase: vi.fn(), onSubmitReleaseAction: vi.fn(), onCancelReleaseAction: vi.fn() })}</AuthContext.Provider>)
|
||||
expect(html).toContain('production release authorization required')
|
||||
})
|
||||
})
|
||||
|
||||
+84
-8
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { TopBarUser } from './auth'
|
||||
import { TopBarUser, useOptionalAuth } from './auth'
|
||||
import { DocumentWorkbench } from './DocumentWorkbench'
|
||||
import { SourceCatalogBrowser } from './SourceCatalogBrowser'
|
||||
import { WeKnoraMonitor } from './WeKnoraMonitor'
|
||||
@@ -12,7 +12,8 @@ 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, reviewPublication, sealReleaseBatch } from './reviewRelease'
|
||||
import { confirmReleaseChangeSet, createReleaseBatch, fetchLatestReleaseChangeSet, fetchReleaseAuditChain, fetchReleaseBatch, fetchReleaseBatches, fetchReleaseCapabilities, fetchReleaseExecution, fetchReleaseExecutions, fetchReleasePlan, fetchReviewCandidates, generateReleaseChangeSet, productionConfirmationPhrases, reviewPublication, runProductionAction, sealReleaseBatch } from './reviewRelease'
|
||||
import type { ProductionActionKind } 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'
|
||||
@@ -220,6 +221,22 @@ interface ModulePageProps extends JobDashboardProps {
|
||||
executionError?: string | null
|
||||
onSelectExecution?: (executionID: string) => void
|
||||
onExecutionRefresh?: () => void
|
||||
pendingReleaseAction?: PendingReleaseAction | null
|
||||
releaseActionPhrase?: string
|
||||
releaseActionBusy?: boolean
|
||||
releaseActionError?: string | null
|
||||
releaseActionMessage?: string | null
|
||||
onStartReleaseAction?: (kind: ProductionActionKind, id: string, label: string) => void
|
||||
onReleaseActionPhrase?: (value: string) => void
|
||||
onSubmitReleaseAction?: () => void
|
||||
onCancelReleaseAction?: () => void
|
||||
}
|
||||
|
||||
// 页面上的生产动作:管理员会话 + 服务端逐字核对的固定确认语句。
|
||||
export interface PendingReleaseAction {
|
||||
kind: ProductionActionKind
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const emptyAssetPage: SourceAssetPage = { items: [], summary: { total: 0, completed: 0, content_alias: 0, over_limit: 0 }, limit: 50, offset: 0 }
|
||||
@@ -237,7 +254,7 @@ const emptyExecutionPage: ReleaseExecutionPage = { items: [], total: 0, limit: 2
|
||||
const disabledReleaseCapabilities: ReleaseCapabilities = { production_release_requests_enabled: false, production_backup_restore_verified: false }
|
||||
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, 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 }: 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, 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 <TestPublications page={publicationPage} loading={publicationLoading} error={publicationError} state={publicationState} retryingId={retryingId} onStateChange={onPublicationStateChange} onPageChange={onPublicationPageChange} onRefresh={onPublicationRefresh} onRetry={onRetry} />
|
||||
}
|
||||
@@ -248,7 +265,7 @@ export function ModulePage({ moduleId, jobs, loading, error, onRefresh, connecti
|
||||
return <SourceConnections page={connectionPage} loading={connectionLoading} error={connectionError} scanningConnectionId={scanningConnectionId} scanPage={scanPage} scanDetail={scanDetail} scanLoading={scanLoading} scanError={scanError} selectedScanId={selectedScanId} onRefresh={onConnectionRefresh} onTriggerScan={onTriggerSourceScan} onSelectScan={onSelectScan} />
|
||||
}
|
||||
if (moduleId === 'releases') {
|
||||
return <ReleaseManagement reviewPage={reviewPage} reviewLoading={reviewLoading} reviewError={reviewError} reviewingId={reviewingId} batchPage={batchPage} batchDetail={batchDetail} batchLoading={batchLoading} batchError={batchError} mutatingBatchId={mutatingBatchId} onReview={onReview} onReviewRefresh={onReviewRefresh} onCreateBatch={onCreateBatch} onSealBatch={onSealBatch} onSelectBatch={onSelectBatch} onBatchRefresh={onBatchRefresh} releaseCapabilities={releaseCapabilities} releasePlan={releasePlan} releaseChangeSet={releaseChangeSet} changeSetBusy={changeSetBusy} changeSetError={changeSetError} onGenerateChangeSet={onGenerateChangeSet} onConfirmChangeSet={onConfirmChangeSet} selectedDeleteIDs={selectedDeleteIDs} onToggleDelete={onToggleDelete} executionPage={executionPage} executionDetail={executionDetail} executionChain={executionChain} executionLoading={executionLoading} executionError={executionError} onSelectExecution={onSelectExecution} onExecutionRefresh={onExecutionRefresh} />
|
||||
return <ReleaseManagement reviewPage={reviewPage} reviewLoading={reviewLoading} reviewError={reviewError} reviewingId={reviewingId} batchPage={batchPage} batchDetail={batchDetail} batchLoading={batchLoading} batchError={batchError} mutatingBatchId={mutatingBatchId} onReview={onReview} onReviewRefresh={onReviewRefresh} onCreateBatch={onCreateBatch} onSealBatch={onSealBatch} onSelectBatch={onSelectBatch} onBatchRefresh={onBatchRefresh} releaseCapabilities={releaseCapabilities} releasePlan={releasePlan} releaseChangeSet={releaseChangeSet} changeSetBusy={changeSetBusy} changeSetError={changeSetError} onGenerateChangeSet={onGenerateChangeSet} onConfirmChangeSet={onConfirmChangeSet} selectedDeleteIDs={selectedDeleteIDs} onToggleDelete={onToggleDelete} executionPage={executionPage} executionDetail={executionDetail} executionChain={executionChain} executionLoading={executionLoading} executionError={executionError} onSelectExecution={onSelectExecution} onExecutionRefresh={onExecutionRefresh} pendingReleaseAction={pendingReleaseAction} releaseActionPhrase={releaseActionPhrase} releaseActionBusy={releaseActionBusy} releaseActionError={releaseActionError} releaseActionMessage={releaseActionMessage} onStartReleaseAction={onStartReleaseAction} onReleaseActionPhrase={onReleaseActionPhrase} onSubmitReleaseAction={onSubmitReleaseAction} onCancelReleaseAction={onCancelReleaseAction} />
|
||||
}
|
||||
if (moduleId === 'settings') {
|
||||
return <SystemSettings />
|
||||
@@ -661,8 +678,19 @@ interface ReleaseManagementProps {
|
||||
executionError: string | null
|
||||
onSelectExecution: (executionID: string) => void
|
||||
onExecutionRefresh: () => void
|
||||
pendingReleaseAction?: PendingReleaseAction | null
|
||||
releaseActionPhrase?: string
|
||||
releaseActionBusy?: boolean
|
||||
releaseActionError?: string | null
|
||||
releaseActionMessage?: string | null
|
||||
onStartReleaseAction?: (kind: ProductionActionKind, id: string, label: string) => void
|
||||
onReleaseActionPhrase?: (value: string) => void
|
||||
onSubmitReleaseAction?: () => void
|
||||
onCancelReleaseAction?: () => void
|
||||
}
|
||||
|
||||
const releaseActionLabels: Record<ProductionActionKind, string> = { authorize: '授权发布', retry: '重排执行', rollback: '回滚发布' }
|
||||
|
||||
const acceptanceLabels: Record<string, string> = {
|
||||
pending: '待验收',
|
||||
passed: '通过',
|
||||
@@ -839,6 +867,8 @@ function releaseGuidance(props: ReleaseManagementProps, pending: number) {
|
||||
|
||||
export function ReleaseManagement(props: ReleaseManagementProps) {
|
||||
/* R3 pagination probe */
|
||||
const auth = useOptionalAuth()
|
||||
const isAdmin = auth?.identity?.role === 'admin'
|
||||
const changeSet = props.releaseChangeSet ?? null
|
||||
const changeSetBusy = props.changeSetBusy ?? false
|
||||
const changeSetError = props.changeSetError ?? null
|
||||
@@ -1013,15 +1043,23 @@ export function ReleaseManagement(props: ReleaseManagementProps) {
|
||||
{props.batchError ? <div className="error-banner" role="alert">{props.batchError}</div> : null}
|
||||
{!props.batchLoading && !props.batchError && props.batchPage.items.length === 0 ? <div className="empty-state">尚无草稿或已锁定的发布清单。</div> : null}
|
||||
{props.batchPage.items.length > 0 ? <div className="batch-grid"><div className="table-wrap"><table><thead><tr><th>批次名称</th><th>状态</th><th>知识数量</th><th>内容状态</th><th>操作</th></tr></thead><tbody>
|
||||
{props.batchPage.items.map((batch) => <tr key={batch.id}><td><strong>{batch.name}</strong></td><td><span className={`status status-${batch.state === 'sealed' ? 'completed' : 'pending'}`}>{batchLabels[batch.state]}</span></td><td>{batch.item_count}</td><td>{batch.state === 'sealed' ? '内容已锁定' : '仍可检查调整'}</td><td><div className="row-actions"><button type="button" className="secondary-action" onClick={() => props.onSelectBatch(batch.id)}>查看内容</button>{batch.state === 'draft' ? <button type="button" disabled={props.mutatingBatchId === batch.id} onClick={() => props.onSealBatch(batch.id)}>锁定清单(不会上线)</button> : null}</div></td></tr>)}
|
||||
{props.batchPage.items.map((batch) => <tr key={batch.id}><td><strong>{batch.name}</strong></td><td><span className={`status status-${batch.state === 'sealed' ? 'completed' : 'pending'}`}>{batchLabels[batch.state]}</span></td><td>{batch.item_count}</td><td>{batch.state === 'sealed' ? '内容已锁定' : '仍可检查调整'}</td><td><div className="row-actions"><button type="button" className="secondary-action" onClick={() => props.onSelectBatch(batch.id)}>查看内容</button>{batch.state === 'draft' ? <button type="button" disabled={props.mutatingBatchId === batch.id} onClick={() => props.onSealBatch(batch.id)}>锁定清单(不会上线)</button> : null}{batch.state === 'sealed' && isAdmin ? <button type="button" onClick={() => props.onStartReleaseAction?.('authorize', batch.id, batch.name)}>授权发布</button> : null}</div></td></tr>)}
|
||||
</tbody></table></div><div className="batch-detail"><h3>本批次包含</h3>{props.batchDetail ? <>{props.batchDetail.items.map((item) => <div className="batch-detail-row" key={item.publication_id}><span><strong>{item.title}</strong><small>{item.action === 'delete' ? '删除生产知识' : '新增或更新生产知识'}</small></span><b>{item.decided_visibility === 'internal' ? '仅内部员工' : '允许外部用户'}</b></div>)}{props.batchDetail.items.length === 0 ? <div className="empty-state">批次没有知识。</div> : null}</> : <div className="empty-state">点击“查看内容”核对本次发布清单。</div>}</div></div> : null}
|
||||
</section>
|
||||
<section className="panel execution-panel" id="release-execution">
|
||||
<div className="panel-heading"><div><h2>第 6–8 步:生产确认、发布验证与回滚</h2><p>这里仅供查看生产负责人确认后的发布结果。普通用户不能在本页面发起生产发布或回滚。</p></div><span className="safe-badge">{props.releaseCapabilities.production_release_requests_enabled ? '等待生产负责人确认' : '生产操作已锁定'}</span></div>
|
||||
<div className="panel-heading"><div><h2>第 6–8 步:生产确认、发布验证与回滚</h2><p>生产动作只对管理员开放:点击后需逐字输入固定确认语句;服务端请求门禁与执行门禁仍须开启。发布后的三通道读回与官方 API 验收由 Release Worker 自动完成,这里只读展示结果。</p></div><span className="safe-badge">{props.releaseCapabilities.production_release_requests_enabled ? '生产操作可执行(待确认)' : '生产操作已锁定'}</span></div>
|
||||
{props.pendingReleaseAction ? <div className="release-action-confirm" role="group" aria-label="生产动作确认">
|
||||
<p><b>{releaseActionLabels[props.pendingReleaseAction.kind]}</b>「{props.pendingReleaseAction.label}」—— 请输入固定确认语句 <code>{productionConfirmationPhrases[props.pendingReleaseAction.kind]}</code> 后执行。</p>
|
||||
<input value={props.releaseActionPhrase ?? ''} onChange={(event) => props.onReleaseActionPhrase?.(event.target.value)} placeholder={productionConfirmationPhrases[props.pendingReleaseAction.kind]} aria-label="生产动作确认语句" />
|
||||
<button type="button" disabled={props.releaseActionBusy || (props.releaseActionPhrase ?? '').trim() !== productionConfirmationPhrases[props.pendingReleaseAction.kind]} onClick={() => props.onSubmitReleaseAction?.()}>{props.releaseActionBusy ? '提交中…' : '确认执行'}</button>
|
||||
<button type="button" className="secondary-action" disabled={props.releaseActionBusy} onClick={() => props.onCancelReleaseAction?.()}>取消</button>
|
||||
</div> : null}
|
||||
{props.releaseActionError ? <div className="error-banner" role="alert">{props.releaseActionError}</div> : null}
|
||||
{props.releaseActionMessage ? <p className="success-message" role="status">{props.releaseActionMessage}</p> : null}
|
||||
{props.executionError ? <div className="error-banner" role="alert">{props.executionError}</div> : null}
|
||||
{!props.executionLoading && !props.executionError && props.executionPage.items.length === 0 ? <div className="empty-state">尚无生产发布或回滚执行记录。</div> : null}
|
||||
{props.executionPage.items.length > 0 ? <div className="batch-grid execution-grid"><div className="table-wrap"><table><thead><tr><th>开始时间</th><th>操作</th><th>状态</th><th>批次进度</th><th>完成进度</th><th>授权人</th><th>操作</th></tr></thead><tbody>
|
||||
{props.executionPage.items.map((execution) => <tr key={execution.id}><td>{formatTime(execution.created_at)}</td><td>{execution.operation === 'publish' ? '发布' : '回滚'}</td><td><span className={`status status-${execution.state}`}>{executionLabels[execution.state]}</span>{executionStateDetail(execution, props.executionPage.items) ? <small>{executionStateDetail(execution, props.executionPage.items)}</small> : null}{execution.error_code ? <small>{releaseFailureMessage(execution.error_code)}</small> : null}</td><td>{execution.total_batches>0?`批次 ${execution.current_batch} / ${execution.total_batches}`:'等待开始'}<small>每批最多 {execution.batch_size} 项</small></td><td>{execution.completed_item_count}/{execution.item_count}{execution.failed_item_count ? <small>失败 {execution.failed_item_count}</small> : null}</td><td>{execution.authorized_by}</td><td><button type="button" className="secondary-action" onClick={() => props.onSelectExecution(execution.id)}>查看结果</button></td></tr>)}
|
||||
{props.executionPage.items.map((execution) => <tr key={execution.id}><td>{formatTime(execution.created_at)}</td><td>{execution.operation === 'publish' ? '发布' : '回滚'}</td><td><span className={`status status-${execution.state}`}>{executionLabels[execution.state]}</span>{executionStateDetail(execution, props.executionPage.items) ? <small>{executionStateDetail(execution, props.executionPage.items)}</small> : null}{execution.error_code ? <small>{releaseFailureMessage(execution.error_code)}</small> : null}</td><td>{execution.total_batches>0?`批次 ${execution.current_batch} / ${execution.total_batches}`:'等待开始'}<small>每批最多 {execution.batch_size} 项</small></td><td>{execution.completed_item_count}/{execution.item_count}{execution.failed_item_count ? <small>失败 {execution.failed_item_count}</small> : null}</td><td>{execution.authorized_by}</td><td><div className="row-actions"><button type="button" className="secondary-action" onClick={() => props.onSelectExecution(execution.id)}>查看结果</button>{isAdmin && execution.state === 'failed' ? <button type="button" onClick={() => props.onStartReleaseAction?.('retry', execution.id, `执行 ${execution.id.slice(0, 8)}…`)}>重排</button> : null}{isAdmin && execution.operation === 'publish' && execution.state === 'completed' ? <button type="button" onClick={() => props.onStartReleaseAction?.('rollback', execution.id, `执行 ${execution.id.slice(0, 8)}…`)}>回滚</button> : null}</div></td></tr>)}
|
||||
</tbody></table></div><div className="batch-detail execution-detail"><h3>每项知识的结果</h3>{props.executionDetail ? <>{props.executionDetail.items.map((item) => { const result = releaseItemResult(item); return <div className="batch-detail-row execution-detail-row" key={item.publication_id}><span><strong>{item.title}</strong><small>{result.detail}{item.error_code ? ` · ${releaseFailureMessage(item.error_code)}` : ''}</small></span><span><b>{result.outcome}</b></span></div> })}{props.executionDetail.items.length === 0 ? <div className="empty-state">发布尚无逐项结果。</div> : null}</> : <div className="empty-state">点击“查看结果”核对每项知识。</div>}</div></div> : null}
|
||||
{props.executionChain ? <div className="release-audit-chain">
|
||||
<h3>发布审计链</h3>
|
||||
@@ -1154,6 +1192,11 @@ export default function App() {
|
||||
const [executionChain, setExecutionChain] = useState<ReleaseAuditChain | null>(null)
|
||||
const [executionLoading, setExecutionLoading] = useState(true)
|
||||
const [executionError, setExecutionError] = useState<string | null>(null)
|
||||
const [pendingReleaseAction, setPendingReleaseAction] = useState<PendingReleaseAction | null>(null)
|
||||
const [releaseActionPhrase, setReleaseActionPhrase] = useState('')
|
||||
const [releaseActionBusy, setReleaseActionBusy] = useState(false)
|
||||
const [releaseActionError, setReleaseActionError] = useState<string | null>(null)
|
||||
const [releaseActionMessage, setReleaseActionMessage] = useState<string | null>(null)
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState>(() => {
|
||||
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}
|
||||
/>}
|
||||
</WorkspaceShell>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ProductionActionKind, string> = {
|
||||
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<ProductionActionKind, (id: string) => 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<ProductionActionSummary> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user