系列文章:Go 聊天室实战系列(5/6)

项目地址https://gitee.com/gaogaohang/go-land_-learning

技术栈:Go 1.23 + Gin + PostgreSQL + Redis + Vue3


一、数据库设计

1.1 会话表

-- 会话表(私聊/群聊)
CREATE TABLE sessions (
    id BIGSERIAL PRIMARY KEY,
    type INT NOT NULL,  -- 1:私聊 2:群聊
    name VARCHAR(100),  -- 群聊名称(私聊为空)
    avatar VARCHAR(255), -- 群头像
    owner_id BIGINT,    -- 群主 ID(私聊为空)
    member_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
​
-- 私聊关系表
CREATE TABLE session_members (
    id BIGSERIAL PRIMARY KEY,
    session_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    role INT DEFAULT 0,  -- 0:成员 1:管理员 2:群主
    unread_count INT DEFAULT 0,
    last_msg_id BIGINT,
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_session_user (session_id, user_id)
);
​
-- 索引
CREATE INDEX idx_sessions_type ON sessions(type);
CREATE INDEX idx_session_members_user ON session_members(user_id);

1.2 消息表

-- 消息表
CREATE TABLE messages (
    id BIGSERIAL PRIMARY KEY,
    session_id BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    content TEXT NOT NULL,
    type INT DEFAULT 1,  -- 1:文本 2:图片 3:语音 4:视频 5:文件 6:系统
    status INT DEFAULT 0, -- 0:未读 1:已读 2:已撤回 3:已删除
    extra JSONB,         -- 扩展信息(图片 URL、文件大小等)
    reply_to_id BIGINT,  -- 回复的消息 ID
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
​
-- 索引
CREATE INDEX idx_messages_session ON messages(session_id);
CREATE INDEX idx_messages_sender ON messages(sender_id);
CREATE INDEX idx_messages_created ON messages(created_at DESC);
CREATE INDEX idx_messages_status ON messages(status);
​
-- 群组表
CREATE TABLE groups (
    id BIGSERIAL PRIMARY KEY,
    session_id BIGINT NOT NULL,
    name VARCHAR(100) NOT NULL,
    avatar VARCHAR(255),
    owner_id BIGINT NOT NULL,
    notice TEXT,
    max_members INT DEFAULT 500,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
​
-- 群组成员表
CREATE TABLE group_members (
    id BIGSERIAL PRIMARY KEY,
    group_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    role INT DEFAULT 0,  -- 0:成员 1:管理员 2:群主
    remark VARCHAR(50),  -- 群昵称
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE KEY uk_group_user (group_id, user_id)
);

二、数据模型

2.1 会话模型

// internal/model/session.go
​
package model
​
import "time"
​
// SessionType 会话类型
type SessionType int
​
const (
    SessionTypePrivate SessionType = 1 // 私聊
    SessionTypeGroup   SessionType = 2 // 群聊
)
​
// Session 会话
type Session struct {
    ID          int64       `json:"id" gorm:"primaryKey"`
    Type        SessionType `json:"type" gorm:"not null;index"`
    Name        string      `json:"name" gorm:"size:100"`
    Avatar      string      `json:"avatar" gorm:"size:255"`
    OwnerID     int64       `json:"owner_id"`
    MemberCount int         `json:"member_count" gorm:"default:0"`
    CreatedAt   time.Time   `json:"created_at"`
    UpdatedAt   time.Time   `json:"updated_at"`
    
    // 关联字段
    Members []*SessionMember `json:"members,omitempty" gorm:"foreignKey:SessionID"`
    LastMsg *Message         `json:"last_message,omitempty"`
}
​
// SessionMember 会话成员
type SessionMember struct {
    ID          int64     `json:"id" gorm:"primaryKey"`
    SessionID   int64     `json:"session_id" gorm:"not null;index"`
    UserID      int64     `json:"user_id" gorm:"not null;index"`
    Role        int       `json:"role" gorm:"default:0"`
    UnreadCount int       `json:"unread_count" gorm:"default:0"`
    LastMsgID   int64     `json:"last_msg_id"`
    JoinedAt    time.Time `json:"joined_at"`
    
    User *User `json:"user,omitempty" gorm:"foreignKey:UserID"`
}
​
// Message 消息
type Message struct {
    ID         int64          `json:"id" gorm:"primaryKey"`
    SessionID  int64          `json:"session_id" gorm:"not null;index"`
    SenderID   int64          `json:"sender_id" gorm:"not null;index"`
    Content    string         `json:"content" gorm:"type:text;not null"`
    Type       int            `json:"type" gorm:"default:1"`
    Status     int            `json:"status" gorm:"default:0;index"`
    Extra      map[string]interface{} `json:"extra" gorm:"type:jsonb"`
    ReplyToID  int64          `json:"reply_to_id"`
    CreatedAt  time.Time      `json:"created_at"`
    
    Sender *User `json:"sender,omitempty" gorm:"foreignKey:SenderID"`
}

三、私聊功能

3.1 创建/获取私聊会话

// internal/service/session_service.go
​
package service
​
import (
    "go-chat/internal/model"
    "go-chat/internal/repository"
    "errors"
)
​
// SessionService 会话服务
type SessionService struct {
    sessionRepo *repository.SessionRepository
    messageRepo *repository.MessageRepository
}
​
// GetOrCreatePrivateSession 获取或创建私聊会话
func (s *SessionService) GetOrCreatePrivateSession(userID1, userID2 int64) (*model.Session, error) {
    // 1. 查找是否已存在
    session, err := s.sessionRepo.FindPrivateSession(userID1, userID2)
    if err == nil {
        return session, nil
    }
    
    // 2. 创建新会话
    session = &model.Session{
        Type:        model.SessionTypePrivate,
        MemberCount: 2,
    }
    
    err = s.sessionRepo.Create(session)
    if err != nil {
        return nil, err
    }
    
    // 3. 添加成员
    member1 := &model.SessionMember{
        SessionID: session.ID,
        UserID:    userID1,
    }
    member2 := &model.SessionMember{
        SessionID: session.ID,
        UserID:    userID2,
    }
    
    s.sessionRepo.AddMembers(member1, member2)
    
    return session, nil
}
​
// GetSessionList 获取会话列表
func (s *SessionService) GetSessionList(userID int64, page, pageSize int) (*SessionListResponse, error) {
    sessions, total, err := s.sessionRepo.GetUserSessions(userID, page, pageSize)
    if err != nil {
        return nil, err
    }
    
    // 加载每个会话的最后一条消息
    for _, session := range sessions {
        lastMsg, _ := s.messageRepo.GetLastMessage(session.ID)
        session.LastMsg = lastMsg
    }
    
    return &SessionListResponse{
        List:  sessions,
        Total: total,
    }, nil
}

3.2 发送消息

// internal/service/message_service.go
​
package service
​
import (
    "go-chat/internal/model"
    "go-chat/internal/websocket"
    "time"
)
​
// SendMessageRequest 发送消息请求
type SendMessageRequest struct {
    SessionID int64  `json:"session_id" binding:"required"`
    Content   string `json:"content" binding:"required"`
    Type      int    `json:"type" binding:"oneof=1 2 3 4 5 6"`
    ReplyToID int64  `json:"reply_to_id"`
}
​
// SendMessage 发送消息
func (s *MessageService) SendMessage(userID int64, req *SendMessageRequest) (*model.Message, error) {
    // 1. 验证会话成员资格
    isMember, err := s.sessionRepo.IsMember(req.SessionID, userID)
    if err != nil || !isMember {
        return nil, errors.New("无权在此会话发送消息")
    }
    
    // 2. 创建消息
    msg := &model.Message{
        SessionID: req.SessionID,
        SenderID:  userID,
        Content:   req.Content,
        Type:      req.Type,
        Status:    0,
        ReplyToID: req.ReplyToID,
    }
    
    err = s.messageRepo.Create(msg)
    if err != nil {
        return nil, err
    }
    
    // 3. 通过 WebSocket 推送
    s.pushMessage(msg)
    
    // 4. 更新会话最后消息时间
    s.sessionRepo.UpdateLastMessage(req.SessionID, msg.ID)
    
    return msg, nil
}
​
// pushMessage 推送消息
func (s *MessageService) pushMessage(msg *model.Message) {
    // 获取会话所有成员
    members, _ := s.sessionRepo.GetMembers(msg.SessionID)
    
    for _, member := range members {
        if member.UserID == msg.SenderID {
            continue // 不推送给自己
        }
        
        // 检查是否在线
        if websocket.IsOnline(member.UserID) {
            // 在线:WebSocket 推送
            websocket.SendToUser(member.UserID, websocket.Message{
                Type:      websocket.MsgTypeText,
                FromID:    msg.SenderID,
                SessionID: msg.SessionID,
                Content:   msg.Content,
                Timestamp: time.Now().UnixMilli(),
            })
        } else {
            // 离线:增加未读数
            s.sessionRepo.IncrementUnread(member.UserID, msg.SessionID)
        }
    }
}

3.3 消息撤回

// WithdrawMessage 撤回消息
func (s *MessageService) WithdrawMessage(userID, messageID int64) error {
    msg, err := s.messageRepo.GetByID(messageID)
    if err != nil {
        return errors.New("消息不存在")
    }
    
    // 只能撤回自己的消息
    if msg.SenderID != userID {
        return errors.New("只能撤回自己的消息")
    }
    
    // 2 分钟内可撤回
    if time.Since(msg.CreatedAt) > 2*time.Minute {
        return errors.New("超过撤回时限")
    }
    
    // 更新状态
    msg.Status = 2 // 已撤回
    msg.Content = "消息已撤回"
    
    return s.messageRepo.Update(msg)
}

四、群聊功能

4.1 创建群组

// internal/service/group_service.go
​
package service
​
// CreateGroupRequest 创建群组请求
type CreateGroupRequest struct {
    Name      string   `json:"name" binding:"required,max=100"`
    Avatar    string   `json:"avatar"`
    Notice    string   `json:"notice"`
    MemberIDs []int64  `json:"member_ids"` // 初始成员
}
​
// CreateGroup 创建群组
func (s *GroupService) CreateGroup(ownerID int64, req *CreateGroupRequest) (*model.Group, error) {
    // 1. 创建会话
    session := &model.Session{
        Type:        model.SessionTypeGroup,
        Name:        req.Name,
        Avatar:      req.Avatar,
        OwnerID:     ownerID,
        MemberCount: 1 + len(req.MemberIDs),
    }
    
    err := s.sessionRepo.Create(session)
    if err != nil {
        return nil, err
    }
    
    // 2. 创建群组
    group := &model.Group{
        SessionID:   session.ID,
        Name:        req.Name,
        Avatar:      req.Avatar,
        OwnerID:     ownerID,
        Notice:      req.Notice,
    }
    
    err = s.groupRepo.Create(group)
    if err != nil {
        return nil, err
    }
    
    // 3. 添加群主
    s.groupRepo.AddMember(group.ID, ownerID, 2) // 2=群主
    
    // 4. 添加初始成员
    for _, memberID := range req.MemberIDs {
        s.groupRepo.AddMember(group.ID, memberID, 0) // 0=普通成员
    }
    
    return group, nil
}

4.2 群成员管理

// JoinGroup 加入群组
func (s *GroupService) JoinGroup(userID, groupID int64) error {
    group, err := s.groupRepo.GetByID(groupID)
    if err != nil {
        return errors.New("群组不存在")
    }
    
    // 检查人数限制
    if group.MemberCount >= group.MaxMembers {
        return errors.New("群组人数已达上限")
    }
    
    // 检查是否已在群内
    isMember, _ := s.groupRepo.IsMember(groupID, userID)
    if isMember {
        return errors.New("已在群内")
    }
    
    return s.groupRepo.AddMember(groupID, userID, 0)
}
​
// QuitGroup 退出群组
func (s *GroupService) QuitGroup(userID, groupID int64) error {
    group, _ := s.groupRepo.GetByID(groupID)
    
    // 群主不能直接退出,需转让或解散
    if group.OwnerID == userID {
        return errors.New("群主需先转让或解散群组")
    }
    
    return s.groupRepo.RemoveMember(groupID, userID)
}
​
// KickMember 踢出成员
func (s *GroupService) KickMember(operatorID, groupID, targetID int64) error {
    group, _ := s.groupRepo.GetByID(groupID)
    
    // 只有群主和管理员可以踢人
    role, _ := s.groupRepo.GetMemberRole(groupID, operatorID)
    if role < 1 {
        return errors.New("无权限")
    }
    
    // 不能踢群主
    if targetID == group.OwnerID {
        return errors.New("不能踢群主")
    }
    
    return s.groupRepo.RemoveMember(groupID, targetID)
}

4.3 群消息推送

// SendGroupMessage 发送群消息
func (s *MessageService) SendGroupMessage(userID, groupID int64, content string) (*model.Message, error) {
    // 1. 获取群会话 ID
    sessionID, err := s.groupRepo.GetSessionID(groupID)
    if err != nil {
        return nil, err
    }
    
    // 2. 检查群成员资格
    isMember, _ := s.sessionRepo.IsMember(sessionID, userID)
    if !isMember {
        return nil, errors.New("不是群成员")
    }
    
    // 3. 创建消息
    msg := &model.Message{
        SessionID: sessionID,
        SenderID:  userID,
        Content:   content,
        Type:      1, // 文本
    }
    
    s.messageRepo.Create(msg)
    
    // 4. 推送给所有群成员(除自己)
    members, _ := s.sessionRepo.GetMembers(sessionID)
    for _, member := range members {
        if member.UserID == userID {
            continue
        }
        
        websocket.SendToUser(member.UserID, websocket.Message{
            Type:      websocket.MsgTypeText,
            FromID:    userID,
            SessionID: sessionID,
            Content:   content,
        })
    }
    
    return msg, nil
}

五、消息历史

5.1 分页查询

// GetMessages 获取消息历史
func (s *MessageService) GetMessages(sessionID int64, beforeMsgID int64, limit int) ([]*model.Message, error) {
    if limit > 50 {
        limit = 50 // 最多 50 条
    }
    
    return s.messageRepo.GetBySession(sessionID, beforeMsgID, limit)
}
​
// GetBySession 按会话查询
func (r *MessageRepository) GetBySession(sessionID, beforeMsgID int64, limit int) ([]*model.Message, error) {
    var messages []*model.Message
    
    query := r.db.Where("session_id = ?", sessionID).
        Preload("Sender").
        Order("created_at DESC").
        Limit(limit)
    
    if beforeMsgID > 0 {
        query = query.Where("id < ?", beforeMsgID)
    }
    
    err := query.Find(&messages).Error
    
    // 反转顺序(旧→新)
    for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
        messages[i], messages[j] = messages[j], messages[i]
    }
    
    return messages, err
}

5.2 消息搜索

// SearchMessages 搜索消息
func (s *MessageService) SearchMessages(userID int64, keyword string, limit int) ([]*model.Message, error) {
    // 获取用户所有会话
    sessionIDs, _ := s.sessionRepo.GetUserSessionIDs(userID)
    
    var messages []*model.Message
    r.db.Where("session_id IN ? AND content LIKE ?", sessionIDs, "%"+keyword+"%").
        Preload("Sender").
        Order("created_at DESC").
        Limit(limit).
        Find(&messages)
    
    return messages, nil
}

六、API 接口

6.1 会话接口

// internal/api/session.go
​
// GetSessionList 获取会话列表
// GET /api/v1/sessions?page=1&pageSize=20
func (a *SessionAPI) GetSessionList(c *gin.Context) {
    userID, _ := middleware.CurrentUser(c)
    
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
    
    list, err := a.sessionService.GetSessionList(userID, page, pageSize)
    if err != nil {
        c.JSON(http.StatusInternalServerError, response.Error(5001, err.Error()))
        return
    }
    
    c.JSON(http.StatusOK, response.Success(list))
}
​
// GetMessages 获取消息历史
// GET /api/v1/sessions/:id/messages?before_msg_id=123&limit=20
func (a *SessionAPI) GetMessages(c *gin.Context) {
    sessionID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
    beforeMsgID, _ := strconv.ParseInt(c.Query("before_msg_id"), 10, 64)
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
    
    messages, err := a.messageService.GetMessages(sessionID, beforeMsgID, limit)
    if err != nil {
        c.JSON(http.StatusInternalServerError, response.Error(6001, err.Error()))
        return
    }
    
    c.JSON(http.StatusOK, response.Success(messages))
}

6.2 群聊接口

// internal/api/group.go
​
// CreateGroup 创建群组
// POST /api/v1/groups
func (a *GroupAPI) CreateGroup(c *gin.Context) {
    userID, _ := middleware.CurrentUser(c)
    
    var req service.CreateGroupRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, response.Error(1001, "参数错误"))
        return
    }
    
    group, err := a.groupService.CreateGroup(userID, &req)
    if err != nil {
        c.JSON(http.StatusBadRequest, response.Error(7001, err.Error()))
        return
    }
    
    c.JSON(http.StatusOK, response.Success(group))
}
​
// JoinGroup 加入群组
// POST /api/v1/groups/:id/join
func (a *GroupAPI) JoinGroup(c *gin.Context) {
    userID, _ := middleware.CurrentUser(c)
    groupID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
    
    err := a.groupService.JoinGroup(userID, groupID)
    if err != nil {
        c.JSON(http.StatusBadRequest, response.Error(7002, err.Error()))
        return
    }
    
    c.JSON(http.StatusOK, response.Success(nil))
}
​
// QuitGroup 退出群组
// POST /api/v1/groups/:id/quit
func (a *GroupAPI) QuitGroup(c *gin.Context) {
    userID, _ := middleware.CurrentUser(c)
    groupID, _ := strconv.ParseInt(c.Param("id"), 10, 64)
    
    err := a.groupService.QuitGroup(userID, groupID)
    if err != nil {
        c.JSON(http.StatusBadRequest, response.Error(7003, err.Error()))
        return
    }
    
    c.JSON(http.StatusOK, response.Success(nil))
}

七、前端对接

7.1 会话列表

<!-- web/src/views/Sessions.vue -->
<template>
  <div class="sessions">
    <el-scrollbar>
      <div v-for="session in sessions" :key="session.id"
           class="session-item"
           :class="{ active: session.id === currentSessionId }"
           @click="selectSession(session)">
        <el-avatar :src="session.avatar" />
        <div class="info">
          <div class="name">{{ session.name || session.members[0]?.user.nickname }}</div>
          <div class="last-msg">{{ session.last_message?.content }}</div>
        </div>
        <div v-if="session.unread_count > 0" class="badge">
          {{ session.unread_count }}
        </div>
      </div>
    </el-scrollbar>
  </div>
</template>

7.2 聊天窗口

<!-- web/src/views/ChatWindow.vue -->
<template>
  <div class="chat-window">
    <!-- 消息列表 -->
    <div class="message-list" ref="msgListRef">
      <div v-for="msg in messages" :key="msg.id"
           :class="['message', msg.sender_id === userID ? 'self' : 'other']">
        <el-avatar :src="msg.sender.avatar" size="small" />
        <div class="bubble">
          <div class="content">{{ msg.content }}</div>
          <div class="meta">
            <span>{{ formatTime(msg.created_at) }}</span>
            <span v-if="msg.status === 0">未读</span>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 输入框 -->
    <div class="input-area">
      <el-input
        v-model="inputText"
        type="textarea"
        :rows="3"
        @keyup.enter.exact="sendMessage"
        placeholder="输入消息..."
      />
      <el-button type="primary" @click="sendMessage">发送</el-button>
    </div>
  </div>
</template>
​
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useWebSocket } from '@/hooks/useWebSocket'
​
const props = defineProps<{ sessionId: number }>()
const { send } = useWebSocket()
const messages = ref([])
const inputText = ref('')
​
const loadMessages = async () => {
  const res = await api.get(`/api/v1/sessions/${props.sessionId}/messages`)
  messages.value = res.data.data
}
​
const sendMessage = () => {
  if (!inputText.value.trim()) return
  
  send('text', {
    session_id: props.sessionId,
    content: inputText.value
  })
  
  // 乐观更新
  messages.value.push({
    sender_id: currentUser.value.id,
    content: inputText.value,
    created_at: new Date()
  })
  
  inputText.value = ''
}
​
onMounted(() => {
  loadMessages()
})
</script>

八、总结

本篇完成了:

  • ✅ 私聊会话管理

  • ✅ 群聊创建与管理

  • ✅ 消息发送/接收

  • ✅ 消息撤回

  • ✅ 消息历史查询

  • ✅ 前后端完整对接

下一篇预告:部署与优化 🚀