fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置
- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复) - 修复IoT子会话工具调用链路日志缺失 - 新增最终审查子会话(review_provider) 支持消息格式解析拆分 - 实现历史消息持久化(后端存储+前端分页加载) - 前端新增动作消息(ActionMessage)类型和渲染 - 优化对话链路速度(非阻塞子会话+快速问候通道) - JWT密钥环境变量化(无默认值启动panic) - Token自动刷新机制(401拦截器+refresh接口) - WebSocket指数退避重连(jitter+最大10次) - localStorage清理一致性(cyrene_前缀+版本检查) - IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
@@ -78,7 +78,24 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Load 从环境变量加载配置
|
||||
// 注意:JWT_SECRET 和 INTERNAL_SERVICE_TOKEN 必须在环境变量中设置,否则启动时 panic
|
||||
func Load() *Config {
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
panic("致命错误: 环境变量 JWT_SECRET 未设置,服务拒绝启动。请在 .env 文件中设置 JWT_SECRET。")
|
||||
}
|
||||
|
||||
internalServiceToken := os.Getenv("INTERNAL_SERVICE_TOKEN")
|
||||
if internalServiceToken == "" {
|
||||
panic("致命错误: 环境变量 INTERNAL_SERVICE_TOKEN 未设置,服务拒绝启动。请在 .env 文件中设置 INTERNAL_SERVICE_TOKEN。")
|
||||
}
|
||||
|
||||
// IoT 服务 URL:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL(向后兼容)
|
||||
iotServiceURL := os.Getenv("IOT_SERVICE_URL")
|
||||
if iotServiceURL == "" {
|
||||
iotServiceURL = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Env: getEnv("ENV", "development"),
|
||||
Port: getEnv("GATEWAY_PORT", "8080"),
|
||||
@@ -93,7 +110,7 @@ func Load() *Config {
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPass: getEnv("REDIS_PASSWORD", ""),
|
||||
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||
JWTSecret: jwtSecret,
|
||||
JWTExpiryHours: time.Duration(getEnvInt("JWT_EXPIRY_HOURS", 720)) * time.Hour,
|
||||
|
||||
// 管理员账户 (开发阶段使用)
|
||||
@@ -108,7 +125,7 @@ func Load() *Config {
|
||||
|
||||
MemoryServiceURL: getEnv("MEMORY_SERVICE_URL", "http://localhost:8091"),
|
||||
|
||||
IoTDebugServiceURL: getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083"),
|
||||
IoTDebugServiceURL: iotServiceURL,
|
||||
|
||||
VoiceServiceURL: getEnv("VOICE_SERVICE_URL", "http://localhost:8093"),
|
||||
|
||||
@@ -122,7 +139,7 @@ func Load() *Config {
|
||||
SessionIdleTimeoutMin: getEnvInt("SESSION_IDLE_TIMEOUT_MIN", 30),
|
||||
|
||||
WebhookAPIKey: getEnv("WEBHOOK_API_KEY", ""),
|
||||
InternalServiceToken: getEnv("INTERNAL_SERVICE_TOKEN", "cyrene-internal-token-change-me"),
|
||||
InternalServiceToken: internalServiceToken,
|
||||
|
||||
AllowedOrigins: parseAllowedOrigins(getEnv("ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5199,http://localhost:3000")),
|
||||
|
||||
@@ -140,10 +157,11 @@ func (c *Config) DatabaseURL() string {
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
// GenerateToken 生成JWT token (短期 access token)
|
||||
func (c *Config) GenerateToken(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"type": "access",
|
||||
"exp": time.Now().Add(c.JWTExpiryHours).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
@@ -151,6 +169,18 @@ func (c *Config) GenerateToken(userID string) (string, error) {
|
||||
return token.SignedString([]byte(c.JWTSecret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken 生成 refresh token (长期有效,30天)
|
||||
func (c *Config) GenerateRefreshToken(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"type": "refresh",
|
||||
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30天
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(c.JWTSecret))
|
||||
}
|
||||
|
||||
// ValidateToken 验证JWT token
|
||||
func (c *Config) ValidateToken(tokenString string) (string, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -172,6 +202,33 @@ func (c *Config) ValidateToken(tokenString string) (string, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 验证 refresh token
|
||||
func (c *Config) ValidateRefreshToken(tokenString string) (string, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(c.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return "", jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
// 验证类型必须是 "refresh"
|
||||
tokenType, _ := claims["type"].(string)
|
||||
if tokenType != "refresh" {
|
||||
return "", fmt.Errorf("无效的刷新令牌类型")
|
||||
}
|
||||
|
||||
userID, _ := claims["user_id"].(string)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -108,11 +108,19 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 refresh_token (长期有效)
|
||||
refreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"nickname": req.Nickname,
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"refresh_token": refreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"nickname": req.Nickname,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,10 +194,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 refresh_token (长期有效)
|
||||
refreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"user_id": userID,
|
||||
"token": token,
|
||||
"refresh_token": refreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,18 +235,38 @@ func (h *AuthHandler) verifyUserPassword(username, password string) (bool, error
|
||||
}
|
||||
|
||||
// RefreshToken 刷新令牌
|
||||
// 支持两种方式:
|
||||
// 1. 在 Authorization header 中传入有效的 access_token (可以已过期但 refresh_token 有效)
|
||||
// 2. 在请求体中传入 refresh_token
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || len(authHeader) < 8 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
|
||||
tokenString := authHeader[7:] // 去掉 "Bearer "
|
||||
userID, err := h.cfg.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
|
||||
return
|
||||
// 优先从请求体获取 refresh_token
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" {
|
||||
uid, err := h.cfg.ValidateRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "刷新令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
userID = uid
|
||||
} else {
|
||||
// 回退:从 Authorization header 获取 access_token 并验证
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || len(authHeader) < 8 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := authHeader[7:] // 去掉 "Bearer "
|
||||
uid, err := h.cfg.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "令牌无效或已过期"})
|
||||
return
|
||||
}
|
||||
userID = uid
|
||||
}
|
||||
|
||||
newToken, err := h.cfg.GenerateToken(userID)
|
||||
@@ -239,8 +275,16 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新的 refresh_token
|
||||
newRefreshToken, err := h.cfg.GenerateRefreshToken(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成刷新令牌失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": newToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
"token": newToken,
|
||||
"refresh_token": newRefreshToken,
|
||||
"expires": time.Now().Add(h.cfg.JWTExpiryHours).Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,21 +17,24 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/config"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/store"
|
||||
"github.com/yourname/cyrene-ai/gateway/internal/ws"
|
||||
)
|
||||
|
||||
// ChatHandler 聊天处理器
|
||||
type ChatHandler struct {
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
upgrader websocket.Upgrader
|
||||
cfg *config.Config
|
||||
hub *ws.Hub
|
||||
sessionStore *store.SessionStore
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewChatHandler 创建聊天处理器
|
||||
func NewChatHandler(cfg *config.Config, hub *ws.Hub) *ChatHandler {
|
||||
func NewChatHandler(cfg *config.Config, hub *ws.Hub, sessionStore *store.SessionStore) *ChatHandler {
|
||||
return &ChatHandler{
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
cfg: cfg,
|
||||
hub: hub,
|
||||
sessionStore: sessionStore,
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
@@ -123,6 +126,13 @@ func (h *ChatHandler) handleChatMessage(client *ws.Client, msg ws.ClientMessage)
|
||||
mode = "text"
|
||||
}
|
||||
|
||||
// 持久化用户消息到数据库(在 WebSocket 发送之前)
|
||||
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
|
||||
if err := h.sessionStore.AddMessage(client.SessionID, "user", msg.Content); err != nil {
|
||||
log.Printf("[chat] 持久化用户消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录用户消息
|
||||
h.hub.RecordMessage(client.SessionID, "user", msg.Content)
|
||||
|
||||
@@ -359,8 +369,14 @@ func (h *ChatHandler) streamResponse(client *ws.Client, mode string, reqBody []b
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
|
||||
// 缓存完整响应
|
||||
// 持久化 AI 回复到数据库(在 WebSocket 发送之前)
|
||||
if fullText != "" {
|
||||
if h.sessionStore != nil && h.sessionStore.IsAvailable() {
|
||||
if err := h.sessionStore.AddMessage(client.SessionID, "assistant", fullText); err != nil {
|
||||
log.Printf("[chat] 持久化 AI 回复失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h.hub.CacheMessage(client.UserID, client.SessionID, ws.Message{
|
||||
ID: msgID,
|
||||
Role: "assistant",
|
||||
|
||||
@@ -285,8 +285,22 @@ func (h *SessionHandler) GetMessages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
offset := 0
|
||||
if o := c.Query("offset"); o != "" {
|
||||
parsed := 0
|
||||
for _, ch := range o {
|
||||
if ch < '0' || ch > '9' {
|
||||
break
|
||||
}
|
||||
parsed = parsed*10 + int(ch-'0')
|
||||
}
|
||||
if parsed > 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if h.useDB {
|
||||
messages, err := h.store.GetMessages(sessionID, limit)
|
||||
messages, err := h.store.GetMessages(sessionID, limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
@@ -536,7 +550,7 @@ func (h *SessionHandler) ExportSession(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 获取所有消息 (不限制数量,导出全部)
|
||||
messages, err := h.store.GetMessages(sessionID, 0)
|
||||
messages, err := h.store.GetMessages(sessionID, 0, 0)
|
||||
if err != nil {
|
||||
log.Printf("[SessionHandler] 查询消息失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败", "errorType": "db_error"})
|
||||
|
||||
@@ -27,7 +27,7 @@ func Setup(r *gin.Engine, hub *ws.Hub, cfg *config.Config, sessionStore *store.S
|
||||
authHandler := handler.NewAuthHandler(cfg, authDB)
|
||||
sessionHandler := handler.NewSessionHandler(hub, sessionStore)
|
||||
memoryHandler := handler.NewMemoryHandler(cfg.MemoryServiceURL)
|
||||
chatHandler := handler.NewChatHandler(cfg, hub)
|
||||
chatHandler := handler.NewChatHandler(cfg, hub, sessionStore)
|
||||
webhookHandler := handler.NewWebhookHandler(cfg, hub)
|
||||
notificationHandler := handler.NewNotificationHandler(cfg, hub)
|
||||
reminderHandler := handler.NewReminderHandler(reminderStore, hub)
|
||||
|
||||
@@ -211,18 +211,21 @@ func (s *SessionStore) AddMessage(sessionID, role, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMessages 获取会话的消息列表(按时间正序)
|
||||
func (s *SessionStore) GetMessages(sessionID string, limit int) ([]Message, error) {
|
||||
// GetMessages 获取会话的消息列表(按时间正序,支持分页)
|
||||
func (s *SessionStore) GetMessages(sessionID string, limit, offset int) ([]Message, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, session_id, role, content, created_at
|
||||
FROM messages WHERE session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2`,
|
||||
sessionID, limit,
|
||||
LIMIT $2 OFFSET $3`,
|
||||
sessionID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询消息失败: %w", err)
|
||||
|
||||
@@ -553,7 +553,11 @@ func (h *Hub) pollAndBroadcastIoT() {
|
||||
h.mu.RUnlock()
|
||||
|
||||
if url == "" {
|
||||
url = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
// 向后兼容:优先使用 IOT_SERVICE_URL,回退到 IOT_DEBUG_SERVICE_URL
|
||||
url = getEnv("IOT_SERVICE_URL", "")
|
||||
if url == "" {
|
||||
url = getEnv("IOT_DEBUG_SERVICE_URL", "http://localhost:8083")
|
||||
}
|
||||
}
|
||||
|
||||
devices, err := fetchIoTDevices(url)
|
||||
|
||||
Reference in New Issue
Block a user