diff --git a/backend/api/agent.go b/backend/api/agent.go index 88f10c1..531900b 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "time" @@ -122,3 +123,52 @@ func (api *AgentHandler) GetConversationMeta(c *gin.Context) { // 5. 返回统一响应结构。 c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, meta)) } + +// GetConversationList 返回当前登录用户的会话列表(分页)。 +// +// 设计说明: +// 1) 接口只返回“列表元信息”,不返回消息正文,避免列表接口过重; +// 2) page/page_size 为可选参数,缺省值由 service 层统一兜底; +// 3) status 可选,支持 active/archived,非法值直接返回 400。 +func (api *AgentHandler) GetConversationList(c *gin.Context) { + // 1. 从 JWT 上下文读取 user_id,保证只查“当前用户自己的会话”。 + userID := c.GetInt("user_id") + + // 2. 解析分页参数(可选): + // 2.1 参数不存在时保持 0,让 service 使用默认值; + // 2.2 参数存在但格式非法时直接返回 400,避免脏参数下沉。 + page := 0 + if rawPage := strings.TrimSpace(c.Query("page")); rawPage != "" { + parsedPage, err := strconv.Atoi(rawPage) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + page = parsedPage + } + + pageSize := 0 + if rawPageSize := strings.TrimSpace(c.Query("page_size")); rawPageSize != "" { + parsedPageSize, err := strconv.Atoi(rawPageSize) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + pageSize = parsedPageSize + } + + // 3. status 过滤器可选,最终合法性由 service 层统一校验。 + status := strings.TrimSpace(c.Query("status")) + + // 4. 读接口设置短超时,避免慢查询占用连接。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + + // 5. 调 service 查询并返回统一响应结构。 + resp, err := api.svc.GetConversationList(ctx, userID, page, pageSize, status) + if err != nil { + respond.DealWithError(c, err) + return + } + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) +} diff --git a/backend/dao/agent.go b/backend/dao/agent.go index bdd7e1d..3a26268 100644 --- a/backend/dao/agent.go +++ b/backend/dao/agent.go @@ -169,3 +169,48 @@ func (a *AgentDAO) UpdateConversationTitleIfEmpty(ctx context.Context, userID in Where("user_id = ? AND chat_id = ? AND (title IS NULL OR title = '')", userID, chatID). Update("title", normalized).Error } + +// GetConversationList 按分页查询指定用户的会话列表。 +// +// 职责边界: +// 1. 只负责读库,不负责缓存; +// 2. 只负责 user_id 数据隔离,不负责参数合法性兜底(由 service 负责); +// 3. 返回总数 total 供上层计算 has_more。 +func (a *AgentDAO) GetConversationList(ctx context.Context, userID, page, pageSize int, status string) ([]model.AgentChat, int64, error) { + // 1. 先构造统一过滤条件,保证 total 与 list 的统计口径一致。 + baseQuery := a.db.WithContext(ctx).Model(&model.AgentChat{}).Where("user_id = ?", userID) + if strings.TrimSpace(status) != "" { + baseQuery = baseQuery.Where("status = ?", status) + } + + // 2. 先查总条数,给前端分页器提供完整元信息。 + var total int64 + if err := baseQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return make([]model.AgentChat, 0), 0, nil + } + + // 3. 再查当前页数据: + // 3.1 按最近消息时间倒序,保证“最近活跃”优先展示; + // 3.2 同时间戳下按 id 倒序,避免翻页时顺序抖动。 + offset := (page - 1) * pageSize + var chats []model.AgentChat + query := a.db.WithContext(ctx). + Model(&model.AgentChat{}). + Select("id", "chat_id", "title", "message_count", "last_message_at", "status", "created_at"). + Where("user_id = ?", userID) + if strings.TrimSpace(status) != "" { + query = query.Where("status = ?", status) + } + + if err := query.Order("last_message_at DESC"). + Order("id DESC"). + Offset(offset). + Limit(pageSize). + Find(&chats).Error; err != nil { + return nil, 0, err + } + return chats, total, nil +} diff --git a/backend/model/agent.go b/backend/model/agent.go index 234c139..61bc69b 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -36,6 +36,36 @@ type GetConversationMetaResponse struct { Status string `json:"status"` } +// GetConversationListItem 是“会话列表”中的单项数据。 +// +// 职责边界: +// 1. 仅承载列表展示所需的轻量字段,不承载具体消息正文; +// 2. title 允许为空字符串,has_title 用于前端快速判断占位文案; +// 3. message_count/last_message_at 用于排序展示与角标扩展。 +type GetConversationListItem struct { + ConversationID string `json:"conversation_id"` + Title string `json:"title"` + HasTitle bool `json:"has_title"` + MessageCount int `json:"message_count"` + LastMessageAt *time.Time `json:"last_message_at,omitempty"` + Status string `json:"status"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// GetConversationListResponse 是“获取用户会话列表”接口的统一响应体。 +// +// 职责边界: +// 1. list 承载当前页数据; +// 2. page/page_size/total/has_more 承载分页语义; +// 3. 不负责返回会话正文明细(正文仍由聊天历史接口承担)。 +type GetConversationListResponse struct { + List []GetConversationListItem `json:"list"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + HasMore bool `json:"has_more"` +} + type SSEResponse struct { Event string `json:"event"` ID int `json:"id,omitempty"` diff --git a/backend/routers/routers.go b/backend/routers/routers.go index f28ecff..505d0ae 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -89,6 +89,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk agentGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) agentGroup.POST("/chat", handlers.AgentHandler.ChatAgent) agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta) + agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList) } } // 初始化Gin引擎 diff --git a/backend/service/agentsvc/agent_meta.go b/backend/service/agentsvc/agent_meta.go index a153c45..95f2760 100644 --- a/backend/service/agentsvc/agent_meta.go +++ b/backend/service/agentsvc/agent_meta.go @@ -9,6 +9,7 @@ import ( "unicode/utf8" "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" "github.com/cloudwego/eino-ext/components/model/ark" einoModel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" @@ -25,6 +26,12 @@ const ( // conversationTitleMaxChars 是标题最大字符数(按 rune 计)。 // 控制标题长度,避免前端展示溢出。 conversationTitleMaxChars = 24 + // conversationListDefaultPage 是会话列表默认页码。 + conversationListDefaultPage = 1 + // conversationListDefaultPageSize 是会话列表默认分页大小。 + conversationListDefaultPageSize = 20 + // conversationListMaxPageSize 是会话列表单页上限,避免超大分页压垮数据库。 + conversationListMaxPageSize = 100 ) const conversationTitlePrompt = `你是 SmartFlow 的会话标题生成器。 @@ -61,6 +68,89 @@ func (s *AgentService) GetConversationMeta(ctx context.Context, userID int, chat }, nil } +// GetConversationList 返回“当前用户会话列表(分页)”。 +// +// 职责边界: +// 1. 负责分页参数规范化(默认值、上限保护); +// 2. 负责状态过滤值校验(仅允许 active/archived); +// 3. 负责把 DAO 模型转换成前端响应 DTO; +// 4. 不负责缓存(由上层架构决策按需引入)。 +func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pageSize int, status string) (*model.GetConversationListResponse, error) { + // 1. 先做参数规范化,保证 DAO 层始终收到安全参数。 + normalizedPage := normalizeConversationListPage(page) + normalizedPageSize := normalizeConversationListPageSize(pageSize) + + // 2. 校验状态过滤器: + // 2.1 允许空值(表示不过滤); + // 2.2 仅接受 active/archived,避免把任意字符串下推到 SQL。 + normalizedStatus, valid := normalizeConversationStatus(status) + if !valid { + return nil, respond.WrongParamType + } + + // 3. 查库拿分页结果。 + chats, total, err := s.repo.GetConversationList(ctx, userID, normalizedPage, normalizedPageSize, normalizedStatus) + if err != nil { + return nil, err + } + + // 4. 转换为响应 DTO,统一 title/has_title 语义,避免前端重复处理空指针。 + items := make([]model.GetConversationListItem, 0, len(chats)) + for _, chatItem := range chats { + title := "" + if chatItem.Title != nil { + title = strings.TrimSpace(*chatItem.Title) + } + items = append(items, model.GetConversationListItem{ + ConversationID: chatItem.ChatID, + Title: title, + HasTitle: title != "", + MessageCount: chatItem.MessageCount, + LastMessageAt: chatItem.LastMessageAt, + Status: chatItem.Status, + CreatedAt: chatItem.CreatedAt, + }) + } + + // 5. 计算 has_more 语义,前端可直接用于“继续加载”按钮。 + hasMore := int64(normalizedPage*normalizedPageSize) < total + return &model.GetConversationListResponse{ + List: items, + Page: normalizedPage, + PageSize: normalizedPageSize, + Total: total, + HasMore: hasMore, + }, nil +} + +func normalizeConversationListPage(page int) int { + if page <= 0 { + return conversationListDefaultPage + } + return page +} + +func normalizeConversationListPageSize(pageSize int) int { + if pageSize <= 0 { + return conversationListDefaultPageSize + } + if pageSize > conversationListMaxPageSize { + return conversationListMaxPageSize + } + return pageSize +} + +func normalizeConversationStatus(status string) (string, bool) { + normalized := strings.TrimSpace(strings.ToLower(status)) + if normalized == "" { + return "", true + } + if normalized == "active" || normalized == "archived" { + return normalized, true + } + return "", false +} + // ensureConversationTitleAsync 在后台异步生成并写入会话标题。 // 设计约束: // 1) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;