Files
knowledge-governance/internal/httpapi/server.go
T
姚章浩 517d5f862d feat(library): 首次发布接上目标权威基线与整库空判定
目标非空又没有生效版本时,首次发布以前只能停在"权威版本未知"。现在有了
真实入口:管理员发起 baseline 作业,worker 在维护隔离下把目标整库捕获成
归档快照,再用同一套 Verify 证明目标与快照逐类一致,最后登记
production_effective(证据写明 origin=target_baseline、快照 ID、逐表行数、
目标索引映射)。不写目标内容、不清空目标、不手写版本。

同时修两处会误导人的判定:
- productionScopeEmpty 原来只数 knowledges,"文档删了但分块/Wiki/资源还在"
  的目标会被当成空库;改用适配层同一份表清单与范围过滤逐表计数。
- 概览里的"测试封存候选"原来取该 scope 最新一份归档,而归档里还有生产
  预备份与目标基线快照;改为只认版本登记里 kind=test_sealed 的那一份。

迁移 075 放开 kind='baseline' 并把它并入 sync/restore 的写互斥索引;
worker 重启时用同一作业 id 接管未完成的基线窗口。维护接口补 capability,
页面才能区分"未运行维护"与"维护能力没接线"。

验证:go build ./... / go vet ./... 通过;新增用例覆盖管理员校验、已有生效
版本拒绝、目标读不到时零作业零版本、候选来自登记、缺项文案指向基线入口、
capability 逐项缺失;反向自检(把候选改回按最新归档取、把文案改回旧句、
去掉管理员判断)三项均按预期变红后复原。
2026-10-29 18:16:21 +08:00

2845 lines
131 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"knowledge-governance/internal/backuphost"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/monitoring"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/qaopsai"
"knowledge-governance/internal/qaopsmirror"
"knowledge-governance/internal/qaopsorg"
)
type JobStore interface {
Enqueue(ctx context.Context, request jobs.EnqueueRequest, now time.Time) (*jobs.Job, error)
List(ctx context.Context, limit int) ([]jobs.Job, error)
}
type MonitorProvider interface {
Overview(ctx context.Context) (governance.MonitorOverview, error)
ListFiles(ctx context.Context, knowledgeBaseID, status string, limit, offset int) (governance.KnowledgeMonitorFilePage, error)
Metrics(ctx context.Context) (governance.RunMetrics, error)
Services(ctx context.Context) []governance.ServiceHealth
}
type Store interface {
JobStore
ListSourceConnections(ctx context.Context) (governance.SourceConnectionPage, error)
CreateSourceSettingsRevisionWithOperation(ctx context.Context, connectionID string, draft postgresstore.SourceSettingsDraft, operationID string, now time.Time) (string, bool, error)
ActivateSourceSettingsRevisionWithOperation(ctx context.Context, connectionID, revisionID, operationID string, now time.Time) (bool, error)
ListSourceConnectionRevisions(ctx context.Context, connectionID string) ([]governance.SourceConnectionRevision, error)
TriggerPPSourceScan(ctx context.Context, connectionID string, now time.Time) (governance.SourceScan, error)
TriggerPPSourceScanWithOperation(ctx context.Context, connectionID string, now time.Time, operationID string) (governance.SourceScan, bool, error)
ListSourceScans(ctx context.Context, connectionID string, limit, offset int) (governance.SourceScanPage, error)
GetSourceScanDetail(ctx context.Context, connectionID, scanID string) (governance.SourceScanDetailView, error)
ListReviewCandidates(ctx context.Context, filter governance.ReviewCandidateFilter) (governance.ReviewCandidatePage, error)
ReviewPublication(ctx context.Context, request governance.ReviewPublicationRequest) (governance.PublicationReview, error)
ReviewPublicationWithOperation(ctx context.Context, request governance.ReviewPublicationRequest, operationID string) (governance.PublicationReview, bool, error)
CreateReleaseBatch(ctx context.Context, request governance.CreateReleaseBatchRequest) (governance.ReleaseBatch, error)
CreateReleaseBatchWithOperation(ctx context.Context, request governance.CreateReleaseBatchRequest, operationID string) (governance.ReleaseBatch, bool, error)
GetReleaseAuditChain(ctx context.Context, executionID string) (governance.ReleaseAuditChain, error)
GenerateReleaseChangeSetWithOperation(ctx context.Context, request governance.GenerateReleaseChangeSetRequest, operationID string) (governance.ReleaseChangeSet, bool, error)
ConfirmReleaseChangeSetWithOperation(ctx context.Context, request governance.ConfirmReleaseChangeSetRequest, operationID string) (governance.ReleaseChangeSet, bool, error)
ListReleaseChangeSets(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseChangeSetPage, error)
GetReleaseChangeSet(ctx context.Context, id string) (governance.ReleaseChangeSetDetail, error)
LatestReleaseChangeSet(ctx context.Context) (governance.ReleaseChangeSetDetail, error)
SealReleaseBatch(ctx context.Context, request governance.SealReleaseBatchRequest) (governance.ReleaseBatch, error)
SealReleaseBatchWithOperation(ctx context.Context, request governance.SealReleaseBatchRequest, operationID string) (governance.ReleaseBatch, bool, error)
ListReleaseBatches(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseBatchPage, error)
GetReleaseBatch(ctx context.Context, batchID string) (governance.ReleaseBatchDetail, error)
AuthorizeReleaseBatch(ctx context.Context, request governance.AuthorizeReleaseBatchRequest) (governance.ReleaseExecution, error)
AuthorizeReleaseBatchWithOperation(ctx context.Context, request governance.AuthorizeReleaseBatchRequest, operationID string) (governance.ReleaseExecution, bool, error)
AuthorizeReleaseRollback(ctx context.Context, request governance.AuthorizeReleaseRollbackRequest) (governance.ReleaseExecution, error)
AuthorizeReleaseRollbackWithOperation(ctx context.Context, request governance.AuthorizeReleaseRollbackRequest, operationID string) (governance.ReleaseExecution, bool, error)
RetryReleaseExecution(ctx context.Context, request governance.RetryReleaseExecutionRequest) (governance.ReleaseExecution, error)
RetryReleaseExecutionWithOperation(ctx context.Context, request governance.RetryReleaseExecutionRequest, operationID string) (governance.ReleaseExecution, bool, error)
ListReleaseExecutions(ctx context.Context, filter governance.ReleaseExecutionFilter) (governance.ReleaseExecutionPage, error)
GetReleaseExecution(ctx context.Context, executionID string) (governance.ReleaseExecutionDetail, error)
ListReleasePlan(ctx context.Context) (governance.ReleasePlan, error)
GetActiveUISettings(ctx context.Context) (governance.UISettings, error)
GetUISettingsRevision(ctx context.Context, revisionID string) (governance.UISettings, error)
CreateUISettingsCandidate(ctx context.Context, request governance.CreateUISettingsCandidateRequest) (governance.UISettings, error)
CreateUISettingsCandidateWithOperation(ctx context.Context, request governance.CreateUISettingsCandidateRequest, operationID string) (governance.UISettings, bool, error)
ActivateUISettings(ctx context.Context, revisionID string, now time.Time) (governance.UISettings, error)
ActivateUISettingsWithOperation(ctx context.Context, revisionID string, now time.Time, operationID string) (governance.UISettings, bool, error)
ListSourceAssets(ctx context.Context, filter governance.SourceAssetFilter) (governance.SourceAssetPage, error)
ListCatalogSources(ctx context.Context) ([]governance.CatalogSource, error)
ListCatalogNodes(ctx context.Context, filter governance.CatalogNodeFilter) (governance.CatalogNodePage, error)
ListCatalogSnapshots(ctx context.Context, connectionID string, limit, offset int) (governance.CatalogSnapshotPage, error)
ListCatalogNodesInSnapshot(ctx context.Context, filter governance.CatalogSnapshotNodeFilter) (governance.CatalogNodePage, error)
GetLatestCatalogChangeSet(ctx context.Context, filter governance.CatalogChangeSetFilter) (governance.CatalogChangeSetDetail, error)
RouteCatalogFiles(ctx context.Context, request governance.CatalogRouteRequest) (governance.CatalogRouteResult, error)
RouteCatalogFilesWithOperation(ctx context.Context, request governance.CatalogRouteRequest, operationID string) (governance.CatalogRouteResult, bool, error)
EnqueueCatalogRefreshWithOperation(ctx context.Context, source governance.CatalogSource, operationID string, now time.Time) (jobs.Job, bool, error)
ListScopeRules(ctx context.Context, connectionID string) ([]governance.ScopeRule, error)
MutateScopeRule(ctx context.Context, request governance.ScopeRuleMutation) (governance.ScopeRule, error)
MutateScopeRuleWithOperation(ctx context.Context, request governance.ScopeRuleMutation, operationID string) (governance.ScopeRule, bool, error)
ListFilenameFilterRules(ctx context.Context, connectionID string) ([]governance.FilenameFilterRule, error)
MutateFilenameFilterRuleWithOperation(ctx context.Context, request governance.FilenameFilterMutation, operationID string) (governance.FilenameFilterRule, bool, error)
ListTestPublications(ctx context.Context, filter governance.TestPublicationFilter) (governance.TestPublicationPage, error)
StartTestPublications(ctx context.Context, request governance.StartTestPublicationsRequest) (governance.StartTestPublicationsResult, error)
StartTestPublicationsWithOperation(ctx context.Context, request governance.StartTestPublicationsRequest, operationID string) (governance.StartTestPublicationsResult, bool, error)
ConfirmCatalogChangeSetWithOperation(ctx context.Context, request governance.ConfirmCatalogChangeSetRequest, operationID string) (governance.ConfirmCatalogChangeSetResult, bool, error)
ListAcceptanceResults(ctx context.Context, publicationID string) ([]governance.AcceptanceRecord, error)
RetryPublication(ctx context.Context, publicationID string, now time.Time) error
RetryPublicationWithOperation(ctx context.Context, publicationID string, now time.Time, operationID string) (bool, error)
ListJobFailures(ctx context.Context, filter governance.JobFailureFilter) (governance.JobFailurePage, error)
RetryPublicationsBatchWithOperation(ctx context.Context, request governance.RetryPublicationsBatchRequest, operationID string) (governance.RetryPublicationsBatchResult, bool, error)
CancelPublicationsBatchWithOperation(ctx context.Context, request governance.CancelPublicationsBatchRequest, operationID string) (governance.RetryPublicationsBatchResult, bool, error)
RequeueArtifactFetchWithOperation(ctx context.Context, request governance.RequeueArtifactFetchRequest, operationID string) (governance.RequeueArtifactFetchResult, bool, error)
}
// libraryWithDeadline 给整库只读接口一个**真实**的请求期限:仓库读取按 ctx 在版本之间
// 中断,超时由 handler 自己返回 503 并说明原因。
//
// 为什么不再"另起 goroutine + 到点先回 503":那样主 goroutine 已经把响应结束,
// 被放弃的 handler 十几秒后仍会写同一个 ResponseWriter——候选环境实测日志
// `http: superfluous response.WriteHeader call`,并且留下仍在扫全部 blob 的 goroutine。
// 响应只允许一个写入者。
func libraryWithDeadline(w http.ResponseWriter, r *http.Request, limit time.Duration,
handler func(http.ResponseWriter, *http.Request)) {
ctx, cancel := context.WithTimeout(r.Context(), limit)
defer cancel()
handler(w, r.WithContext(ctx))
}
type Config struct {
QAAI *qaopsai.Store
QAAITrustTLSProxy bool
QAQuality QAQualityStore
QAOrgSync QAOrgSyncStore
QAOrgSyncEnabled bool
QAOrg QAOrgStore
QAOps QAOpsReader
QAWeeklyMail any
QAOpsTenantKey string
QAOpsEnabled bool
QAOpsProblem string
Store Store
Monitor MonitorProvider
InternalRoutingKnowledgeBaseID string
ExternalRoutingKnowledgeBaseID string
RoutingBindings RoutingBindingStore
KnowledgeBases KnowledgeBaseLister
// ProductionBindings is the 源库 → 目标库 mapping store; ProductionKnowledgeBases
// enumerates the production-side bases the operator can bind.
// BackupSchedule 是「系统设置 → 存储与备份」的计划存储;BackupHost 负责在目标库
// 所在主机上读回/下发计划并触发一次整库备份(未接线时页面降级展示)。
// VersionHistory 是「版本记录」页的只读查询面;VersionHistoryRepo 是固定的仓库标识
// (owner/name)。两者都给才接线,否则页面降级为"未接线"(不报 500)。
VersionHistory VersionHistoryStore
VersionHistoryRepo string
// LibraryArchiveRoot 是整库封存产物的只读归档根;留空即未接线(页面降级)。
LibraryArchiveRoot string
// LibraryTargetInstance 是实例级维护的目标实例名;留空即"未接线",
// 页面不能发起按预备份恢复,接口返回原因而不是假装能恢复。
LibraryTargetInstance string
// 维护能力的其余配置项只用于如实展示"是否接线":未运行维护与没有维护能力
// 是两件事,页面不能混成一句"未接入"。真正停写入者/关入口的能力由 worker
// 在执行作业时校验(socket、入口、排空文件都在 worker 侧)。
LibraryTargetProject string
LibraryTargetWriters []string
LibraryTargetIngressURL string
LibraryTargetIngressService string
LibraryTargetDockerSocket string
LibraryTargetJournalRoot string
// LibrarySourceInstance 是死信解除记录的来源实例名(封存源),留空回落到目标实例名。
LibrarySourceInstance string
// LibraryAgentSocket 是主机侧封存 agent 的 unix socket;留空即页面不能发起封存。
LibraryAgentSocket string
// LibrarySourceProject 是执行器应指向的实例名(agent -project,封存源)。
// 与它不一致时按"封错库"处理:不可发起,并说明差在哪。
LibrarySourceProject string
// LibraryExecutorStatusMaxAge 是执行器观测的有效期;超过即"无法确认"。
LibraryExecutorStatusMaxAge time.Duration
// LibraryCapacityBudgetBytes 是整库归档容量预算;0 = 未设置(容量门禁不放行)。
LibraryCapacityBudgetBytes int64
// 整库生产目标的只读探测配置:首次发布要能证明"生产可读且为空"。
LibraryProductionDatabaseURL string
LibraryProductionInternalKB string
LibraryProductionExternalKB string
LibraryProductionTenantID uint64
// LibraryRetentionVersions/Days 是保留策略;都为 0 = 未设置(不生成清理候选)。
LibraryRetentionVersions int
LibraryRetentionDays int
BackupSchedule BackupScheduleStore
BackupHost BackupHost
BackupRuns *BackupRunTracker
ProductionBindings ProductionBindingStore
ProductionKnowledgeBases ProductionKnowledgeBaseLister
ProductionWeKnoraBaseURL string
ServiceTargetVerifier ServiceTargetVerifier
Now func() time.Time
RequireWeKnoraReady bool
ProductionReleaseRequestsEnabled bool
// TestAutoReviewPolicy 只用于展示:让运维在界面上看到"现在自动批准到什么程度"。
TestAutoReviewPolicy string
ProductionBackupRestoreVerified bool
ProductionReleaseAuthorizerRef string
ProductionReleaseAuthorizationToken string
// DingtalkSources 枚举「我账号下能看到」的钉钉知识库与钉盘空间(只读,dws)。
// 未接线时对应路由返回 501,页面显示"列不出来"而不是空列表。
DingtalkSources dingtalkSourcesLister
// DingtalkDocuments 列在线文档/表格并校验可读(只读,dws)。
DingtalkDocuments dingtalkDocLister
NewID func() (string, error)
Auth *AuthConfig
}
func NewHandler(config Config) (http.Handler, error) {
if config.Store == nil {
return nil, errors.New("job store is required")
}
if config.Now == nil {
config.Now = time.Now
}
if config.Monitor == nil {
config.Monitor = monitoring.NewSimulationProvider(config.Now)
}
if config.BackupRuns == nil {
config.BackupRuns = &BackupRunTracker{}
}
if config.BackupHost == nil {
// 未挂载主机凭据:Available()=false,页面显示"未接线"而不是 500。
config.BackupHost = backuphost.New(backuphost.Config{})
}
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
if knowledgeBaseID != "" && !validCatalogID(knowledgeBaseID) {
return nil, errors.New("routing knowledge base ID is invalid")
}
}
if config.ProductionReleaseRequestsEnabled &&
(strings.TrimSpace(config.ProductionReleaseAuthorizerRef) == "" || strings.TrimSpace(config.ProductionReleaseAuthorizationToken) == "") {
return nil, errors.New("production release authorization identity and token are required")
}
if config.NewID == nil {
config.NewID = newID
}
mux := http.NewServeMux()
registerQAAIRoutes(mux, config)
mux.HandleFunc("POST /api/v1/qa-ops/departments/statistics", func(w http.ResponseWriter, r *http.Request) { qaDepartmentStatistics(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/dashboard", func(w http.ResponseWriter, r *http.Request) { qaOperationsDashboard(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/quality/query", func(w http.ResponseWriter, r *http.Request) { qaQualityQuery(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/records/{id}/reviews", func(w http.ResponseWriter, r *http.Request) { qaQualityReview(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/history", func(w http.ResponseWriter, r *http.Request) { qaQualityHistory(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}/quality/inputs/{epoch}", func(w http.ResponseWriter, r *http.Request) { qaQualitySnapshot(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/usage/people", func(w http.ResponseWriter, r *http.Request) { qaMonthlyPeople(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/usage", func(w http.ResponseWriter, r *http.Request) { qaOpsUsage(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/organization/sync", func(w http.ResponseWriter, r *http.Request) { qaOrganizationSync(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/sync", func(w http.ResponseWriter, r *http.Request) { qaRecordsSync(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) {
qaOpsStatus(w, r, config)
})
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftRead(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, false) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-drafts/{week}/prefill", func(w http.ResponseWriter, r *http.Request) { qaWeeklyDraftWrite(w, r, config, true) })
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/settings", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSettings(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/settings", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSettings(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
mux.HandleFunc("DELETE /api/v1/qa-ops/master/weekly-mail/confirmations/{week}", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailConfirm(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/weekly-mail/sends", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailSends(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/test", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailTest(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/weekly-mail/confirmations/{week}/image", func(w http.ResponseWriter, r *http.Request) { qaWeeklyMailImage(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/field-options", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldOptions(w, r, config) })
mux.HandleFunc("POST /api/v1/qa-ops/master/records/{id}/fields", func(w http.ResponseWriter, r *http.Request) { qaMasterFieldPatch(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/master/records", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, listQAOpsRecords) })
mux.HandleFunc("GET /api/v1/qa-ops/master/records/{id}", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, getQAOpsRecord) })
mux.HandleFunc("POST /api/v1/qa-ops/master/quality/query", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualityQuery) })
mux.HandleFunc("POST /api/v1/qa-ops/master/records/{id}/reviews", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualityReview) })
mux.HandleFunc("GET /api/v1/qa-ops/master/records/{id}/quality/history", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualityHistory) })
mux.HandleFunc("GET /api/v1/qa-ops/master/records/{id}/quality/inputs/{epoch}", func(w http.ResponseWriter, r *http.Request) { withQAMaster(w, r, config, qaQualitySnapshot) })
mux.HandleFunc("GET /api/v1/qa-ops/records", func(w http.ResponseWriter, r *http.Request) {
listQAOpsRecords(w, r, config)
})
mux.HandleFunc("GET /api/v1/qa-ops/records/{id}", func(w http.ResponseWriter, r *http.Request) {
getQAOpsRecord(w, r, config)
})
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// P10: liveness answers "is this process serving HTTP" and NEVER depends on
// third parties; readiness answers "can this instance accept dependent work".
mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "live", "observed_at": config.Now().UTC()})
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
health := config.Monitor.Services(r.Context())
// The API's own hard dependencies gate readiness. Worker and MinerU
// outages degrade the matrix (visible in Services) but must not take
// the admin UI out of rotation — the page is how operators fix them.
critical := map[string]bool{"postgres": true, "queue": true}
blocked := make([]string, 0)
degraded := make([]string, 0)
warnings := make([]string, 0)
for _, item := range health {
if !item.Applicable {
continue
}
if !item.Configured {
warnings = append(warnings, item.Key)
continue
}
key := strings.SplitN(item.Key, ":", 2)[0]
if item.Readiness == "ready" {
continue
}
if critical[key] || key == "weknora" && config.RequireWeKnoraReady {
blocked = append(blocked, item.Key)
} else {
degraded = append(degraded, item.Key)
}
}
status := map[string]any{"status": "ready", "blocked": blocked, "degraded": degraded, "not_configured": warnings, "observed_at": config.Now().UTC()}
if len(blocked) > 0 {
status["status"] = "blocked"
writeJSON(w, http.StatusServiceUnavailable, status)
return
}
if len(degraded) > 0 {
status["status"] = "degraded"
}
writeJSON(w, http.StatusOK, status)
})
mux.HandleFunc("POST /api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
createJob(w, r, config)
})
mux.HandleFunc("GET /api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
listJobs(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-items", func(w http.ResponseWriter, r *http.Request) {
listSourceAssets(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/sources", func(w http.ResponseWriter, r *http.Request) {
listCatalogSources(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/nodes", func(w http.ResponseWriter, r *http.Request) {
listCatalogNodes(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/snapshots", func(w http.ResponseWriter, r *http.Request) {
listCatalogSnapshots(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/snapshots/{id}/nodes", func(w http.ResponseWriter, r *http.Request) {
listCatalogSnapshotNodes(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/change-sets/latest", func(w http.ResponseWriter, r *http.Request) {
getLatestCatalogChangeSet(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-catalog/routing-capabilities", func(w http.ResponseWriter, r *http.Request) {
internal, external := config.effectiveRoutingTargets(r.Context())
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"internal_enabled": internal != "", "external_enabled": external != ""}})
})
mux.HandleFunc("GET /api/v1/routing-bindings", func(w http.ResponseWriter, r *http.Request) {
if config.RoutingBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "routing bindings storage unavailable"})
return
}
config.handleRoutingBindings(w, r)
})
mux.HandleFunc("GET /api/v1/routing-bindings/options", func(w http.ResponseWriter, r *http.Request) {
config.handleRoutingKnowledgeBaseOptions(w, r)
})
mux.HandleFunc("PUT /api/v1/routing-bindings", func(w http.ResponseWriter, r *http.Request) {
if config.RoutingBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "routing bindings storage unavailable"})
return
}
config.handleSaveRoutingBindings(w, r)
})
mux.HandleFunc("GET /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) {
if config.BackupSchedule == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
return
}
config.handleBackupSchedule(w, r)
})
mux.HandleFunc("GET /api/v1/backup-schedule/host", func(w http.ResponseWriter, r *http.Request) {
if config.BackupSchedule == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
return
}
config.handleBackupScheduleHost(w, r)
})
mux.HandleFunc("PUT /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) {
if config.BackupSchedule == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
return
}
config.handleSaveBackupSchedule(w, r)
})
mux.HandleFunc("POST /api/v1/backup-schedule/apply", func(w http.ResponseWriter, r *http.Request) {
if config.BackupSchedule == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
return
}
config.handleApplyBackupSchedule(w, r)
})
mux.HandleFunc("POST /api/v1/backup-schedule/run", func(w http.ResponseWriter, r *http.Request) {
if config.BackupSchedule == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
return
}
config.handleRunBackupNow(w, r)
})
mux.HandleFunc("GET /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) {
if config.ProductionBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"})
return
}
config.handleProductionBindings(w, r)
})
mux.HandleFunc("GET /api/v1/production-kb-bindings/options", func(w http.ResponseWriter, r *http.Request) {
config.handleProductionKnowledgeBaseOptions(w, r)
})
mux.HandleFunc("PUT /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) {
if config.ProductionBindings == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"})
return
}
config.handleSaveProductionBindings(w, r)
})
mux.HandleFunc("POST /api/v1/source-catalog/route", func(w http.ResponseWriter, r *http.Request) {
routeCatalogFiles(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-catalog/sources/{id}/refresh", func(w http.ResponseWriter, r *http.Request) {
triggerCatalogRefresh(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-catalog/change-sets/{id}/confirm", func(w http.ResponseWriter, r *http.Request) {
confirmCatalogChangeSet(w, r, config)
})
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)
})
// 整库版本(只读):列出已封存的整库候选产物与其分项数量/字节/摘要。
mux.HandleFunc("GET /api/v1/library/snapshots", func(w http.ResponseWriter, r *http.Request) {
librarySnapshotsList(w, r, config)
})
mux.HandleFunc("GET /api/v1/library/snapshots/{id}", func(w http.ResponseWriter, r *http.Request) {
librarySnapshotDetail(w, r, config)
})
// 页面发起封存:只投递作业(主机侧 agent 执行),并回读作业真实状态。
mux.HandleFunc("POST /api/v1/library/captures", func(w http.ResponseWriter, r *http.Request) {
libraryCaptureEnqueue(w, r, config)
})
mux.HandleFunc("GET /api/v1/library/captures", func(w http.ResponseWriter, r *http.Request) {
libraryCaptureJobs(w, r, config)
})
// 死信解除:只写治理库审计,不删上游行;封存时按精确指纹放行。
mux.HandleFunc("GET /api/v1/library/dead-letter-releases", func(w http.ResponseWriter, r *http.Request) {
deadLetterReleaseHandlers(w, r, config)
})
mux.HandleFunc("POST /api/v1/library/dead-letter-releases", func(w http.ResponseWriter, r *http.Request) {
deadLetterReleaseHandlers(w, r, config)
})
// 整库状态:测试封存 / 生产生效 / 生产预备份 三类版本 + 差异 + 持久作业。
// 归档可能很大:加请求级超时,读不动就明确降级,别让页面请求挂死。
mux.HandleFunc("GET /api/v1/library/overview", func(w http.ResponseWriter, r *http.Request) {
libraryWithDeadline(w, r, 10*time.Second, func(w http.ResponseWriter, r *http.Request) {
libraryOverviewHandler(w, r, config)
})
})
// 存储管理:容量、保留策略、依赖保护与清理预览(只读,没有删除动作)。
mux.HandleFunc("GET /api/v1/library/storage", func(w http.ResponseWriter, r *http.Request) {
libraryWithDeadline(w, r, 10*time.Second, func(w http.ResponseWriter, r *http.Request) {
libraryStorageHandler(w, r, config)
})
})
// 目标维护窗口状态:谁持有、处于哪个阶段、何时释放。
mux.HandleFunc("GET /api/v1/library/maintenance", func(w http.ResponseWriter, r *http.Request) {
scope := r.URL.Query().Get("scope")
if scope != "internal" && scope != "external" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
return
}
store, ok := config.Store.(interface {
LibraryMaintenanceOf(ctx context.Context, scope string) (*postgresstore.LibraryMaintenance, error)
})
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "维护窗口存储不可用"})
return
}
held, err := store.LibraryMaintenanceOf(r.Context(), scope)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "无法读取维护窗口"})
return
}
response := map[string]any{"held": held != nil && held.ReleasedAt == nil, "maintenance": held}
// 有没有维护能力是配置事实,和"此刻没有窗口"必须分开回答。
response["capability"] = libraryMaintenanceCapability(config)
// 实例级占用才是真正的互斥来源:跨 scope 也在这里体现。
if instanceStore, ok := config.Store.(interface {
InstanceMaintenanceOf(ctx context.Context, instance string) (*postgresstore.LibraryInstanceMaintenance, error)
}); ok && config.LibraryTargetInstance != "" {
if instanceHeld, err := instanceStore.InstanceMaintenanceOf(r.Context(), config.LibraryTargetInstance); err == nil {
response["instance"] = config.LibraryTargetInstance
response["instance_maintenance"] = instanceHeld
response["held"] = instanceHeld != nil && instanceHeld.ReleasedAt == nil
}
}
writeJSON(w, http.StatusOK, response)
})
// 按预备份恢复并逐类校验:维护窗口卡住时唯一的收尾入口(worker 执行)。
mux.HandleFunc("POST /api/v1/library/maintenance/recover", func(w http.ResponseWriter, r *http.Request) {
libraryMaintenanceRecoverHandler(w, r, config)
})
mux.HandleFunc("GET /api/v1/library/jobs", func(w http.ResponseWriter, r *http.Request) {
libraryJobsHandler(w, r, config)
})
mux.HandleFunc("GET /api/v1/library/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
libraryJobDetailHandler(w, r, config)
})
// 生产发布授权:管理员签发,绑定目标+快照+参数摘要+有效期;可撤销。
mux.HandleFunc("POST /api/v1/library/authorizations", func(w http.ResponseWriter, r *http.Request) {
libraryAuthorizationCreate(w, r, config)
})
mux.HandleFunc("GET /api/v1/library/authorizations", func(w http.ResponseWriter, r *http.Request) {
libraryAuthorizationList(w, r, config)
})
mux.HandleFunc("POST /api/v1/library/authorizations/{id}/revoke", func(w http.ResponseWriter, r *http.Request) {
libraryAuthorizationRevoke(w, r, config)
})
// 按指定整库版本恢复(管理员 + 该快照的恢复授权)。
mux.HandleFunc("POST /api/v1/library/restores", func(w http.ResponseWriter, r *http.Request) {
libraryRestoreEnqueue(w, r, config)
})
// 页面问"这份快照现在能不能恢复":条件来自双闸、授权与实例维护,而不是写死的按钮。
mux.HandleFunc("GET /api/v1/library/restore-availability", func(w http.ResponseWriter, r *http.Request) {
libraryRestoreAvailability(w, r, config)
})
// 失败/取消作业续作:按原参数重新排队,旧作业保留审计。
mux.HandleFunc("POST /api/v1/library/jobs/{id}/resume", func(w http.ResponseWriter, r *http.Request) {
libraryJobResume(w, r, config)
})
// 生产同步唯一入口:闸口未齐不建作业,返回缺项而不是假 Accepted。
mux.HandleFunc("POST /api/v1/library/syncs", func(w http.ResponseWriter, r *http.Request) {
librarySyncEnqueue(w, r, config)
})
// 首次发布的目标权威基线:管理员在维护隔离下捕获目标整库并校验后登记版本。
mux.HandleFunc("POST /api/v1/library/baselines", func(w http.ResponseWriter, r *http.Request) {
libraryBaselineEnqueue(w, r, config)
})
// 全部 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)
})
mux.HandleFunc("GET /api/v1/monitor/tasks", func(w http.ResponseWriter, r *http.Request) {
listMonitorTasks(w, r, config)
})
mux.HandleFunc("GET /api/v1/monitor/metrics", func(w http.ResponseWriter, r *http.Request) {
monitorMetrics(w, r, config.Monitor)
})
mux.HandleFunc("GET /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
listScopeRules(w, r, config.Store)
})
mux.HandleFunc("PUT /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
mutateScopeRule(w, r, config, false)
})
mux.HandleFunc("DELETE /api/v1/scope-rules", func(w http.ResponseWriter, r *http.Request) {
mutateScopeRule(w, r, config, true)
})
mux.HandleFunc("GET /api/v1/filename-filters", func(w http.ResponseWriter, r *http.Request) {
listFilenameFilters(w, r, config.Store)
})
mux.HandleFunc("PUT /api/v1/filename-filters", func(w http.ResponseWriter, r *http.Request) {
mutateFilenameFilter(w, r, config, false)
})
mux.HandleFunc("DELETE /api/v1/filename-filters", func(w http.ResponseWriter, r *http.Request) {
mutateFilenameFilter(w, r, config, true)
})
mux.HandleFunc("GET /api/v1/source-connections/audit", func(w http.ResponseWriter, r *http.Request) { listSourceAudit(w, r, config) })
mux.HandleFunc("GET /api/v1/source-connections", func(w http.ResponseWriter, r *http.Request) {
listSourceConnections(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/dingtalk-session", func(w http.ResponseWriter, r *http.Request) {
startDingtalkSession(w, r)
})
mux.HandleFunc("POST /api/v1/dingtalk-spaces", func(w http.ResponseWriter, r *http.Request) {
registerDingtalkSpace(w, r, config)
})
mux.HandleFunc("GET /api/v1/source-catalog/available", func(w http.ResponseWriter, r *http.Request) {
listAvailableSources(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-catalog/validate", func(w http.ResponseWriter, r *http.Request) {
validateSourceSelection(w, r, config)
})
mux.HandleFunc("GET /api/v1/dingtalk-spaces/credential-status", func(w http.ResponseWriter, r *http.Request) { readDWSCredentials(w, r, config) })
mux.HandleFunc("GET /api/v1/dingtalk-spaces/available", func(w http.ResponseWriter, r *http.Request) {
listDingtalkSources(w, r, config)
})
mux.HandleFunc("GET /api/v1/dingtalk-session/{id}", func(w http.ResponseWriter, r *http.Request) {
getDingtalkSession(w, r)
})
mux.HandleFunc("DELETE /api/v1/dingtalk-session/{id}", func(w http.ResponseWriter, r *http.Request) {
deleteDingtalkSession(w, r)
})
mux.HandleFunc("POST /api/v1/source-connections/{id}/scan", func(w http.ResponseWriter, r *http.Request) {
triggerPPSourceScan(w, r, config)
})
mux.HandleFunc("GET /api/v1/source-connections/{id}/scans", func(w http.ResponseWriter, r *http.Request) {
listSourceScans(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-connections/{id}/scans/{scan_id}", func(w http.ResponseWriter, r *http.Request) {
getSourceScanDetail(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/source-connections/{id}/settings/revisions", func(w http.ResponseWriter, r *http.Request) {
listSourceSettingsRevisions(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/source-connections/{id}/settings/revisions", func(w http.ResponseWriter, r *http.Request) {
createSourceSettingsRevision(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-connections/{id}/settings/revisions/{revision_id}/activate", func(w http.ResponseWriter, r *http.Request) {
activateSourceSettingsRevision(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-connections/{id}/archive", func(w http.ResponseWriter, r *http.Request) {
archiveSourceConnection(w, r, config)
})
mux.HandleFunc("POST /api/v1/source-connections/{id}/restore", func(w http.ResponseWriter, r *http.Request) {
restoreSourceConnection(w, r, config)
})
mux.HandleFunc("GET /api/v1/review-candidates", func(w http.ResponseWriter, r *http.Request) {
listReviewCandidates(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/review-candidates/batch-decision", func(w http.ResponseWriter, r *http.Request) {
batchReviewCandidates(w, r, config)
})
mux.HandleFunc("POST /api/v1/review-candidates/{id}/decision", func(w http.ResponseWriter, r *http.Request) {
reviewPublication(w, r, config)
})
mux.HandleFunc("GET /api/v1/release-batches", func(w http.ResponseWriter, r *http.Request) {
listReleaseBatches(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/release-executions/{id}/audit-chain", func(w http.ResponseWriter, r *http.Request) {
getReleaseAuditChain(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/release-change-sets", func(w http.ResponseWriter, r *http.Request) {
generateReleaseChangeSet(w, r, config)
})
mux.HandleFunc("GET /api/v1/release-change-sets", func(w http.ResponseWriter, r *http.Request) {
listReleaseChangeSets(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/release-change-sets/latest", func(w http.ResponseWriter, r *http.Request) {
getReleaseChangeSet(w, r, config.Store, "latest")
})
mux.HandleFunc("GET /api/v1/release-change-sets/{id}", func(w http.ResponseWriter, r *http.Request) {
getReleaseChangeSet(w, r, config.Store, r.PathValue("id"))
})
mux.HandleFunc("POST /api/v1/release-change-sets/{id}/confirm", func(w http.ResponseWriter, r *http.Request) {
confirmReleaseChangeSet(w, r, config)
})
mux.HandleFunc("GET /api/v1/release-plan", func(w http.ResponseWriter, r *http.Request) {
plan, err := config.Store.ListReleasePlan(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not build release plan"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": plan})
})
mux.HandleFunc("GET /api/v1/ui-settings", func(w http.ResponseWriter, r *http.Request) {
getActiveUISettings(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/ui-settings/candidates", func(w http.ResponseWriter, r *http.Request) {
createUISettingsCandidate(w, r, config)
})
mux.HandleFunc("POST /api/v1/ui-settings/{id}/activate", func(w http.ResponseWriter, r *http.Request) {
activateUISettings(w, r, config)
})
mux.HandleFunc("POST /api/v1/release-batches", func(w http.ResponseWriter, r *http.Request) {
createReleaseBatch(w, r, config)
})
mux.HandleFunc("GET /api/v1/release-batches/{id}", func(w http.ResponseWriter, r *http.Request) {
getReleaseBatch(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/release-batches/{id}/seal", func(w http.ResponseWriter, r *http.Request) {
sealReleaseBatch(w, r, config)
})
// 一个动作把"建批(冻结+锁定)→ 授权生产发布"走完:门禁、固定确认语句、管理员身份、
// 发布前快照门禁全部沿用既有实现,不新增旁路。
mux.HandleFunc("POST /api/v1/release-batches/publish-now", func(w http.ResponseWriter, r *http.Request) {
publishNowPublications(w, r, config)
})
mux.HandleFunc("POST /api/v1/release-batches/{id}/authorize-production", func(w http.ResponseWriter, r *http.Request) {
authorizeProductionRelease(w, r, config)
})
mux.HandleFunc("POST /api/v1/release-executions/{id}/authorize-rollback", func(w http.ResponseWriter, r *http.Request) {
authorizeProductionRollback(w, r, config)
})
mux.HandleFunc("POST /api/v1/release-executions/{id}/retry", func(w http.ResponseWriter, r *http.Request) {
retryProductionRelease(w, r, config)
})
mux.HandleFunc("GET /api/v1/release-executions", func(w http.ResponseWriter, r *http.Request) {
listReleaseExecutions(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/release-executions/{id}", func(w http.ResponseWriter, r *http.Request) {
getReleaseExecution(w, r, config.Store)
})
mux.HandleFunc("GET /api/v1/release-capabilities", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"production_release_requests_enabled": config.ProductionReleaseRequestsEnabled,
"production_backup_restore_verified": config.ProductionBackupRestoreVerified,
"test_auto_review_policy": config.TestAutoReviewPolicy,
}})
})
mux.HandleFunc("GET /api/v1/test-publications", func(w http.ResponseWriter, r *http.Request) {
listTestPublications(w, r, config.Store)
})
// 需下架条目的移除入口:把「被禁止导入但仍存活在测试库」的知识从测试库移除
// (置 disabling + 入队既有 test.remove 工作流)。与开始处理同级:engineer 即可。
mux.HandleFunc("POST /api/v1/test-publications/{id}/removal-retry", func(w http.ResponseWriter, r *http.Request) {
retryMonitorRemoval(w, r, config)
})
mux.HandleFunc("POST /api/v1/test-publications/{id}/removal", func(w http.ResponseWriter, r *http.Request) {
startTestPublicationRemoval(w, r, config)
})
mux.HandleFunc("POST /api/v1/test-publications/start", func(w http.ResponseWriter, r *http.Request) {
startTestPublications(w, r, config)
})
mux.HandleFunc("GET /api/v1/test-publications/{id}/acceptance", func(w http.ResponseWriter, r *http.Request) {
listAcceptance(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/test-publications/{id}/retry", func(w http.ResponseWriter, r *http.Request) {
retryPublication(w, r, config)
})
mux.HandleFunc("POST /api/v1/test-publications/retry-batch", func(w http.ResponseWriter, r *http.Request) {
retryPublicationsBatch(w, r, config)
})
mux.HandleFunc("POST /api/v1/test-publications/cancel-batch", func(w http.ResponseWriter, r *http.Request) {
cancelPublicationsBatch(w, r, config)
})
mux.HandleFunc("GET /api/v1/job-failures", func(w http.ResponseWriter, r *http.Request) {
listJobFailures(w, r, config.Store)
})
mux.HandleFunc("POST /api/v1/job-failures/{id}/requeue", func(w http.ResponseWriter, r *http.Request) {
requeueJobFailure(w, r, config)
})
if config.Auth != nil {
if config.Auth.Store == nil {
return nil, errors.New("auth configuration requires a store")
}
config.authRoutes(mux)
config.userRoutes(mux)
return masterNoStore(config.Auth.authenticate(mux)), nil
}
return masterNoStore(mux), nil
}
type createJobRequest struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
Priority int `json:"priority"`
MaxAttempts int `json:"max_attempts"`
IdempotencyKey string `json:"idempotency_key"`
}
func createJob(w http.ResponseWriter, r *http.Request, config Config) {
var input createJobRequest
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"})
return
}
if strings.HasPrefix(input.Type, qaopsorg.SyncJobPrefix) || strings.HasPrefix(input.Type, qaopsmirror.SyncJobPrefix) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "通讯录同步任务只能从问答运营的管理员入口创建"})
return
}
if strings.TrimSpace(input.Type) == "" || len(input.Type) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type is required and must not exceed 100 characters"})
return
}
if strings.TrimSpace(input.IdempotencyKey) == "" || len(input.IdempotencyKey) > 255 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "idempotency_key is required and must not exceed 255 characters"})
return
}
if input.MaxAttempts == 0 {
input.MaxAttempts = 5
}
if input.MaxAttempts < 1 || input.MaxAttempts > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "max_attempts must be between 1 and 100"})
return
}
if len(input.Payload) == 0 {
input.Payload = json.RawMessage(`{}`)
}
if !json.Valid(input.Payload) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "payload must be valid JSON"})
return
}
id, err := config.NewID()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create job ID"})
return
}
now := config.Now().UTC()
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
ID: id,
Type: input.Type,
Payload: input.Payload,
Priority: input.Priority,
AvailableAt: now,
MaxAttempts: input.MaxAttempts,
IdempotencyKey: input.IdempotencyKey,
}, now)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not enqueue job"})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"data": job})
}
func listJobs(w http.ResponseWriter, r *http.Request, store JobStore) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = value
}
result, err := store.List(r.Context(), limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list jobs"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": result})
}
func listSourceAssets(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.SourceAssetFilter{State: r.URL.Query().Get("state"), Limit: 50}
if filter.State != "" && filter.State != "completed" && filter.State != "content_alias" && filter.State != "over_limit" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state must be completed, content_alias, or over_limit"})
return
}
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
filter.Limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
filter.Offset = value
}
page, err := store.ListSourceAssets(r.Context(), filter)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source items"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": page.Items, "summary": page.Summary, "limit": filter.Limit, "offset": filter.Offset,
})
}
func listCatalogSources(w http.ResponseWriter, r *http.Request, store Store) {
sources, err := store.ListCatalogSources(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source catalogs"})
return
}
sources = append(sources, scanningCatalogSources(r.Context(), store)...)
writeJSON(w, http.StatusOK, map[string]any{"data": sources, "summary": map[string]int{"total": len(sources)}})
}
// catalogScanProgressLister 是本路由需要的最小能力面:列出「已登记、还没有快照」的来源的
// 首次扫描进度。真实 Store 实现它;未接线时安静跳过(只是没有"扫描中"那几行,不影响既有列表)。
type catalogScanProgressLister interface {
ListCatalogScanProgress(context.Context) ([]governance.CatalogScanProgress, error)
}
// scanningCatalogSources 把首次扫描中的来源投影成目录列表里的行:新来源不必等快照落地才出现。
func scanningCatalogSources(ctx context.Context, store Store) []governance.CatalogSource {
lister, ok := store.(catalogScanProgressLister)
if !ok {
return nil
}
progress, err := lister.ListCatalogScanProgress(ctx)
if err != nil {
return nil
}
out := make([]governance.CatalogSource, 0, len(progress))
for _, item := range progress {
out = append(out, governance.CatalogSource{
ConnectionID: item.ConnectionID,
SourceType: item.SourceType,
Name: item.Name,
// 还没有快照:根节点与计数只报"已扫到多少",由前端按"首次扫描中"呈现。
RootNodeID: "",
FolderCount: item.DiscoveredDirectories,
FileCount: item.ScannedFiles,
ObservedAt: item.StartedAt,
SnapshotOnly: true,
SnapshotStatus: "scanning",
ScanProgress: item.Progress,
ScanMessage: item.Message,
})
}
return out
}
func listCatalogNodes(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.CatalogNodeFilter{ConnectionID: r.URL.Query().Get("connection_id"), ParentNodeID: r.URL.Query().Get("parent_id"), Limit: 50}
if !validCatalogID(filter.ConnectionID) || !validCatalogID(filter.ParentNodeID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog location"})
return
}
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
filter.Limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
filter.Offset = value
}
page, err := store.ListCatalogNodes(r.Context(), filter)
if errors.Is(err, postgresstore.ErrCatalogNodeNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog location not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source catalog"})
return
}
writeJSON(w, http.StatusOK, page)
}
func listCatalogSnapshots(w http.ResponseWriter, r *http.Request, store Store) {
connectionID := r.URL.Query().Get("connection_id")
if !validCatalogID(connectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
return
}
limit, offset, ok := parseLimitOffset(w, r, 20)
if !ok {
return
}
page, err := store.ListCatalogSnapshots(r.Context(), connectionID, limit, offset)
if errors.Is(err, postgresstore.ErrCatalogSnapshotNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list snapshot history"})
return
}
writeJSON(w, http.StatusOK, page)
}
func listCatalogSnapshotNodes(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.CatalogSnapshotNodeFilter{ConnectionID: r.URL.Query().Get("connection_id"), SnapshotID: r.PathValue("id"), ParentNodeID: r.URL.Query().Get("parent_id"), Limit: 50}
if !validCatalogID(filter.ConnectionID) || !validCatalogID(filter.SnapshotID) || !validCatalogID(filter.ParentNodeID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid snapshot location"})
return
}
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
filter.Limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
filter.Offset = value
}
page, err := store.ListCatalogNodesInSnapshot(r.Context(), filter)
if errors.Is(err, postgresstore.ErrCatalogSnapshotNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "snapshot not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list snapshot nodes"})
return
}
writeJSON(w, http.StatusOK, page)
}
func getLatestCatalogChangeSet(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.CatalogChangeSetFilter{ConnectionID: strings.TrimSpace(r.URL.Query().Get("connection_id")), Action: strings.TrimSpace(r.URL.Query().Get("action")), Limit: 20}
if !validCatalogID(filter.ConnectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
return
}
allowedActions := map[string]bool{"": true, "add": true, "update": true, "move": true, "move_scope": true, "delete": true, "no_change": true}
if !allowedActions[filter.Action] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid change action"})
return
}
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
filter.Limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
filter.Offset = value
}
detail, err := store.GetLatestCatalogChangeSet(r.Context(), filter)
if errors.Is(err, postgresstore.ErrCatalogChangeSetNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog change set not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load source catalog change set"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
}
func validCatalogID(value string) bool {
if value == "" || len(value) > 36 {
return false
}
for _, character := range value {
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' || character == '-' || character == '_' {
continue
}
return false
}
return true
}
func requireOperationID(w http.ResponseWriter, r *http.Request) (string, bool) {
operationID := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if operationID == "" || len(operationID) > 128 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Idempotency-Key is required and must not exceed 128 characters"})
return "", false
}
return operationID, true
}
type catalogRouteInput struct {
ConnectionID string `json:"connection_id"`
NodeIDs []string `json:"node_ids"`
Policy string `json:"policy"`
Visibility string `json:"visibility"`
Reason string `json:"reason"`
}
func routeCatalogFiles(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input catalogRouteInput
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || len(input.NodeIDs) == 0 || len(input.NodeIDs) > 100 || !validCatalogID(input.ConnectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid catalog route request"})
return
}
policy := strings.TrimSpace(input.Policy)
if policy == "" {
policy = strings.TrimSpace(input.Visibility)
}
visibility := governance.Visibility(policy)
effectiveInternal, effectiveExternal := config.effectiveRoutingTargets(r.Context())
targetKnowledgeBaseIDs := make([]string, 0, 2)
switch visibility {
case governance.VisibilityInternal:
if effectiveInternal != "" {
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, effectiveInternal)
}
case governance.VisibilityExternal:
if effectiveExternal != "" {
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, effectiveExternal)
}
case governance.VisibilityBoth:
if effectiveInternal != "" {
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, effectiveInternal)
}
if effectiveExternal != "" {
targetKnowledgeBaseIDs = append(targetKnowledgeBaseIDs, effectiveExternal)
}
case governance.VisibilityBlocked:
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "policy must be internal, external, both, or blocked"})
return
}
if visibility != governance.VisibilityBlocked &&
((visibility == governance.VisibilityBoth && len(targetKnowledgeBaseIDs) != 2) ||
(visibility != governance.VisibilityBoth && len(targetKnowledgeBaseIDs) != 1)) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "routing target is not configured"})
return
}
targetKnowledgeBaseID := ""
if len(targetKnowledgeBaseIDs) > 0 {
targetKnowledgeBaseID = targetKnowledgeBaseIDs[0]
}
result, replayed, err := config.Store.RouteCatalogFilesWithOperation(r.Context(), governance.CatalogRouteRequest{
ConnectionID: input.ConnectionID, NodeIDs: input.NodeIDs, Visibility: visibility,
TargetKnowledgeBaseID: targetKnowledgeBaseID, TargetKnowledgeBaseIDs: targetKnowledgeBaseIDs,
OperatorRef: actorFor(r), Reason: strings.TrimSpace(input.Reason), Now: config.Now().UTC(),
}, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not route selected files"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": result, "operation": operation})
}
func triggerCatalogRefresh(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
connectionID := r.PathValue("id")
if !validCatalogID(connectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source catalog"})
return
}
sources, err := config.Store.ListCatalogSources(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not inspect source catalog"})
return
}
var source *governance.CatalogSource
for index := range sources {
if sources[index].ConnectionID == connectionID {
source = &sources[index]
break
}
}
if source == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source catalog not found"})
return
}
job, replayed, err := config.Store.EnqueueCatalogRefreshWithOperation(r.Context(), *source, operationID, config.Now().UTC())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not enqueue catalog refresh"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": job, "operation": operation})
}
func monitorOverview(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
overview, err := provider.Overview(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load monitor overview"})
return
}
writeJSON(w, http.StatusOK, overview)
}
func monitorMetrics(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
metrics, err := provider.Metrics(r.Context())
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "run metrics unavailable"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": metrics})
}
func monitorFiles(w http.ResponseWriter, r *http.Request, provider MonitorProvider) {
knowledgeBaseID := r.PathValue("id")
status := r.URL.Query().Get("status")
allowedStatus := map[string]bool{"": true, "completed": true, "pending": true, "processing": true, "finalizing": true, "failed": true, "cancelled": true}
if !validCatalogID(knowledgeBaseID) || !allowedStatus[status] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid monitor file query"})
return
}
limit, offset := 20, 0
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
offset = value
}
page, err := provider.ListFiles(r.Context(), knowledgeBaseID, status, limit, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load monitored files"})
return
}
writeJSON(w, http.StatusOK, page)
}
type scopeRuleInput struct {
TargetType governance.ScopeTargetType `json:"target_type"`
ConnectionID string `json:"connection_id"`
DirectoryPath string `json:"directory_path"`
SourceItemID string `json:"source_item_id"`
Visibility governance.Visibility `json:"visibility"`
Reason string `json:"reason"`
}
func listScopeRules(w http.ResponseWriter, r *http.Request, store Store) {
connectionID := strings.TrimSpace(r.URL.Query().Get("connection_id"))
if connectionID == "" || len(connectionID) > 36 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "connection_id is required"})
return
}
rules, err := store.ListScopeRules(r.Context(), connectionID)
if errors.Is(err, postgresstore.ErrScopeTargetNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "scope target not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list scope rules"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": rules, "summary": map[string]int{"total": len(rules)}})
}
func mutateScopeRule(w http.ResponseWriter, r *http.Request, config Config, clear bool) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input scopeRuleInput
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope rule request"})
return
}
request := governance.ScopeRuleMutation{
TargetType: input.TargetType, ConnectionID: strings.TrimSpace(input.ConnectionID),
DirectoryPath: input.DirectoryPath, SourceItemID: strings.TrimSpace(input.SourceItemID),
Visibility: input.Visibility, OperatorRef: actorFor(r), Reason: strings.TrimSpace(input.Reason),
Clear: clear, Now: config.Now().UTC(),
}
if clear {
request.Visibility = ""
}
if err := request.Validate(); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid scope rule request"})
return
}
rule, replayed, err := config.Store.MutateScopeRuleWithOperation(r.Context(), request, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrScopeTargetNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "scope target not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not update scope rule"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": rule, "operation": operation})
}
type sourceSettingsRevisionRequest struct {
SourceType string `json:"source_type"`
Name string `json:"name"`
BaseURL string `json:"endpoint"`
SecretRef string `json:"credential_ref"`
Enabled bool `json:"enabled"`
Schedule struct {
RefreshIntervalSeconds *int `json:"refresh_interval_seconds"`
} `json:"schedule"`
}
// toDraft stamps the actor server-side; the browser never names the operator.
func (req sourceSettingsRevisionRequest) toDraft(actor string) postgresstore.SourceSettingsDraft {
schedule := "{}"
if req.Schedule.RefreshIntervalSeconds != nil {
schedule = fmt.Sprintf(`{"refresh_interval_seconds":%d}`, *req.Schedule.RefreshIntervalSeconds)
}
return postgresstore.SourceSettingsDraft{
SourceType: req.SourceType, Name: req.Name, BaseURL: req.BaseURL,
SecretRef: req.SecretRef, Enabled: req.Enabled,
Schedule: json.RawMessage(schedule), Operator: actor,
}
}
func listSourceSettingsRevisions(w http.ResponseWriter, r *http.Request, store Store) {
connectionID := r.PathValue("id")
if !validCatalogID(connectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection"})
return
}
revisions, err := store.ListSourceConnectionRevisions(r.Context(), connectionID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source settings revisions"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": revisions})
}
func createSourceSettingsRevision(w http.ResponseWriter, r *http.Request, config Config) {
if r.Header.Get("X-Governance-Action") != "source-settings" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source settings write intent is required"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
connectionID := r.PathValue("id")
if !validCatalogID(connectionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection"})
return
}
var request sourceSettingsRevisionRequest
if err := decodeJSONStrict(r, &request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
return
}
revisionID, replayed, err := config.Store.CreateSourceSettingsRevisionWithOperation(r.Context(), connectionID, request.toDraft(actorFor(r)), operationID, config.Now().UTC())
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrInvalidSourceSettings) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
return
}
if errors.Is(err, postgresstore.ErrSourceConnectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source connection not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create source settings revision"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusCreated, map[string]any{"data": map[string]string{"id": revisionID}, "operation": operation})
}
func activateSourceSettingsRevision(w http.ResponseWriter, r *http.Request, config Config) {
if r.Header.Get("X-Governance-Action") != "source-settings" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "source settings write intent is required"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
connectionID, revisionID := r.PathValue("id"), r.PathValue("revision_id")
if !validCatalogID(connectionID) || !validCatalogID(revisionID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings revision"})
return
}
var request struct{}
if err := decodeJSONStrict(r, &request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source settings activation"})
return
}
replayed, err := config.Store.ActivateSourceSettingsRevisionWithOperation(r.Context(), connectionID, revisionID, operationID, config.Now().UTC())
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrInvalidSourceSettings) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only validated revisions can be activated"})
return
}
if errors.Is(err, postgresstore.ErrSourceConnectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source settings revision not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not activate source settings revision"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]string{"connection_id": connectionID, "revision_id": revisionID}, "operation": operation})
}
func listSourceConnections(w http.ResponseWriter, r *http.Request, store Store) {
page, err := store.ListSourceConnections(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source connections"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "summary": page.Summary})
}
func triggerPPSourceScan(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
scan, replayed, err := config.Store.TriggerPPSourceScanWithOperation(r.Context(), r.PathValue("id"), config.Now().UTC(), operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrSourceConnectionNotScannable) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source connection is not scannable"})
return
}
if errors.Is(err, postgresstore.ErrSourceScanAlreadyActive) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "source scan already active"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not trigger source scan"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": scan, "operation": operation})
}
func listSourceScans(w http.ResponseWriter, r *http.Request, store Store) {
connectionID := r.PathValue("id")
if connectionID == "" || len(connectionID) > 64 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source connection ID"})
return
}
limit, offset := 20, 0
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
offset = value
}
page, err := store.ListSourceScans(r.Context(), connectionID, limit, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list source scans"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
}
func getSourceScanDetail(w http.ResponseWriter, r *http.Request, store Store) {
connectionID, scanID := r.PathValue("id"), r.PathValue("scan_id")
if connectionID == "" || len(connectionID) > 64 || scanID == "" || len(scanID) > 64 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source scan ID"})
return
}
detail, err := store.GetSourceScanDetail(r.Context(), connectionID, scanID)
if errors.Is(err, postgresstore.ErrSourceScanNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "source scan not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load source scan"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
}
func listReviewCandidates(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.ReviewCandidateFilter{ReviewState: r.URL.Query().Get("review_state"), Limit: 20}
if filter.ReviewState != "" && filter.ReviewState != "pending" && filter.ReviewState != "approved" && filter.ReviewState != "rejected" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "review_state must be pending, approved, or rejected"})
return
}
limit, offset, ok := parseLimitOffset(w, r, 20)
if !ok {
return
}
filter.Limit, filter.Offset = limit, offset
page, err := store.ListReviewCandidates(r.Context(), filter)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list review candidates"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
}
type reviewPublicationRequest struct {
Decision string `json:"decision"`
DecidedVisibility string `json:"decided_visibility"`
DecisionCode string `json:"decision_code"`
}
func reviewPublication(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input reviewPublicationRequest
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review request"})
return
}
review, replayed, err := config.Store.ReviewPublicationWithOperation(r.Context(), governance.ReviewPublicationRequest{
PublicationID: r.PathValue("id"), Decision: governance.ReviewState(input.Decision),
DecidedVisibility: governance.Visibility(input.DecidedVisibility), ReviewerRef: actorFor(r),
DecisionCode: input.DecisionCode, Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidPublicationReview):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review request"})
case errors.Is(err, postgresstore.ErrPublicationReviewNotEligible):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "publication is not eligible for this review decision"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not record review decision"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusCreated, map[string]any{"data": review, "operation": operation})
}
}
type createUISettingsCandidateRequest struct {
DocumentRefreshSeconds int `json:"document_refresh_seconds"`
ResourceRefreshSeconds int `json:"resource_refresh_seconds"`
StalledAfterSeconds int `json:"stalled_after_seconds"`
FinalizingAfterSeconds int `json:"finalizing_after_seconds"`
IngestionConcurrency int `json:"ingestion_concurrency"`
TestWeKnoraBaseURL string `json:"test_weknora_base_url"`
ProductionWeKnoraBaseURL string `json:"production_weknora_base_url"`
ProductionTenantID string `json:"production_tenant_id"`
ProductionKnowledgeBaseID string `json:"production_knowledge_base_id"`
}
// adminAuthorized reports whether the caller may change operator-level
// configuration (endpoints, production IDs). With auth enabled the RBAC middleware
// has already put a session identity in the context; without auth (development
// socket only) there is no identity to check, matching productionAuthorizer's
// fallback rather than inventing a second rule.
func adminAuthorized(r *http.Request) bool {
identity, ok := sessionFrom(r.Context())
if !ok {
return true
}
return identity.Role == roleAdmin
}
// operatorServiceFields are the operator-level entries of 「服务配置」: the test
// and production endpoints, the production tenant and the production knowledge
// base. Engineers may still tune refresh windows; only an admin may repoint the
// console's environments or rewrite its production IDs.
type operatorServiceFields struct {
TestWeKnoraBaseURL string
ProductionWeKnoraBaseURL string
ProductionTenantID string
ProductionKnowledgeBaseID string
}
func operatorServiceFieldsOf(settings governance.UISettings) operatorServiceFields {
return operatorServiceFields{
TestWeKnoraBaseURL: settings.TestWeKnoraBaseURL,
ProductionWeKnoraBaseURL: settings.ProductionWeKnoraBaseURL,
ProductionTenantID: settings.ProductionTenantID,
ProductionKnowledgeBaseID: settings.ProductionKnowledgeBaseID,
}
}
// operatorChangeNeedsAdmin keeps the operator-level service configuration
// admin-only on both ends of the versioned settings flow: authoring a candidate,
// and activating a revision whose values differ from the active ones (otherwise
// an engineer could activate an admin-authored change).
func operatorChangeNeedsAdmin(w http.ResponseWriter, r *http.Request, current, incoming operatorServiceFields) bool {
if current == incoming || adminAuthorized(r) {
return false
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "service configuration changes require an admin"})
return true
}
func getActiveUISettings(w http.ResponseWriter, r *http.Request, store Store) {
settings, err := store.GetActiveUISettings(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load active UI settings"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": settings})
}
// ServiceTargetVerifier performs a real read against the configured endpoints so
// that 「服务配置」cannot store an ID nobody ever checked. It decides no policy:
// it answers, and the caller refuses only a definitive negative answer.
type ServiceTargetVerifier interface {
VerifyServiceTarget(ctx context.Context, target governance.ServiceTarget) governance.ServiceTargetVerification
}
// verifyServiceTarget runs the configured verifier over the staged service
// fields. When the endpoint answered "not there" it writes the 400 itself and
// reports rejected=true, so the caller stops before touching the store.
func verifyServiceTarget(w http.ResponseWriter, r *http.Request, config Config, fields operatorServiceFields) (governance.ServiceTargetVerification, bool) {
if config.ServiceTargetVerifier == nil {
return governance.ServiceTargetVerification{}, false
}
verification := config.ServiceTargetVerifier.VerifyServiceTarget(r.Context(), governance.ServiceTarget{
TestBaseURL: fields.TestWeKnoraBaseURL,
ProductionBaseURL: fields.ProductionWeKnoraBaseURL,
ProductionTenantID: fields.ProductionTenantID,
ProductionKnowledgeBaseID: fields.ProductionKnowledgeBaseID,
})
if verification.Blocked {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": verification.Message, "verification": verification})
return verification, true
}
return verification, false
}
// uiSettingsWriteResponse keeps the write response shape stable across the paths
// that do and do not run a real verification (Status is empty when no verifier
// is configured).
func uiSettingsWriteResponse(settings governance.UISettings, operation string, verification governance.ServiceTargetVerification) map[string]any {
payload := map[string]any{"data": settings, "operation": operation}
if verification.Status != "" {
payload["verification"] = verification
}
return payload
}
func createUISettingsCandidate(w http.ResponseWriter, r *http.Request, config Config) {
if r.Header.Get("X-Governance-Action") != "ui-settings" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "UI settings write intent is required"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var request createUISettingsCandidateRequest
if err := decodeJSONStrict(r, &request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings candidate"})
return
}
// The endpoints and the production IDs are operator-level config: engineers
// may tune refresh windows, only an admin may repoint the console at a
// different environment or rewrite its production IDs.
incoming := operatorServiceFields{
TestWeKnoraBaseURL: strings.TrimSpace(request.TestWeKnoraBaseURL),
ProductionWeKnoraBaseURL: strings.TrimSpace(request.ProductionWeKnoraBaseURL),
ProductionTenantID: strings.TrimSpace(request.ProductionTenantID),
ProductionKnowledgeBaseID: strings.TrimSpace(request.ProductionKnowledgeBaseID),
}
current := operatorServiceFields{}
if active, err := config.Store.GetActiveUISettings(r.Context()); err == nil {
current = operatorServiceFieldsOf(active)
}
if operatorChangeNeedsAdmin(w, r, current, incoming) {
return
}
// The four service fields are only worth storing if the endpoint really has
// them, so ask it now: a definitive "not there" refuses the save, a flaky or
// unconfigured endpoint only warns (an outage must not stop an operator from
// fixing the address).
verification, rejected := verifyServiceTarget(w, r, config, incoming)
if rejected {
return
}
settings, replayed, err := config.Store.CreateUISettingsCandidateWithOperation(r.Context(), governance.CreateUISettingsCandidateRequest{
DocumentRefreshSeconds: request.DocumentRefreshSeconds,
ResourceRefreshSeconds: request.ResourceRefreshSeconds,
StalledAfterSeconds: request.StalledAfterSeconds,
FinalizingAfterSeconds: request.FinalizingAfterSeconds,
IngestionConcurrency: request.IngestionConcurrency,
TestWeKnoraBaseURL: incoming.TestWeKnoraBaseURL,
ProductionWeKnoraBaseURL: incoming.ProductionWeKnoraBaseURL,
ProductionTenantID: incoming.ProductionTenantID,
ProductionKnowledgeBaseID: incoming.ProductionKnowledgeBaseID,
UpdatedBy: actorFor(r),
Now: config.Now(),
}, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrInvalidUISettings) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings candidate"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create UI settings candidate"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusCreated, uiSettingsWriteResponse(settings, operation, verification))
}
func activateUISettings(w http.ResponseWriter, r *http.Request, config Config) {
if r.Header.Get("X-Governance-Action") != "ui-settings" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "UI settings write intent is required"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var request struct{}
if err := decodeJSONStrict(r, &request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings activation"})
return
}
// Activation applies the revision: if it would flip the endpoints or the
// production IDs, the caller must be an admin even when they did not author it.
revision, err := config.Store.GetUISettingsRevision(r.Context(), r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings activation"})
return
}
current := operatorServiceFields{}
if active, err := config.Store.GetActiveUISettings(r.Context()); err == nil {
current = operatorServiceFieldsOf(active)
}
staged := operatorServiceFieldsOf(revision)
if operatorChangeNeedsAdmin(w, r, current, staged) {
return
}
// Re-verify right before the values go live: a candidate can sit for days and
// its knowledge base can be deleted in between. An unchanged activation (a
// replay of an applied revision) skips the check so a retry stays idempotent.
verification := governance.ServiceTargetVerification{}
if staged != current {
var rejected bool
if verification, rejected = verifyServiceTarget(w, r, config, staged); rejected {
return
}
}
settings, replayed, err := config.Store.ActivateUISettingsWithOperation(r.Context(), r.PathValue("id"), config.Now(), operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrInvalidUISettings) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid UI settings activation"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not activate UI settings"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, uiSettingsWriteResponse(settings, operation, verification))
}
type createReleaseBatchRequest struct {
Name string `json:"name"`
PublicationIDs []string `json:"publication_ids"`
DeletePublicationIDs []string `json:"delete_publication_ids"`
ReleaseChangeSetID string `json:"release_change_set_id"`
// Seal:建批后立即锁定(P0-3,清单已不可变,草稿态只是多按一次)。
Seal bool `json:"seal"`
}
// reviewBatchStore 是本路由需要的最小能力面:真实 Store 实现它,既有 fake 不必跟着改。
type reviewBatchStore interface {
ReviewPublicationsBatchWithOperation(context.Context, governance.ReviewPublicationsBatchRequest, string) (governance.ReviewPublicationsBatchResult, bool, error)
}
// batchReviewCandidates:一次对多条候选做同一个审核决定(批量批准/驳回)。
// 服务端逐条复用单条审核写入(同一套合法性校验),并逐条回报"哪几条没批成"。
func batchReviewCandidates(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input struct {
PublicationIDs []string `json:"publication_ids"`
Decision string `json:"decision"`
DecidedVisibility string `json:"decided_visibility"`
DecisionCode string `json:"decision_code"`
}
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review batch request"})
return
}
batcher, ok := config.Store.(reviewBatchStore)
if !ok {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "review batch is not wired"})
return
}
result, replayed, err := batcher.ReviewPublicationsBatchWithOperation(r.Context(), governance.ReviewPublicationsBatchRequest{
PublicationIDs: input.PublicationIDs, Decision: governance.ReviewState(input.Decision),
DecidedVisibility: governance.Visibility(input.DecidedVisibility), DecisionCode: input.DecisionCode,
ReviewerRef: actorFor(r), Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidPublicationReview):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid review batch request"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not record review batch"})
default:
operation := "decided"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
}
}
func createReleaseBatch(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input createReleaseBatchRequest
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release batch request"})
return
}
batch, replayed, err := config.Store.CreateReleaseBatchWithOperation(r.Context(), governance.CreateReleaseBatchRequest{
Name: input.Name, CreatedBy: actorFor(r), PublicationIDs: input.PublicationIDs, DeletePublicationIDs: input.DeletePublicationIDs, ReleaseChangeSetID: input.ReleaseChangeSetID, SealImmediately: input.Seal, Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseBatch):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release batch request"})
case errors.Is(err, postgresstore.ErrReleaseBatchNotEligible):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release batch contains ineligible publications"})
case errors.Is(err, postgresstore.ErrReleaseChangeSetUnconfirmed):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release change set must be confirmed and cover every requested publication"})
case errors.Is(err, postgresstore.ErrReleaseChangeSetEmpty):
// 建批时冻结依赖当前实时计划;计划为空就没得冻。
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "current release plan has no releasable item"})
case errors.Is(err, postgresstore.ErrReleaseBatchGateConflict):
// 建批即锁定时逐项复核门禁失败:整批已回滚,不会留下半成品。
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release batch contains ineligible publications"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create release batch"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusCreated, map[string]any{"data": batch, "operation": operation})
}
}
type generateReleaseChangeSetRequest struct {
Name string `json:"name"`
}
func getReleaseAuditChain(w http.ResponseWriter, r *http.Request, store Store) {
id := r.PathValue("id")
if !validCatalogID(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release execution id"})
return
}
chain, err := store.GetReleaseAuditChain(r.Context(), id)
switch {
case errors.Is(err, postgresstore.ErrReleaseAuditChainNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release audit chain not found"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release audit chain"})
default:
writeJSON(w, http.StatusOK, map[string]any{"data": chain})
}
}
func generateReleaseChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input generateReleaseChangeSetRequest
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set request"})
return
}
set, replayed, err := config.Store.GenerateReleaseChangeSetWithOperation(r.Context(), governance.GenerateReleaseChangeSetRequest{
Name: input.Name, CreatedBy: actorFor(r), Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseChangeSet):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set request"})
case errors.Is(err, postgresstore.ErrReleaseChangeSetEmpty):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "当前发布计划没有可发布项"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not generate release change set"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusCreated, map[string]any{"data": set, "operation": operation})
}
}
func listReleaseChangeSets(w http.ResponseWriter, r *http.Request, store Store) {
limit, offset, ok := parseLimitOffset(w, r, 20)
if !ok {
return
}
page, err := store.ListReleaseChangeSets(r.Context(), governance.ReleaseBatchFilter{Limit: limit, Offset: offset})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release change sets"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page})
}
func getReleaseChangeSet(w http.ResponseWriter, r *http.Request, store Store, id string) {
if id == "" {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
return
}
var detail governance.ReleaseChangeSetDetail
var err error
if id == "latest" {
detail, err = store.LatestReleaseChangeSet(r.Context())
} else {
if !validCatalogID(id) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release change set id"})
return
}
detail, err = store.GetReleaseChangeSet(r.Context(), id)
}
switch {
case errors.Is(err, postgresstore.ErrReleaseChangeSetNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release change set"})
default:
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
}
}
func confirmReleaseChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input struct{}
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid confirmation request"})
return
}
id := r.PathValue("id")
set, replayed, err := config.Store.ConfirmReleaseChangeSetWithOperation(r.Context(), governance.ConfirmReleaseChangeSetRequest{
ChangeSetID: id, ConfirmedBy: actorFor(r), Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseChangeSet):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid confirmation request"})
case errors.Is(err, postgresstore.ErrReleaseChangeSetNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release change set not found"})
case errors.Is(err, postgresstore.ErrReleaseChangeSetStateConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "只有 planned 状态的发布计划可以确认"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not confirm release change set"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": set, "operation": operation})
}
}
type sealReleaseBatchRequest struct{}
func sealReleaseBatch(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input sealReleaseBatchRequest
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid seal request"})
return
}
batch, replayed, err := config.Store.SealReleaseBatchWithOperation(r.Context(), governance.SealReleaseBatchRequest{
BatchID: r.PathValue("id"), SealedBy: actorFor(r), Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseBatch):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid seal request"})
case errors.Is(err, postgresstore.ErrReleaseBatchGateConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "release batch no longer satisfies the seal gate"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not seal release batch"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": batch, "operation": operation})
}
}
type authorizeProductionReleaseRequest struct {
Confirmation string `json:"confirmation"`
}
type publishNowRequest struct {
PublicationIDs []string `json:"publication_ids"`
DeletePublicationIDs []string `json:"delete_publication_ids"`
Name string `json:"name"`
Confirmation string `json:"confirmation"`
}
// publishNowPublications:把「建批(冻结 + 锁定)」和「授权生产发布」合成一个动作。
//
// 它不放松任何约束——只是把操作员本来要按顺序点两次按钮、再来一次确认语句的流程,
// 变成一次带确认语句的提交:请求门禁、备份恢复验证门禁、管理员身份、固定确认语句、
// 发布前快照硬门禁、清单不可变全都照旧由既有两个入口把关。
func publishNowPublications(w http.ResponseWriter, r *http.Request, config Config) {
if !config.ProductionReleaseRequestsEnabled {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
return
}
if !config.ProductionBackupRestoreVerified {
writeJSON(w, http.StatusPreconditionFailed, map[string]string{"error": "production backup restore verification is required"})
return
}
actor, releaseAuthorized := productionReleaseActor(r, config)
if !releaseAuthorized {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
return
}
var input publishNowRequest
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "publish-to-production" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
return
}
if len(input.PublicationIDs) == 0 && len(input.DeletePublicationIDs) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "select at least one publication"})
return
}
seen := make(map[string]bool, len(input.PublicationIDs)+len(input.DeletePublicationIDs))
for _, publicationID := range append(append([]string{}, input.PublicationIDs...), input.DeletePublicationIDs...) {
if !validCatalogID(publicationID) || seen[publicationID] || len(seen) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
return
}
seen[publicationID] = true
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
name := strings.TrimSpace(input.Name)
if name == "" || len(name) > 200 || strings.ContainsAny(name, "\x00\r\n") {
// 固定回退名:同一请求重放时指纹一致,幂等不会被时间戳破坏。
name = "立即发布"
}
now := config.Now().UTC()
batch, _, err := config.Store.CreateReleaseBatchWithOperation(r.Context(), governance.CreateReleaseBatchRequest{
Name: name, CreatedBy: actor, PublicationIDs: input.PublicationIDs, DeletePublicationIDs: input.DeletePublicationIDs,
SealImmediately: true, Now: now,
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
case errors.Is(err, postgresstore.ErrInvalidReleaseBatch):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release batch request"})
return
case errors.Is(err, postgresstore.ErrReleaseBatchNotEligible), errors.Is(err, postgresstore.ErrReleaseBatchGateConflict):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release batch contains ineligible publications"})
return
case errors.Is(err, postgresstore.ErrReleaseChangeSetUnconfirmed), errors.Is(err, postgresstore.ErrReleaseChangeSetEmpty):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "release change set must be confirmed and cover every requested publication"})
return
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create release batch"})
return
}
execution, _, err := config.Store.AuthorizeReleaseBatchWithOperation(r.Context(), governance.AuthorizeReleaseBatchRequest{
BatchID: batch.ID, AuthorizedBy: actor, AuthorizationCode: "production_release_authorized", Now: now,
}, operationID+":authorize")
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseAuthorization):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
case errors.Is(err, postgresstore.ErrReleaseAuthorizationGateConflict):
// 批次已锁定但不满足生产门禁(例如快照缺失):如实说明,批次留着不浪费。
writeJSON(w, http.StatusConflict, map[string]string{"error": "sealed batch no longer satisfies the production release gate"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not authorize production release"})
default:
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"batch": batch, "execution": execution}})
}
}
func authorizeProductionRelease(w http.ResponseWriter, r *http.Request, config Config) {
if !config.ProductionReleaseRequestsEnabled {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
return
}
if !config.ProductionBackupRestoreVerified {
writeJSON(w, http.StatusPreconditionFailed, map[string]string{"error": "production backup restore verification is required"})
return
}
actor, releaseAuthorized := productionReleaseActor(r, config)
if !releaseAuthorized {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
return
}
var input authorizeProductionReleaseRequest
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "publish-to-production" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
execution, replayed, err := config.Store.AuthorizeReleaseBatchWithOperation(r.Context(), governance.AuthorizeReleaseBatchRequest{
BatchID: r.PathValue("id"), AuthorizedBy: actor,
AuthorizationCode: "production_release_authorized", Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseAuthorization):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release authorization"})
case errors.Is(err, postgresstore.ErrReleaseAuthorizationGateConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "sealed batch no longer satisfies the production release gate"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not authorize production release"})
default:
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
"operation": execution.Operation, "state": execution.State,
"target_environment": execution.TargetEnvironment, "job_id": execution.JobID,
"item_count": execution.ItemCount, "created_at": execution.CreatedAt,
}, "operation": map[bool]string{true: "replayed", false: "created"}[replayed]})
}
}
type authorizeProductionRollbackRequest struct {
Confirmation string `json:"confirmation"`
}
func authorizeProductionRollback(w http.ResponseWriter, r *http.Request, config Config) {
if !config.ProductionReleaseRequestsEnabled {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
return
}
actor, releaseAuthorized := productionReleaseActor(r, config)
if !releaseAuthorized {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
return
}
var input authorizeProductionRollbackRequest
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "rollback-production-release" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production rollback authorization"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
execution, replayed, err := config.Store.AuthorizeReleaseRollbackWithOperation(r.Context(), governance.AuthorizeReleaseRollbackRequest{
PriorExecutionID: r.PathValue("id"), AuthorizedBy: actor,
AuthorizationCode: "production_rollback_authorized", Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrInvalidReleaseAuthorization):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production rollback authorization"})
case errors.Is(err, postgresstore.ErrReleaseAuthorizationGateConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "release execution is not eligible for rollback"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not authorize production rollback"})
default:
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
"operation": execution.Operation, "state": execution.State,
"target_environment": execution.TargetEnvironment, "job_id": execution.JobID,
"item_count": execution.ItemCount, "created_at": execution.CreatedAt,
}, "operation": map[bool]string{true: "replayed", false: "created"}[replayed]})
}
}
type retryProductionReleaseRequest struct {
Confirmation string `json:"confirmation"`
}
func retryProductionRelease(w http.ResponseWriter, r *http.Request, config Config) {
if !config.ProductionReleaseRequestsEnabled {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "production release requests are disabled"})
return
}
// 重排沿用原执行的授权人记录(改写它会篡改历史),因此这里只取授权结果。
if _, releaseAuthorized := productionReleaseActor(r, config); !releaseAuthorized {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "production release authorization required"})
return
}
var input retryProductionReleaseRequest
if err := decodeStrictJSON(r, &input); err != nil || input.Confirmation != "retry-production-release" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid production release retry confirmation"})
return
}
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
execution, replayed, err := config.Store.RetryReleaseExecutionWithOperation(r.Context(), governance.RetryReleaseExecutionRequest{ExecutionID: r.PathValue("id"), Now: config.Now().UTC()}, operationID)
if err != nil {
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if errors.Is(err, postgresstore.ErrReleaseExecutionRetryConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "release execution cannot be retried"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "retry release execution"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": safeReleaseExecution(execution), "operation": operation})
}
// productionAuthorizer names the human behind a production double-gate call.
// With auth enabled the session identity (an admin, enforced by the RBAC
// matrix) is authoritative; the env-declared ref is only the fallback for the
// auth-disabled development socket.
func productionAuthorizer(r *http.Request, config Config) string {
if identity, ok := sessionFrom(r.Context()); ok {
return identity.Username
}
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
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
func listReleaseExecutions(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.ReleaseExecutionFilter{Operation: r.URL.Query().Get("operation"), State: r.URL.Query().Get("state"), Limit: 20}
if filter.Operation != "" && !governance.ReleaseOperation(filter.Operation).Valid() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "operation must be publish or rollback"})
return
}
if filter.State != "" && !governance.ReleaseExecutionState(filter.State).Valid() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release execution state"})
return
}
limit, offset, ok := parseLimitOffset(w, r, 20)
if !ok {
return
}
filter.Limit, filter.Offset = limit, offset
page, err := store.ListReleaseExecutions(r.Context(), filter)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release executions"})
return
}
items := make([]map[string]any, 0, len(page.Items))
for _, execution := range page.Items {
items = append(items, safeReleaseExecution(execution))
}
writeJSON(w, http.StatusOK, map[string]any{"data": items, "total": page.Total, "limit": limit, "offset": offset})
}
func getReleaseExecution(w http.ResponseWriter, r *http.Request, store Store) {
detail, err := store.GetReleaseExecution(r.Context(), r.PathValue("id"))
if errors.Is(err, postgresstore.ErrReleaseExecutionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release execution not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release execution"})
return
}
result := safeReleaseExecution(detail.ReleaseExecution)
items := make([]map[string]any, 0, len(detail.Items))
for _, item := range detail.Items {
items = append(items, map[string]any{
"publication_id": item.PublicationID, "title": item.Title, "action": item.Action, "state": item.State,
"previous_production_revision": item.PreviousProductionRevision,
"production_revision": item.ProductionRevision, "error_code": item.ErrorCode,
"created_at": item.CreatedAt, "completed_at": item.CompletedAt,
"target_knowledge_base_id": item.TargetKnowledgeBaseID,
})
}
result["items"] = items
writeJSON(w, http.StatusOK, map[string]any{"data": result})
}
func safeReleaseExecution(execution governance.ReleaseExecution) map[string]any {
return map[string]any{
"id": execution.ID, "release_batch_id": execution.ReleaseBatchID,
"operation": execution.Operation, "prior_execution_id": execution.PriorExecutionID,
"state": execution.State, "target_environment": execution.TargetEnvironment,
"authorized_by": execution.AuthorizedBy, "job_id": execution.JobID,
"item_count": execution.ItemCount, "batch_size": execution.BatchSize,
"current_batch": execution.CurrentBatch, "total_batches": execution.TotalBatches,
"completed_item_count": execution.CompletedItemCount,
"failed_item_count": execution.FailedItemCount, "error_code": execution.ErrorCode,
"failure_level": execution.FailureLevel, "restore_trigger": execution.RestoreTrigger,
"release_change_set_id": execution.ReleaseChangeSetID,
"created_at": execution.CreatedAt, "started_at": execution.StartedAt, "completed_at": execution.CompletedAt,
}
}
func listReleaseBatches(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.ReleaseBatchFilter{State: r.URL.Query().Get("state"), Limit: 20}
if filter.State != "" && filter.State != "draft" && filter.State != "sealed" && filter.State != "cancelled" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state must be draft, sealed, or cancelled"})
return
}
limit, offset, ok := parseLimitOffset(w, r, 20)
if !ok {
return
}
filter.Limit, filter.Offset = limit, offset
page, err := store.ListReleaseBatches(r.Context(), filter)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list release batches"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page.Items, "total": page.Total, "limit": limit, "offset": offset})
}
func getReleaseBatch(w http.ResponseWriter, r *http.Request, store Store) {
detail, err := store.GetReleaseBatch(r.Context(), r.PathValue("id"))
if errors.Is(err, postgresstore.ErrReleaseBatchNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "release batch not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load release batch"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": detail})
}
func listTestPublications(w http.ResponseWriter, r *http.Request, store Store) {
filter := governance.TestPublicationFilter{ProcessingState: r.URL.Query().Get("processing_state"), TargetScope: r.URL.Query().Get("target_scope"), Limit: 50}
if raw := r.URL.Query().Get("removal"); raw != "" {
if raw != "required" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "removal must be required"})
return
}
filter.RemovalRequired = true
}
if filter.ProcessingState != "" && !validProcessingStateFilter(filter.ProcessingState) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "processing_state must be a comma-separated list of planned, pending, processing, finalizing, completed, failed, stalled, or cancelled"})
return
}
if filter.TargetScope != "" && filter.TargetScope != "internal" && filter.TargetScope != "external" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "target_scope must be internal or external"})
return
}
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
filter.Limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return
}
filter.Offset = value
}
page, err := store.ListTestPublications(r.Context(), filter)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list test publications"})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": page.Items, "total": page.Total, "limit": filter.Limit, "offset": filter.Offset,
})
}
type confirmChangeSetInput struct {
ItemSeqs []int64 `json:"item_seqs"`
Reason string `json:"reason"`
}
func confirmCatalogChangeSet(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input confirmChangeSetInput
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<16))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || len(input.ItemSeqs) == 0 || len(input.ItemSeqs) > 200 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid change set confirmation"})
return
}
internalTarget, externalTarget := config.effectiveRoutingTargets(r.Context())
result, replayed, err := config.Store.ConfirmCatalogChangeSetWithOperation(r.Context(), governance.ConfirmCatalogChangeSetRequest{
ChangeSetID: r.PathValue("id"), ItemSeqs: input.ItemSeqs,
OperatorRef: actorFor(r), Reason: input.Reason,
InternalTargetKBID: internalTarget, ExternalTargetKBID: externalTarget,
Now: config.Now().UTC(),
}, operationID)
if err != nil {
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrCatalogChangeSetNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "change set not found"})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not confirm change set"})
}
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
}
type startTestPublicationsInput struct {
PublicationIDs []string `json:"publication_ids"`
Reason string `json:"reason"`
}
func startTestPublications(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input startTestPublicationsInput
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid processing confirmation"})
return
}
reason := strings.TrimSpace(input.Reason)
if reason == "" || len(reason) > 500 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason is required"})
return
}
seen := make(map[string]bool, len(input.PublicationIDs))
for _, publicationID := range input.PublicationIDs {
if !validCatalogID(publicationID) || seen[publicationID] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
return
}
seen[publicationID] = true
}
result, replayed, err := config.Store.StartTestPublicationsWithOperation(r.Context(), governance.StartTestPublicationsRequest{
PublicationIDs: input.PublicationIDs,
OperatorRef: actorFor(r),
Reason: reason,
Now: config.Now().UTC(),
}, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not start test processing"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusAccepted, map[string]any{"data": result, "operation": operation})
}
func listAcceptance(w http.ResponseWriter, r *http.Request, store Store) {
records, err := store.ListAcceptanceResults(r.Context(), r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list acceptance results"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": records})
}
func listJobFailures(w http.ResponseWriter, r *http.Request, store Store) {
limit, offset, ok := parseLimitOffset(w, r, 50)
if !ok {
return
}
page, err := store.ListJobFailures(r.Context(), governance.JobFailureFilter{
State: r.URL.Query().Get("state"), Limit: limit, Offset: offset,
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid failure filter"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page})
}
// requeueJobFailure re-enqueues one terminal 源头取件 (artifact.fetch) dead
// letter. 上传解析 dead letters keep their publication-keyed retry path; a
// fetch failure carries no publication id, so without this route the file is
// stuck forever after a single terminal fetch failure (expired source session).
func requeueJobFailure(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
failureID := strings.TrimSpace(r.PathValue("id"))
if failureID == "" || len(failureID) > 64 || !validCatalogID(failureID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid failure id"})
return
}
var input struct {
Reason string `json:"reason"`
}
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid requeue request"})
return
}
reason := strings.TrimSpace(input.Reason)
if reason == "" || len(reason) > 500 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason is required"})
return
}
result, replayed, err := config.Store.RequeueArtifactFetchWithOperation(r.Context(), governance.RequeueArtifactFetchRequest{
FailureID: failureID,
OperatorRef: actorFor(r),
Reason: reason,
Now: config.Now().UTC(),
}, operationID)
switch {
case errors.Is(err, postgresstore.ErrJobFailureNotRequeueable):
writeJSON(w, http.StatusConflict, map[string]string{"error": "该失败记录不是可重排的源头取件任务,或已经处理过"})
return
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not requeue artifact fetch"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": result, "replayed": replayed})
}
func retryPublicationsBatch(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input startTestPublicationsInput
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid retry batch"})
return
}
reason := strings.TrimSpace(input.Reason)
if reason == "" || len(reason) > 500 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason is required"})
return
}
seen := make(map[string]bool, len(input.PublicationIDs))
for _, publicationID := range input.PublicationIDs {
if !validCatalogID(publicationID) || seen[publicationID] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
return
}
seen[publicationID] = true
}
result, replayed, err := config.Store.RetryPublicationsBatchWithOperation(r.Context(), governance.RetryPublicationsBatchRequest{
PublicationIDs: input.PublicationIDs,
OperatorRef: actorFor(r),
Reason: reason,
Now: config.Now().UTC(),
}, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not retry publications"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
}
func cancelPublicationsBatch(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
var input startTestPublicationsInput
if err := decodeStrictJSON(r, &input); err != nil || len(input.PublicationIDs) == 0 || len(input.PublicationIDs) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid cancel batch"})
return
}
reason := strings.TrimSpace(input.Reason)
if reason == "" || len(reason) > 500 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reason is required"})
return
}
seen := make(map[string]bool, len(input.PublicationIDs))
for _, publicationID := range input.PublicationIDs {
if !validCatalogID(publicationID) || seen[publicationID] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid publication_ids"})
return
}
seen[publicationID] = true
}
result, replayed, err := config.Store.CancelPublicationsBatchWithOperation(r.Context(), governance.CancelPublicationsBatchRequest{
PublicationIDs: input.PublicationIDs,
OperatorRef: actorFor(r),
Reason: reason,
Now: config.Now().UTC(),
}, operationID)
if errors.Is(err, postgresstore.ErrOperationRequestConflict) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not cancel publications"})
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": result, "operation": operation})
}
func retryPublication(w http.ResponseWriter, r *http.Request, config Config) {
operationID, ok := requireOperationID(w, r)
if !ok {
return
}
publicationID := r.PathValue("id")
replayed, err := config.Store.RetryPublicationWithOperation(r.Context(), publicationID, config.Now().UTC(), operationID)
switch {
case errors.Is(err, postgresstore.ErrOperationRequestConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "Idempotency-Key conflicts with a different request"})
case errors.Is(err, postgresstore.ErrPublicationNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": "publication not found"})
case errors.Is(err, postgresstore.ErrPublicationStateConflict):
writeJSON(w, http.StatusConflict, map[string]string{"error": "publication is not in a retryable state"})
case errors.Is(err, postgresstore.ErrPublicationNotRetryable):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "publication has no verified spool artifact"})
case err != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not retry publication"})
default:
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]string{"id": publicationID, "status": "retry_queued"}, "operation": operation})
}
}
func parseLimitOffset(w http.ResponseWriter, r *http.Request, defaultLimit int) (int, int, bool) {
limit, offset := defaultLimit, 0
if raw := r.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return 0, 0, false
}
limit = value
}
if raw := r.URL.Query().Get("offset"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000000 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "offset must be between 0 and 1000000"})
return 0, 0, false
}
offset = value
}
return limit, offset, true
}
func decodeStrictJSON(r *http.Request, destination any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return errors.New("trailing JSON value")
}
return nil
}
func validProcessingState(state string) bool {
switch state {
case "planned", "pending", "processing", "finalizing", "completed", "failed", "stalled", "cancelled":
return true
default:
return false
}
}
// validProcessingStateFilter accepts a single state or a comma-separated list so
// callers can ask for several lanes at once (e.g. the attention lane needs
// failed+stalled in one query instead of paging the whole queue).
func validProcessingStateFilter(filter string) bool {
for _, state := range strings.Split(filter, ",") {
if !validProcessingState(state) {
return false
}
}
return true
}
func newID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("read random ID: %w", err)
}
raw[6] = (raw[6] & 0x0f) | 0x40
raw[8] = (raw[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(raw[:])
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
}
func decodeJSONStrict(r *http.Request, target any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values are not allowed")
}
return err
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
// SourceInstance 是死信解除记录绑定的实例名:来源实例与同步目标常常不是同一个,
// 用目标名去绑来源死信会让真实观测永远对不上。
func (config Config) SourceInstance() string {
if config.LibrarySourceInstance != "" {
return config.LibrarySourceInstance
}
return config.LibraryTargetInstance
}