- config.Explain(envFiles, cliOverrides): per-key source layer following the application precedence cli > process-env > env-file > default; registry is reporting-only (values still applied solely by Load); secrets never shown - ParseEnvFileKeys shares applyEnvFile's parsing rules - cmd/server 'config explain' resolves the same env-file stack as Load and renders the attribution table; smoke: HTTP_PORT/AGENT_BACKEND -> cli - tests: registry liveness sample + layer attribution (file/process/cli/ default) + secret masking - full per-field snapshot with applied/desired revisions remains U10b core
273 lines
9.6 KiB
Go
273 lines
9.6 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"flag"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"chat-agent-system/internal/app"
|
||
"chat-agent-system/internal/app/eventsconsumer"
|
||
"chat-agent-system/internal/config"
|
||
"chat-agent-system/internal/localpg"
|
||
"chat-agent-system/internal/server"
|
||
"chat-agent-system/internal/workflow"
|
||
)
|
||
|
||
func main() {
|
||
// KTD16:同一程序提供运维子命令;默认(无子命令)保持 serve 行为不变。
|
||
if len(os.Args) > 1 {
|
||
switch os.Args[1] {
|
||
case "config":
|
||
if len(os.Args) > 2 {
|
||
switch os.Args[2] {
|
||
case "check":
|
||
configCheck(os.Args[3:])
|
||
return
|
||
case "explain":
|
||
configExplain(os.Args[3:])
|
||
return
|
||
}
|
||
}
|
||
fmt.Fprintln(os.Stderr, "用法: server config check|explain [--env path] [--port N] [--host H] [--backend name]")
|
||
os.Exit(2)
|
||
case "-h", "--help", "help":
|
||
fmt.Println("用法: server [serve flags] 启动服务(默认)\n server config check [flags] 加载并校验配置(脱敏摘要,无副作用)")
|
||
return
|
||
}
|
||
}
|
||
|
||
envPath := flag.String("env", ".env", "环境配置文件路径")
|
||
port := flag.Int("port", 0, "HTTP 服务端口 (默认读取配置文件或 8080)")
|
||
host := flag.String("host", "", "HTTP 监听主机 (默认读取配置文件或 127.0.0.1)")
|
||
backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)")
|
||
flag.Parse()
|
||
|
||
cfg, err := config.Load(*envPath)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
if *port > 0 {
|
||
cfg.HTTPPort = *port
|
||
}
|
||
if *host != "" {
|
||
cfg.HTTPHost = *host
|
||
}
|
||
if *backend != "" {
|
||
cfg.AgentBackend = *backend
|
||
}
|
||
|
||
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
defer stop()
|
||
|
||
pg, err := localpg.Ensure(rootCtx, cfg)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "localpg: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if pg != nil {
|
||
defer func() { _ = pg.Stop() }()
|
||
fmt.Printf("localpg managed PGDATA=%s port=%d\n", pg.PGData, pg.Port)
|
||
}
|
||
|
||
rt, err := app.New(cfg)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "业务服务初始化失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
// Inject executor/quality-gate key validators without giving workflowdef a production import of workflow.
|
||
if rt.WFDef != nil {
|
||
rt.WFDef.SetRegistryValidators(workflow.ValidateExecutorKey, workflow.ValidateQualityGateKey)
|
||
}
|
||
if err := rt.Start(rootCtx); err != nil {
|
||
fmt.Fprintf(os.Stderr, "后台 worker 启动失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
var eventsH *eventsconsumer.Handle
|
||
if eventsconsumer.Enabled() && rt.Pool != nil {
|
||
eventsH, err = eventsconsumer.Start(rootCtx, rt.Pool, cfg)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "eventsconsumer: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("events in-process owner=%s mode=cluster-table\n", eventsH.Owner)
|
||
}
|
||
|
||
srv := server.NewFromRuntime(rt)
|
||
if eventsH != nil {
|
||
srv.SetEventsAlive(func() bool { return eventsH.Alive(30 * time.Second) })
|
||
}
|
||
|
||
addr := fmt.Sprintf("%s:%d", cfg.HTTPHost, cfg.HTTPPort)
|
||
fmt.Println("==================================================================")
|
||
fmt.Println(" 🤖 医疗器械商务与研发智能参谋系统 - HTTP / SSE API 守护服务 ")
|
||
fmt.Println("==================================================================")
|
||
fmt.Printf("服务环境: %s | 智能体后端: %s\n", cfg.Env, cfg.AgentBackend)
|
||
fmt.Printf("监听地址: http://%s\n", addr)
|
||
fmt.Println("核心服务路由:")
|
||
fmt.Println(" • [GET] /api/v1/health - 服务健康检查与基础设施状态")
|
||
fmt.Println(" • [GET] /api/v1/projects - 查询项目当前上下文与黑板事实")
|
||
fmt.Println(" • [POST] /api/v1/chat - 标准阻塞式对话协同接口")
|
||
fmt.Println(" • [POST] /api/v1/chat/stream - 核心 SSE 流式长连接对话协议 (分层按需)")
|
||
fmt.Println(" • [GET] /api/v1/chat/stream - SSE 流式长连接对话调试接口 (EventSource)")
|
||
fmt.Println(" • [POST] /api/v1/tasks/run/stream - 独立任务与阶段执行 SSE 监控流 (精简进度与拦截)")
|
||
fmt.Println(" • [GET] /api/v1/context/user - 获取单用户私有上下文与习惯偏好画像")
|
||
fmt.Println(" • [PUT] /api/v1/context/user/preference - 整理或更新用户个性化产出风格")
|
||
fmt.Println(" • [GET] /api/v1/context/project - 获取项目多方协同黑板事实与里程碑决策流")
|
||
fmt.Println(" • [GET] /api/v1/chat/sessions - 分页查询用户/项目历史会话列表")
|
||
fmt.Println(" • [GET] /api/v1/chat/messages - 分页拉取指定会话多轮历史消息记录")
|
||
fmt.Println(" • [GET] /api/v1/context/cases/recommend - 往期受控标杆案例与历史报价语义自推荐")
|
||
fmt.Println(" • [GET] /api/v1/docgov/document - 查询受控文档治理状态看板")
|
||
fmt.Println(" • [POST] /api/v1/docgov/claim - 查阅并认领受控文档 (正式稿方可认领)")
|
||
fmt.Println(" • [POST] /api/v1/docgov/proposal - 对已认领文档发起变更提案")
|
||
fmt.Println(" • [POST] /api/v1/docgov/proposal/decide - 编制负责人拍板同意/拒绝变更提案")
|
||
fmt.Println(" • [POST] /api/v1/docgov/submit-review - 编制完成提交审核 (文档正文锁定)")
|
||
fmt.Println(" • [POST] /api/v1/docgov/review-decision - 审核通过或驳回 (驳回必填原因且解锁)")
|
||
fmt.Println(" • [POST] /api/v1/docgov/approver-decision - 最终批准或驳回")
|
||
fmt.Println(" • [GET/POST] /api/v1/docgov/comments - 批注与修改建议管理")
|
||
fmt.Println(" • [GET] /api/v1/docgov/audit-logs - 全生命周期审计轨迹流")
|
||
fmt.Println("==================================================================")
|
||
|
||
go func() {
|
||
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
|
||
fmt.Fprintf(os.Stderr, "服务启动失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
}()
|
||
|
||
<-rootCtx.Done()
|
||
fmt.Println("\n正在优雅停止服务...")
|
||
if eventsH != nil {
|
||
eventsH.Stop()
|
||
}
|
||
// T20-B7:drain 窗口与 AGENT_TIMEOUT 量级对齐(默认 min(AgentTimeout, 35m)+30s 缓冲),
|
||
// 消除「5s Shutdown vs 30m 长任务」错配;可由 SHUTDOWN_DRAIN_TIMEOUT 显式覆盖。
|
||
drain := shutdownDrainTimeout(cfg)
|
||
ctx, cancel := context.WithTimeout(context.Background(), drain)
|
||
defer cancel()
|
||
fmt.Printf("等待 in-flight 请求/长任务排空(drain 上限 %s)...\n", drain)
|
||
if err := srv.Shutdown(ctx); err != nil {
|
||
fmt.Fprintf(os.Stderr, "服务停止异常: %v\n", err)
|
||
} else {
|
||
fmt.Println("服务已安全退出。")
|
||
}
|
||
}
|
||
|
||
// shutdownDrainTimeout 解析 T20-B7 drain 窗口:
|
||
// - SHUTDOWN_DRAIN_TIMEOUT 显式设置则优先(校验 >0);
|
||
// - 否则默认 min(AGENT_TIMEOUT, 35m) + 30s 缓冲(compose stop_grace_period 需 ≥ 此值)。
|
||
func shutdownDrainTimeout(cfg *config.Config) time.Duration {
|
||
if v := os.Getenv("SHUTDOWN_DRAIN_TIMEOUT"); v != "" {
|
||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||
return d
|
||
}
|
||
}
|
||
at := 30 * time.Minute
|
||
if cfg != nil && cfg.AgentTimeout > 0 {
|
||
at = cfg.AgentTimeout
|
||
}
|
||
cap := 35 * time.Minute
|
||
if at > cap {
|
||
at = cap
|
||
}
|
||
return at + 30*time.Second
|
||
}
|
||
|
||
// configCheck 加载并校验配置:输出脱敏摘要与问题清单。
|
||
// 不创建 DB、不启动服务、不写业务目录(KTD17 config check 约束)。
|
||
func configCheck(args []string) {
|
||
fs := flag.NewFlagSet("config check", flag.ExitOnError)
|
||
envPath := fs.String("env", ".env", "环境配置文件路径")
|
||
port := fs.Int("port", 0, "HTTP 端口覆盖")
|
||
host := fs.String("host", "", "HTTP 主机覆盖")
|
||
backend := fs.String("backend", "", "智能体后端覆盖")
|
||
_ = fs.Parse(args)
|
||
|
||
cfg, err := config.Load(*envPath)
|
||
if err != nil {
|
||
fmt.Fprintf(os.Stderr, "config check: 加载失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
if *port > 0 {
|
||
cfg.HTTPPort = *port
|
||
}
|
||
if *host != "" {
|
||
cfg.HTTPHost = *host
|
||
}
|
||
if *backend != "" {
|
||
cfg.AgentBackend = *backend
|
||
}
|
||
|
||
fmt.Println(cfg.MaskedSummary())
|
||
if issues := cfg.Check(); len(issues) > 0 {
|
||
fmt.Fprintln(os.Stderr, "config check: 未通过")
|
||
for _, it := range issues {
|
||
fmt.Fprintf(os.Stderr, " - %s\n", it)
|
||
}
|
||
os.Exit(1)
|
||
}
|
||
fmt.Println("config check: 通过")
|
||
}
|
||
|
||
// configExplain 解释每个配置键的最终来源层(KTD17 第一片):
|
||
// cli > process-env > env-file > default;秘密值只显示 set 状态。
|
||
func configExplain(args []string) {
|
||
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
|
||
envPath := fs.String("env", "", "环境配置文件路径(默认根 .env + configs/.env.{ENV} 叠加)")
|
||
port := fs.Int("port", 0, "HTTP 端口覆盖")
|
||
host := fs.String("host", "", "HTTP 主机覆盖")
|
||
backend := fs.String("backend", "", "智能体后端覆盖")
|
||
_ = fs.Parse(args)
|
||
|
||
var envFiles []string
|
||
if *envPath != "" {
|
||
envFiles = append(envFiles, *envPath)
|
||
} else {
|
||
envFiles = append(envFiles, ".env")
|
||
envName := "dev"
|
||
if keys, err := config.ParseEnvFileKeys(".env"); err == nil {
|
||
for _, k := range keys {
|
||
if k == "ENV" {
|
||
// 从文件行取值
|
||
if b, rerr := os.ReadFile(".env"); rerr == nil {
|
||
for _, line := range strings.Split(string(b), "\n") {
|
||
if strings.HasPrefix(strings.TrimSpace(line), "ENV=") {
|
||
envName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "ENV="))
|
||
}
|
||
}
|
||
}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if v := os.Getenv("ENV"); v != "" {
|
||
envName = v
|
||
}
|
||
envFiles = append(envFiles, filepath.Join("configs", ".env."+envName))
|
||
}
|
||
|
||
cli := map[string]string{}
|
||
if *port > 0 {
|
||
cli["HTTP_PORT"] = strconv.Itoa(*port)
|
||
}
|
||
if *host != "" {
|
||
cli["HTTP_HOST"] = *host
|
||
}
|
||
if *backend != "" {
|
||
cli["AGENT_BACKEND"] = *backend
|
||
}
|
||
|
||
fmt.Println("env files:", strings.Join(envFiles, ", "))
|
||
fmt.Print(config.FormatExplanation(config.Explain(envFiles, cli)))
|
||
}
|