Version: 0.6.2.dev.260316

 新增获取用户对话列表接口,采用分页读库方式实现,暂未引入缓存,以保证数据一致性
This commit is contained in:
Losita
2026-03-16 16:53:14 +08:00
parent 626fc700d2
commit daeff0afab
5 changed files with 216 additions and 0 deletions

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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引擎

View File

@@ -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) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;