系列文章: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>八、总结
本篇完成了:
✅ 私聊会话管理
✅ 群聊创建与管理
✅ 消息发送/接收
✅ 消息撤回
✅ 消息历史查询
✅ 前后端完整对接
下一篇预告:部署与优化 🚀