Files
EchoChat/docs/conventions/backend-module-architecture.md
bujinyuan 19979da59e 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
2026-03-04 14:58:23 +08:00

14 KiB
Raw Blame History

Go 后端模块架构规范

适用范围EchoChat Go 后端(backend/go-service/ 创建日期2026-03-04 最后更新2026-03-04基于全量代码审查以实际代码为准 关联文档docs/conventions/frontend-backend-integration.md

核心原则:本文档所有代码模板均直接摘自项目现有代码,编写新模块时必须严格遵循,禁止自创新模式。


一、模块分层架构

每个业务模块采用四层架构,目录结构如下:

app/{module_name}/
├── controller/
│   └── {module}_controller.go   # HTTP 请求处理
├── service/
│   └── {module}_service.go      # 业务逻辑
├── dao/
│   └── {module}_dao.go          # 数据访问
├── model/
│   └── {module}.go              # 数据模型
├── handler/
│   └── {module}_handler.go      # [可选] WS 事件处理器
├── router.go                    # 路由注册
└── provider.go                  # Wire ProviderSet

二、层间调用规则

Controller → Service → DAO → 数据库
                 ↓
           其他模块接口(通过注入的 interface
                 ↓
           PubSub / Redis
  • Controller 只处理 HTTP 关注点:参数绑定、调用 Service、返回响应
  • Service 只处理业务逻辑:权限校验、业务规则、事务编排、推送通知
  • DAO 只处理数据存取GORM 查询、批量操作、不含业务逻辑
  • 禁止跨层调用Controller 不能直接调用 DAODAO 不能调用 Service

三、日志 API以实际代码为准严禁使用不存在的 API

项目 logs 包(pkg/logs只有以下 5 个公开日志方法

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严禁调用LogFunctionEntryLogFunctionExitLogSuccessLogFailure 等均不存在。

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

// 摘自 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
  • 不导入 logszap,不记日志
  • 没有 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 或内联错误处理

// 摘自 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 层风格一致。

// 摘自 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 层代码风格(统一)

// 摘自 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 代码风格

// 摘自 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 代码风格

// 摘自 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(如 AuthSetContactSetIMSetFileSetGroupSet


九、常量定义风格

// 摘自 app/constants/im.go实际代码
package constants

const (
    ConversationTypePrivate = 1
    ConversationTypeGroup   = 2
)

var ConversationTypeMap = map[int]string{
    ConversationTypePrivate: "单聊",
    ConversationTypeGroup:   "群聊",
}

关键要点

  • 常量命名 camelCaseGroupStatusNormal(不是 GROUP_STATUS_NORMAL
  • 每组常量配套一个 XxxMap 中文映射
  • 文件按模块拆分:im.gocontact.gogroup.go

十、DTO 定义风格

// 摘自 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.gocontact_dto.goadmin_dto.gogroup_dto.go
  • Request 用 json tagPOST bodyform tagGET query
  • Response 用 json tag + omitempty 可选
  • 时间字段在 DTO 中用 string 类型(格式化后的 "2006-01-02 15:04:05"

十一、Model 定义风格

// 摘自 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.TimeGORM tag 含 type:timestamp(0)
  • 可选字段用指针类型(*int64*time.Time*string
  • 每个字段必须有注释说明用途

十二、跨模块接口注入模式

模块间通信通过 interface injection 实现,禁止直接 import 其他模块包。

标准流程

步骤1: 在消费方 Service 中定义 interface
步骤2: 在提供方 DAO 中实现该接口
步骤3: 在 app/provider/wire.go 中用 wire.Bind 绑定
步骤4: 更新 wire_gen.go

已有接口注入清单

接口 定义方 实现方 用途
FriendIDsGetter ws/handler contact/dao 获取用户好友 ID 列表
FriendChecker im/service contact/dao 检查是否为好友
UserInfoGetter im/service contact/dao 获取用户信息
OnlineChecker contact/service ws/service 检查在线状态
TokenValidator ws/handler auth/service WS 连接 Token 校验

十三、批量查询优化

获取关联信息时,必须使用批量查询 + Map 映射,严禁 N+1 查询


十四、系统消息规范Phase 2c+

系统消息写入 im_messages 表,type=10MessageTypeSystemsender_id=0(表示系统),content 使用纯文本格式。前端居中显示、灰色小字体、无头像、无气泡。


十五、依赖管理

  • 添加新依赖前必须检查 go.mod 中的 Go 版本(当前 go 1.23.12
  • 选择与当前 Go 版本兼容的包版本,禁止触发 Go 工具链自动升级
  • 优先复用 go.mod 中已有的间接依赖