Files
CamTalk/backend/internal/api/conversation.go

323 lines
8.4 KiB
Go
Raw Normal View History

// Package api 提供 REST API 处理函数。
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/hhs/camtalk/internal/auth"
apperr "github.com/hhs/camtalk/internal/errors"
"github.com/hhs/camtalk/internal/models"
"github.com/hhs/camtalk/internal/session"
2026-06-14 18:40:37 +08:00
"github.com/hhs/camtalk/internal/store"
)
// ConversationHandler 提供对话相关的 REST 端点。
type ConversationHandler struct {
sessionMgr session.Manager
tokenMgr *auth.TokenManager
2026-06-14 18:40:37 +08:00
msgRepo store.MessageRepository // 可选,为 nil 时 fallback 到内存查询
}
// NewConversationHandler 创建 ConversationHandler。
2026-06-14 18:40:37 +08:00
// msgRepo 可选,为 nil 时消息查询走内存。
func NewConversationHandler(sessionMgr session.Manager, tokenMgr *auth.TokenManager, msgRepo store.MessageRepository) *ConversationHandler {
return &ConversationHandler{
sessionMgr: sessionMgr,
tokenMgr: tokenMgr,
2026-06-14 18:40:37 +08:00
msgRepo: msgRepo,
}
}
// RegisterRoutes 注册对话相关路由到给定的路由组。所有端点需要认证。
func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) {
conv := rg.Group("/conversations", auth.AuthMiddleware(h.tokenMgr))
{
conv.GET("", h.List)
conv.POST("", h.Create)
conv.GET("/:id", h.Get)
conv.PATCH("/:id", h.UpdateTitle)
conv.DELETE("/:id", h.Delete)
conv.GET("/:id/messages", h.GetMessages)
}
}
// List GET /api/conversations — 获取当前用户的对话列表。
func (h *ConversationHandler) List(c *gin.Context) {
userID := c.GetString(auth.ContextKeyUserID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
if page <= 0 {
page = 1
}
if size <= 0 || size > 100 {
size = 20
}
summaries, total, err := h.sessionMgr.ListByUser(c.Request.Context(), userID, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to list conversations",
})
return
}
c.JSON(http.StatusOK, gin.H{
"conversations": summaries,
"total": total,
"page": page,
"size": size,
})
}
// CreateConversationRequest POST /api/conversations 请求体。
type CreateConversationRequest struct {
Config *models.SessionConfig `json:"config,omitempty"`
}
// Create POST /api/conversations — 创建新对话。
func (h *ConversationHandler) Create(c *gin.Context) {
userID := c.GetString(auth.ContextKeyUserID)
var req CreateConversationRequest
_ = c.ShouldBindJSON(&req)
cfg := models.DefaultConfig()
if req.Config != nil {
cfg = *req.Config
}
sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to create conversation",
})
return
}
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to retrieve created conversation",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": sess.ID,
"title": sess.Title,
"created_at": sess.CreatedAt,
"updated_at": sess.UpdatedAt,
})
}
// Get GET /api/conversations/:id — 获取对话详情。
func (h *ConversationHandler) Get(c *gin.Context) {
sessionID := c.Param("id")
sess, err := h.getSessionForUser(c, sessionID)
if err != nil {
return // getSessionForUser 已写入响应
}
c.JSON(http.StatusOK, gin.H{
"id": sess.ID,
"title": sess.Title,
"created_at": sess.CreatedAt,
"updated_at": sess.UpdatedAt,
"config": sess.Config,
})
}
// UpdateTitleRequest PATCH /api/conversations/:id 请求体。
type UpdateTitleRequest struct {
Title string `json:"title"`
}
// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。
func (h *ConversationHandler) UpdateTitle(c *gin.Context) {
sessionID := c.Param("id")
// 先校验归属
if _, err := h.getSessionForUser(c, sessionID); err != nil {
return
}
var req UpdateTitleRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "title is required",
})
return
}
if len([]rune(req.Title)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{
"code": apperr.CodeInvalidInput,
"message": "title must be 100 characters or less",
})
return
}
if err := h.sessionMgr.UpdateTitle(c.Request.Context(), sessionID, req.Title); err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
c.JSON(http.StatusNotFound, gin.H{
"code": apperr.CodeSessionNotFound,
"message": "conversation not found",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to update title",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "title updated",
})
}
// Delete DELETE /api/conversations/:id — 删除对话。
func (h *ConversationHandler) Delete(c *gin.Context) {
sessionID := c.Param("id")
// 先校验归属
if _, err := h.getSessionForUser(c, sessionID); err != nil {
return
}
if err := h.sessionMgr.Destroy(c.Request.Context(), sessionID); err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
c.JSON(http.StatusNotFound, gin.H{
"code": apperr.CodeSessionNotFound,
"message": "conversation not found",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to delete conversation",
})
return
}
c.Status(http.StatusNoContent)
}
// GetMessages GET /api/conversations/:id/messages — 获取对话消息列表。
//
// 查询参数:
// - limit: 返回消息数量上限,默认 50
2026-06-14 18:40:37 +08:00
// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息
func (h *ConversationHandler) GetMessages(c *gin.Context) {
sessionID := c.Param("id")
// 先校验归属
if _, err := h.getSessionForUser(c, sessionID); err != nil {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
2026-06-14 18:40:37 +08:00
beforeID, _ := strconv.ParseInt(c.DefaultQuery("before", "0"), 10, 64)
2026-06-14 18:40:37 +08:00
// 优先从 PostgreSQL 查询(支持持久化后的全量历史)
if h.msgRepo != nil {
messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to get messages",
})
return
}
count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID)
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": count,
})
return
}
// fallback从内存查询
allMessages, err := h.sessionMgr.GetHistory(c.Request.Context(), sessionID, 0)
if err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
c.JSON(http.StatusNotFound, gin.H{
"code": apperr.CodeSessionNotFound,
"message": "conversation not found",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "failed to get messages",
})
return
}
total := len(allMessages)
2026-06-14 18:40:37 +08:00
// beforeID > 0 时表示偏移量(兼容旧接口语义)
if beforeID > 0 && int(beforeID) <= total {
allMessages = allMessages[:beforeID]
}
// 取最后 limit 条
start := len(allMessages) - limit
if start < 0 {
start = 0
}
messages := allMessages[start:]
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": total,
})
}
// getSessionForUser 获取会话并校验当前用户是否有权限访问。
// 返回 404而非 403以避免信息泄露。
func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) {
sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID)
if err != nil {
if errors.Is(err, session.ErrSessionNotFound) {
c.JSON(http.StatusNotFound, gin.H{
"code": apperr.CodeSessionNotFound,
"message": "conversation not found",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{
"code": apperr.CodeInternalError,
"message": "internal server error",
})
}
return nil, err
}
userID := c.GetString(auth.ContextKeyUserID)
if sess.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{
"code": apperr.CodeSessionNotFound,
"message": "conversation not found",
})
return nil, errors.New("forbidden")
}
return sess, nil
}