Version: 0.6.2.dev.260316
✨ 新增获取用户对话列表接口,采用分页读库方式实现,暂未引入缓存,以保证数据一致性
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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引擎
|
||||
|
||||
@@ -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) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;
|
||||
|
||||
Reference in New Issue
Block a user