378 lines
12 KiB
Go
378 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/auth"
|
|
"chat-agent-system/internal/agent"
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/chat"
|
|
"chat-agent-system/internal/config"
|
|
"chat-agent-system/internal/coordinator"
|
|
"chat-agent-system/internal/ctxmgr"
|
|
"chat-agent-system/internal/intent"
|
|
)
|
|
|
|
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务
|
|
type Server struct {
|
|
cfg *config.Config
|
|
svc *bizdev.Service
|
|
rec intent.Recognizer
|
|
engine *coordinator.CoordinatorEngine
|
|
authSvc *auth.Service
|
|
sessions map[string]*chat.Session
|
|
sessionsMu sync.RWMutex
|
|
httpServer *http.Server
|
|
}
|
|
|
|
// NewServer 构造函数
|
|
func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
|
if cfg == nil {
|
|
cfg, _ = config.Load("")
|
|
}
|
|
if svc == nil {
|
|
svc = bizdev.NewService(cfg)
|
|
}
|
|
|
|
rec := intent.NewRecognizer()
|
|
dialogueBackend := agent.NewBackend(cfg.AgentBackend, cfg)
|
|
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(svc, dialogueBackend, cfg.DataDir)
|
|
authStore := auth.NewMemoryStore(cfg)
|
|
authSvc := auth.NewService(authStore, cfg)
|
|
|
|
s := &Server{
|
|
cfg: cfg,
|
|
svc: svc,
|
|
rec: rec,
|
|
engine: coordEngine,
|
|
authSvc: authSvc,
|
|
sessions: make(map[string]*chat.Session),
|
|
}
|
|
return s
|
|
}
|
|
|
|
// AuthService 获取认证服务
|
|
func (s *Server) AuthService() *auth.Service {
|
|
return s.authSvc
|
|
}
|
|
|
|
// Routes 构建 HTTP 路由分发器
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// 1. 健康检查与系统自检
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
|
|
|
// 2. 项目状态与会话查询
|
|
mux.HandleFunc("GET /api/v1/projects", s.handleGetProject)
|
|
|
|
// 3. 对话协同服务 (标准 JSON 阻塞)
|
|
mux.HandleFunc("POST /api/v1/chat", s.handleChat)
|
|
|
|
// 4. 对话协同服务 (核心 SSE 流式长连接)
|
|
mux.HandleFunc("POST /api/v1/chat/stream", s.handleChatStream)
|
|
mux.HandleFunc("GET /api/v1/chat/stream", s.handleChatStream)
|
|
|
|
// 5. 独立任务 / 阶段执行
|
|
mux.HandleFunc("POST /api/v1/tasks/run", s.handleRunTask)
|
|
mux.HandleFunc("POST /api/v1/tasks/run/stream", s.handleRunTaskStream)
|
|
mux.HandleFunc("GET /api/v1/tasks/run/stream", s.handleRunTaskStream)
|
|
|
|
// 6. 多级上下文与偏好记忆管理
|
|
mux.HandleFunc("GET /api/v1/context/user", s.handleGetUserContext)
|
|
mux.HandleFunc("PUT /api/v1/context/user/preference", s.handleUpdateUserPreference)
|
|
mux.HandleFunc("GET /api/v1/context/project", s.handleGetProjectContext)
|
|
|
|
// 7. 历史多轮会话管理与标杆案例语义检索
|
|
mux.HandleFunc("GET /api/v1/chat/sessions", s.handleListChatSessions)
|
|
mux.HandleFunc("GET /api/v1/chat/messages", s.handleGetChatMessages)
|
|
mux.HandleFunc("GET /api/v1/chat/messages/window", s.handleGetChatWindow)
|
|
mux.HandleFunc("GET /api/v1/chat/messages/day", s.handleGetChatDayMessages)
|
|
mux.HandleFunc("GET /api/v1/chat/dates", s.handleListChatDates)
|
|
mux.HandleFunc("GET /api/v1/chat/categories", s.handleListChatCategories)
|
|
mux.HandleFunc("GET /api/v1/chat/search", s.handleChatSearch)
|
|
mux.HandleFunc("GET /api/v1/chat/digests", s.handleListDailyDigests)
|
|
mux.HandleFunc("POST /api/v1/chat/digests/generate", s.handleGenerateDailyDigest)
|
|
mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages)
|
|
mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases)
|
|
|
|
// 8. 受控文档编制、审核、批准生命周期与认领治理
|
|
mux.HandleFunc("GET /api/v1/docgov/document", s.handleDocGovGetDocument)
|
|
mux.HandleFunc("POST /api/v1/docgov/claim", s.handleDocGovClaim)
|
|
mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview)
|
|
mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision)
|
|
mux.HandleFunc("POST /api/v1/docgov/approver-decision", s.handleDocGovApproverDecision)
|
|
mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal)
|
|
mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments)
|
|
mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment)
|
|
mux.HandleFunc("GET /api/v1/docgov/audit-logs", s.handleDocGovListAuditLogs)
|
|
mux.HandleFunc("POST /api/v1/docgov/content", s.handleDocGovSaveContent)
|
|
mux.HandleFunc("POST /api/v1/docgov/lease/acquire", s.handleDocGovAcquireLease)
|
|
mux.HandleFunc("POST /api/v1/docgov/lease/heartbeat", s.handleDocGovHeartbeatLease)
|
|
mux.HandleFunc("POST /api/v1/docgov/lease/release", s.handleDocGovReleaseLease)
|
|
|
|
// 9. 用户认证、安全改密与超级管理员后台接口
|
|
mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin)
|
|
mux.HandleFunc("POST /api/v1/auth/register", s.handleAuthRegister)
|
|
mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
|
|
mux.HandleFunc("POST /api/v1/auth/change-password", s.handleAuthChangePassword)
|
|
mux.HandleFunc("GET /api/v1/auth/me", s.handleAuthMe)
|
|
mux.HandleFunc("GET /api/v1/admin/users", s.handleAdminUsers)
|
|
mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers)
|
|
mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole)
|
|
mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus)
|
|
mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig)
|
|
mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig)
|
|
|
|
// 10. 现代 SPA 前端页面与静态资产托管
|
|
mux.HandleFunc("GET /", s.serveSPA)
|
|
|
|
return s.corsMiddleware(mux)
|
|
}
|
|
|
|
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退
|
|
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
candidates := []string{
|
|
"web/dist",
|
|
"../web/dist",
|
|
"../../web/dist",
|
|
filepath.Join(s.cfg.DataDir, "web/dist"),
|
|
filepath.Join(s.cfg.DataDir, "../web/dist"),
|
|
}
|
|
|
|
var distDir string
|
|
for _, dir := range candidates {
|
|
if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() {
|
|
distDir = dir
|
|
break
|
|
}
|
|
}
|
|
|
|
if distDir == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
reqPath := filepath.Clean(r.URL.Path)
|
|
if reqPath == "/" {
|
|
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
|
|
return
|
|
}
|
|
|
|
targetFile := filepath.Join(distDir, reqPath)
|
|
if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() {
|
|
http.ServeFile(w, r, targetFile)
|
|
return
|
|
}
|
|
|
|
// SPA Fallback 默认回退 index.html
|
|
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
|
|
}
|
|
|
|
// Start 启动 HTTP 守护进程
|
|
func (s *Server) Start() error {
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort)
|
|
s.httpServer = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.Routes(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
// Shutdown 优雅停机
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
if s.httpServer != nil {
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanStaleSessions 清理超过空闲 TTL 的内存会话 (释放内存,后续请求可通过 RestoreSessionContext 自动从 DB 恢复)
|
|
func (s *Server) CleanStaleSessions(ttl time.Duration) int {
|
|
s.sessionsMu.Lock()
|
|
defer s.sessionsMu.Unlock()
|
|
return s.cleanStaleSessionsLocked(ttl)
|
|
}
|
|
|
|
func (s *Server) cleanStaleSessionsLocked(ttl time.Duration) int {
|
|
if ttl <= 0 {
|
|
ttl = 120 * time.Minute
|
|
}
|
|
now := time.Now()
|
|
cleaned := 0
|
|
for id, sess := range s.sessions {
|
|
if sess == nil || now.Sub(sess.LastAccessedAt) > ttl {
|
|
delete(s.sessions, id)
|
|
cleaned++
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// getOrCreateSession 获取或动态构建会话
|
|
func (s *Server) getOrCreateSession(
|
|
sessionID string,
|
|
projectName string,
|
|
customerName string,
|
|
deviceType string,
|
|
intendedUse string,
|
|
targetMarket string,
|
|
workDir string,
|
|
userName string,
|
|
userRole string,
|
|
) *chat.Session {
|
|
if sessionID == "" {
|
|
sessionID = fmt.Sprintf("sess_%d", time.Now().UnixNano())
|
|
}
|
|
|
|
s.sessionsMu.RLock()
|
|
existing, exists := s.sessions[sessionID]
|
|
s.sessionsMu.RUnlock()
|
|
if exists && existing != nil {
|
|
existing.LastAccessedAt = time.Now()
|
|
return existing
|
|
}
|
|
|
|
s.sessionsMu.Lock()
|
|
defer s.sessionsMu.Unlock()
|
|
// 双重检查
|
|
if existing, exists := s.sessions[sessionID]; exists && existing != nil {
|
|
existing.LastAccessedAt = time.Now()
|
|
return existing
|
|
}
|
|
|
|
// 惰性内存防泄漏保护 (会话数超过 200 时清理空闲超 120 分钟会话)
|
|
if len(s.sessions) > 200 {
|
|
s.cleanStaleSessionsLocked(120 * time.Minute)
|
|
}
|
|
|
|
// 尝试从持久层恢复会话上下文 (服务重启后跨进程保持 G3)
|
|
var restored *ctxmgr.RestoredSessionContext
|
|
if s.svc != nil && s.svc.ContextManager() != nil {
|
|
if r, err := s.svc.ContextManager().RestoreSessionContext(context.Background(), sessionID); err == nil && r != nil && len(r.Messages) > 0 {
|
|
restored = r
|
|
}
|
|
}
|
|
|
|
if userName == "" {
|
|
userName = "用户"
|
|
}
|
|
if userRole == "" {
|
|
userRole = "engineer"
|
|
}
|
|
|
|
u := coordinator.UserInfo{
|
|
ID: "u_" + sessionID,
|
|
Name: userName,
|
|
Role: userRole,
|
|
ProjectRole: "composer",
|
|
}
|
|
|
|
var userCtx *coordinator.UserProjectContext
|
|
if workDir != "" {
|
|
assembler := coordinator.NewContextAssembler()
|
|
userCtx, _ = assembler.AssembleFromWorkspace(workDir, u)
|
|
}
|
|
|
|
if userCtx == nil {
|
|
if projectName == "" {
|
|
projectName = "医疗器械研发项目"
|
|
}
|
|
if customerName == "" {
|
|
customerName = "水木东方客户"
|
|
}
|
|
userCtx = &coordinator.UserProjectContext{
|
|
SnapshotID: fmt.Sprintf("ctx_%d", time.Now().UnixNano()),
|
|
AssembledAt: time.Now(),
|
|
User: u,
|
|
WorkDir: workDir,
|
|
Project: coordinator.ProjectInfo{
|
|
Name: projectName,
|
|
CustomerName: customerName,
|
|
DeviceType: deviceType,
|
|
IntendedUse: intendedUse,
|
|
TargetMarket: targetMarket,
|
|
CurrentWorkflow: "intake",
|
|
CurrentStage: bizdev.StageV01_Triage,
|
|
},
|
|
BlackboardFacts: make(map[string]string),
|
|
AvailableActions: coordinator.ComputeAvailableActions(bizdev.StageV01_Triage, u.ProjectRole),
|
|
}
|
|
if targetMarket != "" {
|
|
userCtx.BlackboardFacts["target_market"] = targetMarket
|
|
}
|
|
if deviceType != "" {
|
|
userCtx.BlackboardFacts["device_type"] = deviceType
|
|
}
|
|
}
|
|
|
|
sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine)
|
|
if s.svc != nil {
|
|
sess.CtxMgr = s.svc.ContextManager()
|
|
}
|
|
|
|
// 若从持久层恢复了会话历史,回填消息列表、事实黑板与近期对话 (G3)
|
|
if restored != nil {
|
|
if restored.UserID > 0 {
|
|
sess.UserID = restored.UserID
|
|
}
|
|
if restored.ProjectID > 0 {
|
|
sess.ProjectID = restored.ProjectID
|
|
}
|
|
for k, v := range restored.Facts {
|
|
if _, exists := userCtx.BlackboardFacts[k]; !exists {
|
|
userCtx.BlackboardFacts[k] = v
|
|
}
|
|
}
|
|
for _, rm := range restored.Messages {
|
|
sess.Messages = append(sess.Messages, chat.Message{
|
|
ID: fmt.Sprintf("msg_%d", rm.ID),
|
|
Role: rm.Role,
|
|
Content: rm.Content,
|
|
Intent: intent.IntentType(rm.Intent),
|
|
Branch: intent.ResolutionBranch(rm.Branch),
|
|
Timestamp: rm.CreatedAt,
|
|
})
|
|
}
|
|
sess.SyncRecentDialogue()
|
|
|
|
if sess.UserID > 0 && sess.CtxMgr != nil {
|
|
if guidance, err := sess.CtxMgr.CompilePromptGuidance(context.Background(), sess.UserID); err == nil && guidance != "" {
|
|
userCtx.PersonalizedGuidance = guidance
|
|
}
|
|
}
|
|
}
|
|
|
|
sess.LastAccessedAt = time.Now()
|
|
s.sessions[sessionID] = sess
|
|
return sess
|
|
}
|
|
|
|
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|