feat(phase2c): 群聊与已读回执功能完整实现

Phase 2c 全部 14 个 Task 完成,包含:

后端(Go):
- MinIO 文件存储服务集成(Docker + Go SDK + 通用上传 API)
- Group 模块完整实现(DAO + Service + Controller + Router + Wire)
  - 18 个群管理 REST API + 11 个 WS 群事件推送
  - 群创建/解散/邀请/踢人/退出/转让群主/设管理员/禁言/全体禁言/群公告/群昵称/免打扰/搜索
- IM Service 扩展(群消息发送/撤回 + @提醒 + 管理员无时限撤回)
- 已读回执后端(单聊会话级 last_read_msg_id + 群聊消息级 im_message_reads)
- 管理端群组管理(列表/详情/解散)
- 数据库迁移(3 张新表 + 2 张表字段扩展)

前端(uni-app):
- 群聊 Store + API 封装 + 11 个 WS 事件监听
- 已读回执 UI(单聊已读/未读标记 + 群聊 X人已读 + 已读详情页)
- 7 个群聊页面(对话/创建/设置/成员/邀请/审批/搜索)
- 会话列表改造(全部/单聊/群聊 Tab + @标记 + 免打扰标识)

管理端(Vue 3 + Element Plus):
- 群组列表页(搜索/分页/详情弹窗/解散群聊)
- 侧边栏群组管理入口

文档同步:进度/架构/设计/API/规范文档全部更新

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-04 14:58:23 +08:00
parent ad76d9a613
commit 19979da59e
77 changed files with 10053 additions and 490 deletions

View File

@@ -2,9 +2,11 @@
> **适用范围**EchoChat Go 后端(`backend/go-service/`
> **创建日期**2026-03-04
> **最后更新**2026-03-04Phase 2c 设计阶段整理
> **最后更新**2026-03-04基于全量代码审查,以实际代码为准
> **关联文档**`docs/conventions/frontend-backend-integration.md`
**核心原则:本文档所有代码模板均直接摘自项目现有代码,编写新模块时必须严格遵循,禁止自创新模式。**
---
## 一、模块分层架构
@@ -14,13 +16,13 @@
```
app/{module_name}/
├── controller/
│ └── {module}_controller.go # HTTP 请求处理(参数绑定/校验 → 调用 Service → 响应)
│ └── {module}_controller.go # HTTP 请求处理
├── service/
│ └── {module}_service.go # 业务逻辑(事务协调、权限校验、多 DAO 编排)
│ └── {module}_service.go # 业务逻辑
├── dao/
│ └── {module}_dao.go # 数据访问GORM 操作,不含业务逻辑)
│ └── {module}_dao.go # 数据访问
├── model/
│ └── {module}.go # 数据模型GORM 结构体,对应数据库表)
│ └── {module}.go # 数据模型
├── handler/
│ └── {module}_handler.go # [可选] WS 事件处理器
├── router.go # 路由注册
@@ -46,254 +48,417 @@ Controller → Service → DAO → 数据库
---
## 三、跨模块接口注入模式
## 三、日志 API以实际代码为准严禁使用不存在的 API
项目 logs 包(`pkg/logs`**只有以下 5 个公开日志方法**
```go
logs.Debug(ctx, funcName, message, ...zap.Field)
logs.Info(ctx, funcName, message, ...zap.Field)
logs.Warn(ctx, funcName, message, ...zap.Field)
logs.Error(ctx, funcName, message, ...zap.Field)
logs.Fatal(ctx, funcName, message, ...zap.Field)
```
辅助方法:`logs.Init()`, `logs.Sync()`, `logs.GetTraceID()`, `logs.MaskEmail()`
**不存在的 API严禁调用**`LogFunctionEntry``LogFunctionExit``LogSuccess``LogFailure` 等均不存在。
**funcName 命名规则**`"{层级}.{文件名}.{方法名}"`
- DAO 层:`"dao.conversation_dao.FindPrivateConversation"`
- Service 层:`"service.im_service.SendMessage"`
- Controller 层:`"controller.auth_controller.Register"`
---
## 四、Controller 层代码风格
项目中存在两种 Controller 风格,新模块应根据所属类型选择对应风格:
### 4.1 前台业务模块 Controllercontact/im 模块风格)
**适用于**contact、im、group、file 等前台用户端业务模块
**特征**:接收器 `ctl`,不记日志,方法级 `handleError`
```go
// 摘自 app/contact/controller/contact_controller.go实际代码
package controller
import (
"strconv"
"github.com/echochat/backend/app/contact/service"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/middleware"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
)
type ContactController struct {
contactService *service.ContactService
}
func NewContactController(contactService *service.ContactService) *ContactController {
return &ContactController{contactService: contactService}
}
// GetFriendList 获取好友列表
// GET /api/v1/contacts?group_id=xx
func (ctl *ContactController) GetFriendList(c *gin.Context) {
ctx := c.Request.Context()
userID, ok := middleware.GetCurrentUserID(c)
if !ok {
utils.ResponseUnauthorized(c, "无法获取当前用户信息")
return
}
// ... 参数解析 ...
friends, err := ctl.contactService.GetFriendList(ctx, userID, groupID)
if err != nil {
ctl.handleError(c, err, "获取好友列表失败")
return
}
utils.ResponseOK(c, friends)
}
// handleError 统一业务错误映射
func (ctl *ContactController) handleError(c *gin.Context, err error, fallbackMsg ...string) {
switch err {
case service.ErrSelfRequest:
utils.ResponseBadRequest(c, err.Error())
case service.ErrAlreadyFriend:
utils.ResponseBadRequest(c, err.Error())
// ... 覆盖所有已知业务错误 ...
default:
msg := "服务器内部错误"
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
msg = fallbackMsg[0]
}
utils.ResponseError(c, msg)
}
}
```
**关键要点**
- 接收器变量名:`ctl`
- **不导入** `logs``zap`,不记日志
- 没有 `funcName` 变量
-`ctx := c.Request.Context()` 获取上下文
-`middleware.GetCurrentUserID(c)` 获取当前用户
- `handleError` 是**方法**(不是包级函数),签名 `(c *gin.Context, err error, fallbackMsg ...string)`
- 响应统一用 `utils.ResponseOK/ResponseBadRequest/ResponseError`
- 参数绑定JSON 用 `c.ShouldBindJSON(&req)`Query 用 `c.ShouldBindQuery(&req)`
### 4.2 auth/admin 模块 Controller 风格
**适用于**auth、admin 模块(涉及安全审计和管理操作,需要更详细的日志)
**特征**:接收器 `ctrl`/`ctl`,有 funcName+logs+zap包级函数 `handleAuthError` 或内联错误处理
```go
// 摘自 app/auth/controller/auth_controller.go实际代码
func (ctrl *AuthController) Register(c *gin.Context) {
funcName := "controller.auth_controller.Register"
ctx := c.Request.Context()
var req dto.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
logs.Warn(ctx, funcName, "参数校验失败", zap.Error(err))
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
return
}
logs.Info(ctx, funcName, "注册请求",
zap.String("username", req.Username),
zap.String("email", logs.MaskEmail(req.Email)),
)
resp, err := ctrl.authService.Register(ctx, &req)
if err != nil {
handleAuthError(c, err, "注册失败")
return
}
utils.ResponseOK(c, resp)
}
```
---
## 五、Service 层代码风格(统一)
所有模块的 Service 层风格一致。
```go
// 摘自 app/im/service/im_service.go + app/contact/service/contact_service.go实际代码
package service
import (
"context"
"errors"
"github.com/echochat/backend/app/xxx/dao"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
)
// 错误变量在包顶部定义
var (
ErrNotFriend = errors.New("对方不是你的好友")
ErrEmptyContent = errors.New("消息内容不能为空")
// ...
)
type IMService struct {
convDAO *dao.ConversationDAO
msgDAO *dao.MessageDAO
// ...
}
func NewIMService(...) *IMService {
return &IMService{...}
}
// SendMessage 发送消息
func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) {
funcName := "service.im_service.SendMessage"
logs.Info(ctx, funcName, "发送消息",
zap.Int64("sender_id", senderID),
zap.Int64("conversation_id", req.ConversationID))
// 业务逻辑...
if err != nil {
logs.Error(ctx, funcName, "操作失败", zap.Error(err))
return nil, err
}
return result, nil
}
```
**关键要点**
- 接收器变量名:`s`
- 每个公开方法开头声明 `funcName` 并记一次 Info/Debug 日志
- 错误时记 `logs.Error` 日志
- 错误定义为包级 `var ErrXxx = errors.New("中文描述")`
- 跨模块依赖通过 interface 注入(定义在 Service 包内)
---
## 六、DAO 层代码风格(统一)
```go
// 摘自 app/im/dao/conversation_dao.go实际代码
type ConversationDAO struct {
db *gorm.DB
}
func NewConversationDAO(db *gorm.DB) *ConversationDAO {
return &ConversationDAO{db: db}
}
func (d *ConversationDAO) FindPrivateConversation(ctx context.Context, userID, targetUserID int64) (*model.Conversation, error) {
funcName := "dao.conversation_dao.FindPrivateConversation"
logs.Debug(ctx, funcName, "查找单聊会话",
zap.Int64("user_id", userID), zap.Int64("target_user_id", targetUserID))
var conv model.Conversation
err := d.db.WithContext(ctx).
Raw(`SELECT ...`, userID, targetUserID).
Scan(&conv).Error
if err != nil {
logs.Error(ctx, funcName, "查找单聊会话失败", zap.Error(err))
return nil, err
}
if conv.ID == 0 {
return nil, nil
}
return &conv, nil
}
```
**关键要点**
- 接收器变量名:`d`
- 所有 DB 操作使用 `d.db.WithContext(ctx)`
- 查询方法开头记 `logs.Debug`,写操作记 `logs.Info`
- 错误时记 `logs.Error`
---
## 七、Router 代码风格
```go
// 摘自 app/contact/router.go实际代码
package contact
import (
"github.com/echochat/backend/app/contact/controller"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(r *gin.Engine, ctrl *controller.ContactController, jwtAuth gin.HandlerFunc) {
authed := r.Group("/api/v1")
authed.Use(jwtAuth)
{
authed.GET("/contacts", ctrl.GetFriendList)
authed.POST("/contacts/request", ctrl.SendFriendRequest)
// ...
}
}
```
**关键要点**
- 函数签名:`RegisterRoutes(r *gin.Engine, ctrl *controller.XxxController, jwtAuth gin.HandlerFunc)`
- 参数名用 `r`(不是 `engine`
- 路由组用 `r.Group(...)` + `.Use(jwtAuth)`
---
## 八、Provider 代码风格
```go
// 摘自 app/contact/provider.go简洁风格
package contact
import (
"github.com/echochat/backend/app/contact/controller"
"github.com/echochat/backend/app/contact/dao"
"github.com/echochat/backend/app/contact/service"
"github.com/google/wire"
)
var ContactSet = wire.NewSet(
dao.NewFriendshipDAO,
dao.NewFriendGroupDAO,
service.NewContactService,
controller.NewContactController,
)
```
**ProviderSet 命名**`{ModuleName}Set`(如 `AuthSet``ContactSet``IMSet``FileSet``GroupSet`
---
## 九、常量定义风格
```go
// 摘自 app/constants/im.go实际代码
package constants
const (
ConversationTypePrivate = 1
ConversationTypeGroup = 2
)
var ConversationTypeMap = map[int]string{
ConversationTypePrivate: "单聊",
ConversationTypeGroup: "群聊",
}
```
**关键要点**
- 常量命名 camelCase`GroupStatusNormal`(不是 `GROUP_STATUS_NORMAL`
- 每组常量配套一个 `XxxMap` 中文映射
- 文件按模块拆分:`im.go``contact.go``group.go`
---
## 十、DTO 定义风格
```go
// 摘自 app/dto/im_dto.go实际代码
package dto
type SendMessageRequest struct {
ConversationID int64 `json:"conversation_id"`
TargetUserID int64 `json:"target_user_id"`
Type int `json:"type"`
Content string `json:"content"`
ClientMsgID string `json:"client_msg_id"`
}
type MessageDTO struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
// ...
}
```
**关键要点**
- 按模块分文件:`im_dto.go``contact_dto.go``admin_dto.go``group_dto.go`
- Request 用 `json` tagPOST body`form` tagGET query
- Response 用 `json` tag + `omitempty` 可选
- 时间字段在 DTO 中用 `string` 类型(格式化后的 `"2006-01-02 15:04:05"`
---
## 十一、Model 定义风格
```go
// 摘自 app/im/model/conversation.go实际代码
package model
import "time"
type Conversation struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
Type int `json:"type" gorm:"not null;default:1"`
CreatorID int64 `json:"creator_id" gorm:"not null"`
LastMessageID *int64 `json:"last_message_id"`
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"`
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"`
}
func (Conversation) TableName() string {
return "im_conversations"
}
```
**关键要点**
- 必须有 `TableName()` 方法
- 时间字段用 `time.Time`GORM tag 含 `type:timestamp(0)`
- 可选字段用指针类型(`*int64``*time.Time``*string`
- 每个字段必须有注释说明用途
---
## 十二、跨模块接口注入模式
模块间通信通过 **interface injection** 实现,禁止直接 import 其他模块包。
### 3.1 标准流程
### 标准流程
```
步骤1: 在消费方 Service 中定义 interface(如 im/service → GroupMemberChecker
步骤2: 在提供方 DAO 中实现该接口(如 group/dao.GroupDAO
步骤3: 在 app/provider/wire.go 中用 wire.Bind 绑定接口和实现
步骤4: 重新生成 wire_gen.go
步骤1: 在消费方 Service 中定义 interface
步骤2: 在提供方 DAO 中实现该接口
步骤3: 在 app/provider/wire.go 中用 wire.Bind 绑定
步骤4: 更新 wire_gen.go
```
### 3.2 接口命名约定
| 接口类型 | 命名模式 | 示例 |
|---------|---------|------|
| 数据查询 | `{Entity}{Action}er` | `FriendIDsGetter`, `GroupInfoGetter` |
| 状态检查 | `{Entity}{State}Checker` | `GroupMemberChecker`, `OnlineChecker` |
| 操作执行 | `{Entity}{Action}er` | `OfflineMessagePusher` |
### 3.3 已有接口注入清单
### 已有接口注入清单
| 接口 | 定义方 | 实现方 | 用途 |
|------|--------|--------|------|
| FriendIDsGetter | ws/handler | contact/dao | 获取用户好友 ID 列表 |
| FriendChecker | im/service | contact/dao | 检查是否为好友 |
| UserInfoGetter | im/service | auth/dao | 获取用户信息 |
| UserInfoGetter | im/service | contact/dao | 获取用户信息 |
| OnlineChecker | contact/service | ws/service | 检查在线状态 |
| OfflineMessagePusher | im/service | ws/handler | 离线消息推送 |
| GroupMemberChecker | im/service | group/dao | 检查群成员身份Phase 2c |
| GroupInfoGetter | im/service | group/dao | 获取群信息Phase 2c |
| GroupRoleChecker | im/service | group/dao | 检查用户群角色Phase 2c |
| TokenValidator | ws/handler | auth/service | WS 连接 Token 校验 |
---
## 四、Wire 依赖注入规范
## 十三、批量查询优化
- 每个模块在 `provider.go` 中导出 `ProviderSet``wire.NewSet(...)`
- 接口绑定统一在 `app/provider/wire.go` 中声明
- 修改 wire.go 后必须重新运行 `wire gen ./app/provider/` 生成 wire_gen.go
- Wire 有过手动 patch 历史Phase 2b修改后需检查 wire_gen.go 一致性
获取关联信息时,**必须使用批量查询 + Map 映射,严禁 N+1 查询**。
---
## 五、日志记录标准
## 十四、系统消息规范Phase 2c+
所有 DAO 和 Service 的公开方法必须记录入口和出口日志:
```go
func (d *SomeDAO) SomeMethod(ctx context.Context, param int64) (result *Model, err error) {
funcName := "dao.some_dao.SomeMethod"
logs.LogFunctionEntry(ctx, funcName, map[string]interface{}{
"param": param,
})
defer func() {
logs.LogFunctionExit(ctx, funcName, result, err)
}()
// 业务逻辑
return result, err
}
```
**funcName 命名规则:** `{层级}.{模块}_{文件}.{方法名}`
- DAO 层:`dao.group_dao.CreateGroup`
- Service 层:`service.group_service.CreateGroup`
- Controller 层:`controller.group_controller.CreateGroup`
系统消息写入 `im_messages` 表,`type=10``MessageTypeSystem``sender_id=0`(表示系统),`content` 使用**纯文本格式**。前端居中显示、灰色小字体、无头像、无气泡。
---
## 六、Controller 错误处理标准
## 十五、依赖管理
```go
func (ctrl *Controller) HandleAction(c *gin.Context) {
funcName := "controller.module.HandleAction"
// 参数绑定
var req dto.SomeRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ResponseBadRequest(c, "参数校验失败: "+err.Error())
return
}
// 调用 Service
result, err := ctrl.service.DoAction(c.Request.Context(), &req)
if err != nil {
ctrl.handleError(c, funcName, err)
return
}
utils.ResponseOK(c, "操作成功", result)
}
// handleError 必须覆盖所有已知业务错误
func (ctrl *Controller) handleError(c *gin.Context, funcName string, err error) {
switch err {
case service.ErrNotFound:
utils.ResponseNotFound(c, err.Error())
case service.ErrPermission:
utils.ResponseForbidden(c, err.Error())
// ... 覆盖所有已知错误
default:
logs.Error(c.Request.Context(), funcName, "操作失败", zap.Error(err))
utils.ResponseError(c, "操作失败")
}
}
```
**关键要求:**
- `handleError` 必须覆盖所有已知业务错误,不能用 `default` 笼统处理
- 错误 message 使用中文,面向用户
- 未知错误必须记录日志
---
## 七、批量查询优化
获取关联信息时,**必须使用批量查询 + Map 映射,严禁 N+1 查询**
```go
// ✅ 正确:批量查询
userIDs := extractUserIDs(members)
users, _ := userDAO.GetByIDs(ctx, userIDs)
userMap := make(map[int64]*User)
for _, u := range users {
userMap[u.ID] = u
}
for _, m := range members {
m.Nickname = userMap[m.UserID].Nickname
}
// ❌ 错误N+1 查询
for _, m := range members {
user, _ := userDAO.GetByID(ctx, m.UserID)
m.Nickname = user.Nickname
}
```
---
## 八、系统消息规范Phase 2c+
### 8.1 系统消息定义
系统消息是由服务端自动生成的提示类消息,写入 `im_messages`type=10`MessageTypeSystem`)。
### 8.2 内容格式
系统消息的 `content` 字段使用**纯文本格式**,不使用 JSON 结构。
### 8.3 前端渲染
系统消息在聊天页面中**居中显示,灰色小字体,无头像,无气泡**。
### 8.4 sender_id
系统消息的 `sender_id` 设为 0表示系统前端根据 sender_id=0 和 type=10 判断为系统消息。
---
## 九、WS 事件推送模式
### 9.1 S→C 推送Service 层触发)
```go
pubsub.PublishToUser(ctx, targetUserID, &ws.Message{
Event: "group.member.join",
Data: map[string]interface{}{...},
})
pubsub.PublishToUsers(ctx, memberIDs, &ws.Message{
Event: "im.message.new",
Data: messageDTO,
})
```
### 9.2 C→S 事件处理Hub 事件路由表注册)
```go
hub.RegisterEvent("im.message.send", handler.HandleSendMessage)
hub.RegisterEvent("im.message.read", handler.HandleReadMessage)
```
---
## 十、前端 Store 与 API 封装规范
### 10.1 API 封装标准
文件位置:`frontend/src/api/{module}.js`
```javascript
import request from '@/utils/request'
// 获取群详情
export function getGroupDetail(groupId) {
return request.get(`/groups/${groupId}`)
}
// 创建群聊
export function createGroup(data) {
return request.post('/groups', data)
}
```
**命名规则:**
- GET 请求:`get{Entity}` / `get{Entity}List`
- POST 请求:`create{Entity}` / `{action}{Entity}`
- PUT 请求:`update{Entity}` / `set{Entity}{Field}`
- DELETE 请求:`delete{Entity}` / `remove{Entity}`
### 10.2 Pinia Store 标准结构
文件位置:`frontend/src/store/{module}.js`
```javascript
import { defineStore } from 'pinia'
export const useGroupStore = defineStore('group', {
state: () => ({
conversations: [], // 群会话列表
currentGroup: null, // 当前群详情
messages: {}, // {conversationId: Message[]}
members: {}, // {groupId: Member[]}
}),
getters: {
unreadTotal: (state) => { ... },
},
actions: {
// 初始化 WS 事件监听(在 App.vue onLaunch 中调用)
initWsListeners() { ... },
// 加载群会话列表
async loadConversations() { ... },
// 发送群消息
async sendMessage(conversationId, content, atUserIds) { ... },
}
})
```
**Store 设计原则:**
1. **单一职责**:每个模块一个 Store不混合不同模块数据
2. **WS 监听统一初始化**:所有 Store 的 WS 事件监听在 `App.vue``_initGlobalWS` 中统一调用
3. **消息缓存**:按 conversationId 键值对缓存,避免重复请求
4. **乐观更新**:发送消息时先本地插入,再等待服务端 ack 确认
- 添加新依赖前必须检查 `go.mod` 中的 Go 版本(当前 `go 1.23.12`
- 选择与当前 Go 版本兼容的包版本,禁止触发 Go 工具链自动升级
- 优先复用 `go.mod` 中已有的间接依赖

View File

@@ -2,7 +2,7 @@
> **适用范围**EchoChat 项目全端Go 后端 + admin 管理端 + frontend 用户端)
> **创建日期**2026-03-02
> **最后更新**2026-03-04Phase 2c 设计完成:新增群聊模块MinIO 文件存储已读回执规划
> **最后更新**2026-03-04Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项
---