qdy 3 недель назад
Родитель
Сommit
b4384d4859

+ 12
- 59
internal/opencode/direct_client.go Просмотреть файл

@@ -202,7 +202,7 @@ func (c *DirectClient) SendPromptStream(ctx context.Context, sessionID string, p
202 202
 	fmt.Printf("🔍 [opencode.DirectClient] 请求体: %s\n", string(reqBody))
203 203
 	fmt.Printf("🔍 [opencode.DirectClient] 端口: %d\n", c.port)
204 204
 
205
-	// 测试异步端点
205
+	// 发送异步请求到 opencode(触发AI处理)
206 206
 	url := fmt.Sprintf("%s/session/%s/prompt_async", c.baseURL, sessionID)
207 207
 	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody))
208 208
 	if err != nil {
@@ -214,82 +214,35 @@ func (c *DirectClient) SendPromptStream(ctx context.Context, sessionID string, p
214 214
 	fmt.Printf("🔍 [opencode.DirectClient] 请求 URL: %s\n", url)
215 215
 	fmt.Printf("🔍 [opencode.DirectClient] 请求头: %v\n", req.Header)
216 216
 
217
-	// 为SSE流创建独立的httpClient,不设置超时限制
217
+	// 发送请求,不等待SSE响应(事件通过EventDispatcher分发)
218 218
 	sseClient := &http.Client{
219
-		// 不设置Timeout,允许长连接
220
-		// Timeout: 0 表示无超时限制
219
+		Timeout: 30 * time.Second, // 设置超时,避免长时间阻塞
221 220
 	}
222 221
 	resp, err := sseClient.Do(req)
223 222
 	if err != nil {
224 223
 		return nil, fmt.Errorf("发送请求失败: %w", err)
225 224
 	}
225
+	defer resp.Body.Close()
226 226
 
227 227
 	fmt.Printf("🔍 [opencode.DirectClient] 响应状态: %d\n", resp.StatusCode)
228 228
 	fmt.Printf("🔍 [opencode.DirectClient] 响应头: %v\n", resp.Header)
229 229
 
230
-	if resp.StatusCode != http.StatusOK {
230
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
231 231
 		body, _ := io.ReadAll(resp.Body)
232
-		resp.Body.Close()
233 232
 		fmt.Printf("🔍 [opencode.DirectClient] 错误响应体: %s\n", string(body))
234
-
235
-		// 如果返回 204,尝试其他流式端点
236
-		if resp.StatusCode == http.StatusNoContent {
237
-			fmt.Printf("🔍 [opencode.DirectClient] 异步端点返回 204,尝试 /global/event SSE 流\n")
238
-			return c.subscribeGlobalEvents(ctx)
239
-		}
240
-
241 233
 		return nil, fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
242 234
 	}
243 235
 
244
-	ch := make(chan string, 100)
236
+	// 异步读取响应体(避免资源泄漏)
245 237
 	go func() {
246
-		defer resp.Body.Close()
247
-		defer close(ch)
248
-
249
-		reader := bufio.NewReader(resp.Body)
250
-		eventCount := 0
251
-
252
-		for {
253
-			line, err := reader.ReadString('\n')
254
-			if err != nil {
255
-				if err == io.EOF {
256
-					fmt.Printf("🔍 [opencode.DirectClient] SSE流结束,共收到 %d 个事件\n", eventCount)
257
-				} else {
258
-					// 区分正常取消和错误
259
-					if ctx.Err() != nil {
260
-						fmt.Printf("🔍 [opencode.DirectClient] SSE流正常结束(上下文取消)\n")
261
-					} else {
262
-						fmt.Printf("🔍 [opencode.DirectClient] 读取错误: %v\n", err)
263
-					}
264
-				}
265
-				return
266
-			}
267
-
268
-			line = strings.TrimSpace(line)
269
-			if line == "" {
270
-				continue
271
-			}
272
-
273
-			if strings.HasPrefix(line, "data: ") {
274
-				data := strings.TrimPrefix(line, "data: ")
275
-				eventCount++
276
-				fmt.Printf("🔍 [opencode.DirectClient] 收到SSE数据[%d]: %s\n", eventCount, data)
277
-
278
-				// 写入日志文件用于分析
279
-				writeStreamLog(sessionID, data)
280
-
281
-				select {
282
-				case ch <- data:
283
-				case <-ctx.Done():
284
-					fmt.Printf("🔍 [opencode.DirectClient] 上下文取消\n")
285
-					return
286
-				}
287
-			} else {
288
-				fmt.Printf("🔍 [opencode.DirectClient] 忽略非数据行: %s\n", line)
289
-			}
290
-		}
238
+		io.Copy(io.Discard, resp.Body)
291 239
 	}()
292 240
 
241
+	fmt.Printf("🔍 [opencode.DirectClient] 异步请求发送成功,事件将通过EventDispatcher分发\n")
242
+
243
+	// 返回一个立即关闭的空通道(保持接口兼容,实际事件通过EventDispatcher分发)
244
+	ch := make(chan string)
245
+	close(ch)
293 246
 	return ch, nil
294 247
 }
295 248
 

+ 59
- 0
internal/routes/menu_routes.go Просмотреть файл

@@ -0,0 +1,59 @@
1
+package routes
2
+
3
+import (
4
+	"context"
5
+
6
+	"git.x2erp.com/qdy/go-base/authbase"
7
+	"git.x2erp.com/qdy/go-base/ctx"
8
+	"git.x2erp.com/qdy/go-base/logger"
9
+	"git.x2erp.com/qdy/go-base/model/response"
10
+	"git.x2erp.com/qdy/go-base/webx/router"
11
+	"git.x2erp.com/qdy/go-svc-code/internal/service/menu"
12
+)
13
+
14
+// RegisterMenuRoutes 注册菜单路由
15
+func RegisterMenuRoutes(ws *router.RouterService, mappingService *menu.MappingService) {
16
+	// 获取顶部菜单(需要Token认证)
17
+	ws.GET("/api/menu/top",
18
+		func(ctx context.Context, reqCtx *ctx.RequestContext) (*response.QueryResult[[]menu.MenuItem], error) {
19
+			return menu.GetTopMenu(ctx, reqCtx), nil
20
+		},
21
+	).Use(authbase.TokenAuth).Desc("获取顶部菜单").Register()
22
+
23
+	// 获取菜单项的所有会话ID(需要Token认证)
24
+	ws.GET("/api/menu/sessions",
25
+		func(menuItemID string, ctx context.Context, reqCtx *ctx.RequestContext) (*response.QueryResult[[]string], error) {
26
+			if menuItemID == "" {
27
+				return &response.QueryResult[[]string]{
28
+					Success: false,
29
+					Message: "参数 menu_item_id 不能为空",
30
+				}, nil
31
+			}
32
+
33
+			// 获取用户ID
34
+			userID := reqCtx.UserID
35
+			if userID == "" {
36
+				logger.Warn("无法从请求上下文获取用户ID")
37
+				return &response.QueryResult[[]string]{
38
+					Success: false,
39
+					Message: "用户认证信息不完整",
40
+				}, nil
41
+			}
42
+
43
+			// 获取菜单项的所有会话ID
44
+			sessionIDs, err := mappingService.GetSessionIDsByMenuItemID(ctx, menuItemID, userID)
45
+			if err != nil {
46
+				logger.Error("获取菜单项会话ID失败", "menu_item_id", menuItemID, "user_id", userID, "error", err)
47
+				return &response.QueryResult[[]string]{
48
+					Success: false,
49
+					Message: "获取菜单项会话ID失败: " + err.Error(),
50
+				}, nil
51
+			}
52
+
53
+			return &response.QueryResult[[]string]{
54
+				Success: true,
55
+				Data:    sessionIDs,
56
+			}, nil
57
+		},
58
+	).Use(authbase.TokenAuth).Desc("获取菜单项的所有会话ID").Register()
59
+}

+ 40
- 7
internal/routes/prompt_stream_routes.go Просмотреть файл

@@ -12,6 +12,7 @@ import (
12 12
 	"git.x2erp.com/qdy/go-base/webx"
13 13
 	"git.x2erp.com/qdy/go-base/webx/router"
14 14
 	"git.x2erp.com/qdy/go-svc-code/internal/opencode"
15
+	"git.x2erp.com/qdy/go-svc-code/internal/service/event"
15 16
 )
16 17
 
17 18
 // PromptStreamRequest 流式对话请求
@@ -52,7 +53,7 @@ func StreamPromptHandler(client opencode.OpenCodeClient) http.HandlerFunc {
52 53
 		// 创建 prompt 请求
53 54
 		prompt := &opencode.PromptRequest{
54 55
 			Parts: req.Parts,
55
-			Agent: req.Agent,
56
+			Agent: "code-sql", //req.Agent,
56 57
 			Model: req.Model,
57 58
 		}
58 59
 
@@ -66,15 +67,47 @@ func StreamPromptHandler(client opencode.OpenCodeClient) http.HandlerFunc {
66 67
 		ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
67 68
 		defer cancel()
68 69
 
69
-		fmt.Printf("🔍 [StreamPromptHandler] 调用 SendPromptStream, sessionID=%s\n", req.SessionID)
70
-		// 获取流式响应通道
71
-		ch, err := client.SendPromptStream(ctx, req.SessionID, prompt)
70
+		// 获取事件分发器实例
71
+		dispatcher := event.GetEventDispatcher(client.GetBaseURL(), client.GetPort())
72
+
73
+		// 从认证上下文中获取用户ID(用于缓存,按sessionID分发事件)
74
+		userID := "unknown-user"
75
+
76
+		// TokenAuth中间件通常将用户信息存储在context中
77
+		if userVal := r.Context().Value("user"); userVal != nil {
78
+			if userMap, ok := userVal.(map[string]interface{}); ok {
79
+				if id, ok := userMap["id"].(string); ok {
80
+					userID = id
81
+				} else if id, ok := userMap["user_id"].(string); ok {
82
+					userID = id
83
+				}
84
+			}
85
+		}
86
+
87
+		fmt.Printf("🔍 [StreamPromptHandler] 用户ID: %s, 会话ID: %s\n", userID, req.SessionID)
88
+
89
+		// 注册会话到缓存
90
+		dispatcher.RegisterSession(req.SessionID, userID)
91
+
92
+		// 订阅该会话的事件
93
+		ch, err := dispatcher.Subscribe(req.SessionID, userID)
94
+		if err != nil {
95
+			fmt.Printf("🔍 [StreamPromptHandler] 订阅事件失败: %v\n", err)
96
+			http.Error(w, fmt.Sprintf("订阅事件失败: %v", err), http.StatusInternalServerError)
97
+			return
98
+		}
99
+		defer dispatcher.Unsubscribe(req.SessionID, ch)
100
+
101
+		// 发送异步请求到 opencode(触发AI处理)
102
+		fmt.Printf("🔍 [StreamPromptHandler] 发送异步请求到 opencode, sessionID=%s\n", req.SessionID)
103
+		// 忽略返回的通道,事件通过EventDispatcher分发
104
+		_, err = client.SendPromptStream(ctx, req.SessionID, prompt)
72 105
 		if err != nil {
73
-			fmt.Printf("🔍 [StreamPromptHandler] 发送流式请求失败: %v\n", err)
74
-			http.Error(w, fmt.Sprintf("发送流式请求失败: %v", err), http.StatusInternalServerError)
106
+			fmt.Printf("🔍 [StreamPromptHandler] 发送请求失败: %v\n", err)
107
+			http.Error(w, fmt.Sprintf("发送请求失败: %v", err), http.StatusInternalServerError)
75 108
 			return
76 109
 		}
77
-		fmt.Printf("🔍 [StreamPromptHandler] 成功获取流式响应通道\n")
110
+		fmt.Printf("🔍 [StreamPromptHandler] 异步请求发送成功,等待事件流\n")
78 111
 
79 112
 		// 发送流式响应
80 113
 		flusher, ok := w.(http.Flusher)

+ 55
- 2
internal/routes/session_routes.go Просмотреть файл

@@ -5,14 +5,17 @@ import (
5 5
 
6 6
 	"git.x2erp.com/qdy/go-base/authbase"
7 7
 	"git.x2erp.com/qdy/go-base/ctx"
8
+	"git.x2erp.com/qdy/go-base/logger"
8 9
 	"git.x2erp.com/qdy/go-base/model/response"
9 10
 	"git.x2erp.com/qdy/go-base/webx/router"
10 11
 	"git.x2erp.com/qdy/go-svc-code/internal/opencode"
12
+	"git.x2erp.com/qdy/go-svc-code/internal/service/menu"
11 13
 )
12 14
 
13 15
 // SessionCreateRequest 创建会话请求
14 16
 type SessionCreateRequest struct {
15
-	Title string `json:"title" binding:"required"`
17
+	Title      string `json:"title" binding:"required"`
18
+	MenuItemID string `json:"menu_item_id" binding:"required"` // 菜单项ID,必填
16 19
 }
17 20
 
18 21
 // SessionResponse 会话响应
@@ -24,7 +27,7 @@ type SessionResponse struct {
24 27
 }
25 28
 
26 29
 // RegisterSessionRoutes 注册会话管理路由
27
-func RegisterSessionRoutes(ws *router.RouterService, client opencode.OpenCodeClient) {
30
+func RegisterSessionRoutes(ws *router.RouterService, client opencode.OpenCodeClient, mappingService *menu.MappingService) {
28 31
 	// 创建会话(需要Token认证)
29 32
 	ws.POST("/api/session/create",
30 33
 		func(req *SessionCreateRequest, ctx context.Context, reqCtx *ctx.RequestContext) (*response.QueryResult[SessionResponse], error) {
@@ -36,6 +39,29 @@ func RegisterSessionRoutes(ws *router.RouterService, client opencode.OpenCodeCli
36 39
 				}, nil
37 40
 			}
38 41
 
42
+			// 从认证信息中获取用户ID和租户ID
43
+			// TokenAuth中间件已经在reqCtx中设置了UserID和TenantID
44
+			userID := reqCtx.UserID
45
+			tenantID := reqCtx.TenantID
46
+
47
+			// 如果无法获取,使用占位符(实际生产环境应该确保认证信息正确设置)
48
+			if userID == "" {
49
+				userID = "unknown_user"
50
+				logger.Warn("无法从请求上下文获取用户ID,使用默认值", "session_id", session.ID)
51
+			}
52
+			if tenantID == "" {
53
+				tenantID = "default"
54
+			}
55
+			if tenantID == "" {
56
+				tenantID = "default"
57
+			}
58
+
59
+			// 保存会话-菜单映射
60
+			if err := mappingService.CreateMapping(ctx, session.ID, req.MenuItemID, userID, tenantID); err != nil {
61
+				logger.Warn("创建会话-菜单映射失败", "session_id", session.ID, "menu_item_id", req.MenuItemID, "error", err)
62
+				// 注意:映射创建失败不影响会话创建,但会记录警告
63
+			}
64
+
39 65
 			return &response.QueryResult[SessionResponse]{
40 66
 				Success: true,
41 67
 				Data: SessionResponse{
@@ -66,6 +92,33 @@ func RegisterSessionRoutes(ws *router.RouterService, client opencode.OpenCodeCli
66 92
 		},
67 93
 	).Use(authbase.TokenAuth).Desc("获取 opencode 会话列表").Register()
68 94
 
95
+	// 获取会话的菜单项ID(需要Token认证)
96
+	ws.GET("/api/session/menu",
97
+		func(sessionID string, ctx context.Context, reqCtx *ctx.RequestContext) (*response.QueryResult[string], error) {
98
+			if sessionID == "" {
99
+				return &response.QueryResult[string]{
100
+					Success: false,
101
+					Message: "参数 session_id 不能为空",
102
+				}, nil
103
+			}
104
+
105
+			// 获取会话的菜单项ID
106
+			menuItemID, err := mappingService.GetMenuItemBySessionID(ctx, sessionID)
107
+			if err != nil {
108
+				logger.Error("获取会话菜单项失败", "session_id", sessionID, "error", err)
109
+				return &response.QueryResult[string]{
110
+					Success: false,
111
+					Message: "获取会话菜单项失败: " + err.Error(),
112
+				}, nil
113
+			}
114
+
115
+			return &response.QueryResult[string]{
116
+				Success: true,
117
+				Data:    menuItemID,
118
+			}, nil
119
+		},
120
+	).Use(authbase.TokenAuth).Desc("获取会话的菜单项ID").Register()
121
+
69 122
 	// 获取单个会话(暂不实现,因为 opencode API 可能不支持)
70 123
 	// ws.GET("/api/session/get",
71 124
 	// 	func(ctx context.Context, reqCtx *ctx.RequestContext) (*response.QueryResult[opencode.Session], error) {

+ 118
- 0
internal/service/event/cache.go Просмотреть файл

@@ -0,0 +1,118 @@
1
+package event
2
+
3
+import (
4
+	"sync"
5
+	"time"
6
+)
7
+
8
+// SessionUserCache 会话-用户映射缓存,支持TTL过期
9
+type SessionUserCache struct {
10
+	mu       sync.RWMutex
11
+	cache    map[string]cacheEntry
12
+	ttl      time.Duration
13
+	stopChan chan struct{}
14
+}
15
+
16
+// cacheEntry 缓存条目
17
+type cacheEntry struct {
18
+	userID    string
19
+	expiresAt time.Time
20
+}
21
+
22
+// NewSessionUserCache 创建新的会话-用户缓存
23
+func NewSessionUserCache(ttl time.Duration) *SessionUserCache {
24
+	cache := &SessionUserCache{
25
+		cache:    make(map[string]cacheEntry),
26
+		ttl:      ttl,
27
+		stopChan: make(chan struct{}),
28
+	}
29
+
30
+	// 启动清理协程
31
+	go cache.cleanupWorker()
32
+
33
+	return cache
34
+}
35
+
36
+// Set 设置会话-用户映射
37
+func (c *SessionUserCache) Set(sessionID, userID string) {
38
+	c.mu.Lock()
39
+	defer c.mu.Unlock()
40
+
41
+	c.cache[sessionID] = cacheEntry{
42
+		userID:    userID,
43
+		expiresAt: time.Now().Add(c.ttl),
44
+	}
45
+}
46
+
47
+// Get 获取用户ID,如果不存在或已过期返回空字符串
48
+func (c *SessionUserCache) Get(sessionID string) string {
49
+	c.mu.RLock()
50
+	entry, exists := c.cache[sessionID]
51
+	c.mu.RUnlock()
52
+
53
+	if !exists {
54
+		return ""
55
+	}
56
+
57
+	// 检查是否过期
58
+	if time.Now().After(entry.expiresAt) {
59
+		// 异步删除过期条目
60
+		go c.deleteIfExpired(sessionID)
61
+		return ""
62
+	}
63
+
64
+	return entry.userID
65
+}
66
+
67
+// Delete 删除指定会话的缓存
68
+func (c *SessionUserCache) Delete(sessionID string) {
69
+	c.mu.Lock()
70
+	defer c.mu.Unlock()
71
+
72
+	delete(c.cache, sessionID)
73
+}
74
+
75
+// deleteIfExpired 检查并删除过期条目
76
+func (c *SessionUserCache) deleteIfExpired(sessionID string) {
77
+	c.mu.Lock()
78
+	defer c.mu.Unlock()
79
+
80
+	if entry, exists := c.cache[sessionID]; exists {
81
+		if time.Now().After(entry.expiresAt) {
82
+			delete(c.cache, sessionID)
83
+		}
84
+	}
85
+}
86
+
87
+// cleanupWorker 定期清理过期条目的工作协程
88
+func (c *SessionUserCache) cleanupWorker() {
89
+	ticker := time.NewTicker(c.ttl / 2)
90
+	defer ticker.Stop()
91
+
92
+	for {
93
+		select {
94
+		case <-ticker.C:
95
+			c.cleanupExpired()
96
+		case <-c.stopChan:
97
+			return
98
+		}
99
+	}
100
+}
101
+
102
+// cleanupExpired 清理所有过期条目
103
+func (c *SessionUserCache) cleanupExpired() {
104
+	c.mu.Lock()
105
+	defer c.mu.Unlock()
106
+
107
+	now := time.Now()
108
+	for sessionID, entry := range c.cache {
109
+		if now.After(entry.expiresAt) {
110
+			delete(c.cache, sessionID)
111
+		}
112
+	}
113
+}
114
+
115
+// Stop 停止缓存清理协程
116
+func (c *SessionUserCache) Stop() {
117
+	close(c.stopChan)
118
+}

+ 490
- 0
internal/service/event/dispatcher.go Просмотреть файл

@@ -0,0 +1,490 @@
1
+package event
2
+
3
+import (
4
+	"bufio"
5
+	"context"
6
+	"encoding/json"
7
+	"fmt"
8
+	"io"
9
+	"net/http"
10
+	"strings"
11
+	"sync"
12
+	"time"
13
+
14
+	"git.x2erp.com/qdy/go-base/logger"
15
+)
16
+
17
+// EventDispatcher 事件分发器 - 单例模式
18
+type EventDispatcher struct {
19
+	mu               sync.RWMutex
20
+	baseURL          string
21
+	port             int
22
+	subscriptions    map[string]map[chan string]struct{} // sessionID -> set of channels
23
+	sessionUserCache *SessionUserCache                   // sessionID -> userID 映射缓存(用于用户验证)
24
+	client           *http.Client
25
+	cancelFunc       context.CancelFunc
26
+	running          bool
27
+}
28
+
29
+// EventData opencode事件数据结构 - 匹配实际事件格式
30
+type EventData struct {
31
+	Directory string                 `json:"directory,omitempty"`
32
+	Payload   map[string]interface{} `json:"payload"`
33
+}
34
+
35
+// PayloadData payload内部结构(辅助类型)
36
+type PayloadData struct {
37
+	Type       string                 `json:"type"`
38
+	Properties map[string]interface{} `json:"properties,omitempty"`
39
+}
40
+
41
+// NewEventDispatcher 创建新的事件分发器
42
+func NewEventDispatcher(baseURL string, port int) *EventDispatcher {
43
+	return &EventDispatcher{
44
+		baseURL:          baseURL,
45
+		port:             port,
46
+		subscriptions:    make(map[string]map[chan string]struct{}),
47
+		sessionUserCache: NewSessionUserCache(20 * time.Minute),
48
+		client: &http.Client{
49
+			Timeout: 0, // 无超时限制,用于长连接
50
+		},
51
+		running: false,
52
+	}
53
+}
54
+
55
+// Start 启动事件分发器,连接到opencode全局事件流
56
+func (ed *EventDispatcher) Start(ctx context.Context) error {
57
+	ed.mu.Lock()
58
+	if ed.running {
59
+		ed.mu.Unlock()
60
+		return fmt.Errorf("event dispatcher already running")
61
+	}
62
+
63
+	// 创建子上下文用于控制SSE连接
64
+	sseCtx, cancel := context.WithCancel(ctx)
65
+	ed.cancelFunc = cancel
66
+	ed.running = true
67
+	ed.mu.Unlock()
68
+
69
+	// 启动SSE连接协程
70
+	go ed.runSSEConnection(sseCtx)
71
+
72
+	logger.Info(fmt.Sprintf("事件分发器已启动 baseURL=%s port=%d",
73
+		ed.baseURL, ed.port))
74
+	return nil
75
+}
76
+
77
+// Stop 停止事件分发器
78
+func (ed *EventDispatcher) Stop() {
79
+	ed.mu.Lock()
80
+	if !ed.running {
81
+		ed.mu.Unlock()
82
+		return
83
+	}
84
+
85
+	if ed.cancelFunc != nil {
86
+		ed.cancelFunc()
87
+	}
88
+
89
+	// 清理所有订阅通道
90
+	for sessionID, channels := range ed.subscriptions {
91
+		for ch := range channels {
92
+			close(ch)
93
+		}
94
+		delete(ed.subscriptions, sessionID)
95
+	}
96
+
97
+	ed.running = false
98
+	ed.mu.Unlock()
99
+
100
+	logger.Info("事件分发器已停止")
101
+}
102
+
103
+// Subscribe 订阅指定会话的事件
104
+func (ed *EventDispatcher) Subscribe(sessionID, userID string) (<-chan string, error) {
105
+	ed.mu.Lock()
106
+	defer ed.mu.Unlock()
107
+
108
+	// 缓存会话-用户映射(用于未来需要用户验证时)
109
+	ed.sessionUserCache.Set(sessionID, userID)
110
+
111
+	// 创建缓冲通道
112
+	ch := make(chan string, 100)
113
+
114
+	// 添加到订阅列表
115
+	if _, exists := ed.subscriptions[sessionID]; !exists {
116
+		ed.subscriptions[sessionID] = make(map[chan string]struct{})
117
+	}
118
+	ed.subscriptions[sessionID][ch] = struct{}{}
119
+
120
+	logger.Debug(fmt.Sprintf("新订阅添加 sessionID=%s userID=%s totalSubscriptions=%d",
121
+		sessionID, userID, len(ed.subscriptions[sessionID])))
122
+
123
+	return ch, nil
124
+}
125
+
126
+// Unsubscribe 取消订阅指定会话的事件
127
+func (ed *EventDispatcher) Unsubscribe(sessionID string, ch <-chan string) {
128
+	ed.mu.Lock()
129
+	defer ed.mu.Unlock()
130
+
131
+	if channels, exists := ed.subscriptions[sessionID]; exists {
132
+		// 遍历查找对应的通道(因为ch是只读通道,无法直接作为key)
133
+		var foundChan chan string
134
+		for candidate := range channels {
135
+			// 比较通道值
136
+			if candidate == ch {
137
+				foundChan = candidate
138
+				break
139
+			}
140
+		}
141
+
142
+		if foundChan != nil {
143
+			close(foundChan)
144
+			delete(channels, foundChan)
145
+			logger.Debug(fmt.Sprintf("订阅已移除 sessionID=%s remainingSubscriptions=%d",
146
+				sessionID, len(channels)))
147
+		}
148
+
149
+		// 如果没有订阅者了,清理该会话的映射
150
+		if len(channels) == 0 {
151
+			delete(ed.subscriptions, sessionID)
152
+			ed.sessionUserCache.Delete(sessionID)
153
+		}
154
+	}
155
+}
156
+
157
+// RegisterSession 注册会话(前端调用SendPromptStream时调用)
158
+func (ed *EventDispatcher) RegisterSession(sessionID, userID string) {
159
+	ed.sessionUserCache.Set(sessionID, userID)
160
+	logger.Debug(fmt.Sprintf("会话已注册 sessionID=%s userID=%s",
161
+		sessionID, userID))
162
+}
163
+
164
+// buildSSEURL 构建SSE URL,避免端口重复
165
+func (ed *EventDispatcher) buildSSEURL() string {
166
+	// 检查baseURL是否已包含端口
167
+	base := ed.baseURL
168
+	// 简单检查:如果baseURL已经包含端口号模式(冒号后跟数字),就不再加端口
169
+	// 查找最后一个冒号的位置
170
+	lastColon := -1
171
+	for i := len(base) - 1; i >= 0; i-- {
172
+		if base[i] == ':' {
173
+			lastColon = i
174
+			break
175
+		}
176
+	}
177
+
178
+	if lastColon != -1 {
179
+		// 检查冒号后是否都是数字(端口号)
180
+		hasPort := true
181
+		for i := lastColon + 1; i < len(base); i++ {
182
+			if base[i] < '0' || base[i] > '9' {
183
+				hasPort = false
184
+				break
185
+			}
186
+		}
187
+		if hasPort {
188
+			// baseURL已有端口,直接拼接路径
189
+			if strings.HasSuffix(base, "/") {
190
+				return base + "global/event"
191
+			}
192
+			return base + "/global/event"
193
+		}
194
+	}
195
+
196
+	// baseURL没有端口或端口格式不正确,添加端口
197
+	if strings.HasSuffix(base, "/") {
198
+		return fmt.Sprintf("%s:%d/global/event", strings.TrimSuffix(base, "/"), ed.port)
199
+	}
200
+	return fmt.Sprintf("%s:%d/global/event", base, ed.port)
201
+}
202
+
203
+// runSSEConnection 运行SSE连接,读取全局事件并分发
204
+func (ed *EventDispatcher) runSSEConnection(ctx context.Context) {
205
+	// 构建SSE URL,避免重复端口
206
+	url := ed.buildSSEURL()
207
+
208
+	for {
209
+		select {
210
+		case <-ctx.Done():
211
+			logger.Info("SSE连接停止(上下文取消)")
212
+			return
213
+		default:
214
+			// 建立SSE连接
215
+			logger.Info(fmt.Sprintf("正在连接SSE流 url=%s",
216
+				url))
217
+			if err := ed.connectAndProcessSSE(ctx, url); err != nil {
218
+				logger.Error(fmt.Sprintf("SSE连接失败,5秒后重试 error=%s url=%s",
219
+					err.Error(), url))
220
+
221
+				select {
222
+				case <-ctx.Done():
223
+					return
224
+				case <-time.After(5 * time.Second):
225
+					continue
226
+				}
227
+			}
228
+		}
229
+	}
230
+}
231
+
232
+// connectAndProcessSSE 连接并处理SSE流
233
+func (ed *EventDispatcher) connectAndProcessSSE(ctx context.Context, url string) error {
234
+	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
235
+	if err != nil {
236
+		return fmt.Errorf("创建请求失败: %w", err)
237
+	}
238
+	req.Header.Set("Accept", "text/event-stream")
239
+
240
+	resp, err := ed.client.Do(req)
241
+	if err != nil {
242
+		return fmt.Errorf("发送请求失败: %w", err)
243
+	}
244
+	defer resp.Body.Close()
245
+
246
+	if resp.StatusCode != http.StatusOK {
247
+		body, _ := io.ReadAll(resp.Body)
248
+		return fmt.Errorf("SSE请求失败,状态码: %d, 响应: %s", resp.StatusCode, string(body))
249
+	}
250
+
251
+	logger.Info(fmt.Sprintf("SSE连接已建立 url=%s",
252
+		url))
253
+
254
+	reader := bufio.NewReader(resp.Body)
255
+	eventCount := 0
256
+
257
+	for {
258
+		select {
259
+		case <-ctx.Done():
260
+			return nil
261
+		default:
262
+			line, err := reader.ReadString('\n')
263
+			if err != nil {
264
+				if err == io.EOF {
265
+					logger.Info(fmt.Sprintf("SSE流正常结束 totalEvents=%d",
266
+						eventCount))
267
+				} else if ctx.Err() != nil {
268
+					logger.Info("SSE流上下文取消")
269
+				} else {
270
+					logger.Error(fmt.Sprintf("读取SSE流错误 error=%s",
271
+						err.Error()))
272
+				}
273
+				return err
274
+			}
275
+
276
+			line = strings.TrimSpace(line)
277
+			if line == "" {
278
+				continue
279
+			}
280
+
281
+			if strings.HasPrefix(line, "data: ") {
282
+				data := strings.TrimPrefix(line, "data: ")
283
+				eventCount++
284
+
285
+				// 分发事件
286
+				ed.dispatchEvent(data)
287
+
288
+				if eventCount%100 == 0 {
289
+					logger.Debug(fmt.Sprintf("事件处理统计 totalEvents=%d activeSessions=%d",
290
+						eventCount, len(ed.subscriptions)))
291
+				}
292
+			}
293
+		}
294
+	}
295
+}
296
+
297
+// dispatchEvent 分发事件到相关订阅者
298
+func (ed *EventDispatcher) dispatchEvent(data string) {
299
+	// 解析事件数据获取sessionID
300
+	sessionID := extractSessionIDFromEvent(data)
301
+	if sessionID == "" {
302
+		// 没有sessionID的事件(如全局心跳)分发给所有订阅者
303
+		logger.Debug(fmt.Sprintf("广播全局事件 dataPreview=%s", safeSubstring(data, 0, 100)))
304
+		ed.broadcastToAll(data)
305
+		return
306
+	}
307
+
308
+	logger.Debug(fmt.Sprintf("路由事件到会话 sessionID=%s dataPreview=%s",
309
+		sessionID, safeSubstring(data, 0, 100)))
310
+
311
+	// 只分发给订阅该会话的通道
312
+	ed.mu.RLock()
313
+	channels, exists := ed.subscriptions[sessionID]
314
+	ed.mu.RUnlock()
315
+
316
+	if !exists {
317
+		// 没有该会话的订阅者,忽略事件
318
+		logger.Debug(fmt.Sprintf("忽略事件,无订阅者 sessionID=%s", sessionID))
319
+		return
320
+	}
321
+
322
+	// 发送事件到所有订阅该会话的通道
323
+	ed.mu.RLock()
324
+	for ch := range channels {
325
+		select {
326
+		case ch <- data:
327
+			// 成功发送
328
+		default:
329
+			// 通道已满,丢弃事件并记录警告
330
+			logger.Warn(fmt.Sprintf("事件通道已满,丢弃事件 sessionID=%s",
331
+				sessionID))
332
+		}
333
+	}
334
+	ed.mu.RUnlock()
335
+}
336
+
337
+// broadcastToAll 广播事件给所有订阅者(用于全局事件如心跳)
338
+func (ed *EventDispatcher) broadcastToAll(data string) {
339
+	logger.Debug(fmt.Sprintf("广播事件给所有订阅者 dataPreview=%s", safeSubstring(data, 0, 100)))
340
+	ed.mu.RLock()
341
+	defer ed.mu.RUnlock()
342
+
343
+	for sessionID, channels := range ed.subscriptions {
344
+		for ch := range channels {
345
+			select {
346
+			case ch <- data:
347
+				// 成功发送
348
+			default:
349
+				// 通道已满,丢弃事件
350
+				logger.Warn(fmt.Sprintf("事件通道已满,丢弃全局事件 sessionID=%s",
351
+					sessionID))
352
+			}
353
+		}
354
+	}
355
+}
356
+
357
+// extractSessionIDFromEvent 从事件数据中提取sessionID
358
+func extractSessionIDFromEvent(data string) string {
359
+	// 尝试解析为JSON
360
+	var eventMap map[string]interface{}
361
+	if err := json.Unmarshal([]byte(data), &eventMap); err != nil {
362
+		logger.Debug(fmt.Sprintf("无法解析事件JSON error=%s dataPreview=%s",
363
+			err.Error(), safeSubstring(data, 0, 200)))
364
+		return ""
365
+	}
366
+
367
+	// 添加调试日志,显示完整事件结构(仅调试时启用)
368
+	debugMode := false
369
+	if debugMode {
370
+		eventJSON, _ := json.MarshalIndent(eventMap, "", "  ")
371
+		logger.Debug(fmt.Sprintf("事件数据结构 eventStructure=%s",
372
+			string(eventJSON)))
373
+	}
374
+
375
+	// 递归查找sessionID字段
376
+	sessionID := findSessionIDRecursive(eventMap)
377
+
378
+	if sessionID == "" {
379
+		logger.Debug(fmt.Sprintf("未找到sessionID字段 eventType=%s dataPreview=%s",
380
+			getEventType(eventMap), safeSubstring(data, 0, 100)))
381
+	} else {
382
+		logger.Debug(fmt.Sprintf("成功提取sessionID sessionID=%s eventType=%s",
383
+			sessionID, getEventType(eventMap)))
384
+	}
385
+
386
+	return sessionID
387
+}
388
+
389
+// findSessionIDRecursive 递归查找sessionID字段
390
+func findSessionIDRecursive(data interface{}) string {
391
+	switch v := data.(type) {
392
+	case map[string]interface{}:
393
+		// 检查当前层级的sessionID字段(支持多种命名变体)
394
+		for _, key := range []string{"sessionID", "session_id", "sessionId"} {
395
+			if val, ok := v[key]; ok {
396
+				if str, ok := val.(string); ok && str != "" {
397
+					return str
398
+				}
399
+			}
400
+		}
401
+
402
+		// 检查常见嵌套路径
403
+		// 1. payload.properties.sessionID (session.status事件)
404
+		if payload, ok := v["payload"].(map[string]interface{}); ok {
405
+			if props, ok := payload["properties"].(map[string]interface{}); ok {
406
+				if sessionID, ok := props["sessionID"].(string); ok && sessionID != "" {
407
+					return sessionID
408
+				}
409
+			}
410
+		}
411
+
412
+		// 2. payload.properties.part.sessionID (message.part.updated事件)
413
+		if payload, ok := v["payload"].(map[string]interface{}); ok {
414
+			if props, ok := payload["properties"].(map[string]interface{}); ok {
415
+				if part, ok := props["part"].(map[string]interface{}); ok {
416
+					if sessionID, ok := part["sessionID"].(string); ok && sessionID != "" {
417
+						return sessionID
418
+					}
419
+				}
420
+			}
421
+		}
422
+
423
+		// 3. payload.properties.info.sessionID (message.updated事件)
424
+		if payload, ok := v["payload"].(map[string]interface{}); ok {
425
+			if props, ok := payload["properties"].(map[string]interface{}); ok {
426
+				if info, ok := props["info"].(map[string]interface{}); ok {
427
+					if sessionID, ok := info["sessionID"].(string); ok && sessionID != "" {
428
+						return sessionID
429
+					}
430
+				}
431
+			}
432
+		}
433
+
434
+		// 递归遍历所有值
435
+		for _, value := range v {
436
+			if result := findSessionIDRecursive(value); result != "" {
437
+				return result
438
+			}
439
+		}
440
+
441
+	case []interface{}:
442
+		// 遍历数组
443
+		for _, item := range v {
444
+			if result := findSessionIDRecursive(item); result != "" {
445
+				return result
446
+			}
447
+		}
448
+	}
449
+
450
+	return ""
451
+}
452
+
453
+// getEventType 获取事件类型
454
+func getEventType(eventMap map[string]interface{}) string {
455
+	if payload, ok := eventMap["payload"].(map[string]interface{}); ok {
456
+		if eventType, ok := payload["type"].(string); ok {
457
+			return eventType
458
+		}
459
+	}
460
+	return "unknown"
461
+}
462
+
463
+// safeSubstring 安全的子字符串函数
464
+func safeSubstring(s string, start, length int) string {
465
+	if start < 0 {
466
+		start = 0
467
+	}
468
+	if start >= len(s) {
469
+		return ""
470
+	}
471
+	end := start + length
472
+	if end > len(s) {
473
+		end = len(s)
474
+	}
475
+	return s[start:end]
476
+}
477
+
478
+// GetInstance 获取单例实例(线程安全)
479
+var (
480
+	instance     *EventDispatcher
481
+	instanceOnce sync.Once
482
+)
483
+
484
+// GetEventDispatcher 获取事件分发器单例
485
+func GetEventDispatcher(baseURL string, port int) *EventDispatcher {
486
+	instanceOnce.Do(func() {
487
+		instance = NewEventDispatcher(baseURL, port)
488
+	})
489
+	return instance
490
+}

+ 59
- 0
internal/service/menu/get_menu.go Просмотреть файл

@@ -0,0 +1,59 @@
1
+package menu
2
+
3
+import (
4
+	"context"
5
+
6
+	"git.x2erp.com/qdy/go-base/ctx"
7
+	"git.x2erp.com/qdy/go-base/logger"
8
+	"git.x2erp.com/qdy/go-base/model/response"
9
+	"git.x2erp.com/qdy/go-base/util"
10
+)
11
+
12
+// MenuItem 顶部菜单项结构
13
+type MenuItem struct {
14
+	ID    string `json:"id"`
15
+	Name  string `json:"name"`
16
+	Icon  string `json:"icon,omitempty"`
17
+	Type  string `json:"type"` // top-menu
18
+	Route string `json:"route,omitempty"`
19
+}
20
+
21
+// GetTopMenu 获取顶部菜单
22
+func GetTopMenu(ctx context.Context, reqCtx *ctx.RequestContext) *response.QueryResult[[]MenuItem] {
23
+	logger.Debug("GetTopMenu-开始获取顶部菜单")
24
+
25
+	// 构建顶部菜单项
26
+	menu := []MenuItem{
27
+		{
28
+			ID:    "replenish",
29
+			Name:  "补货",
30
+			Icon:  "inventory_2", // 库存补充图标
31
+			Type:  "top-menu",
32
+			Route: "/replenish",
33
+		},
34
+		{
35
+			ID:    "transfer",
36
+			Name:  "调拨",
37
+			Icon:  "swap_horiz", // 横向交换图标
38
+			Type:  "top-menu",
39
+			Route: "/transfer",
40
+		},
41
+		{
42
+			ID:    "allocation",
43
+			Name:  "配货",
44
+			Icon:  "local_shipping", // 本地货运图标
45
+			Type:  "top-menu",
46
+			Route: "/allocation",
47
+		},
48
+		{
49
+			ID:    "report",
50
+			Name:  "报表",
51
+			Icon:  "assessment", // 评估报表图标
52
+			Type:  "top-menu",
53
+			Route: "/report",
54
+		},
55
+	}
56
+
57
+	logger.Debug("返回顶部菜单结构")
58
+	return util.CreateSuccessResultData(menu, reqCtx)
59
+}

+ 150
- 0
internal/service/menu/mapping.go Просмотреть файл

@@ -0,0 +1,150 @@
1
+package menu
2
+
3
+import (
4
+	"context"
5
+	"errors"
6
+	"time"
7
+
8
+	"git.x2erp.com/qdy/go-base/logger"
9
+	"git.x2erp.com/qdy/go-db/factory/mongodb"
10
+	"go.mongodb.org/mongo-driver/bson"
11
+	"go.mongodb.org/mongo-driver/bson/primitive"
12
+)
13
+
14
+// SessionMenuMapping 会话-菜单映射结构
15
+type SessionMenuMapping struct {
16
+	ID         primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
17
+	SessionID  string             `bson:"session_id" json:"session_id"`     // 会话ID
18
+	MenuItemID string             `bson:"menu_item_id" json:"menu_item_id"` // 菜单项ID
19
+	UserID     string             `bson:"user_id" json:"user_id"`           // 用户ID
20
+	TenantID   string             `bson:"tenant_id" json:"tenant_id"`       // 租户ID
21
+	CreatedAt  time.Time          `bson:"created_at" json:"created_at"`     // 创建时间
22
+}
23
+
24
+// CollectionName 返回MongoDB集合名称
25
+func (SessionMenuMapping) CollectionName() string {
26
+	return "session_menu_mappings"
27
+}
28
+
29
+// MappingService 会话-菜单映射服务
30
+type MappingService struct {
31
+	mongoFactory *mongodb.MongoDBFactory
32
+}
33
+
34
+// NewMappingService 创建新的映射服务
35
+func NewMappingService(factory *mongodb.MongoDBFactory) *MappingService {
36
+	return &MappingService{mongoFactory: factory}
37
+}
38
+
39
+// CreateMapping 创建会话-菜单映射
40
+func (s *MappingService) CreateMapping(ctx context.Context, sessionID, menuItemID, userID, tenantID string) error {
41
+	mapping := SessionMenuMapping{
42
+		SessionID:  sessionID,
43
+		MenuItemID: menuItemID,
44
+		UserID:     userID,
45
+		TenantID:   tenantID,
46
+		CreatedAt:  time.Now(),
47
+	}
48
+
49
+	_, success := s.mongoFactory.InsertOneWithResult(SessionMenuMapping{}.CollectionName(), mapping)
50
+	if !success {
51
+		logger.Error("创建会话-菜单映射失败", "session_id", sessionID, "menu_item_id", menuItemID)
52
+		return errors.New("failed to create session-menu mapping")
53
+	}
54
+
55
+	logger.Debug("创建会话-菜单映射成功", "session_id", sessionID, "menu_item_id", menuItemID)
56
+	return nil
57
+}
58
+
59
+// GetMenuItemBySessionID 根据会话ID获取菜单项ID
60
+func (s *MappingService) GetMenuItemBySessionID(ctx context.Context, sessionID string) (string, error) {
61
+	filter := bson.M{"session_id": sessionID}
62
+	var mapping SessionMenuMapping
63
+
64
+	err := s.mongoFactory.FindOne(SessionMenuMapping{}.CollectionName(), filter, &mapping)
65
+	if err != nil {
66
+		if err.Error() == "mongo: no documents in result" {
67
+			return "", nil // 未找到映射,返回空字符串
68
+		}
69
+		logger.Error("根据会话ID查询菜单项失败", "session_id", sessionID, "error", err)
70
+		return "", err
71
+	}
72
+
73
+	return mapping.MenuItemID, nil
74
+}
75
+
76
+// GetSessionIDsByMenuItemID 根据菜单项ID获取会话ID列表
77
+func (s *MappingService) GetSessionIDsByMenuItemID(ctx context.Context, menuItemID, userID string) ([]string, error) {
78
+	filter := bson.M{"menu_item_id": menuItemID, "user_id": userID}
79
+	var mappings []SessionMenuMapping
80
+
81
+	err := s.mongoFactory.Find(SessionMenuMapping{}.CollectionName(), filter, &mappings)
82
+	if err != nil {
83
+		logger.Error("根据菜单项ID查询会话ID失败", "menu_item_id", menuItemID, "error", err)
84
+		return nil, err
85
+	}
86
+
87
+	var sessionIDs []string
88
+	for _, mapping := range mappings {
89
+		sessionIDs = append(sessionIDs, mapping.SessionID)
90
+	}
91
+
92
+	return sessionIDs, nil
93
+}
94
+
95
+// GetMappingsByUser 获取用户的所有会话-菜单映射
96
+func (s *MappingService) GetMappingsByUser(ctx context.Context, userID string) (map[string]string, error) {
97
+	filter := bson.M{"user_id": userID}
98
+	var mappings []SessionMenuMapping
99
+
100
+	err := s.mongoFactory.Find(SessionMenuMapping{}.CollectionName(), filter, &mappings)
101
+	if err != nil {
102
+		logger.Error("查询用户会话-菜单映射失败", "user_id", userID, "error", err)
103
+		return nil, err
104
+	}
105
+
106
+	result := make(map[string]string)
107
+	for _, mapping := range mappings {
108
+		result[mapping.SessionID] = mapping.MenuItemID
109
+	}
110
+
111
+	return result, nil
112
+}
113
+
114
+// DeleteMappingBySessionID 根据会话ID删除映射
115
+func (s *MappingService) DeleteMappingBySessionID(ctx context.Context, sessionID string) error {
116
+	filter := bson.M{"session_id": sessionID}
117
+	success, deletedCount := s.mongoFactory.DeleteOne(SessionMenuMapping{}.CollectionName(), filter)
118
+	if !success {
119
+		logger.Error("根据会话ID删除映射失败", "session_id", sessionID)
120
+		return errors.New("failed to delete session-menu mapping")
121
+	}
122
+
123
+	logger.Debug("删除会话-菜单映射成功", "session_id", sessionID, "deleted_count", deletedCount)
124
+	return nil
125
+}
126
+
127
+// EnsureIndexes 确保集合索引
128
+func (s *MappingService) EnsureIndexes(ctx context.Context) error {
129
+	// 创建会话ID唯一索引
130
+	sessionIDIndexKeys := bson.D{{Key: "session_id", Value: 1}}
131
+	sessionIDSuccess := s.mongoFactory.CreateIndex(SessionMenuMapping{}.CollectionName(), sessionIDIndexKeys)
132
+	if !sessionIDSuccess {
133
+		logger.Error("创建会话ID索引失败")
134
+		return errors.New("failed to create session_id index")
135
+	}
136
+
137
+	// 创建复合索引:菜单项ID + 用户ID
138
+	menuItemUserIndexKeys := bson.D{
139
+		{Key: "menu_item_id", Value: 1},
140
+		{Key: "user_id", Value: 1},
141
+	}
142
+	menuItemUserSuccess := s.mongoFactory.CreateIndex(SessionMenuMapping{}.CollectionName(), menuItemUserIndexKeys)
143
+	if !menuItemUserSuccess {
144
+		logger.Error("创建菜单项用户复合索引失败")
145
+		return errors.New("failed to create menu_item_id-user_id index")
146
+	}
147
+
148
+	logger.Debug("会话-菜单映射索引创建成功")
149
+	return nil
150
+}

+ 36
- 7
main.go Просмотреть файл

@@ -1,8 +1,8 @@
1 1
 package main
2 2
 
3 3
 import (
4
+	"context"
4 5
 	"log"
5
-	"sync"
6 6
 
7 7
 	"git.x2erp.com/qdy/go-base/config"
8 8
 	"git.x2erp.com/qdy/go-base/container"
@@ -12,9 +12,12 @@ import (
12 12
 	"git.x2erp.com/qdy/go-base/webx"
13 13
 	"git.x2erp.com/qdy/go-base/webx/health"
14 14
 	"git.x2erp.com/qdy/go-base/webx/router"
15
+	"git.x2erp.com/qdy/go-db/factory/mongodb"
15 16
 
16 17
 	"git.x2erp.com/qdy/go-svc-code/internal/opencode"
17 18
 	"git.x2erp.com/qdy/go-svc-code/internal/routes"
19
+	"git.x2erp.com/qdy/go-svc-code/internal/service/event"
20
+	"git.x2erp.com/qdy/go-svc-code/internal/service/menu"
18 21
 )
19 22
 
20 23
 var (
@@ -24,9 +27,9 @@ var (
24 27
 
25 28
 // 全局变量存储 opencode 进程信息
26 29
 var (
27
-	opencodeProcess *opencode.Process
28
-	opencodePort    int
29
-	opencodeMutex   sync.Mutex
30
+	//opencodeProcess *opencode.Process
31
+	opencodePort int
32
+	//opencodeMutex   sync.Mutex
30 33
 )
31 34
 
32 35
 func main() {
@@ -47,6 +50,20 @@ func main() {
47 50
 	// 3. 启用运行日志(需要在启动 opencode 服务之前)
48 51
 	container.Create(ctr, logger.InitRuntimeLogger)
49 52
 
53
+	// 创建mongodb
54
+	mongoDBFactory := container.Create(ctr, mongodb.CreateFactory)
55
+	mongoDBFactory.TestConnection()
56
+
57
+	// 创建会话-菜单映射服务
58
+	mappingService := menu.NewMappingService(mongoDBFactory)
59
+	// 确保索引存在
60
+	ctx := context.Background()
61
+	if err := mappingService.EnsureIndexes(ctx); err != nil {
62
+		log.Printf("警告:创建会话-菜单映射索引失败: %v", err)
63
+	} else {
64
+		log.Printf("会话-菜单映射索引确保成功")
65
+	}
66
+
50 67
 	// 4. 创建 configure 客户端
51 68
 	configClient, err := configure.NewClient()
52 69
 	if err != nil {
@@ -61,6 +78,15 @@ func main() {
61 78
 	}
62 79
 	log.Printf("opencode 客户端已创建,连接端口: %d", opencodePort)
63 80
 
81
+	// 启动事件分发器(用于多用户流式对话隔离)
82
+	dispatcher := event.GetEventDispatcher(client.GetBaseURL(), client.GetPort())
83
+	if err := dispatcher.Start(context.Background()); err != nil {
84
+		log.Printf("警告:事件分发器启动失败(流式功能可能受影响): %v", err)
85
+	} else {
86
+		log.Printf("事件分发器已启动")
87
+		defer dispatcher.Stop()
88
+	}
89
+
64 90
 	// 6. 得到 webservice 服务工厂
65 91
 	webxFactory := webx.GetWebServiceFactory()
66 92
 
@@ -77,7 +103,7 @@ func main() {
77 103
 	health.RegisterConsulHealthCheck(routerService)
78 104
 
79 105
 	// 9. 注册路由--api
80
-	registerRoutes(routerService, webService, configClient, client, nil, opencodePort)
106
+	registerRoutes(routerService, webService, configClient, client, nil, opencodePort, mappingService)
81 107
 
82 108
 	// 9. 注册前端静态文件服务
83 109
 	//registerStaticFiles(webService)
@@ -93,12 +119,12 @@ func main() {
93 119
 }
94 120
 
95 121
 // registerRoutes 注册所有 API 路由
96
-func registerRoutes(ws *router.RouterService, webService *webx.WebService, configClient *configure.Client, client opencode.OpenCodeClient, opencodeProcess *opencode.Process, opencodePort int) {
122
+func registerRoutes(ws *router.RouterService, webService *webx.WebService, configClient *configure.Client, client opencode.OpenCodeClient, opencodeProcess *opencode.Process, opencodePort int, mappingService *menu.MappingService) {
97 123
 	// 认证路由(公开登录接口)
98 124
 	routes.RegisterAuthRoutes(ws, configClient)
99 125
 
100 126
 	// 会话管理路由
101
-	routes.RegisterSessionRoutes(ws, client)
127
+	routes.RegisterSessionRoutes(ws, client, mappingService)
102 128
 
103 129
 	// 同步对话路由
104 130
 	routes.RegisterPromptSyncRoutes(ws, client)
@@ -108,4 +134,7 @@ func registerRoutes(ws *router.RouterService, webService *webx.WebService, confi
108 134
 
109 135
 	// 日志流路由(需要直接 HTTP 处理器)
110 136
 	routes.RegisterLogStreamRoutes(ws, webService, opencodeProcess, opencodePort)
137
+
138
+	// 菜单路由
139
+	routes.RegisterMenuRoutes(ws, mappingService)
111 140
 }

Загрузка…
Отмена
Сохранить