acf053faf3
背景:发布管理把"逐条核验"的仪式强加给每一份文档。实测一份文档要 9~11 次交互,
且有两处顺序陷阱(冻结后批准 → 建批 422;生成即固化 → 批准被永久钉住)。
P0-1 建批时冻结:
- CreateReleaseBatch 在未指定版本时,自己把当前实时计划冻结成一份**已确认**版本并绑定
(同一事务;同摘要复用最近版本,planned 或 confirmed 都可复用,因此同一份计划不堆版本)。
- 语义不变:批一旦建立,清单/摘要/验收轮次/目标库绑定全部不可变、回滚按它走;变的只是
"谁在什么时候按固化键"——从操作员的手动两步变成建批这一下。
- 效果:去掉「生成新版本」「确认冻结」两步;消灭"批准晚于冻结 → 422"这类顺序陷阱;
前端不再传版本 id,也不再有"所选知识不在冻结清单里"的阻断。
- 计划→清单项的映射抽成 ChangeSetItemsFromPlan 供两处共用,否则两处算出的摘要不同,
"同一计划复用同一版本"永远不命中。
P0-2 验收通过自动批准:
- 验收三项结论达发布门槛(knowledge_exists/chunks_present=passed,retrieval_hit 与门槛同
口径)时,由 worker 写入 reviewer_ref='system:auto' 的批准记录,替代"每份文档点一次批准"。
- 策略 TEST_AUTO_REVIEW_POLICY = off | internal(默认,仅对内)| all;值不认识拒绝启动。
只影响审核这一步:生产授权、双闸与发布前快照门禁不受影响。界面上只读展示当前策略。
- 安全边界(写死在审核 SQL 里):系统只允许写"第一条"审核——人工一旦表过态,机器不得用一条
更晚的 approved 把人工结论翻回去;人工随时可再审。集成测试去掉该约束即 FAIL(已实测)。
- 自动批准自身的任何失败都不影响验收结论,只上报。
顺带(P1-1 前半,已被 P0-1 取代的部分已删除):
- 写操作失败把服务端 {"error": ...} 的原因带出来,并对已知原因给出下一步(不再只给 HTTP 状态码)。
- 当前的"不在已冻结清单里"标注与就地修复按钮随 P0-1 一起移除(该失败模式已不存在)。
测试:bind_time_freeze_integration_test.go(绑定已确认版本/同计划复用/冻结后不可变/
动作不符仍 NotEligible)、auto_review_integration_test.go(系统只写第一条、人工可再审)、
validate_test.go(对内自动/对外不自动/策略关闭/验收未过不批/自身失败不影响验收)、
auto_review_test.go、config_test 及前端能力解析测试。
实测耗时:本机 Go 全量 26 包通过、前端 178 条通过。
165 lines
6.4 KiB
Go
165 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/backuphost"
|
|
"knowledge-governance/internal/httpapi"
|
|
"knowledge-governance/internal/monitoring"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/platform/weknora"
|
|
"knowledge-governance/internal/serviceverify"
|
|
)
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
if err := run(ctx, os.Getenv); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func run(ctx context.Context, getenv func(string) string) error {
|
|
config, err := appconfig.Load(getenv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pool, err := pgxpool.New(ctx, config.DatabaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
|
|
return err
|
|
}
|
|
authStore := postgresstore.NewStore(pool)
|
|
if err := bootstrapInitialAdmin(ctx, authStore, time.Now); err != nil {
|
|
return err
|
|
}
|
|
releaseAuthorizationToken := ""
|
|
if config.ProductionReleaseRequestsEnabled {
|
|
releaseAuthorizationToken, err = weknora.ReadAPIKeyFile(config.ProductionReleaseAuthTokenFile)
|
|
if err != nil {
|
|
return errors.New("read production release authorization token")
|
|
}
|
|
}
|
|
// P10: with a mounted API key the probe verifies a real knowledge-base read
|
|
// (readiness); without one it degrades to the liveness-only HTTP check.
|
|
var weknoraReadiness func(context.Context) error
|
|
var knowledgeBaseLister httpapi.KnowledgeBaseLister
|
|
if strings.TrimSpace(config.TestWeKnoraBaseURL) != "" {
|
|
if apiKey, keyErr := weknora.ReadAPIKeyFile(config.TestWeKnoraAPIKeyFile); keyErr == nil {
|
|
probeTimeout := config.TestWeKnoraHTTPTimeout
|
|
if probeTimeout > 10*time.Second || probeTimeout <= 0 {
|
|
probeTimeout = 10 * time.Second
|
|
}
|
|
if probeClient, clientErr := weknora.NewClient(weknora.ClientConfig{BaseURL: config.TestWeKnoraBaseURL, APIKey: apiKey, Timeout: probeTimeout, AllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP}); clientErr == nil {
|
|
knowledgeBaseLister = probeClient
|
|
routingKB := config.InternalRoutingKnowledgeBaseID
|
|
weknoraReadiness = func(probeCtx context.Context) error {
|
|
_, _, err := probeClient.ListKnowledge(probeCtx, routingKB, 1, 1)
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Production WeKnora is probed for liveness only: the console must show the
|
|
// real state of that endpoint, while credentials and the publish target stay
|
|
// in the release runtime config (RELEASE_CONFIG_FILE).
|
|
// 生产 WeKnora 端点:配置入口在网页「系统设置 → 服务配置」,管理员保存的值是
|
|
// 这里的真源;容器 env 只作为未在网页配置时的兜底。凭据与发布目标仍留在
|
|
// release 运行时配置(RELEASE_CONFIG_FILE)里,不经网页也不落明文。
|
|
actionStore := postgresstore.NewStore(pool)
|
|
productionEndpointResolver := func(resolveCtx context.Context) string {
|
|
settings, err := actionStore.GetActiveUISettings(resolveCtx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return settings.ProductionWeKnoraBaseURL
|
|
}
|
|
testEndpointResolver := func(resolveCtx context.Context) string {
|
|
settings, err := actionStore.GetActiveUISettings(resolveCtx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return settings.TestWeKnoraBaseURL
|
|
}
|
|
monitorProvider, err := monitoring.NewRealProvider(monitoring.RealProviderConfig{DB: pool, WeKnoraBaseURL: config.TestWeKnoraBaseURL, TestWeKnoraResolver: testEndpointResolver, ProductionWeKnoraBaseURL: config.ProductionWeKnoraBaseURL, ProductionWeKnoraResolver: productionEndpointResolver, MinerUBaseURL: config.MinerUBaseURL, WeKnoraReadiness: weknoraReadiness, WorkerStaleAfter: config.WorkerLivenessStaleAfter})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
serviceTargetVerifier := serviceverify.New(serviceverify.Config{
|
|
TestAPIKeyFile: config.TestWeKnoraAPIKeyFile,
|
|
ProductionAPIKeyFile: config.ProductionWeKnoraAPIKeyFile,
|
|
TestBaseURLFallback: config.TestWeKnoraBaseURL,
|
|
ProductionBaseURLFallback: config.ProductionWeKnoraBaseURL,
|
|
TestAllowDockerHTTP: config.TestWeKnoraAllowDockerHTTP,
|
|
ProductionAllowDockerHTTP: config.ProductionWeKnoraAllowDockerHTTP,
|
|
})
|
|
handler, err := httpapi.NewHandler(httpapi.Config{
|
|
Store: postgresstore.NewStore(pool),
|
|
Monitor: monitorProvider,
|
|
InternalRoutingKnowledgeBaseID: config.InternalRoutingKnowledgeBaseID,
|
|
ExternalRoutingKnowledgeBaseID: config.ExternalRoutingKnowledgeBaseID,
|
|
RoutingBindings: postgresstore.NewStore(pool),
|
|
KnowledgeBases: knowledgeBaseLister,
|
|
ProductionBindings: postgresstore.NewStore(pool),
|
|
ProductionKnowledgeBases: serviceTargetVerifier,
|
|
ProductionWeKnoraBaseURL: config.ProductionWeKnoraBaseURL,
|
|
ServiceTargetVerifier: serviceTargetVerifier,
|
|
ProductionReleaseRequestsEnabled: config.ProductionReleaseRequestsEnabled,
|
|
TestAutoReviewPolicy: config.TestAutoReviewPolicy,
|
|
ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified,
|
|
ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef,
|
|
ProductionReleaseAuthorizationToken: releaseAuthorizationToken,
|
|
BackupSchedule: postgresstore.NewStore(pool),
|
|
BackupHost: backuphost.New(backuphost.Config{
|
|
SSHConfig: config.BackupHostSSHConfig,
|
|
Target: config.BackupHostTarget,
|
|
ScriptDir: config.BackupHostScriptDir,
|
|
Root: config.BackupHostRoot,
|
|
}),
|
|
Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server := &http.Server{
|
|
Addr: config.AppAddr,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- server.ListenAndServe() }()
|
|
log.Printf("governance API listening on %s", config.AppAddr)
|
|
select {
|
|
case err := <-errCh:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
case <-ctx.Done():
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
return server.Shutdown(shutdownCtx)
|
|
}
|
|
}
|