diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 45ac8ac..f0d7eab 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -18,10 +18,11 @@ alwaysApply: true - **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项) - **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md` - 分支:`feature/phase2b-instant-messaging` -- **Phase 2c(群聊与已读回执)**:📋 设计完成,待实施(14 个 Task),设计文档 `docs/plans/2026-03-04-phase2c-design.md`,实施计划 `docs/plans/2026-03-04-phase2c-implementation.plan.md` +- **Phase 2c(群聊与已读回执)**:✅ 全部完成(14 个 Task + 代码审查修复 14 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md` - 分支:`feature/phase2c-group-read-receipt` -- 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群聊管理 -- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService),Phase 2c 新增 im.GroupMemberChecker / im.GroupInfoGetter → group.GroupDAO +- 范围:群聊全功能(三级角色/禁言/@提醒/群公告/入群审批)+ 已读回执(单聊会话级 + 群聊消息级)+ MinIO 文件存储 + 管理端群组管理 +- 代码审查修复:Critical×5(角色类型/公告字段/at_user_ids/@导航/Wire 注册)+ Important×4 + Minor×2 + Suggestion×3 +- **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO) ## 项目概述 @@ -30,8 +31,8 @@ EchoChat 是一个实时音视频通讯平台,包含三个子项目: - `frontend/` — 前台用户端(uni-app + Vue 3.4 + Pinia 2.x) - `admin/` — 后台管理端(Vue 3.5+ + Element Plus + Pinia 3.x) -已实现模块:auth(认证)、contact(联系人)、ws(WebSocket)、admin(管理端)、im(即时通讯) -Phase 2c 待实现模块:group(群聊管理)、file(文件上传/MinIO)+ im 扩展(已读回执) +已实现模块:auth(认证)、contact(联系人)、ws(WebSocket)、admin(管理端)、im(即时通讯 + 已读回执)、group(群聊管理)、file(文件上传/MinIO) +前端常量:`frontend/src/constants/group.js`(GROUP_ROLE / GROUP_STATUS / JOIN_REQUEST_STATUS,与后端 constants/group.go 对齐) ## 核心开发规则 @@ -45,10 +46,48 @@ Phase 2c 待实现模块:group(群聊管理)、file(文件上传/MinIO 8. **角色等级体系**:`auth_roles.level` 字段(值越小权限越高:1=超管, 10=管理员, 100=普通用户),所有管理操作强制执行层级权限校验 9. **代码注释**:所有公开函数、组件、Store 必须有详细注释 10. **后端架构规范**:详见 `docs/conventions/backend-module-architecture.md`(模块分层/接口注入/日志/错误处理/批量查询/系统消息/Store 封装等) -11. **文档自动同步(强制)**:每个 Task 或功能开发完成后,必须自动执行文档同步,详见下方「文档自动同步规则」 -12. **验证方式**:使用 Playwright MCP 进行页面自动化验证 -13. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化审查,确保代码质量和计划一致性 -14. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前运行验证命令并确认输出 +11. **代码风格全局一致(最高优先级)**:新编写的任何模块代码,必须严格参照已有模块的实际代码实现和风格,禁止自创新封装、新 API、新模式。详见下方「代码风格全局一致规则」 +12. **文档自动同步(强制)**:每个 Task 或功能开发完成后,必须自动执行文档同步,详见下方「文档自动同步规则」 +13. **验证方式**:使用 Playwright MCP 进行页面自动化验证 +14. **代码审查**:每个 Task 完成后,使用 `code-reviewer` 子代理进行结构化审查,确保代码质量和计划一致性 +15. **完成验证**:使用 `verification-before-completion` 技能,在声称完成前运行验证命令并确认输出 + +## 代码风格全局一致规则(最高优先级,强制执行) + +> **核心原则:编写任何新模块/新文件的代码前,必须先阅读同层级现有模块的实际代码,严格复制其风格,禁止引入不存在的 API、封装或模式。** + +### 执行流程(强制) + +1. **写代码前**:先用 Read/Grep 工具读取同类型现有文件(如写新 Controller 前先读 `im_controller.go` 和 `contact_controller.go`) +2. **写代码时**:逐行对照现有代码的导入、结构体定义、方法签名、日志调用、错误处理模式 +3. **写代码后**:与参照文件做差异比对,确认风格完全一致 + +### Go 后端各层代码风格(以实际代码为准) + +详细的风格规范和代码模板见 `docs/conventions/backend-module-architecture.md`。以下是关键约定摘要: + +| 层级 | 接收器 | 日志 | funcName | 错误处理 | +|------|--------|------|----------|---------| +| Controller(前台业务) | `ctl` | 不记日志 | 无 | 方法级 `handleError` | +| Controller(auth/admin) | `ctrl`/`ctl` | 有日志 | 有 | `handleAuthError` 包级函数 / 内联 | +| Service | `s` | `logs.Info/Debug/Error` | `"service.{file}.{Method}"` | 包顶部 `var ErrXxx` | +| DAO | `d` | `logs.Info/Debug/Error` | `"dao.{file}.{Method}"` | `logs.Error` + 返回 err | + +### 日志 API(以实际代码为准) + +项目中 logs 包只有 `logs.Info/Debug/Warn/Error/Fatal` 五个方法,签名为 `logs.Xxx(ctx, funcName, message, ...zap.Field)`。 +**不存在** `logs.LogFunctionEntry` / `logs.LogFunctionExit` / `logs.LogSuccess` 等方法,**严禁使用不存在的 API**。 + +### 依赖管理 + +- **禁止随意拉取最新版本的依赖包**,必须选择与当前 Go 版本(go.mod 中的 `go` 指令)兼容的版本 +- **禁止触发 Go 工具链自动升级**,如果某依赖要求更高版本的 Go,必须选择兼容版本而非升级 Go +- 添加依赖前先检查 go.mod 中的 Go 版本和已有依赖,优先复用已有依赖 + +### 前端代码风格 + +- 前端新页面/组件/Store 的编写,同样必须先阅读现有同类文件,严格遵循已有的代码结构、命名规范、状态管理模式 +- 禁止引入项目中未使用的新 UI 框架、状态管理库或工具函数库 ## 文档自动同步规则(强制执行) diff --git a/admin/src/api/group.js b/admin/src/api/group.js new file mode 100644 index 0000000..1b23c8f --- /dev/null +++ b/admin/src/api/group.js @@ -0,0 +1,52 @@ +/** + * 群组管理 API 模块(管理端) + * + * 对应后端路由:/api/v1/admin/groups/* + * 所有接口需要 JWT + admin 角色权限 + * + * 接口列表: + * - GET /api/v1/admin/groups 群组列表(分页 + 搜索) + * - GET /api/v1/admin/groups/:id 群组详情(含成员列表) + * - DELETE /api/v1/admin/groups/:id 解散群聊 + */ +import request from '@/utils/request' + +/** + * 获取群组列表 + * @param {Object} params - 查询参数 + * @param {number} params.page - 页码(从 1 开始) + * @param {number} params.page_size - 每页数量 + * @param {string} [params.keyword] - 搜索关键词(群名称) + * @returns {Promise<{data: {total: number, list: Array}}>} + */ +export function getGroupList(params) { + return request({ + url: '/api/v1/admin/groups', + method: 'get', + params + }) +} + +/** + * 获取群组详情(含成员列表) + * @param {number} id - 群组 ID + * @returns {Promise<{data: Object}>} + */ +export function getGroupDetail(id) { + return request({ + url: `/api/v1/admin/groups/${id}`, + method: 'get' + }) +} + +/** + * 解散群聊 + * @param {number} id - 群组 ID + * @returns {Promise} + */ +export function dissolveGroup(id) { + return request({ + url: `/api/v1/admin/groups/${id}`, + method: 'delete' + }) +} diff --git a/admin/src/router/index.js b/admin/src/router/index.js index 5365703..d47a0b6 100644 --- a/admin/src/router/index.js +++ b/admin/src/router/index.js @@ -56,6 +56,12 @@ const routes = [ name: 'ContactManage', component: () => import('@/views/contact/list.vue'), meta: { title: '好友管理' } + }, + { + path: 'group/list', + name: 'GroupManage', + component: () => import('@/views/group/list.vue'), + meta: { title: '群组管理' } } ] } diff --git a/admin/src/views/group/list.vue b/admin/src/views/group/list.vue new file mode 100644 index 0000000..a292230 --- /dev/null +++ b/admin/src/views/group/list.vue @@ -0,0 +1,322 @@ + + + + + + diff --git a/admin/src/views/layout/index.vue b/admin/src/views/layout/index.vue index 1b4e8d3..633658d 100644 --- a/admin/src/views/layout/index.vue +++ b/admin/src/views/layout/index.vue @@ -48,6 +48,14 @@ 好友关系 + + + 群组列表 + + diff --git a/backend/go-service/app/admin/controller/group_manage_controller.go b/backend/go-service/app/admin/controller/group_manage_controller.go new file mode 100644 index 0000000..e8ff17d --- /dev/null +++ b/backend/go-service/app/admin/controller/group_manage_controller.go @@ -0,0 +1,94 @@ +// Package controller 提供 admin 模块的 HTTP 接口处理 +package controller + +import ( + "strconv" + + "github.com/echochat/backend/app/admin/service" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// GroupManageController 管理端群组管理控制器 +type GroupManageController struct { + groupManageService *service.GroupManageService +} + +// NewGroupManageController 创建群组管理控制器实例 +func NewGroupManageController(svc *service.GroupManageService) *GroupManageController { + return &GroupManageController{groupManageService: svc} +} + +// GetGroupList 获取群组列表 +// GET /api/v1/admin/groups?page=1&page_size=20&keyword=xxx +func (ctl *GroupManageController) GetGroupList(c *gin.Context) { + funcName := "admin.group_manage_controller.GetGroupList" + ctx := c.Request.Context() + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + + list, total, err := ctl.groupManageService.ListGroups(ctx, page, pageSize, keyword) + if err != nil { + logs.Error(ctx, funcName, "获取群组列表失败", zap.Error(err)) + utils.ResponseError(c, "获取群组列表失败") + return + } + + logs.Info(ctx, funcName, "获取群组列表成功", + zap.Int64("total", total), zap.Int("page", page)) + utils.ResponseOK(c, gin.H{ + "list": list, + "total": total, + }) +} + +// GetGroupDetail 获取群组详情 +// GET /api/v1/admin/groups/:id +func (ctl *GroupManageController) GetGroupDetail(c *gin.Context) { + funcName := "admin.group_manage_controller.GetGroupDetail" + ctx := c.Request.Context() + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "ID 格式错误") + return + } + + detail, err := ctl.groupManageService.GetGroupDetail(ctx, id) + if err != nil { + logs.Error(ctx, funcName, "获取群组详情失败", + zap.Int64("group_id", id), zap.Error(err)) + utils.ResponseError(c, "获取群组详情失败") + return + } + + logs.Info(ctx, funcName, "获取群组详情成功", zap.Int64("group_id", id)) + utils.ResponseOK(c, detail) +} + +// DissolveGroup 解散群聊 +// DELETE /api/v1/admin/groups/:id +func (ctl *GroupManageController) DissolveGroup(c *gin.Context) { + funcName := "admin.group_manage_controller.DissolveGroup" + ctx := c.Request.Context() + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "ID 格式错误") + return + } + + if err := ctl.groupManageService.DissolveGroup(ctx, id); err != nil { + logs.Error(ctx, funcName, "解散群聊失败", + zap.Int64("group_id", id), zap.Error(err)) + utils.ResponseError(c, "解散群聊失败") + return + } + + logs.Info(ctx, funcName, "解散群聊成功", zap.Int64("group_id", id)) + utils.ResponseOK(c, nil) +} diff --git a/backend/go-service/app/admin/provider.go b/backend/go-service/app/admin/provider.go index acde569..d288e25 100644 --- a/backend/go-service/app/admin/provider.go +++ b/backend/go-service/app/admin/provider.go @@ -18,4 +18,6 @@ var AdminSet = wire.NewSet( controller.NewOnlineController, service.NewContactManageService, controller.NewContactManageController, + service.NewGroupManageService, + controller.NewGroupManageController, ) diff --git a/backend/go-service/app/admin/router.go b/backend/go-service/app/admin/router.go index da5b10d..d7a5eeb 100644 --- a/backend/go-service/app/admin/router.go +++ b/backend/go-service/app/admin/router.go @@ -15,6 +15,7 @@ func RegisterRoutes( userCtrl *controller.UserManageController, onlineCtrl *controller.OnlineController, contactManageCtrl *controller.ContactManageController, + groupManageCtrl *controller.GroupManageController, jwtAuth gin.HandlerFunc, ) { // 管理端路由组:JWT 认证 + admin/super_admin 角色检查 @@ -38,5 +39,10 @@ func RegisterRoutes( // 好友关系管理 adminGroup.GET("/contacts", contactManageCtrl.GetAllContacts) adminGroup.DELETE("/contacts/:id", contactManageCtrl.DeleteContact) + + // 群组管理 + adminGroup.GET("/groups", groupManageCtrl.GetGroupList) + adminGroup.GET("/groups/:id", groupManageCtrl.GetGroupDetail) + adminGroup.DELETE("/groups/:id", groupManageCtrl.DissolveGroup) } } diff --git a/backend/go-service/app/admin/service/group_manage_service.go b/backend/go-service/app/admin/service/group_manage_service.go new file mode 100644 index 0000000..89ad8e0 --- /dev/null +++ b/backend/go-service/app/admin/service/group_manage_service.go @@ -0,0 +1,247 @@ +// Package service 提供 admin 模块的核心业务逻辑 +package service + +import ( + "context" + "math" + "strconv" + + groupDAO "github.com/echochat/backend/app/group/dao" + groupModel "github.com/echochat/backend/app/group/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// GroupManageService 管理端群组管理服务 +type GroupManageService struct { + db *gorm.DB + groupDAO *groupDAO.GroupDAO +} + +// NewGroupManageService 创建群组管理服务实例 +func NewGroupManageService(db *gorm.DB, groupDAO *groupDAO.GroupDAO) *GroupManageService { + return &GroupManageService{db: db, groupDAO: groupDAO} +} + +// GroupListItem 管理端群组列表项 +type GroupListItem struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversation_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + OwnerID int64 `json:"owner_id"` + OwnerName string `json:"owner_name"` + MemberCount int64 `json:"member_count"` + MaxMembers int `json:"max_members"` + Status int `json:"status"` + IsAllMuted bool `json:"is_all_muted"` + CreatedAt string `json:"created_at"` +} + +// GroupDetailInfo 管理端群组详情 +type GroupDetailInfo struct { + GroupListItem + Notice string `json:"notice"` + IsSearchable bool `json:"is_searchable"` + Members []GroupMemberInfo `json:"members"` +} + +// GroupMemberInfo 群成员信息 +type GroupMemberInfo struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Role int `json:"role"` + IsMuted bool `json:"is_muted"` + JoinedAt string `json:"joined_at"` +} + +// ListGroups 获取群组列表(分页 + 搜索) +func (s *GroupManageService) ListGroups(ctx context.Context, page, pageSize int, keyword string) ([]GroupListItem, int64, error) { + funcName := "service.group_manage_service.ListGroups" + logs.Info(ctx, funcName, "获取群组列表", + zap.Int("page", page), zap.Int("page_size", pageSize), zap.String("keyword", keyword)) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + offset := (page - 1) * pageSize + + var groups []groupModel.Group + var total int64 + + query := s.db.WithContext(ctx).Model(&groupModel.Group{}) + if keyword != "" { + query = query.Where("name ILIKE ?", "%"+keyword+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&groups).Error; err != nil { + return nil, 0, err + } + + // 批量查询群主名称,避免 N+1 + ownerIDs := make([]int64, 0, len(groups)) + convIDs := make([]int64, 0, len(groups)) + for _, g := range groups { + ownerIDs = append(ownerIDs, g.OwnerID) + convIDs = append(convIDs, g.ConversationID) + } + ownerNameMap := s.batchGetUsernames(ctx, ownerIDs) + memberCountMap := s.batchGetMemberCounts(ctx, convIDs) + + list := make([]GroupListItem, 0, len(groups)) + for _, g := range groups { + list = append(list, GroupListItem{ + ID: g.ID, + ConversationID: g.ConversationID, + Name: g.Name, + Avatar: g.Avatar, + OwnerID: g.OwnerID, + OwnerName: ownerNameMap[g.OwnerID], + MemberCount: memberCountMap[g.ConversationID], + MaxMembers: g.MaxMembers, + Status: g.Status, + IsAllMuted: g.IsAllMuted, + CreatedAt: g.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + logs.Info(ctx, funcName, "获取群组列表成功", + zap.Int64("total", total), zap.Int("total_pages", totalPages)) + + return list, total, nil +} + +// GetGroupDetail 获取群组详情(含成员列表) +func (s *GroupManageService) GetGroupDetail(ctx context.Context, groupID int64) (*GroupDetailInfo, error) { + funcName := "service.group_manage_service.GetGroupDetail" + logs.Info(ctx, funcName, "获取群组详情", zap.Int64("group_id", groupID)) + + group, err := s.groupDAO.GetByID(ctx, groupID) + if err != nil { + return nil, err + } + + memberCount, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID) + ownerName := s.getUsername(ctx, group.OwnerID) + + members, err := s.groupDAO.GetMembers(ctx, group.ConversationID) + if err != nil { + return nil, err + } + + memberInfos := make([]GroupMemberInfo, 0, len(members)) + for _, m := range members { + username := s.getUsername(ctx, m.UserID) + memberInfos = append(memberInfos, GroupMemberInfo{ + UserID: m.UserID, + Username: username, + Nickname: m.Nickname, + Role: m.Role, + IsMuted: m.IsMuted, + JoinedAt: m.JoinedAt.Format("2006-01-02 15:04:05"), + }) + } + + detail := &GroupDetailInfo{ + GroupListItem: GroupListItem{ + ID: group.ID, + ConversationID: group.ConversationID, + Name: group.Name, + Avatar: group.Avatar, + OwnerID: group.OwnerID, + OwnerName: ownerName, + MemberCount: memberCount, + MaxMembers: group.MaxMembers, + Status: group.Status, + IsAllMuted: group.IsAllMuted, + CreatedAt: group.CreatedAt.Format("2006-01-02 15:04:05"), + }, + Notice: group.Notice, + IsSearchable: group.IsSearchable, + Members: memberInfos, + } + + return detail, nil +} + +// DissolveGroup 管理端解散群聊 +func (s *GroupManageService) DissolveGroup(ctx context.Context, groupID int64) error { + funcName := "service.group_manage_service.DissolveGroup" + logs.Info(ctx, funcName, "管理端解散群聊", zap.Int64("group_id", groupID)) + return s.groupDAO.DissolveGroup(ctx, groupID) +} + +// getUsername 通过用户 ID 获取用户名(优先昵称,其次用户名) +func (s *GroupManageService) getUsername(ctx context.Context, userID int64) string { + var username string + s.db.WithContext(ctx).Table("auth_users"). + Where("id = ?", userID). + Pluck("COALESCE(NULLIF(nickname, ''), username)", &username) + if username == "" { + return strconv.FormatInt(userID, 10) + } + return username +} + +// batchGetUsernames 批量查询用户名,返回 userID → 显示名 映射 +func (s *GroupManageService) batchGetUsernames(ctx context.Context, userIDs []int64) map[int64]string { + result := make(map[int64]string, len(userIDs)) + if len(userIDs) == 0 { + return result + } + + type row struct { + ID int64 `gorm:"column:id"` + Username string `gorm:"column:display_name"` + } + var rows []row + s.db.WithContext(ctx).Table("auth_users"). + Select("id, COALESCE(NULLIF(nickname, ''), username) AS display_name"). + Where("id IN ?", userIDs). + Find(&rows) + + for _, r := range rows { + result[r.ID] = r.Username + } + for _, uid := range userIDs { + if _, ok := result[uid]; !ok { + result[uid] = strconv.FormatInt(uid, 10) + } + } + return result +} + +// batchGetMemberCounts 批量查询会话的成员数,返回 conversationID → count 映射 +func (s *GroupManageService) batchGetMemberCounts(ctx context.Context, conversationIDs []int64) map[int64]int64 { + result := make(map[int64]int64, len(conversationIDs)) + if len(conversationIDs) == 0 { + return result + } + + type row struct { + ConversationID int64 `gorm:"column:conversation_id"` + Count int64 `gorm:"column:cnt"` + } + var rows []row + s.db.WithContext(ctx).Table("im_conversation_members"). + Select("conversation_id, COUNT(*) AS cnt"). + Where("conversation_id IN ?", conversationIDs). + Group("conversation_id"). + Find(&rows) + + for _, r := range rows { + result[r.ConversationID] = r.Count + } + return result +} diff --git a/backend/go-service/app/constants/group.go b/backend/go-service/app/constants/group.go new file mode 100644 index 0000000..7e617a4 --- /dev/null +++ b/backend/go-service/app/constants/group.go @@ -0,0 +1,46 @@ +package constants + +// 群聊状态 +const ( + GroupStatusNormal = 1 // 正常 + GroupStatusDissolved = 2 // 已解散 +) + +// GroupStatusMap 群聊状态中文映射 +var GroupStatusMap = map[int]string{ + GroupStatusNormal: "正常", + GroupStatusDissolved: "已解散", +} + +// 群成员角色(im_conversation_members.role) +const ( + GroupRoleNormal = 0 // 普通成员 + GroupRoleAdmin = 1 // 管理员 + GroupRoleOwner = 2 // 群主 +) + +// GroupRoleMap 群成员角色中文映射 +var GroupRoleMap = map[int]string{ + GroupRoleNormal: "普通成员", + GroupRoleAdmin: "管理员", + GroupRoleOwner: "群主", +} + +// 入群申请状态(im_group_join_requests.status) +const ( + JoinRequestStatusPending = 0 // 待审批 + JoinRequestStatusApproved = 1 // 通过 + JoinRequestStatusRejected = 2 // 拒绝 +) + +// JoinRequestStatusMap 入群申请状态中文映射 +var JoinRequestStatusMap = map[int]string{ + JoinRequestStatusPending: "待审批", + JoinRequestStatusApproved: "通过", + JoinRequestStatusRejected: "拒绝", +} + +// 群聊默认配置 +const ( + GroupDefaultMaxMembers = 200 // 默认最大成员数 +) diff --git a/backend/go-service/app/constants/im.go b/backend/go-service/app/constants/im.go index dffecf3..9e281e3 100644 --- a/backend/go-service/app/constants/im.go +++ b/backend/go-service/app/constants/im.go @@ -14,20 +14,22 @@ var ConversationTypeMap = map[int]string{ // 消息类型 const ( - MessageTypeText = 1 // 文本消息 - MessageTypeImage = 2 // 图片消息(预留) - MessageTypeVoice = 3 // 语音消息(预留) - MessageTypeVideo = 4 // 视频消息(预留) - MessageTypeFile = 5 // 文件消息(预留) + MessageTypeText = 1 // 文本消息 + MessageTypeImage = 2 // 图片消息(预留) + MessageTypeVoice = 3 // 语音消息(预留) + MessageTypeVideo = 4 // 视频消息(预留) + MessageTypeFile = 5 // 文件消息(预留) + MessageTypeSystem = 10 // 系统消息(群聊操作通知) ) // MessageTypeMap 消息类型中文映射 var MessageTypeMap = map[int]string{ - MessageTypeText: "文本", - MessageTypeImage: "图片", - MessageTypeVoice: "语音", - MessageTypeVideo: "视频", - MessageTypeFile: "文件", + MessageTypeText: "文本", + MessageTypeImage: "图片", + MessageTypeVoice: "语音", + MessageTypeVideo: "视频", + MessageTypeFile: "文件", + MessageTypeSystem: "系统消息", } // 消息状态 diff --git a/backend/go-service/app/dto/group_dto.go b/backend/go-service/app/dto/group_dto.go new file mode 100644 index 0000000..3fa9915 --- /dev/null +++ b/backend/go-service/app/dto/group_dto.go @@ -0,0 +1,177 @@ +package dto + +// ====== 群聊管理 DTO ====== + +// CreateGroupRequest 创建群聊请求 +// POST /api/v1/groups +type CreateGroupRequest struct { + Name string `json:"name" binding:"required,max=100"` // 群名称 + MemberIDs []int64 `json:"member_ids" binding:"required"` // 初始成员用户 ID 列表(不含创建者自身) + Avatar string `json:"avatar"` // 群头像 URL(可选) +} + +// UpdateGroupRequest 更新群信息请求 +// PUT /api/v1/groups/:id +type UpdateGroupRequest struct { + Name *string `json:"name"` // 群名称 + Avatar *string `json:"avatar"` // 群头像 URL + Notice *string `json:"notice"` // 群公告 + IsSearchable *bool `json:"is_searchable"` // 是否可被搜索 +} + +// GroupDTO 群聊信息传输对象(返回给前端) +type GroupDTO struct { + ID int64 `json:"id"` // 群 ID + ConversationID int64 `json:"conversation_id"` // 关联会话 ID + Name string `json:"name"` // 群名称 + Avatar string `json:"avatar"` // 群头像 + OwnerID int64 `json:"owner_id"` // 群主用户 ID + Notice string `json:"notice"` // 群公告 + MaxMembers int `json:"max_members"` // 最大成员数 + MemberCount int `json:"member_count"` // 当前成员数 + IsSearchable bool `json:"is_searchable"` // 是否可被搜索 + IsAllMuted bool `json:"is_all_muted"` // 是否全体禁言 + Status int `json:"status"` // 群状态:1=正常,2=已解散 + CreatedAt string `json:"created_at"` // 创建时间 +} + +// ====== 群成员 DTO ====== + +// GroupMemberDTO 群成员信息传输对象 +type GroupMemberDTO struct { + UserID int64 `json:"user_id"` // 用户 ID + Nickname string `json:"nickname"` // 群内昵称 + UserNickname string `json:"user_nickname"` // 用户原始昵称 + Avatar string `json:"avatar"` // 用户头像 + Role int `json:"role"` // 角色:0=普通成员,1=管理员,2=群主 + IsMuted bool `json:"is_muted"` // 是否被禁言 + JoinedAt string `json:"joined_at"` // 加入时间 +} + +// InviteMembersRequest 邀请入群请求 +// POST /api/v1/groups/:id/members +type InviteMembersRequest struct { + UserIDs []int64 `json:"user_ids" binding:"required"` // 被邀请的用户 ID 列表 +} + +// SetRoleRequest 设置/取消管理员请求 +// PUT /api/v1/groups/:id/members/:uid/role +type SetRoleRequest struct { + Role int `json:"role" binding:"oneof=0 1"` // 目标角色:0=普通成员,1=管理员 +} + +// MuteRequest 禁言/解除禁言请求 +// PUT /api/v1/groups/:id/members/:uid/mute +type MuteRequest struct { + IsMuted bool `json:"is_muted"` // true=禁言, false=解除 +} + +// TransferOwnerRequest 转让群主请求 +// PUT /api/v1/groups/:id/transfer +type TransferOwnerRequest struct { + NewOwnerID int64 `json:"new_owner_id" binding:"required"` // 新群主用户 ID +} + +// UpdateNicknameRequest 修改群昵称请求 +// PUT /api/v1/groups/:id/members/me/nickname +type UpdateNicknameRequest struct { + Nickname string `json:"nickname" binding:"max=50"` // 群内昵称 +} + +// ====== 入群申请 DTO ====== + +// JoinGroupRequest 申请入群请求 +// POST /api/v1/groups/:id/join-requests +type JoinGroupRequest struct { + Message string `json:"message"` // 申请附言 +} + +// ReviewJoinRequest 审批入群申请请求 +// PUT /api/v1/groups/:id/join-requests/:rid +type ReviewJoinRequest struct { + Action string `json:"action" binding:"required,oneof=approve reject"` // 操作:approve=通过, reject=拒绝 +} + +// JoinRequestDTO 入群申请传输对象 +type JoinRequestDTO struct { + ID int64 `json:"id"` // 申请 ID + GroupID int64 `json:"group_id"` // 群 ID + UserID int64 `json:"user_id"` // 申请人用户 ID + UserNickname string `json:"user_nickname"` // 申请人昵称 + UserAvatar string `json:"user_avatar"` // 申请人头像 + Message string `json:"message"` // 申请附言 + Status int `json:"status"` // 状态:0=待审批,1=通过,2=拒绝 + CreatedAt string `json:"created_at"` // 申请时间 +} + +// ====== 群搜索 DTO ====== + +// SearchGroupRequest 搜索群聊请求 +// GET /api/v1/groups/search?keyword=xx&page=1&page_size=20 +type SearchGroupRequest struct { + Keyword string `form:"keyword" binding:"required"` // 搜索关键词 + Page int `form:"page"` // 页码(默认 1) + PageSize int `form:"page_size"` // 每页数量(默认 20) +} + +// SearchGroupResponse 搜索群聊响应 +type SearchGroupResponse struct { + List []GroupDTO `json:"list"` // 搜索结果 + Total int64 `json:"total"` // 总数 +} + +// ====== 已读回执 DTO ====== + +// MarkGroupReadRequest 群聊标记已读请求(WS 事件 im.message.read 的 data 字段) +type MarkGroupReadRequest struct { + ConversationID int64 `json:"conversation_id"` // 会话 ID + MessageIDs []int64 `json:"message_ids"` // 已读消息 ID 列表 +} + +// MessageReadCountDTO 消息已读计数传输对象 +type MessageReadCountDTO struct { + MessageID int64 `json:"message_id"` // 消息 ID + ReadCount int `json:"read_count"` // 已读人数 +} + +// MessageReadDetailDTO 消息已读详情传输对象 +type MessageReadDetailDTO struct { + UserID int64 `json:"user_id"` // 已读用户 ID + UserNickname string `json:"user_nickname"` // 用户昵称 + UserAvatar string `json:"user_avatar"` // 用户头像 + ReadAt string `json:"read_at"` // 已读时间 +} + +// GetReadDetailRequest 获取已读详情请求 +// GET /api/v1/im/messages/:id/reads?page=1&page_size=20 +type GetReadDetailRequest struct { + Page int `form:"page"` // 页码(默认 1) + PageSize int `form:"page_size"` // 每页数量(默认 20) +} + +// GetReadDetailResponse 获取已读详情响应 +type GetReadDetailResponse struct { + ReadList []MessageReadDetailDTO `json:"read_list"` // 已读用户列表 + UnreadList []MessageReadDetailDTO `json:"unread_list"` // 未读用户列表 + ReadCount int `json:"read_count"` // 已读人数 + TotalCount int `json:"total_count"` // 群成员总数(不含发送者) +} + +// ====== 免打扰 DTO ====== + +// SetDoNotDisturbRequest 设置/取消免打扰请求 +// PUT /api/v1/im/conversations/:id/dnd +type SetDoNotDisturbRequest struct { + IsDoNotDisturb bool `json:"is_do_not_disturb"` // true=开启, false=关闭 +} + +// ====== 跨模块接口数据传输 ====== + +// GroupBrief 群简要信息(IM 模块通过 GroupInfoGetter 接口获取) +type GroupBrief struct { + ID int64 `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + IsAllMuted bool `json:"is_all_muted"` + Status int `json:"status"` +} diff --git a/backend/go-service/app/dto/im_dto.go b/backend/go-service/app/dto/im_dto.go index aa390f1..4e670bb 100644 --- a/backend/go-service/app/dto/im_dto.go +++ b/backend/go-service/app/dto/im_dto.go @@ -4,23 +4,25 @@ package dto // SendMessageRequest 发送消息请求(WS 事件 im.message.send 的 data 字段) type SendMessageRequest struct { - ConversationID int64 `json:"conversation_id"` // 会话 ID(与 TargetUserID 二选一) - TargetUserID int64 `json:"target_user_id"` // 对方用户 ID(首次发消息时使用,自动创建会话) - Type int `json:"type"` // 消息类型:1=文本 - Content string `json:"content"` // 消息内容 - ClientMsgID string `json:"client_msg_id"` // 客户端消息唯一 ID,用于幂等去重 + ConversationID int64 `json:"conversation_id"` // 会话 ID(与 TargetUserID 二选一) + TargetUserID int64 `json:"target_user_id"` // 对方用户 ID(首次发消息时使用,自动创建会话) + Type int `json:"type"` // 消息类型:1=文本 + Content string `json:"content"` // 消息内容 + ClientMsgID string `json:"client_msg_id"` // 客户端消息唯一 ID,用于幂等去重 + AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表(含 0 表示 @所有人) } // MessageDTO 消息传输对象(返回给前端) type MessageDTO struct { - ID int64 `json:"id"` // 消息 ID - ConversationID int64 `json:"conversation_id"` // 所属会话 ID - SenderID int64 `json:"sender_id"` // 发送者用户 ID - Type int `json:"type"` // 消息类型 - Content string `json:"content"` // 消息内容 - Status int `json:"status"` // 消息状态:1=正常,2=已撤回 - ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID - CreatedAt string `json:"created_at"` // 发送时间 + ID int64 `json:"id"` // 消息 ID + ConversationID int64 `json:"conversation_id"` // 所属会话 ID + SenderID int64 `json:"sender_id"` // 发送者用户 ID + Type int `json:"type"` // 消息类型 + Content string `json:"content"` // 消息内容 + Status int `json:"status"` // 消息状态:1=正常,2=已撤回 + ClientMsgID string `json:"client_msg_id"` // 客户端消息 ID + AtUserIDs []int64 `json:"at_user_ids"` // @提醒用户 ID 列表 + CreatedAt string `json:"created_at"` // 发送时间 } // RecallMessageRequest 撤回消息请求(WS 事件 im.message.recall 的 data 字段) @@ -32,16 +34,19 @@ type RecallMessageRequest struct { // ConversationDTO 会话列表条目(返回给前端) type ConversationDTO struct { - ID int64 `json:"id"` // 会话 ID - Type int `json:"type"` // 会话类型:1=单聊 - PeerUserID int64 `json:"peer_user_id"` // 单聊对方用户 ID - PeerNickname string `json:"peer_nickname"` // 对方昵称 - PeerAvatar string `json:"peer_avatar"` // 对方头像 - LastMsgContent string `json:"last_msg_content"` // 最后消息预览 - LastMsgTime string `json:"last_msg_time"` // 最后消息时间 + ID int64 `json:"id"` // 会话 ID + Type int `json:"type"` // 会话类型:1=单聊,2=群聊 + PeerUserID int64 `json:"peer_user_id"` // 单聊对方用户 ID(群聊为 0) + PeerNickname string `json:"peer_nickname"` // 对方昵称(群聊时为群名称) + PeerAvatar string `json:"peer_avatar"` // 对方头像(群聊时为群头像) + LastMsgContent string `json:"last_msg_content"` // 最后消息预览 + LastMsgTime string `json:"last_msg_time"` // 最后消息时间 LastMsgSenderID *int64 `json:"last_msg_sender_id"` // 最后消息发送者 ID - IsPinned bool `json:"is_pinned"` // 是否置顶 - UnreadCount int `json:"unread_count"` // 未读消息数 + IsPinned bool `json:"is_pinned"` // 是否置顶 + UnreadCount int `json:"unread_count"` // 未读消息数 + GroupID int64 `json:"group_id,omitempty"` // 群聊 ID(仅 type=2 有值) + IsDoNotDisturb bool `json:"is_do_not_disturb"` // 是否免打扰 + AtMeCount int `json:"at_me_count"` // 被@提醒未读计数 } // ConversationListResponse 会话列表响应 diff --git a/backend/go-service/app/file/controller/file_controller.go b/backend/go-service/app/file/controller/file_controller.go new file mode 100644 index 0000000..6602efb --- /dev/null +++ b/backend/go-service/app/file/controller/file_controller.go @@ -0,0 +1,70 @@ +// Package controller 提供文件上传模块的 HTTP 接口处理 +package controller + +import ( + "github.com/echochat/backend/app/file/service" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +const maxUploadSize = 10 << 20 // 10 MB + +// FileController 文件上传控制器 +type FileController struct { + fileService *service.FileService +} + +// NewFileController 创建文件上传控制器 +func NewFileController(fileService *service.FileService) *FileController { + return &FileController{fileService: fileService} +} + +// Upload 处理文件上传请求 +// POST /api/v1/upload +// 支持 multipart/form-data,字段名为 "file" +func (ctl *FileController) Upload(c *gin.Context) { + ctx := c.Request.Context() + _, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + fileHeader, err := c.FormFile("file") + if err != nil { + utils.ResponseBadRequest(c, "请选择要上传的文件") + return + } + + if fileHeader.Size > maxUploadSize { + utils.ResponseBadRequest(c, "文件大小不能超过 10MB") + return + } + + result, err := ctl.fileService.Upload(ctx, fileHeader) + if err != nil { + ctl.handleError(c, err, "文件上传失败") + return + } + + utils.ResponseOK(c, result) +} + +// handleError 统一业务错误映射 +// 已知业务错误 → 返回 Service 层定义的具体提示 +// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误") +func (ctl *FileController) handleError(c *gin.Context, err error, fallbackMsg ...string) { + switch err { + case service.ErrFileOpen: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrUploadFailed: + utils.ResponseError(c, err.Error()) + default: + msg := "服务器内部错误" + if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { + msg = fallbackMsg[0] + } + utils.ResponseError(c, msg) + } +} diff --git a/backend/go-service/app/file/provider.go b/backend/go-service/app/file/provider.go new file mode 100644 index 0000000..8eb9db5 --- /dev/null +++ b/backend/go-service/app/file/provider.go @@ -0,0 +1,14 @@ +// Package file 文件上传模块 Wire 依赖注入配置 +package file + +import ( + "github.com/echochat/backend/app/file/controller" + "github.com/echochat/backend/app/file/service" + "github.com/google/wire" +) + +// FileSet 文件上传模块 Wire Provider Set +var FileSet = wire.NewSet( + service.NewFileService, + controller.NewFileController, +) diff --git a/backend/go-service/app/file/router.go b/backend/go-service/app/file/router.go new file mode 100644 index 0000000..1eb9d9c --- /dev/null +++ b/backend/go-service/app/file/router.go @@ -0,0 +1,16 @@ +// Package file 文件上传模块路由注册 +package file + +import ( + "github.com/echochat/backend/app/file/controller" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册文件上传模块的所有路由(需要 JWT 中间件) +func RegisterRoutes(r *gin.Engine, ctrl *controller.FileController, jwtAuth gin.HandlerFunc) { + authed := r.Group("/api/v1") + authed.Use(jwtAuth) + { + authed.POST("/upload", ctrl.Upload) + } +} diff --git a/backend/go-service/app/file/service/file_service.go b/backend/go-service/app/file/service/file_service.go new file mode 100644 index 0000000..3aad8d4 --- /dev/null +++ b/backend/go-service/app/file/service/file_service.go @@ -0,0 +1,90 @@ +// Package service 提供文件上传模块的业务逻辑 +// 封装 MinIO 对象存储的上传操作,返回可访问的文件 URL +package service + +import ( + "context" + "errors" + "fmt" + "mime/multipart" + "path/filepath" + "strings" + "time" + + "github.com/echochat/backend/config" + "github.com/echochat/backend/pkg/logs" + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "go.uber.org/zap" +) + +var ( + ErrFileOpen = errors.New("打开上传文件失败") + ErrUploadFailed = errors.New("文件上传失败") +) + +// FileService 文件上传服务 +type FileService struct { + minioClient *minio.Client + minioCfg *config.MinioConfig +} + +// NewFileService 创建文件上传服务实例 +func NewFileService(client *minio.Client, cfg *config.MinioConfig) *FileService { + return &FileService{ + minioClient: client, + minioCfg: cfg, + } +} + +// UploadResult 文件上传结果 +type UploadResult struct { + URL string `json:"url"` + FileName string `json:"file_name"` + Size int64 `json:"size"` +} + +// Upload 上传文件到 MinIO,返回文件访问 URL +// 文件按日期目录组织:uploads/2026/03/04/{uuid}.{ext} +func (s *FileService) Upload(ctx context.Context, fileHeader *multipart.FileHeader) (*UploadResult, error) { + funcName := "service.file_service.Upload" + logs.Info(ctx, funcName, "上传文件", + zap.String("file_name", fileHeader.Filename), + zap.Int64("size", fileHeader.Size)) + + file, err := fileHeader.Open() + if err != nil { + logs.Error(ctx, funcName, "打开上传文件失败", zap.Error(err)) + return nil, ErrFileOpen + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) + now := time.Now() + objectName := fmt.Sprintf("uploads/%s/%s%s", now.Format("2006/01/02"), uuid.New().String(), ext) + + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + _, err = s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectName, file, fileHeader.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + logs.Error(ctx, funcName, "上传文件到 MinIO 失败", zap.Error(err)) + return nil, ErrUploadFailed + } + + scheme := "http" + if s.minioCfg.UseSSL { + scheme = "https" + } + url := fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectName) + + return &UploadResult{ + URL: url, + FileName: fileHeader.Filename, + Size: fileHeader.Size, + }, nil +} diff --git a/backend/go-service/app/group/controller/group_controller.go b/backend/go-service/app/group/controller/group_controller.go new file mode 100644 index 0000000..1533e25 --- /dev/null +++ b/backend/go-service/app/group/controller/group_controller.go @@ -0,0 +1,542 @@ +// Package controller 提供 group 模块的 HTTP 接口处理 +package controller + +import ( + "strconv" + + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/group/service" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +// GroupController 群聊管理控制器(REST API) +type GroupController struct { + groupService *service.GroupService +} + +// NewGroupController 创建 GroupController 实例 +func NewGroupController(groupService *service.GroupService) *GroupController { + return &GroupController{groupService: groupService} +} + +// CreateGroup 创建群聊 +// POST /api/v1/groups +func (ctl *GroupController) CreateGroup(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.CreateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + result, err := ctl.groupService.CreateGroup(ctx, userID, &req) + if err != nil { + ctl.handleError(c, err, "创建群聊失败") + return + } + utils.ResponseOK(c, result) +} + +// GetGroupDetail 获取群详情 +// GET /api/v1/groups/:id +func (ctl *GroupController) GetGroupDetail(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + result, err := ctl.groupService.GetGroupDetail(ctx, userID, groupID) + if err != nil { + ctl.handleError(c, err, "获取群详情失败") + return + } + utils.ResponseOK(c, result) +} + +// UpdateGroup 更新群信息 +// PUT /api/v1/groups/:id +func (ctl *GroupController) UpdateGroup(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var req dto.UpdateGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.UpdateGroup(ctx, userID, groupID, &req); err != nil { + ctl.handleError(c, err, "更新群信息失败") + return + } + utils.ResponseOK(c, nil) +} + +// DissolveGroup 解散群聊 +// DELETE /api/v1/groups/:id +func (ctl *GroupController) DissolveGroup(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + if err := ctl.groupService.DissolveGroup(ctx, userID, groupID); err != nil { + ctl.handleError(c, err, "解散群聊失败") + return + } + utils.ResponseOK(c, nil) +} + +// GetMembers 获取群成员列表 +// GET /api/v1/groups/:id/members +func (ctl *GroupController) GetMembers(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + result, err := ctl.groupService.GetMembers(ctx, userID, groupID) + if err != nil { + ctl.handleError(c, err, "获取成员列表失败") + return + } + utils.ResponseOK(c, result) +} + +// InviteMembers 邀请入群 +// POST /api/v1/groups/:id/members +func (ctl *GroupController) InviteMembers(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var req dto.InviteMembersRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.InviteMembers(ctx, userID, groupID, req.UserIDs); err != nil { + ctl.handleError(c, err, "邀请入群失败") + return + } + utils.ResponseOK(c, nil) +} + +// KickMember 踢出成员 +// DELETE /api/v1/groups/:id/members/:uid +func (ctl *GroupController) KickMember(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "用户 ID 格式错误") + return + } + + if err := ctl.groupService.KickMember(ctx, userID, groupID, targetID); err != nil { + ctl.handleError(c, err, "踢出成员失败") + return + } + utils.ResponseOK(c, nil) +} + +// SetMemberRole 设置/取消管理员 +// PUT /api/v1/groups/:id/members/:uid/role +func (ctl *GroupController) SetMemberRole(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "用户 ID 格式错误") + return + } + + var req dto.SetRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.SetMemberRole(ctx, userID, groupID, targetID, req.Role); err != nil { + ctl.handleError(c, err, "设置角色失败") + return + } + utils.ResponseOK(c, nil) +} + +// MuteMember 禁言/解除禁言 +// PUT /api/v1/groups/:id/members/:uid/mute +func (ctl *GroupController) MuteMember(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + targetID, err := strconv.ParseInt(c.Param("uid"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "用户 ID 格式错误") + return + } + + var req dto.MuteRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.MuteMember(ctx, userID, groupID, targetID, req.IsMuted); err != nil { + ctl.handleError(c, err, "操作失败") + return + } + utils.ResponseOK(c, nil) +} + +// TransferOwner 转让群主 +// PUT /api/v1/groups/:id/transfer +func (ctl *GroupController) TransferOwner(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var req dto.TransferOwnerRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.TransferOwner(ctx, userID, groupID, req.NewOwnerID); err != nil { + ctl.handleError(c, err, "转让群主失败") + return + } + utils.ResponseOK(c, nil) +} + +// LeaveGroup 退出群聊 +// DELETE /api/v1/groups/:id/members/me +func (ctl *GroupController) LeaveGroup(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + if err := ctl.groupService.LeaveGroup(ctx, userID, groupID); err != nil { + ctl.handleError(c, err, "退出群聊失败") + return + } + utils.ResponseOK(c, nil) +} + +// UpdateNickname 修改群内昵称 +// PUT /api/v1/groups/:id/members/me/nickname +func (ctl *GroupController) UpdateNickname(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var req dto.UpdateNicknameRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.UpdateNickname(ctx, userID, groupID, req.Nickname); err != nil { + ctl.handleError(c, err, "修改昵称失败") + return + } + utils.ResponseOK(c, nil) +} + +// SubmitJoinRequest 申请入群 +// POST /api/v1/groups/:id/join-requests +func (ctl *GroupController) SubmitJoinRequest(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var req dto.JoinGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.SubmitJoinRequest(ctx, userID, groupID, req.Message); err != nil { + ctl.handleError(c, err, "申请入群失败") + return + } + utils.ResponseOK(c, nil) +} + +// GetJoinRequests 获取入群申请列表 +// GET /api/v1/groups/:id/join-requests +func (ctl *GroupController) GetJoinRequests(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + result, err := ctl.groupService.GetJoinRequests(ctx, userID, groupID) + if err != nil { + ctl.handleError(c, err, "获取入群申请失败") + return + } + utils.ResponseOK(c, result) +} + +// ReviewJoinRequest 审批入群申请 +// PUT /api/v1/groups/:id/join-requests/:rid +func (ctl *GroupController) ReviewJoinRequest(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + requestID, err := strconv.ParseInt(c.Param("rid"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "申请 ID 格式错误") + return + } + + var req dto.ReviewJoinRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.ReviewJoinRequest(ctx, userID, groupID, requestID, req.Action); err != nil { + ctl.handleError(c, err, "审批失败") + return + } + utils.ResponseOK(c, nil) +} + +// SearchGroups 搜索群聊 +// GET /api/v1/groups/search?keyword=xx&page=1&page_size=20 +func (ctl *GroupController) SearchGroups(c *gin.Context) { + ctx := c.Request.Context() + _, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + var req dto.SearchGroupRequest + if err := c.ShouldBindQuery(&req); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + result, err := ctl.groupService.SearchGroups(ctx, &req) + if err != nil { + ctl.handleError(c, err, "搜索群聊失败") + return + } + utils.ResponseOK(c, result) +} + +// SetAllMuted 设置全体禁言 +// PUT /api/v1/groups/:id/all-mute +func (ctl *GroupController) SetAllMuted(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "群 ID 格式错误") + return + } + + var body struct { + IsAllMuted bool `json:"is_all_muted"` + } + if err := c.ShouldBindJSON(&body); err != nil { + utils.ResponseBadRequest(c, "参数校验失败: "+err.Error()) + return + } + + if err := ctl.groupService.SetAllMuted(ctx, userID, groupID, body.IsAllMuted); err != nil { + ctl.handleError(c, err, "设置全体禁言失败") + return + } + utils.ResponseOK(c, nil) +} + +// handleError 统一业务错误映射 +// 已知业务错误 → 返回 Service 层定义的具体提示 +// 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误") +func (ctl *GroupController) handleError(c *gin.Context, err error, fallbackMsg ...string) { + switch err { + case service.ErrGroupNotFound: + utils.ResponseNotFound(c, err.Error()) + case service.ErrGroupDissolved: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrNotGroupMember: + utils.ResponseForbidden(c, err.Error()) + case service.ErrNotGroupOwner: + utils.ResponseForbidden(c, err.Error()) + case service.ErrNotGroupAdmin: + utils.ResponseForbidden(c, err.Error()) + case service.ErrGroupFull: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrAlreadyMember: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrCannotKickHigherRole: + utils.ResponseForbidden(c, err.Error()) + case service.ErrOwnerCannotLeave: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrCannotMuteSelf: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrAlreadyMuted: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrUserMuted: + utils.ResponseForbidden(c, err.Error()) + case service.ErrGroupAllMuted: + utils.ResponseForbidden(c, err.Error()) + case service.ErrPendingRequestExists: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrJoinRequestNotFound: + utils.ResponseNotFound(c, err.Error()) + default: + msg := "服务器内部错误" + if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { + msg = fallbackMsg[0] + } + utils.ResponseError(c, msg) + } +} diff --git a/backend/go-service/app/group/dao/group_dao.go b/backend/go-service/app/group/dao/group_dao.go new file mode 100644 index 0000000..2a336a0 --- /dev/null +++ b/backend/go-service/app/group/dao/group_dao.go @@ -0,0 +1,346 @@ +// Package dao 提供 group 模块的数据库访问操作 +package dao + +import ( + "context" + "time" + + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/group/model" + imModel "github.com/echochat/backend/app/im/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// GroupDAO 群聊数据访问对象 +type GroupDAO struct { + db *gorm.DB +} + +// NewGroupDAO 创建 GroupDAO 实例 +func NewGroupDAO(db *gorm.DB) *GroupDAO { + return &GroupDAO{db: db} +} + +// CreateGroupWithMembers 在事务中创建群聊(会话 + 群信息 + 群成员) +// 创建者自动成为群主(role=2) +func (d *GroupDAO) CreateGroupWithMembers(ctx context.Context, ownerID int64, name, avatar string, memberIDs []int64) (*model.Group, error) { + funcName := "dao.group_dao.CreateGroupWithMembers" + logs.Info(ctx, funcName, "创建群聊", + zap.Int64("owner_id", ownerID), zap.String("name", name), zap.Int("member_count", len(memberIDs)+1)) + + var group model.Group + err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + conv := &imModel.Conversation{ + Type: constants.ConversationTypeGroup, + CreatorID: ownerID, + } + if err := tx.Create(conv).Error; err != nil { + logs.Error(ctx, funcName, "创建会话失败", zap.Error(err)) + return err + } + + group = model.Group{ + ConversationID: conv.ID, + Name: name, + Avatar: avatar, + OwnerID: ownerID, + MaxMembers: constants.GroupDefaultMaxMembers, + Status: constants.GroupStatusNormal, + } + if err := tx.Create(&group).Error; err != nil { + logs.Error(ctx, funcName, "创建群信息失败", zap.Error(err)) + return err + } + + now := time.Now() + ownerMember := &imModel.ConversationMember{ + ConversationID: conv.ID, + UserID: ownerID, + Role: constants.GroupRoleOwner, + JoinedAt: &now, + } + if err := tx.Create(ownerMember).Error; err != nil { + logs.Error(ctx, funcName, "创建群主成员记录失败", zap.Error(err)) + return err + } + + for _, uid := range memberIDs { + member := &imModel.ConversationMember{ + ConversationID: conv.ID, + UserID: uid, + Role: constants.GroupRoleNormal, + JoinedAt: &now, + } + if err := tx.Create(member).Error; err != nil { + logs.Error(ctx, funcName, "创建群成员记录失败", + zap.Int64("user_id", uid), zap.Error(err)) + return err + } + } + + return nil + }) + + if err != nil { + logs.Error(ctx, funcName, "创建群聊失败", zap.Error(err)) + return nil, err + } + return &group, nil +} + +// GetByID 根据群 ID 获取群信息 +func (d *GroupDAO) GetByID(ctx context.Context, groupID int64) (*model.Group, error) { + funcName := "dao.group_dao.GetByID" + logs.Debug(ctx, funcName, "获取群信息", zap.Int64("group_id", groupID)) + + var group model.Group + err := d.db.WithContext(ctx).First(&group, groupID).Error + if err != nil { + return nil, err + } + return &group, nil +} + +// GetByConversationID 根据会话 ID 获取群信息 +func (d *GroupDAO) GetByConversationID(ctx context.Context, conversationID int64) (*model.Group, error) { + funcName := "dao.group_dao.GetByConversationID" + logs.Debug(ctx, funcName, "按会话 ID 获取群信息", zap.Int64("conversation_id", conversationID)) + + var group model.Group + err := d.db.WithContext(ctx).Where("conversation_id = ?", conversationID).First(&group).Error + if err != nil { + return nil, err + } + return &group, nil +} + +// UpdateGroupInfo 更新群基本信息(名称/头像/公告/可搜索性) +func (d *GroupDAO) UpdateGroupInfo(ctx context.Context, groupID int64, updates map[string]interface{}) error { + funcName := "dao.group_dao.UpdateGroupInfo" + logs.Info(ctx, funcName, "更新群信息", zap.Int64("group_id", groupID)) + + return d.db.WithContext(ctx). + Model(&model.Group{}). + Where("id = ?", groupID). + Updates(updates).Error +} + +// DissolveGroup 解散群聊(标记状态为已解散) +func (d *GroupDAO) DissolveGroup(ctx context.Context, groupID int64) error { + funcName := "dao.group_dao.DissolveGroup" + logs.Info(ctx, funcName, "解散群聊", zap.Int64("group_id", groupID)) + + return d.db.WithContext(ctx). + Model(&model.Group{}). + Where("id = ?", groupID). + Update("status", constants.GroupStatusDissolved).Error +} + +// TransferOwner 转让群主 +func (d *GroupDAO) TransferOwner(ctx context.Context, groupID, oldOwnerID, newOwnerID int64, conversationID int64) error { + funcName := "dao.group_dao.TransferOwner" + logs.Info(ctx, funcName, "转让群主", + zap.Int64("group_id", groupID), zap.Int64("old_owner", oldOwnerID), zap.Int64("new_owner", newOwnerID)) + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Group{}). + Where("id = ?", groupID). + Update("owner_id", newOwnerID).Error; err != nil { + return err + } + + if err := tx.Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, oldOwnerID). + Update("role", constants.GroupRoleNormal).Error; err != nil { + return err + } + + if err := tx.Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, newOwnerID). + Update("role", constants.GroupRoleOwner).Error; err != nil { + return err + } + + return nil + }) +} + +// SetAllMuted 设置/取消全体禁言 +func (d *GroupDAO) SetAllMuted(ctx context.Context, groupID int64, isMuted bool) error { + funcName := "dao.group_dao.SetAllMuted" + logs.Info(ctx, funcName, "设置全体禁言", zap.Int64("group_id", groupID), zap.Bool("is_muted", isMuted)) + + return d.db.WithContext(ctx). + Model(&model.Group{}). + Where("id = ?", groupID). + Update("is_all_muted", isMuted).Error +} + +// GetGroupBrief 获取群简要信息(满足 im/service.GroupInfoGetter 接口) +func (d *GroupDAO) GetGroupBrief(ctx context.Context, conversationID int64) (*dto.GroupBrief, error) { + funcName := "dao.group_dao.GetGroupBrief" + logs.Debug(ctx, funcName, "获取群简要信息", zap.Int64("conversation_id", conversationID)) + + var group model.Group + err := d.db.WithContext(ctx).Where("conversation_id = ?", conversationID).First(&group).Error + if err != nil { + return nil, err + } + return &dto.GroupBrief{ + ID: group.ID, + Name: group.Name, + Avatar: group.Avatar, + IsAllMuted: group.IsAllMuted, + Status: group.Status, + }, nil +} + +// GetMemberCount 获取群成员数量 +func (d *GroupDAO) GetMemberCount(ctx context.Context, conversationID int64) (int64, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ?", conversationID). + Count(&count).Error + return count, err +} + +// GetMember 获取指定群成员记录 +func (d *GroupDAO) GetMember(ctx context.Context, conversationID, userID int64) (*imModel.ConversationMember, error) { + funcName := "dao.group_dao.GetMember" + logs.Debug(ctx, funcName, "查询群成员", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID)) + + var member imModel.ConversationMember + err := d.db.WithContext(ctx). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + First(&member).Error + if err != nil { + return nil, err + } + return &member, nil +} + +// GetMembers 获取群所有成员列表 +func (d *GroupDAO) GetMembers(ctx context.Context, conversationID int64) ([]imModel.ConversationMember, error) { + funcName := "dao.group_dao.GetMembers" + logs.Debug(ctx, funcName, "获取群成员列表", zap.Int64("conversation_id", conversationID)) + + var members []imModel.ConversationMember + err := d.db.WithContext(ctx). + Where("conversation_id = ?", conversationID). + Order("role DESC, joined_at ASC"). + Find(&members).Error + return members, err +} + +// GetMemberIDs 获取群所有成员 ID +func (d *GroupDAO) GetMemberIDs(ctx context.Context, conversationID int64) ([]int64, error) { + var ids []int64 + err := d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ?", conversationID). + Pluck("user_id", &ids).Error + return ids, err +} + +// GetAdminIDs 获取群主和管理员 ID 列表(用于推送入群申请通知) +func (d *GroupDAO) GetAdminIDs(ctx context.Context, conversationID int64) ([]int64, error) { + var ids []int64 + err := d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND role >= ?", conversationID, constants.GroupRoleAdmin). + Pluck("user_id", &ids).Error + return ids, err +} + +// AddMember 添加群成员 +func (d *GroupDAO) AddMember(ctx context.Context, conversationID, userID int64, role int) error { + funcName := "dao.group_dao.AddMember" + logs.Info(ctx, funcName, "添加群成员", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID)) + + now := time.Now() + member := &imModel.ConversationMember{ + ConversationID: conversationID, + UserID: userID, + Role: role, + JoinedAt: &now, + } + return d.db.WithContext(ctx).Create(member).Error +} + +// RemoveMember 移除群成员 +func (d *GroupDAO) RemoveMember(ctx context.Context, conversationID, userID int64) error { + funcName := "dao.group_dao.RemoveMember" + logs.Info(ctx, funcName, "移除群成员", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID)) + + return d.db.WithContext(ctx). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Delete(&imModel.ConversationMember{}).Error +} + +// UpdateMemberRole 更新成员角色 +func (d *GroupDAO) UpdateMemberRole(ctx context.Context, conversationID, userID int64, role int) error { + funcName := "dao.group_dao.UpdateMemberRole" + logs.Info(ctx, funcName, "更新成员角色", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID), zap.Int("role", role)) + + return d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Update("role", role).Error +} + +// UpdateMemberMuted 更新成员禁言状态 +func (d *GroupDAO) UpdateMemberMuted(ctx context.Context, conversationID, userID int64, isMuted bool) error { + funcName := "dao.group_dao.UpdateMemberMuted" + logs.Info(ctx, funcName, "更新成员禁言状态", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID), zap.Bool("is_muted", isMuted)) + + return d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Update("is_muted", isMuted).Error +} + +// UpdateMemberNickname 更新成员群内昵称 +func (d *GroupDAO) UpdateMemberNickname(ctx context.Context, conversationID, userID int64, nickname string) error { + funcName := "dao.group_dao.UpdateMemberNickname" + logs.Info(ctx, funcName, "更新群昵称", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID)) + + return d.db.WithContext(ctx). + Model(&imModel.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Update("nickname", nickname).Error +} + +// SearchGroups 搜索可发现的群聊(按群名称全文搜索) +func (d *GroupDAO) SearchGroups(ctx context.Context, keyword string, offset, limit int) ([]model.Group, int64, error) { + funcName := "dao.group_dao.SearchGroups" + logs.Debug(ctx, funcName, "搜索群聊", zap.String("keyword", keyword)) + + var total int64 + query := d.db.WithContext(ctx). + Model(&model.Group{}). + Where("status = ? AND is_searchable = true AND to_tsvector('simple', name) @@ plainto_tsquery('simple', ?)", + constants.GroupStatusNormal, keyword) + + if err := query.Count(&total).Error; err != nil { + logs.Error(ctx, funcName, "搜索计数失败", zap.Error(err)) + return nil, 0, err + } + + var groups []model.Group + err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&groups).Error + if err != nil { + logs.Error(ctx, funcName, "搜索查询失败", zap.Error(err)) + return nil, 0, err + } + return groups, total, nil +} diff --git a/backend/go-service/app/group/dao/join_request_dao.go b/backend/go-service/app/group/dao/join_request_dao.go new file mode 100644 index 0000000..c2ebdab --- /dev/null +++ b/backend/go-service/app/group/dao/join_request_dao.go @@ -0,0 +1,112 @@ +package dao + +import ( + "context" + + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/group/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// JoinRequestDAO 入群申请数据访问对象 +type JoinRequestDAO struct { + db *gorm.DB +} + +// NewJoinRequestDAO 创建 JoinRequestDAO 实例 +func NewJoinRequestDAO(db *gorm.DB) *JoinRequestDAO { + return &JoinRequestDAO{db: db} +} + +// Create 创建入群申请 +func (d *JoinRequestDAO) Create(ctx context.Context, groupID, userID int64, message string) (*model.GroupJoinRequest, error) { + funcName := "dao.join_request_dao.Create" + logs.Info(ctx, funcName, "创建入群申请", + zap.Int64("group_id", groupID), zap.Int64("user_id", userID)) + + req := &model.GroupJoinRequest{ + GroupID: groupID, + UserID: userID, + Message: message, + Status: constants.JoinRequestStatusPending, + } + err := d.db.WithContext(ctx).Create(req).Error + if err != nil { + logs.Error(ctx, funcName, "创建入群申请失败", zap.Error(err)) + } + return req, err +} + +// GetByID 根据 ID 获取入群申请 +func (d *JoinRequestDAO) GetByID(ctx context.Context, id int64) (*model.GroupJoinRequest, error) { + var req model.GroupJoinRequest + err := d.db.WithContext(ctx).First(&req, id).Error + if err != nil { + return nil, err + } + return &req, nil +} + +// GetPendingByGroupAndUser 查找用户对某群的待处理申请 +func (d *JoinRequestDAO) GetPendingByGroupAndUser(ctx context.Context, groupID, userID int64) (*model.GroupJoinRequest, error) { + funcName := "dao.join_request_dao.GetPendingByGroupAndUser" + logs.Debug(ctx, funcName, "查找待处理申请", + zap.Int64("group_id", groupID), zap.Int64("user_id", userID)) + + var req model.GroupJoinRequest + err := d.db.WithContext(ctx). + Where("group_id = ? AND user_id = ? AND status = ?", groupID, userID, constants.JoinRequestStatusPending). + First(&req).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &req, nil +} + +// GetListByGroup 获取群的入群申请列表 +func (d *JoinRequestDAO) GetListByGroup(ctx context.Context, groupID int64) ([]model.GroupJoinRequest, error) { + funcName := "dao.join_request_dao.GetListByGroup" + logs.Debug(ctx, funcName, "获取入群申请列表", zap.Int64("group_id", groupID)) + + var requests []model.GroupJoinRequest + err := d.db.WithContext(ctx). + Where("group_id = ?", groupID). + Order("created_at DESC"). + Find(&requests).Error + return requests, err +} + +// Approve 通过入群申请 +func (d *JoinRequestDAO) Approve(ctx context.Context, id, reviewerID int64) error { + funcName := "dao.join_request_dao.Approve" + logs.Info(ctx, funcName, "通过入群申请", + zap.Int64("request_id", id), zap.Int64("reviewer_id", reviewerID)) + + return d.db.WithContext(ctx). + Model(&model.GroupJoinRequest{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "status": constants.JoinRequestStatusApproved, + "reviewer_id": reviewerID, + }).Error +} + +// Reject 拒绝入群申请 +func (d *JoinRequestDAO) Reject(ctx context.Context, id, reviewerID int64) error { + funcName := "dao.join_request_dao.Reject" + logs.Info(ctx, funcName, "拒绝入群申请", + zap.Int64("request_id", id), zap.Int64("reviewer_id", reviewerID)) + + return d.db.WithContext(ctx). + Model(&model.GroupJoinRequest{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "status": constants.JoinRequestStatusRejected, + "reviewer_id": reviewerID, + }).Error +} diff --git a/backend/go-service/app/group/dao/message_read_dao.go b/backend/go-service/app/group/dao/message_read_dao.go new file mode 100644 index 0000000..f1e1316 --- /dev/null +++ b/backend/go-service/app/group/dao/message_read_dao.go @@ -0,0 +1,129 @@ +package dao + +import ( + "context" + + "github.com/echochat/backend/app/group/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// MessageReadDAO 消息已读记录数据访问对象 +type MessageReadDAO struct { + db *gorm.DB +} + +// NewMessageReadDAO 创建 MessageReadDAO 实例 +func NewMessageReadDAO(db *gorm.DB) *MessageReadDAO { + return &MessageReadDAO{db: db} +} + +// BatchCreate 批量创建已读记录(忽略重复冲突) +func (d *MessageReadDAO) BatchCreate(ctx context.Context, reads []model.MessageRead) error { + funcName := "dao.message_read_dao.BatchCreate" + logs.Info(ctx, funcName, "批量创建已读记录", zap.Int("count", len(reads))) + + if len(reads) == 0 { + return nil + } + return d.db.WithContext(ctx). + Clauses(clause.OnConflict{DoNothing: true}). + Create(&reads).Error +} + +// GetReadCount 获取消息的已读人数 +func (d *MessageReadDAO) GetReadCount(ctx context.Context, messageID int64) (int64, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&model.MessageRead{}). + Where("message_id = ?", messageID). + Count(&count).Error + return count, err +} + +// GetReadCountBatch 批量获取多条消息的已读人数 +func (d *MessageReadDAO) GetReadCountBatch(ctx context.Context, messageIDs []int64) (map[int64]int, error) { + funcName := "dao.message_read_dao.GetReadCountBatch" + logs.Debug(ctx, funcName, "批量获取已读计数", zap.Int("count", len(messageIDs))) + + type result struct { + MessageID int64 `gorm:"column:message_id"` + ReadCount int `gorm:"column:read_count"` + } + + var results []result + err := d.db.WithContext(ctx). + Model(&model.MessageRead{}). + Select("message_id, COUNT(*) as read_count"). + Where("message_id IN ?", messageIDs). + Group("message_id"). + Scan(&results).Error + + if err != nil { + return nil, err + } + + m := make(map[int64]int, len(results)) + for _, r := range results { + m[r.MessageID] = r.ReadCount + } + return m, nil +} + +// GetReadUsers 获取消息的已读用户列表 +func (d *MessageReadDAO) GetReadUsers(ctx context.Context, messageID int64) ([]model.MessageRead, error) { + funcName := "dao.message_read_dao.GetReadUsers" + logs.Debug(ctx, funcName, "获取已读用户列表", zap.Int64("message_id", messageID)) + + var reads []model.MessageRead + err := d.db.WithContext(ctx). + Where("message_id = ?", messageID). + Order("read_at ASC"). + Find(&reads).Error + return reads, err +} + +// BatchCreateReads 批量标记消息已读(满足 im/service.MessageReadRecorder 接口) +func (d *MessageReadDAO) BatchCreateReads(ctx context.Context, messageIDs []int64, userID int64) error { + funcName := "dao.message_read_dao.BatchCreateReads" + logs.Info(ctx, funcName, "批量标记已读", + zap.Int64("user_id", userID), zap.Int("count", len(messageIDs))) + + if len(messageIDs) == 0 { + return nil + } + + reads := make([]model.MessageRead, 0, len(messageIDs)) + for _, msgID := range messageIDs { + reads = append(reads, model.MessageRead{ + MessageID: msgID, + UserID: userID, + }) + } + return d.BatchCreate(ctx, reads) +} + +// GetReadUserIDs 获取消息的已读用户 ID 列表(满足 im/service.MessageReadRecorder 接口) +func (d *MessageReadDAO) GetReadUserIDs(ctx context.Context, messageID int64) ([]int64, error) { + funcName := "dao.message_read_dao.GetReadUserIDs" + logs.Debug(ctx, funcName, "获取已读用户 ID 列表", zap.Int64("message_id", messageID)) + + var ids []int64 + err := d.db.WithContext(ctx). + Model(&model.MessageRead{}). + Where("message_id = ?", messageID). + Pluck("user_id", &ids).Error + return ids, err +} + +// HasRead 检查用户是否已读某消息 +func (d *MessageReadDAO) HasRead(ctx context.Context, messageID, userID int64) (bool, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&model.MessageRead{}). + Where("message_id = ? AND user_id = ?", messageID, userID). + Count(&count).Error + return count > 0, err +} diff --git a/backend/go-service/app/group/model/group.go b/backend/go-service/app/group/model/group.go new file mode 100644 index 0000000..19dfa91 --- /dev/null +++ b/backend/go-service/app/group/model/group.go @@ -0,0 +1,27 @@ +// Package model 定义 group 模块的数据库模型 +package model + +import "time" + +// Group 群聊信息模型,对应 im_groups 表 +// 与 im_conversations (type=2) 一对一关联 +// Status: 1=正常,2=已解散 +type Group struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 群唯一标识 + ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex"` // 关联 im_conversations.id + Name string `json:"name" gorm:"size:100;not null;default:''"` // 群名称 + Avatar string `json:"avatar" gorm:"size:500;default:''"` // 群头像 URL(MinIO) + OwnerID int64 `json:"owner_id" gorm:"not null;index:idx_im_groups_owner"` // 群主用户 ID + Notice string `json:"notice" gorm:"type:text;default:''"` // 群公告内容 + MaxMembers int `json:"max_members" gorm:"not null;default:200"` // 最大成员数 + IsSearchable bool `json:"is_searchable" gorm:"not null;default:true"` // 是否可被搜索发现 + IsAllMuted bool `json:"is_all_muted" gorm:"not null;default:false"` // 是否全体禁言 + Status int `json:"status" gorm:"not null;default:1"` // 群状态:1=正常,2=已解散 + 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)"` // 更新时间 +} + +// TableName 指定数据库表名 +func (Group) TableName() string { + return "im_groups" +} diff --git a/backend/go-service/app/group/model/join_request.go b/backend/go-service/app/group/model/join_request.go new file mode 100644 index 0000000..fe05ead --- /dev/null +++ b/backend/go-service/app/group/model/join_request.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// GroupJoinRequest 入群申请模型,对应 im_group_join_requests 表 +// Status: 0=待审批,1=通过,2=拒绝 +type GroupJoinRequest struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 申请唯一标识 + GroupID int64 `json:"group_id" gorm:"not null;index:idx_group_join_req_group"` // 目标群 ID + UserID int64 `json:"user_id" gorm:"not null;index:idx_group_join_req_user"` // 申请人用户 ID + Message string `json:"message" gorm:"type:text;default:''"` // 申请附言 + ReviewerID *int64 `json:"reviewer_id"` // 审批人用户 ID + Status int `json:"status" gorm:"not null;default:0"` // 状态:0=待审批,1=通过,2=拒绝 + 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)"` // 更新时间 +} + +// TableName 指定数据库表名 +func (GroupJoinRequest) TableName() string { + return "im_group_join_requests" +} diff --git a/backend/go-service/app/group/model/message_read.go b/backend/go-service/app/group/model/message_read.go new file mode 100644 index 0000000..1c613fd --- /dev/null +++ b/backend/go-service/app/group/model/message_read.go @@ -0,0 +1,16 @@ +package model + +import "time" + +// MessageRead 群聊消息已读记录模型,对应 im_message_reads 表 +// 复合主键 (message_id, user_id),记录每条群消息的已读用户 +type MessageRead struct { + MessageID int64 `json:"message_id" gorm:"primaryKey"` // 消息 ID + UserID int64 `json:"user_id" gorm:"primaryKey"` // 已读用户 ID + ReadAt time.Time `json:"read_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 已读时间 +} + +// TableName 指定数据库表名 +func (MessageRead) TableName() string { + return "im_message_reads" +} diff --git a/backend/go-service/app/group/provider.go b/backend/go-service/app/group/provider.go new file mode 100644 index 0000000..3e7f7e6 --- /dev/null +++ b/backend/go-service/app/group/provider.go @@ -0,0 +1,18 @@ +// Package group 群聊管理模块 +package group + +import ( + "github.com/echochat/backend/app/group/controller" + "github.com/echochat/backend/app/group/dao" + "github.com/echochat/backend/app/group/service" + "github.com/google/wire" +) + +// GroupSet 群聊模块 Wire Provider Set +var GroupSet = wire.NewSet( + dao.NewGroupDAO, + dao.NewJoinRequestDAO, + dao.NewMessageReadDAO, + service.NewGroupService, + controller.NewGroupController, +) diff --git a/backend/go-service/app/group/router.go b/backend/go-service/app/group/router.go new file mode 100644 index 0000000..565353c --- /dev/null +++ b/backend/go-service/app/group/router.go @@ -0,0 +1,38 @@ +package group + +import ( + "github.com/echochat/backend/app/group/controller" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 group 模块的所有路由(需要 JWT 中间件) +func RegisterRoutes(r *gin.Engine, ctrl *controller.GroupController, jwtAuth gin.HandlerFunc) { + authed := r.Group("/api/v1") + authed.Use(jwtAuth) + { + // 群聊管理 + authed.POST("/groups", ctrl.CreateGroup) + authed.GET("/groups/search", ctrl.SearchGroups) + authed.GET("/groups/:id", ctrl.GetGroupDetail) + authed.PUT("/groups/:id", ctrl.UpdateGroup) + authed.DELETE("/groups/:id", ctrl.DissolveGroup) + + // 群成员管理 + authed.GET("/groups/:id/members", ctrl.GetMembers) + authed.POST("/groups/:id/members", ctrl.InviteMembers) + authed.DELETE("/groups/:id/members/me", ctrl.LeaveGroup) + authed.DELETE("/groups/:id/members/:uid", ctrl.KickMember) + authed.PUT("/groups/:id/members/me/nickname", ctrl.UpdateNickname) + authed.PUT("/groups/:id/members/:uid/role", ctrl.SetMemberRole) + authed.PUT("/groups/:id/members/:uid/mute", ctrl.MuteMember) + + // 群主转让 + 全体禁言 + authed.PUT("/groups/:id/transfer", ctrl.TransferOwner) + authed.PUT("/groups/:id/all-mute", ctrl.SetAllMuted) + + // 入群申请 + authed.POST("/groups/:id/join-requests", ctrl.SubmitJoinRequest) + authed.GET("/groups/:id/join-requests", ctrl.GetJoinRequests) + authed.PUT("/groups/:id/join-requests/:rid", ctrl.ReviewJoinRequest) + } +} diff --git a/backend/go-service/app/group/service/group_service.go b/backend/go-service/app/group/service/group_service.go new file mode 100644 index 0000000..505ccb7 --- /dev/null +++ b/backend/go-service/app/group/service/group_service.go @@ -0,0 +1,940 @@ +// Package service 提供 group 模块的业务逻辑 +package service + +import ( + "context" + "errors" + "fmt" + + authModel "github.com/echochat/backend/app/auth/model" + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/dto" + "github.com/echochat/backend/app/group/dao" + "github.com/echochat/backend/app/group/model" + imModel "github.com/echochat/backend/app/im/model" + "github.com/echochat/backend/pkg/logs" + "github.com/echochat/backend/pkg/ws" + "go.uber.org/zap" + "gorm.io/gorm" +) + +var ( + ErrGroupNotFound = errors.New("群聊不存在") + ErrGroupDissolved = errors.New("群聊已解散") + ErrNotGroupMember = errors.New("你不是该群成员") + ErrNotGroupOwner = errors.New("仅群主可执行此操作") + ErrNotGroupAdmin = errors.New("仅群主或管理员可执行此操作") + ErrGroupFull = errors.New("群成员已满") + ErrAlreadyMember = errors.New("该用户已是群成员") + ErrCannotKickHigherRole = errors.New("不能操作同级或更高权限的成员") + ErrOwnerCannotLeave = errors.New("群主不能退出群聊,请先转让群主") + ErrCannotMuteSelf = errors.New("不能禁言自己") + ErrAlreadyMuted = errors.New("该成员已被禁言") + ErrUserMuted = errors.New("你已被禁言,无法发送消息") + ErrGroupAllMuted = errors.New("当前群已开启全体禁言") + ErrPendingRequestExists = errors.New("已有待处理的入群申请") + ErrJoinRequestNotFound = errors.New("入群申请不存在") +) + +// UserInfoProvider 获取用户信息的接口(通过接口注入,由 contact.FriendshipDAO 隐式实现) +type UserInfoProvider interface { + GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error) +} + +// MessageWriter 写入系统消息的接口(由 im.MessageDAO 隐式实现) +type MessageWriter interface { + Create(ctx context.Context, msg *imModel.Message) error +} + +// GroupService 群聊业务服务 +type GroupService struct { + groupDAO *dao.GroupDAO + joinRequestDAO *dao.JoinRequestDAO + userInfo UserInfoProvider + pubsub *ws.PubSub + msgWriter MessageWriter +} + +// NewGroupService 创建 GroupService 实例 +func NewGroupService( + groupDAO *dao.GroupDAO, + joinRequestDAO *dao.JoinRequestDAO, + userInfo UserInfoProvider, + pubsub *ws.PubSub, + msgWriter MessageWriter, +) *GroupService { + return &GroupService{ + groupDAO: groupDAO, + joinRequestDAO: joinRequestDAO, + userInfo: userInfo, + pubsub: pubsub, + msgWriter: msgWriter, + } +} + +// CreateGroup 创建群聊 +func (s *GroupService) CreateGroup(ctx context.Context, ownerID int64, req *dto.CreateGroupRequest) (*dto.GroupDTO, error) { + funcName := "service.group_service.CreateGroup" + logs.Info(ctx, funcName, "创建群聊", + zap.Int64("owner_id", ownerID), zap.String("name", req.Name), zap.Int("member_count", len(req.MemberIDs))) + + group, err := s.groupDAO.CreateGroupWithMembers(ctx, ownerID, req.Name, req.Avatar, req.MemberIDs) + if err != nil { + logs.Error(ctx, funcName, "创建群聊失败", zap.Error(err)) + return nil, err + } + + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 创建了群聊", s.getUserNickname(ctx, ownerID))) + + allMemberIDs := append([]int64{ownerID}, req.MemberIDs...) + s.pushToMembers(ctx, allMemberIDs, 0, "group.created", map[string]interface{}{ + "group_id": group.ID, + "conversation_id": group.ConversationID, + "name": group.Name, + "owner_id": ownerID, + }) + + return s.toGroupDTO(group, int(len(req.MemberIDs)+1)), nil +} + +// GetGroupDetail 获取群详情(需要是群成员) +func (s *GroupService) GetGroupDetail(ctx context.Context, userID, groupID int64) (*dto.GroupDTO, error) { + funcName := "service.group_service.GetGroupDetail" + logs.Debug(ctx, funcName, "获取群详情", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.groupDAO.GetByID(ctx, groupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGroupNotFound + } + return nil, err + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotGroupMember + } + return nil, err + } + + count, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID) + return s.toGroupDTO(group, int(count)), nil +} + +// UpdateGroup 更新群信息(群主或管理员) +func (s *GroupService) UpdateGroup(ctx context.Context, userID, groupID int64, req *dto.UpdateGroupRequest) error { + funcName := "service.group_service.UpdateGroup" + logs.Info(ctx, funcName, "更新群信息", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, _, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Avatar != nil { + updates["avatar"] = *req.Avatar + } + if req.Notice != nil { + updates["notice"] = *req.Notice + } + if req.IsSearchable != nil { + updates["is_searchable"] = *req.IsSearchable + } + + if len(updates) == 0 { + return nil + } + + if err := s.groupDAO.UpdateGroupInfo(ctx, groupID, updates); err != nil { + return err + } + + if req.Notice != nil { + s.writeSystemMessage(ctx, group.ConversationID, + fmt.Sprintf("%s 更新了群公告", s.getUserNickname(ctx, userID))) + } + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.info.update", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "operator_id": userID, + "updates": updates, + }) + + return nil +} + +// DissolveGroup 解散群聊(仅群主) +func (s *GroupService) DissolveGroup(ctx context.Context, userID, groupID int64) error { + funcName := "service.group_service.DissolveGroup" + logs.Info(ctx, funcName, "解散群聊", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + if group.OwnerID != userID { + return ErrNotGroupOwner + } + + memberIDs, _ := s.groupDAO.GetMemberIDs(ctx, group.ConversationID) + + if err := s.groupDAO.DissolveGroup(ctx, groupID); err != nil { + return err + } + + s.writeSystemMessage(ctx, group.ConversationID, "群聊已解散") + + s.pushToMembers(ctx, memberIDs, 0, "group.dissolved", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "operator_id": userID, + }) + + return nil +} + +// GetMembers 获取群成员列表(需要是群成员) +func (s *GroupService) GetMembers(ctx context.Context, userID, groupID int64) ([]dto.GroupMemberDTO, error) { + funcName := "service.group_service.GetMembers" + logs.Debug(ctx, funcName, "获取群成员列表", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return nil, err + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotGroupMember + } + return nil, err + } + + members, err := s.groupDAO.GetMembers(ctx, group.ConversationID) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(members)) + for _, m := range members { + userIDs = append(userIDs, m.UserID) + } + + userMap := make(map[int64]*authModel.User) + if len(userIDs) > 0 && s.userInfo != nil { + users, uErr := s.userInfo.GetUsersByIDs(ctx, userIDs) + if uErr != nil { + logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr)) + } else { + for i := range users { + userMap[users[i].ID] = &users[i] + } + } + } + + list := make([]dto.GroupMemberDTO, 0, len(members)) + for _, m := range members { + item := dto.GroupMemberDTO{ + UserID: m.UserID, + Nickname: m.Nickname, + Role: m.Role, + IsMuted: m.IsMuted, + } + if m.JoinedAt != nil { + item.JoinedAt = m.JoinedAt.Format("2006-01-02 15:04:05") + } + if user, ok := userMap[m.UserID]; ok { + item.UserNickname = user.Nickname + item.Avatar = user.Avatar + } + list = append(list, item) + } + + return list, nil +} + +// InviteMembers 邀请用户入群(群主/管理员) +func (s *GroupService) InviteMembers(ctx context.Context, userID, groupID int64, targetIDs []int64) error { + funcName := "service.group_service.InviteMembers" + logs.Info(ctx, funcName, "邀请入群", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int("count", len(targetIDs))) + + group, _, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + count, err := s.groupDAO.GetMemberCount(ctx, group.ConversationID) + if err != nil { + return err + } + if int(count)+len(targetIDs) > group.MaxMembers { + return ErrGroupFull + } + + addedIDs := make([]int64, 0, len(targetIDs)) + for _, uid := range targetIDs { + existing, _ := s.groupDAO.GetMember(ctx, group.ConversationID, uid) + if existing != nil { + continue + } + if err := s.groupDAO.AddMember(ctx, group.ConversationID, uid, constants.GroupRoleNormal); err != nil { + logs.Error(ctx, funcName, "添加成员失败", zap.Int64("target_id", uid), zap.Error(err)) + return err + } + addedIDs = append(addedIDs, uid) + } + + if len(addedIDs) > 0 { + inviterName := s.getUserNickname(ctx, userID) + addedNames := s.getUserNicknames(ctx, addedIDs) + s.writeSystemMessage(ctx, group.ConversationID, + fmt.Sprintf("%s 邀请 %s 加入了群聊", inviterName, joinNames(addedNames))) + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.join", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_ids": addedIDs, + "operator_id": userID, + }) + } + + return nil +} + +// KickMember 踢出群成员(群主/管理员,不能操作同级或更高权限的成员) +func (s *GroupService) KickMember(ctx context.Context, userID, groupID, targetID int64) error { + funcName := "service.group_service.KickMember" + logs.Info(ctx, funcName, "踢出成员", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int64("target_id", targetID)) + + group, operatorMember, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + targetMember, err := s.groupDAO.GetMember(ctx, group.ConversationID, targetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + if targetMember.Role >= operatorMember.Role { + return ErrCannotKickHigherRole + } + + if err := s.groupDAO.RemoveMember(ctx, group.ConversationID, targetID); err != nil { + return err + } + + targetName := s.getUserNickname(ctx, targetID) + s.writeSystemMessage(ctx, group.ConversationID, + fmt.Sprintf("%s 被移出了群聊", targetName)) + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.kicked", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_id": targetID, + "operator_id": userID, + }) + s.pushToUser(ctx, targetID, "group.member.kicked", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_id": targetID, + "operator_id": userID, + }) + + return nil +} + +// SetMemberRole 设置/取消管理员(仅群主) +func (s *GroupService) SetMemberRole(ctx context.Context, userID, groupID, targetID int64, role int) error { + funcName := "service.group_service.SetMemberRole" + logs.Info(ctx, funcName, "设置成员角色", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), + zap.Int64("target_id", targetID), zap.Int("role", role)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + if group.OwnerID != userID { + return ErrNotGroupOwner + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, targetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + if err := s.groupDAO.UpdateMemberRole(ctx, group.ConversationID, targetID, role); err != nil { + return err + } + + targetName := s.getUserNickname(ctx, targetID) + if role == constants.GroupRoleAdmin { + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 被设为管理员", targetName)) + } else { + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 被取消管理员", targetName)) + } + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.role.update", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_id": targetID, + "role": role, + "operator_id": userID, + }) + + return nil +} + +// MuteMember 禁言/解除禁言成员(群主/管理员) +func (s *GroupService) MuteMember(ctx context.Context, userID, groupID, targetID int64, isMuted bool) error { + funcName := "service.group_service.MuteMember" + logs.Info(ctx, funcName, "更新禁言状态", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), + zap.Int64("target_id", targetID), zap.Bool("is_muted", isMuted)) + + if userID == targetID { + return ErrCannotMuteSelf + } + + group, operatorMember, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + targetMember, err := s.groupDAO.GetMember(ctx, group.ConversationID, targetID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + if targetMember.Role >= operatorMember.Role { + return ErrCannotKickHigherRole + } + + if err := s.groupDAO.UpdateMemberMuted(ctx, group.ConversationID, targetID, isMuted); err != nil { + return err + } + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.mute.update", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_id": targetID, + "is_muted": isMuted, + "operator_id": userID, + }) + + return nil +} + +// LeaveGroup 退出群聊(群主不能退出,需先转让) +func (s *GroupService) LeaveGroup(ctx context.Context, userID, groupID int64) error { + funcName := "service.group_service.LeaveGroup" + logs.Info(ctx, funcName, "退出群聊", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + if group.OwnerID == userID { + return ErrOwnerCannotLeave + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + if err := s.groupDAO.RemoveMember(ctx, group.ConversationID, userID); err != nil { + return err + } + + userName := s.getUserNickname(ctx, userID) + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 退出了群聊", userName)) + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.leave", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_id": userID, + }) + + return nil +} + +// TransferOwner 转让群主(仅群主) +func (s *GroupService) TransferOwner(ctx context.Context, userID, groupID, newOwnerID int64) error { + funcName := "service.group_service.TransferOwner" + logs.Info(ctx, funcName, "转让群主", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Int64("new_owner", newOwnerID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + if group.OwnerID != userID { + return ErrNotGroupOwner + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, newOwnerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + if err := s.groupDAO.TransferOwner(ctx, groupID, userID, newOwnerID, group.ConversationID); err != nil { + return err + } + + oldOwnerName := s.getUserNickname(ctx, userID) + newOwnerName := s.getUserNickname(ctx, newOwnerID) + s.writeSystemMessage(ctx, group.ConversationID, + fmt.Sprintf("%s 将群主转让给了 %s", oldOwnerName, newOwnerName)) + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.owner.transfer", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "old_owner_id": userID, + "new_owner_id": newOwnerID, + }) + + return nil +} + +// UpdateNickname 修改群内昵称 +func (s *GroupService) UpdateNickname(ctx context.Context, userID, groupID int64, nickname string) error { + funcName := "service.group_service.UpdateNickname" + logs.Info(ctx, funcName, "修改群昵称", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + + _, err = s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotGroupMember + } + return err + } + + return s.groupDAO.UpdateMemberNickname(ctx, group.ConversationID, userID, nickname) +} + +// SubmitJoinRequest 提交入群申请 +func (s *GroupService) SubmitJoinRequest(ctx context.Context, userID, groupID int64, message string) error { + funcName := "service.group_service.SubmitJoinRequest" + logs.Info(ctx, funcName, "提交入群申请", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return err + } + + existing, _ := s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if existing != nil { + return ErrAlreadyMember + } + + pending, _ := s.joinRequestDAO.GetPendingByGroupAndUser(ctx, groupID, userID) + if pending != nil { + return ErrPendingRequestExists + } + + req, err := s.joinRequestDAO.Create(ctx, groupID, userID, message) + if err != nil { + return err + } + + adminIDs, _ := s.groupDAO.GetAdminIDs(ctx, group.ConversationID) + userName := s.getUserNickname(ctx, userID) + s.pushToMembers(ctx, adminIDs, 0, "group.join.request", map[string]interface{}{ + "group_id": groupID, + "request_id": req.ID, + "user_id": userID, + "user_nickname": userName, + "message": message, + }) + + return nil +} + +// GetJoinRequests 获取入群申请列表(群主/管理员) +func (s *GroupService) GetJoinRequests(ctx context.Context, userID, groupID int64) ([]dto.JoinRequestDTO, error) { + funcName := "service.group_service.GetJoinRequests" + logs.Debug(ctx, funcName, "获取入群申请列表", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID)) + + _, _, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return nil, err + } + + requests, err := s.joinRequestDAO.GetListByGroup(ctx, groupID) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(requests)) + for _, r := range requests { + userIDs = append(userIDs, r.UserID) + } + + userMap := make(map[int64]*authModel.User) + if len(userIDs) > 0 && s.userInfo != nil { + users, uErr := s.userInfo.GetUsersByIDs(ctx, userIDs) + if uErr != nil { + logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr)) + } else { + for i := range users { + userMap[users[i].ID] = &users[i] + } + } + } + + list := make([]dto.JoinRequestDTO, 0, len(requests)) + for _, r := range requests { + item := dto.JoinRequestDTO{ + ID: r.ID, + GroupID: r.GroupID, + UserID: r.UserID, + Message: r.Message, + Status: r.Status, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + } + if user, ok := userMap[r.UserID]; ok { + item.UserNickname = user.Nickname + item.UserAvatar = user.Avatar + } + list = append(list, item) + } + + return list, nil +} + +// ReviewJoinRequest 审批入群申请(群主/管理员) +func (s *GroupService) ReviewJoinRequest(ctx context.Context, userID, groupID, requestID int64, action string) error { + funcName := "service.group_service.ReviewJoinRequest" + logs.Info(ctx, funcName, "审批入群申请", + zap.Int64("user_id", userID), zap.Int64("request_id", requestID), zap.String("action", action)) + + group, _, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + req, err := s.joinRequestDAO.GetByID(ctx, requestID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrJoinRequestNotFound + } + return err + } + + if req.GroupID != groupID || req.Status != constants.JoinRequestStatusPending { + return ErrJoinRequestNotFound + } + + if action == "approve" { + count, _ := s.groupDAO.GetMemberCount(ctx, group.ConversationID) + if int(count) >= group.MaxMembers { + return ErrGroupFull + } + + if err := s.joinRequestDAO.Approve(ctx, requestID, userID); err != nil { + return err + } + if err := s.groupDAO.AddMember(ctx, group.ConversationID, req.UserID, constants.GroupRoleNormal); err != nil { + return err + } + + newMemberName := s.getUserNickname(ctx, req.UserID) + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 加入了群聊", newMemberName)) + + s.pushToUser(ctx, req.UserID, "group.join.approved", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "request_id": requestID, + }) + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.member.join", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "user_ids": []int64{req.UserID}, + }) + + return nil + } + + return s.joinRequestDAO.Reject(ctx, requestID, userID) +} + +// SearchGroups 搜索公开群 +func (s *GroupService) SearchGroups(ctx context.Context, req *dto.SearchGroupRequest) (*dto.SearchGroupResponse, error) { + funcName := "service.group_service.SearchGroups" + logs.Debug(ctx, funcName, "搜索群聊", zap.String("keyword", req.Keyword)) + + page := req.Page + if page <= 0 { + page = 1 + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + groups, total, err := s.groupDAO.SearchGroups(ctx, req.Keyword, offset, pageSize) + if err != nil { + return nil, err + } + + list := make([]dto.GroupDTO, 0, len(groups)) + for _, g := range groups { + count, _ := s.groupDAO.GetMemberCount(ctx, g.ConversationID) + list = append(list, *s.toGroupDTO(&g, int(count))) + } + + return &dto.SearchGroupResponse{List: list, Total: total}, nil +} + +// SetAllMuted 设置/取消全体禁言(群主/管理员) +func (s *GroupService) SetAllMuted(ctx context.Context, userID, groupID int64, isMuted bool) error { + funcName := "service.group_service.SetAllMuted" + logs.Info(ctx, funcName, "设置全体禁言", + zap.Int64("user_id", userID), zap.Int64("group_id", groupID), zap.Bool("is_muted", isMuted)) + + group, _, err := s.checkGroupAdmin(ctx, groupID, userID) + if err != nil { + return err + } + + if err := s.groupDAO.SetAllMuted(ctx, groupID, isMuted); err != nil { + return err + } + + operatorName := s.getUserNickname(ctx, userID) + if isMuted { + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 开启了全体禁言", operatorName)) + } else { + s.writeSystemMessage(ctx, group.ConversationID, fmt.Sprintf("%s 关闭了全体禁言", operatorName)) + } + + s.pushToGroupMembers(ctx, group.ConversationID, 0, "group.mute.update", map[string]interface{}{ + "group_id": groupID, + "conversation_id": group.ConversationID, + "is_all_muted": isMuted, + "operator_id": userID, + }) + + return nil +} + +// ====== 内部辅助方法 ====== + +// getActiveGroup 获取群聊并校验是否存在且未解散 +func (s *GroupService) getActiveGroup(ctx context.Context, groupID int64) (*model.Group, error) { + group, err := s.groupDAO.GetByID(ctx, groupID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGroupNotFound + } + return nil, err + } + if group.Status == constants.GroupStatusDissolved { + return nil, ErrGroupDissolved + } + return group, nil +} + +// checkGroupAdmin 校验用户是群主或管理员 +func (s *GroupService) checkGroupAdmin(ctx context.Context, groupID, userID int64) (*model.Group, *imMember, error) { + group, err := s.getActiveGroup(ctx, groupID) + if err != nil { + return nil, nil, err + } + + member, err := s.groupDAO.GetMember(ctx, group.ConversationID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, ErrNotGroupMember + } + return nil, nil, err + } + + if member.Role < constants.GroupRoleAdmin { + return nil, nil, ErrNotGroupAdmin + } + + return group, &imMember{ + UserID: member.UserID, + Role: member.Role, + }, nil +} + +// imMember 内部使用的成员简要信息 +type imMember struct { + UserID int64 + Role int +} + +// ====== 推送和系统消息辅助方法 ====== + +// pushToUser 向单个用户推送通知 +func (s *GroupService) pushToUser(ctx context.Context, userID int64, event string, data interface{}) { + if s.pubsub == nil { + return + } + push := ws.NewPushMessage(event, data) + bytes, err := ws.MarshalPush(push) + if err != nil { + logs.Error(ctx, "service.group_service.pushToUser", "序列化推送消息失败", zap.Error(err)) + return + } + if err := s.pubsub.Publish(ctx, userID, bytes); err != nil { + logs.Error(ctx, "service.group_service.pushToUser", "推送失败", + zap.Int64("user_id", userID), zap.Error(err)) + } +} + +// pushToMembers 向指定用户列表推送通知(排除 excludeUID) +func (s *GroupService) pushToMembers(ctx context.Context, memberIDs []int64, excludeUID int64, event string, data interface{}) { + if s.pubsub == nil || len(memberIDs) == 0 { + return + } + push := ws.NewPushMessage(event, data) + bytes, err := ws.MarshalPush(push) + if err != nil { + logs.Error(ctx, "service.group_service.pushToMembers", "序列化推送消息失败", zap.Error(err)) + return + } + for _, uid := range memberIDs { + if uid == excludeUID { + continue + } + if pErr := s.pubsub.Publish(ctx, uid, bytes); pErr != nil { + logs.Error(ctx, "service.group_service.pushToMembers", "推送失败", + zap.Int64("user_id", uid), zap.Error(pErr)) + } + } +} + +// pushToGroupMembers 向群所有成员推送通知(通过 conversationID 查成员) +func (s *GroupService) pushToGroupMembers(ctx context.Context, conversationID int64, excludeUID int64, event string, data interface{}) { + memberIDs, err := s.groupDAO.GetMemberIDs(ctx, conversationID) + if err != nil { + logs.Error(ctx, "service.group_service.pushToGroupMembers", "获取成员列表失败", zap.Error(err)) + return + } + s.pushToMembers(ctx, memberIDs, excludeUID, event, data) +} + +// writeSystemMessage 写入系统消息到群会话 +func (s *GroupService) writeSystemMessage(ctx context.Context, conversationID int64, content string) { + if s.msgWriter == nil { + return + } + msg := &imModel.Message{ + ConversationID: conversationID, + SenderID: 0, + Type: constants.MessageTypeSystem, + Content: content, + Status: constants.MessageStatusNormal, + } + if err := s.msgWriter.Create(ctx, msg); err != nil { + logs.Error(ctx, "service.group_service.writeSystemMessage", "写入系统消息失败", zap.Error(err)) + } +} + +// getUserNickname 获取单个用户昵称(推送文案使用,查询失败返回默认值) +func (s *GroupService) getUserNickname(ctx context.Context, userID int64) string { + if s.userInfo == nil { + return "用户" + } + users, err := s.userInfo.GetUsersByIDs(ctx, []int64{userID}) + if err != nil || len(users) == 0 { + return "用户" + } + return users[0].Nickname +} + +// getUserNicknames 批量获取用户昵称列表 +func (s *GroupService) getUserNicknames(ctx context.Context, userIDs []int64) []string { + if s.userInfo == nil || len(userIDs) == 0 { + return nil + } + users, err := s.userInfo.GetUsersByIDs(ctx, userIDs) + if err != nil { + return nil + } + nameMap := make(map[int64]string, len(users)) + for _, u := range users { + nameMap[u.ID] = u.Nickname + } + names := make([]string, 0, len(userIDs)) + for _, id := range userIDs { + if n, ok := nameMap[id]; ok { + names = append(names, n) + } + } + return names +} + +// joinNames 将名称列表用顿号连接(中文习惯) +func joinNames(names []string) string { + if len(names) == 0 { + return "" + } + result := names[0] + for i := 1; i < len(names); i++ { + result += "、" + names[i] + } + return result +} + +// toGroupDTO 将 model.Group 转换为 dto.GroupDTO +func (s *GroupService) toGroupDTO(g *model.Group, memberCount int) *dto.GroupDTO { + return &dto.GroupDTO{ + ID: g.ID, + ConversationID: g.ConversationID, + Name: g.Name, + Avatar: g.Avatar, + OwnerID: g.OwnerID, + Notice: g.Notice, + MaxMembers: g.MaxMembers, + MemberCount: memberCount, + IsSearchable: g.IsSearchable, + IsAllMuted: g.IsAllMuted, + Status: g.Status, + CreatedAt: g.CreatedAt.Format("2006-01-02 15:04:05"), + } +} diff --git a/backend/go-service/app/im/controller/im_controller.go b/backend/go-service/app/im/controller/im_controller.go index aa87459..813146e 100644 --- a/backend/go-service/app/im/controller/im_controller.go +++ b/backend/go-service/app/im/controller/im_controller.go @@ -180,6 +180,30 @@ func (ctl *IMController) GetTotalUnread(c *gin.Context) { utils.ResponseOK(c, gin.H{"total_unread": count}) } +// GetMessageReadDetail 获取消息已读详情 +// GET /api/v1/im/messages/:id/reads +func (ctl *IMController) GetMessageReadDetail(c *gin.Context) { + ctx := c.Request.Context() + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return + } + + messageID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + utils.ResponseBadRequest(c, "消息 ID 格式错误") + return + } + + result, err := ctl.imService.GetMessageReadDetail(ctx, userID, messageID) + if err != nil { + ctl.handleError(c, err, "获取已读详情失败") + return + } + utils.ResponseOK(c, result) +} + // handleError 统一业务错误映射 // 已知业务错误 → 返回 Service 层定义的具体提示 // 未知错误 → 返回 fallbackMsg(未传则默认"服务器内部错误") @@ -203,6 +227,12 @@ func (ctl *IMController) handleError(c *gin.Context, err error, fallbackMsg ...s utils.ResponseForbidden(c, err.Error()) case service.ErrDuplicateMsg: utils.ResponseBadRequest(c, err.Error()) + case service.ErrGroupDissolved: + utils.ResponseBadRequest(c, err.Error()) + case service.ErrGroupAllMuted: + utils.ResponseForbidden(c, err.Error()) + case service.ErrUserMuted: + utils.ResponseForbidden(c, err.Error()) default: msg := "服务器内部错误" if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { diff --git a/backend/go-service/app/im/dao/conversation_dao.go b/backend/go-service/app/im/dao/conversation_dao.go index fd10968..5db91d5 100644 --- a/backend/go-service/app/im/dao/conversation_dao.go +++ b/backend/go-service/app/im/dao/conversation_dao.go @@ -86,13 +86,14 @@ func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64 err := d.db.WithContext(ctx). Raw(`SELECT c.id, c.type, c.last_message_id, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id, cm.is_pinned, cm.unread_count, cm.clear_before_msg_id, - peer.user_id AS peer_user_id + cm.is_do_not_disturb, cm.at_me_count, + COALESCE(peer.user_id, 0) AS peer_user_id FROM im_conversations c JOIN im_conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = ? - LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ? + LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ? AND c.type = ? WHERE cm.is_deleted = false ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`, - userID, userID). + userID, userID, constants.ConversationTypePrivate). Scan(&results).Error if err != nil { @@ -112,6 +113,8 @@ type ConversationWithMember struct { IsPinned bool `json:"is_pinned"` UnreadCount int `json:"unread_count"` ClearBeforeMsgID int64 `json:"clear_before_msg_id"` + IsDoNotDisturb bool `json:"is_do_not_disturb"` + AtMeCount int `json:"at_me_count"` PeerUserID int64 `json:"peer_user_id"` } @@ -216,6 +219,44 @@ func (d *ConversationDAO) IncrementUnread(ctx context.Context, conversationID, u UpdateColumn("unread_count", gorm.Expr("unread_count + 1")).Error } +// IncrementAtMeCount 将指定成员的 @提醒计数 +1 +func (d *ConversationDAO) IncrementAtMeCount(ctx context.Context, conversationID, userID int64) error { + return d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + UpdateColumn("at_me_count", gorm.Expr("at_me_count + 1")).Error +} + +// ClearAtMeCount 清零指定成员的 @提醒计数 +func (d *ConversationDAO) ClearAtMeCount(ctx context.Context, conversationID, userID int64) error { + return d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Update("at_me_count", 0).Error +} + +// GetMemberDNDMap 批量获取会话成员的免打扰状态(返回 userID → isDoNotDisturb 的映射) +func (d *ConversationDAO) GetMemberDNDMap(ctx context.Context, conversationID int64) (map[int64]bool, error) { + type memberDND struct { + UserID int64 `json:"user_id"` + IsDoNotDisturb bool `json:"is_do_not_disturb"` + } + var members []memberDND + err := d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Select("user_id, is_do_not_disturb"). + Where("conversation_id = ?", conversationID). + Scan(&members).Error + if err != nil { + return nil, err + } + result := make(map[int64]bool, len(members)) + for _, m := range members { + result[m.UserID] = m.IsDoNotDisturb + } + return result, nil +} + // ClearUnread 清零指定成员的未读消息数 func (d *ConversationDAO) ClearUnread(ctx context.Context, conversationID, userID int64, lastMsgID int64) error { return d.db.WithContext(ctx). diff --git a/backend/go-service/app/im/handler/event_handler.go b/backend/go-service/app/im/handler/event_handler.go index 7688092..b1335af 100644 --- a/backend/go-service/app/im/handler/event_handler.go +++ b/backend/go-service/app/im/handler/event_handler.go @@ -34,6 +34,7 @@ func (h *EventHandler) registerEvents() { h.hub.RegisterEvent("im.message.send", h.handleSendMessage) h.hub.RegisterEvent("im.message.recall", h.handleRecallMessage) h.hub.RegisterEvent("im.conversation.read", h.handleMarkRead) + h.hub.RegisterEvent("im.group.read", h.handleGroupRead) h.hub.RegisterEvent("im.typing", h.handleTyping) } @@ -111,6 +112,29 @@ func (h *EventHandler) handleMarkRead(client *ws.Client, msg *ws.Message) { h.sendACK(client, msg, 0, "ok", nil) } +// handleGroupRead 处理群聊消息标记已读事件 +func (h *EventHandler) handleGroupRead(client *ws.Client, msg *ws.Message) { + funcName := "handler.event_handler.handleGroupRead" + ctx := context.Background() + + var req dto.MarkGroupReadRequest + if err := json.Unmarshal(msg.Data, &req); err != nil { + logs.Warn(ctx, funcName, "解析群已读请求失败", + zap.Int64("user_id", client.UserID), zap.Error(err)) + h.sendACK(client, msg, -1, "请求参数格式错误", nil) + return + } + + if err := h.imService.MarkGroupMessagesRead(ctx, client.UserID, &req); err != nil { + logs.Warn(ctx, funcName, "群已读标记失败", + zap.Int64("user_id", client.UserID), zap.Error(err)) + h.sendACK(client, msg, -1, err.Error(), nil) + return + } + + h.sendACK(client, msg, 0, "ok", nil) +} + // handleTyping 处理正在输入事件(通过 PubSub 转发给对方,支持跨实例) func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) { funcName := "handler.event_handler.handleTyping" diff --git a/backend/go-service/app/im/model/conversation_member.go b/backend/go-service/app/im/model/conversation_member.go index 6ab62b7..985c8f0 100644 --- a/backend/go-service/app/im/model/conversation_member.go +++ b/backend/go-service/app/im/model/conversation_member.go @@ -4,17 +4,24 @@ import "time" // ConversationMember 会话成员模型,对应 im_conversation_members 表 // 每个会话的每个成员一条记录,存储置顶/未读/软删除等个人视图 +// 群聊场景下额外使用 Role/Nickname/IsMuted/IsDoNotDisturb/JoinedAt/AtMeCount 字段 type ConversationMember struct { - ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 记录唯一标识 - ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 所属会话 ID - UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 成员用户 ID - IsPinned bool `json:"is_pinned" gorm:"default:false"` // 是否置顶该会话 - IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方) - UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数 - LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID - ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 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)"` // 最后更新时间 + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 记录唯一标识 + ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 所属会话 ID + UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:idx_conv_user"` // 成员用户 ID + IsPinned bool `json:"is_pinned" gorm:"default:false"` // 是否置顶该会话 + IsDeleted bool `json:"is_deleted" gorm:"default:false"` // 是否删除该会话(软删除,不影响对方) + UnreadCount int `json:"unread_count" gorm:"default:0"` // 该成员在此会话中的未读消息数 + LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID + ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 ID(个人视图,不影响对方) + Role int `json:"role" gorm:"not null;default:0"` // 成员角色:0=普通成员,1=管理员,2=群主 + Nickname string `json:"nickname" gorm:"size:50;default:''"` // 群内昵称(仅群聊有效) + IsMuted bool `json:"is_muted" gorm:"not null;default:false"` // 是否被禁言 + IsDoNotDisturb bool `json:"is_do_not_disturb" gorm:"not null;default:false"` // 是否消息免打扰 + JoinedAt *time.Time `json:"joined_at" gorm:"type:timestamp(0)"` // 加入群聊时间 + AtMeCount int `json:"at_me_count" gorm:"default:0"` // 被@提醒未读计数 + 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)"` // 最后更新时间 } // TableName 指定数据库表名 diff --git a/backend/go-service/app/im/model/message.go b/backend/go-service/app/im/model/message.go index 14f7320..4849b4f 100644 --- a/backend/go-service/app/im/model/message.go +++ b/backend/go-service/app/im/model/message.go @@ -1,20 +1,25 @@ package model -import "time" +import ( + "time" + + "github.com/echochat/backend/pkg/utils" +) // Message 消息模型,对应 im_messages 表 -// Type: 1=文本(预留 2=图片 3=语音 4=视频 5=文件) +// Type: 1=文本(预留 2=图片 3=语音 4=视频 5=文件),10=系统消息 // Status: 1=正常 2=已撤回 3=已删除 type Message struct { - ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识,自增主键 - ConversationID int64 `json:"conversation_id" gorm:"not null;index:idx_msg_conv_time;index:idx_msg_conv_id"` // 所属会话 ID - SenderID int64 `json:"sender_id" gorm:"not null"` // 发送者用户 ID - Type int `json:"type" gorm:"not null;default:1"` // 消息类型:1=文本(预留扩展) - Content string `json:"content" gorm:"type:text;not null"` // 消息内容 - Extra *string `json:"extra" gorm:"type:jsonb"` // 扩展数据(JSON 格式,预留图片/语音等元信息) - Status int `json:"status" gorm:"not null;default:1"` // 消息状态:1=正常,2=已撤回,3=已删除 - ClientMsgID string `json:"client_msg_id" gorm:"size:64;default:''"` // 客户端消息唯一 ID,用于幂等去重 - CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 消息发送时间 + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识,自增主键 + ConversationID int64 `json:"conversation_id" gorm:"not null;index:idx_msg_conv_time;index:idx_msg_conv_id"` // 所属会话 ID + SenderID int64 `json:"sender_id" gorm:"not null"` // 发送者用户 ID(系统消息为 0) + Type int `json:"type" gorm:"not null;default:1"` // 消息类型:1=文本,10=系统消息 + Content string `json:"content" gorm:"type:text;not null"` // 消息内容 + Extra *string `json:"extra" gorm:"type:jsonb"` // 扩展数据(JSON 格式,预留图片/语音等元信息) + Status int `json:"status" gorm:"not null;default:1"` // 消息状态:1=正常,2=已撤回,3=已删除 + ClientMsgID string `json:"client_msg_id" gorm:"size:64;default:''"` // 客户端消息唯一 ID,用于幂等去重 + AtUserIDs utils.Int64Array `json:"at_user_ids" gorm:"type:bigint[]"` // @提醒用户 ID 列表,nil=无@,含 0 表示 @所有人 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 消息发送时间 } // TableName 指定数据库表名 diff --git a/backend/go-service/app/im/provider.go b/backend/go-service/app/im/provider.go index f042342..1ddc572 100644 --- a/backend/go-service/app/im/provider.go +++ b/backend/go-service/app/im/provider.go @@ -22,7 +22,8 @@ func ProvideMessageDAO(db *gorm.DB) *dao.MessageDAO { } // ProvideIMService 创建 IMService 实例 -// friendChecker 和 userInfoGetter 由 contact 模块的 FriendshipDAO 隐式实现 +// friendChecker / userInfoGetter 由 contact.FriendshipDAO 隐式实现 +// groupInfo 由 group.GroupDAO 隐式实现 func ProvideIMService( convDAO *dao.ConversationDAO, msgDAO *dao.MessageDAO, @@ -30,8 +31,10 @@ func ProvideIMService( rdb *redis.Client, friendChecker service.FriendChecker, userInfoGetter service.UserInfoGetter, + groupInfo service.GroupInfoGetter, + readRecorder service.MessageReadRecorder, ) *service.IMService { - return service.NewIMService(convDAO, msgDAO, pubsub, rdb, friendChecker, userInfoGetter) + return service.NewIMService(convDAO, msgDAO, pubsub, rdb, friendChecker, userInfoGetter, groupInfo, readRecorder) } // ProvideIMEventHandler 创建 IM WS 事件处理器并注册事件到 Hub diff --git a/backend/go-service/app/im/router.go b/backend/go-service/app/im/router.go index 1dcce79..4eb3c92 100644 --- a/backend/go-service/app/im/router.go +++ b/backend/go-service/app/im/router.go @@ -23,5 +23,8 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.Ha // 未读数 authed.GET("/unread", ctrl.GetTotalUnread) + + // 已读回执 + authed.GET("/messages/:id/reads", ctrl.GetMessageReadDetail) } } diff --git a/backend/go-service/app/im/service/im_service.go b/backend/go-service/app/im/service/im_service.go index 8ba1401..4b40ddd 100644 --- a/backend/go-service/app/im/service/im_service.go +++ b/backend/go-service/app/im/service/im_service.go @@ -23,25 +23,30 @@ const ( ) var ( - ErrNotFriend = errors.New("对方不是你的好友") - ErrEmptyContent = errors.New("消息内容不能为空") - ErrConvNotFound = errors.New("会话不存在") - ErrMsgNotFound = errors.New("消息不存在") - ErrNotSender = errors.New("只能撤回自己发送的消息") - ErrRecallTimeout = errors.New("超过撤回时限") - ErrNotMember = errors.New("你不是该会话的成员") - ErrDuplicateMsg = errors.New("重复消息") - ErrInvalidMsgType = errors.New("不支持的消息类型") + ErrNotFriend = errors.New("对方不是你的好友") + ErrEmptyContent = errors.New("消息内容不能为空") + ErrConvNotFound = errors.New("会话不存在") + ErrMsgNotFound = errors.New("消息不存在") + ErrNotSender = errors.New("只能撤回自己发送的消息") + ErrRecallTimeout = errors.New("超过撤回时限") + ErrNotMember = errors.New("你不是该会话的成员") + ErrDuplicateMsg = errors.New("重复消息") + ErrInvalidMsgType = errors.New("不支持的消息类型") + ErrGroupDissolved = errors.New("群聊已解散") + ErrGroupAllMuted = errors.New("当前群已开启全体禁言") + ErrUserMuted = errors.New("你已被禁言,无法发送消息") ) // IMService 即时通讯核心业务服务 type IMService struct { - convDAO *dao.ConversationDAO - msgDAO *dao.MessageDAO - pubsub *ws.PubSub - rdb *redis.Client - friendChecker FriendChecker + convDAO *dao.ConversationDAO + msgDAO *dao.MessageDAO + pubsub *ws.PubSub + rdb *redis.Client + friendChecker FriendChecker userInfoGetter UserInfoGetter + groupInfo GroupInfoGetter + readRecorder MessageReadRecorder } // NewIMService 创建 IMService 实例 @@ -52,6 +57,8 @@ func NewIMService( rdb *redis.Client, friendChecker FriendChecker, userInfoGetter UserInfoGetter, + groupInfo GroupInfoGetter, + readRecorder MessageReadRecorder, ) *IMService { return &IMService{ convDAO: convDAO, @@ -60,15 +67,14 @@ func NewIMService( rdb: rdb, friendChecker: friendChecker, userInfoGetter: userInfoGetter, + groupInfo: groupInfo, + readRecorder: readRecorder, } } -// SendMessage 发送消息(核心流程) -// 1. 校验好友关系 -// 2. 查找或创建会话 -// 3. 幂等去重(client_msg_id) -// 4. 写入消息 + 更新会话最后消息 + 递增对方未读数 -// 5. 通过 PubSub 推送给接收方 +// 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, "发送消息", @@ -87,40 +93,64 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se } convID := req.ConversationID - var peerID int64 if convID == 0 && req.TargetUserID > 0 { - isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID) - if err != nil { - logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err)) - return nil, err - } - if !isFriend { - return nil, ErrNotFriend - } - - conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID) - if err != nil { - return nil, err - } - convID = conv.ID - peerID = req.TargetUserID + return s.sendPrivateMessage(ctx, senderID, req) } else if convID > 0 { - member, err := s.convDAO.GetMember(ctx, convID, senderID) + conv, err := s.convDAO.GetByID(ctx, convID) if err != nil { - return nil, ErrNotMember + return nil, ErrConvNotFound } - if member == nil { - return nil, ErrNotMember + if conv.Type == constants.ConversationTypeGroup { + return s.sendGroupMessage(ctx, senderID, req) } - peerID, err = s.convDAO.GetPeerUserID(ctx, convID, senderID) - if err != nil { - logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err)) - return nil, err - } - } else { - return nil, ErrConvNotFound + return s.sendPrivateMessageByConvID(ctx, senderID, convID, req) } + return nil, ErrConvNotFound +} + +// sendPrivateMessage 发送单聊消息(首次发送,通过 TargetUserID) +func (s *IMService) sendPrivateMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) { + funcName := "service.im_service.sendPrivateMessage" + + isFriend, err := s.friendChecker.IsFriend(ctx, senderID, req.TargetUserID) + if err != nil { + logs.Error(ctx, funcName, "检查好友关系失败", zap.Error(err)) + return nil, err + } + if !isFriend { + return nil, ErrNotFriend + } + + conv, err := s.getOrCreatePrivateConversation(ctx, senderID, req.TargetUserID) + if err != nil { + return nil, err + } + + return s.writeAndPushPrivateMessage(ctx, senderID, conv.ID, req.TargetUserID, req) +} + +// sendPrivateMessageByConvID 发送单聊消息(已有会话 ID) +func (s *IMService) sendPrivateMessageByConvID(ctx context.Context, senderID, convID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) { + funcName := "service.im_service.sendPrivateMessageByConvID" + + member, err := s.convDAO.GetMember(ctx, convID, senderID) + if err != nil || member == nil { + return nil, ErrNotMember + } + + peerID, err := s.convDAO.GetPeerUserID(ctx, convID, senderID) + if err != nil { + logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Error(err)) + return nil, err + } + + return s.writeAndPushPrivateMessage(ctx, senderID, convID, peerID, req) +} + +// writeAndPushPrivateMessage 单聊:幂等去重 + 写消息 + 推送 +func (s *IMService) writeAndPushPrivateMessage(ctx context.Context, senderID, convID, peerID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) { + funcName := "service.im_service.writeAndPushPrivateMessage" if req.ClientMsgID != "" { existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID) @@ -160,40 +190,161 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se s.incrementTotalUnread(ctx, peerID) - pushData := map[string]interface{}{ - "id": msg.ID, - "conversation_id": convID, - "sender_id": senderID, - "type": msg.Type, - "content": msg.Content, - "client_msg_id": msg.ClientMsgID, - "created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"), - } - if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 { - pushData["sender_name"] = senderUsers[0].Nickname - pushData["sender_avatar"] = senderUsers[0].Avatar - } + pushData := s.buildMessagePushData(ctx, msg, senderID) + pushData["conv_type"] = constants.ConversationTypePrivate s.pushToUser(ctx, peerID, "im.message.new", pushData) return s.toMessageDTO(msg), nil } -// RecallMessage 撤回消息(2分钟内) -// 撤回成功后,若被撤回消息是会话最后一条,则同步更新会话预览文本 -func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error { +// sendGroupMessage 发送群聊消息 +func (s *IMService) sendGroupMessage(ctx context.Context, senderID int64, req *dto.SendMessageRequest) (*dto.MessageDTO, error) { + funcName := "service.im_service.sendGroupMessage" + convID := req.ConversationID + + member, err := s.convDAO.GetMember(ctx, convID, senderID) + if err != nil || member == nil { + return nil, ErrNotMember + } + + if s.groupInfo != nil { + groupBrief, gErr := s.groupInfo.GetGroupBrief(ctx, convID) + if gErr != nil { + logs.Error(ctx, funcName, "获取群信息失败", zap.Error(gErr)) + return nil, ErrConvNotFound + } + if groupBrief.Status == constants.GroupStatusDissolved { + return nil, ErrGroupDissolved + } + if groupBrief.IsAllMuted && member.Role < constants.GroupRoleAdmin { + return nil, ErrGroupAllMuted + } + } + + if member.IsMuted { + return nil, ErrUserMuted + } + + if req.ClientMsgID != "" { + existing, err := s.msgDAO.FindByClientMsgID(ctx, convID, req.ClientMsgID) + if err != nil { + logs.Error(ctx, funcName, "幂等去重查询失败", zap.Error(err)) + return nil, err + } + if existing != nil { + return s.toMessageDTO(existing), ErrDuplicateMsg + } + } + + msg := &model.Message{ + ConversationID: convID, + SenderID: senderID, + Type: req.Type, + Content: req.Content, + Status: constants.MessageStatusNormal, + ClientMsgID: req.ClientMsgID, + } + if len(req.AtUserIDs) > 0 { + msg.AtUserIDs = req.AtUserIDs + } + if err := s.msgDAO.Create(ctx, msg); err != nil { + return nil, err + } + + now := time.Now() + if err := s.convDAO.UpdateLastMessage(ctx, convID, msg.ID, truncateContent(req.Content, 100), senderID, now); err != nil { + logs.Error(ctx, funcName, "更新最后消息失败", zap.Error(err)) + } + + memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, convID) + if err != nil { + logs.Error(ctx, funcName, "获取群成员列表失败", zap.Error(err)) + return s.toMessageDTO(msg), nil + } + + dndMap, dndErr := s.convDAO.GetMemberDNDMap(ctx, convID) + if dndErr != nil { + logs.Error(ctx, funcName, "获取免打扰状态失败", zap.Error(dndErr)) + dndMap = make(map[int64]bool) + } + + pushData := s.buildMessagePushData(ctx, msg, senderID) + pushData["conv_type"] = constants.ConversationTypeGroup + if len(req.AtUserIDs) > 0 { + pushData["at_user_ids"] = req.AtUserIDs + } + + for _, uid := range memberIDs { + if uid == senderID { + continue + } + if err := s.convDAO.IncrementUnread(ctx, convID, uid); err != nil { + logs.Error(ctx, funcName, "递增未读计数失败", zap.Int64("user_id", uid), zap.Error(err)) + } + + if !dndMap[uid] { + s.incrementTotalUnread(ctx, uid) + } + + if len(req.AtUserIDs) > 0 { + isAtMe := false + for _, atID := range req.AtUserIDs { + if atID == uid || atID == 0 { + isAtMe = true + break + } + } + if isAtMe { + if err := s.convDAO.IncrementAtMeCount(ctx, convID, uid); err != nil { + logs.Error(ctx, funcName, "递增@计数失败", zap.Int64("user_id", uid), zap.Error(err)) + } + } + } + + s.pushToUser(ctx, uid, "im.message.new", pushData) + } + + return s.toMessageDTO(msg), nil +} + +// RecallMessage 撤回消息 +// 单聊:只能撤回自己的消息,2 分钟内 +// 群聊:自己的消息 2 分钟内撤回;群主/管理员可无时限撤回任何消息 +func (s *IMService) RecallMessage(ctx context.Context, operatorID int64, messageID int64) error { funcName := "service.im_service.RecallMessage" logs.Info(ctx, funcName, "撤回消息", - zap.Int64("sender_id", senderID), zap.Int64("message_id", messageID)) + zap.Int64("operator_id", operatorID), zap.Int64("message_id", messageID)) msg, err := s.msgDAO.GetByID(ctx, messageID) if err != nil { return ErrMsgNotFound } - if msg.SenderID != senderID { - return ErrNotSender + + conv, err := s.convDAO.GetByID(ctx, msg.ConversationID) + if err != nil { + logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err)) + return ErrConvNotFound } - if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) { - return ErrRecallTimeout + + isAdmin := false + if conv.Type == constants.ConversationTypeGroup { + member, mErr := s.convDAO.GetMember(ctx, msg.ConversationID, operatorID) + if mErr != nil || member == nil { + return ErrNotMember + } + isAdmin = member.Role >= constants.GroupRoleAdmin + } + + if msg.SenderID == operatorID { + if time.Since(msg.CreatedAt).Seconds() > float64(constants.MessageRecallTimeLimit) { + if !isAdmin { + return ErrRecallTimeout + } + } + } else { + if !isAdmin { + return ErrNotSender + } } if err := s.msgDAO.UpdateStatus(ctx, messageID, constants.MessageStatusRecalled); err != nil { @@ -201,16 +352,31 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID return err } - conv, err := s.convDAO.GetByID(ctx, msg.ConversationID) - if err != nil { - logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err)) - } else if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID { - senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}) - recallText := "撤回了一条消息" + recallText := "撤回了一条消息" + if msg.SenderID != operatorID && isAdmin { + operatorInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID, msg.SenderID}) + operatorName := "管理员" + senderName := "成员" + if infoErr == nil { + for _, u := range operatorInfo { + if u.ID == operatorID { + operatorName = u.Nickname + } + if u.ID == msg.SenderID { + senderName = u.Nickname + } + } + } + recallText = fmt.Sprintf("管理员 %s 撤回了 %s 的一条消息", operatorName, senderName) + } else { + senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{operatorID}) if infoErr == nil && len(senderInfo) > 0 { recallText = senderInfo[0].Nickname + " 撤回了一条消息" } - if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, senderID, msg.CreatedAt); updateErr != nil { + } + + if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID { + if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, operatorID, msg.CreatedAt); updateErr != nil { logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr)) } } @@ -221,20 +387,22 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID return nil } for _, uid := range memberIDs { - if uid == senderID { + if uid == operatorID { continue } s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{ "message_id": messageID, "conversation_id": msg.ConversationID, - "sender_id": senderID, + "operator_id": operatorID, + "sender_id": msg.SenderID, + "recall_text": recallText, }) } return nil } -// GetConversationList 获取会话列表(含对方用户信息) +// GetConversationList 获取会话列表(含对方用户信息 / 群聊信息) func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto.ConversationListResponse, error) { funcName := "service.im_service.GetConversationList" logs.Debug(ctx, funcName, "获取会话列表", zap.Int64("user_id", userID)) @@ -245,17 +413,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto } peerIDs := make([]int64, 0, len(convs)) + groupConvIDs := make([]int64, 0) for _, c := range convs { - if c.PeerUserID > 0 { + if c.Type == constants.ConversationTypePrivate && c.PeerUserID > 0 { peerIDs = append(peerIDs, c.PeerUserID) + } else if c.Type == constants.ConversationTypeGroup { + groupConvIDs = append(groupConvIDs, c.ID) } } userMap := make(map[int64]*userBrief) if len(peerIDs) > 0 { - users, err := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs) - if err != nil { - logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(err)) + users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, peerIDs) + if uErr != nil { + logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr)) } else { for i := range users { u := users[i] @@ -264,17 +435,31 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto } } + groupMap := make(map[int64]*dto.GroupBrief) + if s.groupInfo != nil && len(groupConvIDs) > 0 { + for _, convID := range groupConvIDs { + brief, gErr := s.groupInfo.GetGroupBrief(ctx, convID) + if gErr != nil { + logs.Error(ctx, funcName, "获取群信息失败", + zap.Int64("conversation_id", convID), zap.Error(gErr)) + continue + } + groupMap[convID] = brief + } + } + list := make([]dto.ConversationDTO, 0, len(convs)) for _, c := range convs { - peerID := c.PeerUserID item := dto.ConversationDTO{ ID: c.ID, Type: c.Type, - PeerUserID: peerID, + PeerUserID: c.PeerUserID, LastMsgContent: c.LastMsgContent, LastMsgSenderID: c.LastMsgSenderID, IsPinned: c.IsPinned, UnreadCount: c.UnreadCount, + IsDoNotDisturb: c.IsDoNotDisturb, + AtMeCount: c.AtMeCount, } if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID { item.LastMsgContent = "" @@ -283,10 +468,20 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto if c.LastMsgTime != nil { item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05") } - if brief, ok := userMap[peerID]; ok { - item.PeerNickname = brief.Nickname - item.PeerAvatar = brief.Avatar + + if c.Type == constants.ConversationTypePrivate { + if brief, ok := userMap[c.PeerUserID]; ok { + item.PeerNickname = brief.Nickname + item.PeerAvatar = brief.Avatar + } + } else if c.Type == constants.ConversationTypeGroup { + if gBrief, ok := groupMap[c.ID]; ok { + item.PeerNickname = gBrief.Name + item.PeerAvatar = gBrief.Avatar + item.GroupID = gBrief.ID + } } + list = append(list, item) } @@ -360,6 +555,133 @@ func (s *IMService) MarkRead(ctx context.Context, userID int64, conversationID i return nil } +// MarkGroupMessagesRead 标记群聊消息已读(消息级别) +// 将指定消息标记为已读 + 推送已读计数变化给消息发送者 +func (s *IMService) MarkGroupMessagesRead(ctx context.Context, userID int64, req *dto.MarkGroupReadRequest) error { + funcName := "service.im_service.MarkGroupMessagesRead" + logs.Info(ctx, funcName, "群消息标记已读", + zap.Int64("user_id", userID), zap.Int64("conversation_id", req.ConversationID), + zap.Int("msg_count", len(req.MessageIDs))) + + member, err := s.convDAO.GetMember(ctx, req.ConversationID, userID) + if err != nil || member == nil { + return ErrNotMember + } + + if s.readRecorder == nil || len(req.MessageIDs) == 0 { + return nil + } + + if err := s.readRecorder.BatchCreateReads(ctx, req.MessageIDs, userID); err != nil { + logs.Error(ctx, funcName, "批量创建已读记录失败", zap.Error(err)) + return err + } + + if member.AtMeCount > 0 { + if err := s.convDAO.ClearAtMeCount(ctx, req.ConversationID, userID); err != nil { + logs.Error(ctx, funcName, "清零@计数失败", zap.Error(err)) + } + } + + readCounts, err := s.readRecorder.GetReadCountBatch(ctx, req.MessageIDs) + if err != nil { + logs.Error(ctx, funcName, "获取已读计数失败", zap.Error(err)) + return nil + } + + for _, msgID := range req.MessageIDs { + msg, mErr := s.msgDAO.GetByID(ctx, msgID) + if mErr != nil || msg.SenderID == userID { + continue + } + count := readCounts[msgID] + s.pushToUser(ctx, msg.SenderID, "im.message.read.count", map[string]interface{}{ + "message_id": msgID, + "conversation_id": req.ConversationID, + "read_count": count, + }) + } + + return nil +} + +// GetMessageReadDetail 获取消息已读详情(已读/未读用户列表) +func (s *IMService) GetMessageReadDetail(ctx context.Context, userID int64, messageID int64) (*dto.GetReadDetailResponse, error) { + funcName := "service.im_service.GetMessageReadDetail" + logs.Debug(ctx, funcName, "获取已读详情", + zap.Int64("user_id", userID), zap.Int64("message_id", messageID)) + + msg, err := s.msgDAO.GetByID(ctx, messageID) + if err != nil { + return nil, ErrMsgNotFound + } + + member, err := s.convDAO.GetMember(ctx, msg.ConversationID, userID) + if err != nil || member == nil { + return nil, ErrNotMember + } + + memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID) + if err != nil { + return nil, err + } + + readUserIDs := make(map[int64]bool) + if s.readRecorder != nil { + ids, rErr := s.readRecorder.GetReadUserIDs(ctx, messageID) + if rErr != nil { + logs.Error(ctx, funcName, "获取已读用户列表失败", zap.Error(rErr)) + } else { + for _, id := range ids { + readUserIDs[id] = true + } + } + } + + allUserIDs := make([]int64, 0, len(memberIDs)) + for _, id := range memberIDs { + if id != msg.SenderID { + allUserIDs = append(allUserIDs, id) + } + } + + userMap := make(map[int64]*userBrief) + if len(allUserIDs) > 0 { + users, uErr := s.userInfoGetter.GetUsersByIDs(ctx, allUserIDs) + if uErr != nil { + logs.Error(ctx, funcName, "批量查询用户信息失败", zap.Error(uErr)) + } else { + for i := range users { + u := users[i] + userMap[u.ID] = &userBrief{Nickname: u.Nickname, Avatar: u.Avatar} + } + } + } + + var readList, unreadList []dto.MessageReadDetailDTO + for _, uid := range allUserIDs { + item := dto.MessageReadDetailDTO{ + UserID: uid, + } + if brief, ok := userMap[uid]; ok { + item.UserNickname = brief.Nickname + item.UserAvatar = brief.Avatar + } + if readUserIDs[uid] { + readList = append(readList, item) + } else { + unreadList = append(unreadList, item) + } + } + + return &dto.GetReadDetailResponse{ + ReadList: readList, + UnreadList: unreadList, + ReadCount: len(readList), + TotalCount: len(allUserIDs), + }, nil +} + // PinConversation 置顶/取消置顶会话 func (s *IMService) PinConversation(ctx context.Context, userID int64, conversationID int64, isPinned bool) error { funcName := "service.im_service.PinConversation" @@ -580,7 +902,7 @@ func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, coun // toMessageDTO 将 model.Message 转换为 dto.MessageDTO func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO { - return &dto.MessageDTO{ + d := &dto.MessageDTO{ ID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, @@ -590,6 +912,28 @@ func (s *IMService) toMessageDTO(m *model.Message) *dto.MessageDTO { ClientMsgID: m.ClientMsgID, CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"), } + if len(m.AtUserIDs) > 0 { + d.AtUserIDs = []int64(m.AtUserIDs) + } + return d +} + +// buildMessagePushData 构建消息推送数据(单聊/群聊通用) +func (s *IMService) buildMessagePushData(ctx context.Context, msg *model.Message, senderID int64) map[string]interface{} { + pushData := map[string]interface{}{ + "id": msg.ID, + "conversation_id": msg.ConversationID, + "sender_id": senderID, + "type": msg.Type, + "content": msg.Content, + "client_msg_id": msg.ClientMsgID, + "created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"), + } + if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 { + pushData["sender_name"] = senderUsers[0].Nickname + pushData["sender_avatar"] = senderUsers[0].Avatar + } + return pushData } // userBrief 用户简要信息(内部使用) diff --git a/backend/go-service/app/im/service/interfaces.go b/backend/go-service/app/im/service/interfaces.go index 5be155e..cd60fe4 100644 --- a/backend/go-service/app/im/service/interfaces.go +++ b/backend/go-service/app/im/service/interfaces.go @@ -5,6 +5,7 @@ import ( "context" authModel "github.com/echochat/backend/app/auth/model" + "github.com/echochat/backend/app/dto" ) // FriendChecker 好友关系校验接口 @@ -18,3 +19,19 @@ type FriendChecker interface { type UserInfoGetter interface { GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error) } + +// GroupInfoGetter 群信息查询接口(Phase 2c) +// 由 group.GroupDAO 隐式实现,通过 Wire 接口注入 +// IM 模块通过此接口获取群表信息(判断全体禁言、已解散等) +// 成员信息(角色/禁言/列表)通过 convDAO 直接查询 im_conversation_members 表 +type GroupInfoGetter interface { + GetGroupBrief(ctx context.Context, conversationID int64) (*dto.GroupBrief, error) +} + +// MessageReadRecorder 群消息已读记录接口(Phase 2c) +// 由 group.MessageReadDAO 隐式实现 +type MessageReadRecorder interface { + BatchCreateReads(ctx context.Context, messageIDs []int64, userID int64) error + GetReadCountBatch(ctx context.Context, messageIDs []int64) (map[int64]int, error) + GetReadUserIDs(ctx context.Context, messageID int64) ([]int64, error) +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index 6adc268..8a651e3 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -7,13 +7,17 @@ import ( authController "github.com/echochat/backend/app/auth/controller" "github.com/echochat/backend/app/auth/service" contactController "github.com/echochat/backend/app/contact/controller" + fileController "github.com/echochat/backend/app/file/controller" + groupController "github.com/echochat/backend/app/group/controller" imController "github.com/echochat/backend/app/im/controller" imHandler "github.com/echochat/backend/app/im/handler" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" + "github.com/echochat/backend/pkg/storage" "github.com/echochat/backend/pkg/ws" "github.com/google/wire" + "github.com/minio/minio-go/v7" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -23,12 +27,14 @@ type App struct { Config *config.Config DB *gorm.DB Redis *redis.Client + MinioClient *minio.Client // MinIO 对象存储客户端 AuthService *service.AuthService // Auth 认证服务 AuthController *authController.AuthController // 前台认证控制器 AdminAuthController *authController.AdminAuthController // 后台认证控制器 UserManageController *adminController.UserManageController // 管理端用户管理控制器 OnlineController *adminController.OnlineController // 管理端在线监控控制器 ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器 + GroupManageController *adminController.GroupManageController // 管理端群组管理控制器 WSHandler *wsApp.Handler // WebSocket 连接处理器 Hub *ws.Hub // WebSocket Hub 连接管理 PubSub *ws.PubSub // Redis Pub/Sub 消息路由 @@ -37,6 +43,8 @@ type App struct { IMController *imController.IMController // IM 即时通讯控制器 IMEventHandler *imHandler.EventHandler // IM WS 事件处理器 OfflinePusher *imHandler.OfflinePusher // 离线消息推送器 + FileController *fileController.FileController // 文件上传控制器 + GroupController *groupController.GroupController // 群聊管理控制器 } // NewApp 创建应用实例 @@ -44,12 +52,14 @@ func NewApp( cfg *config.Config, gormDB *gorm.DB, redisClient *redis.Client, + minioClient *minio.Client, authService *service.AuthService, authCtrl *authController.AuthController, adminAuthCtrl *authController.AdminAuthController, userManageCtrl *adminController.UserManageController, onlineCtrl *adminController.OnlineController, contactManageCtrl *adminController.ContactManageController, + groupManageCtrl *adminController.GroupManageController, wsHandler *wsApp.Handler, hub *ws.Hub, pubsub *ws.PubSub, @@ -58,6 +68,8 @@ func NewApp( imCtrl *imController.IMController, imEventHandler *imHandler.EventHandler, offlinePusher *imHandler.OfflinePusher, + fileCtrl *fileController.FileController, + groupCtrl *groupController.GroupController, ) *App { // 注入离线消息推送器到 WS Handler wsHandler.SetOfflinePusher(offlinePusher) @@ -66,12 +78,14 @@ func NewApp( Config: cfg, DB: gormDB, Redis: redisClient, + MinioClient: minioClient, AuthService: authService, AuthController: authCtrl, AdminAuthController: adminAuthCtrl, UserManageController: userManageCtrl, OnlineController: onlineCtrl, ContactManageController: contactManageCtrl, + GroupManageController: groupManageCtrl, WSHandler: wsHandler, Hub: hub, PubSub: pubsub, @@ -80,6 +94,8 @@ func NewApp( IMController: imCtrl, IMEventHandler: imEventHandler, OfflinePusher: offlinePusher, + FileController: fileCtrl, + GroupController: groupCtrl, } } @@ -98,12 +114,19 @@ func provideJWTConfig(cfg *config.Config) *config.JWTConfig { return &cfg.JWT } +// provideMinioConfig 从全局 Config 中提取 MinioConfig +func provideMinioConfig(cfg *config.Config) *config.MinioConfig { + return &cfg.Minio +} + // InfraSet 基础设施层 Provider Set var InfraSet = wire.NewSet( provideDBConfig, provideRedisConfig, provideJWTConfig, + provideMinioConfig, db.NewPostgres, db.NewRedis, + storage.NewMinioClient, NewApp, ) diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index 2e9d3a6..54058de 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -6,11 +6,16 @@ package provider import ( "github.com/echochat/backend/app/admin" "github.com/echochat/backend/app/auth" - "github.com/echochat/backend/app/contact" authService "github.com/echochat/backend/app/auth/service" + "github.com/echochat/backend/app/contact" contactDAO "github.com/echochat/backend/app/contact/dao" contactService "github.com/echochat/backend/app/contact/service" + fileApp "github.com/echochat/backend/app/file" + groupApp "github.com/echochat/backend/app/group" + groupDAO "github.com/echochat/backend/app/group/dao" + groupService "github.com/echochat/backend/app/group/service" imApp "github.com/echochat/backend/app/im" + imDAO "github.com/echochat/backend/app/im/dao" imService "github.com/echochat/backend/app/im/service" wsApp "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" @@ -26,11 +31,17 @@ func InitializeApp(cfg *config.Config) (*App, error) { wsApp.WSSet, contact.ContactSet, imApp.IMSet, + fileApp.FileSet, + groupApp.GroupSet, wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)), + wire.Bind(new(imService.MessageReadRecorder), new(*groupDAO.MessageReadDAO)), wire.Bind(new(wsApp.TokenValidator), new(*authService.AuthService)), wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)), wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)), wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)), + wire.Bind(new(groupService.MessageWriter), new(*imDAO.MessageDAO)), ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index 8b960c3..442d0b4 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -16,10 +16,16 @@ import ( controller3 "github.com/echochat/backend/app/contact/controller" dao3 "github.com/echochat/backend/app/contact/dao" service3 "github.com/echochat/backend/app/contact/service" + controller4 "github.com/echochat/backend/app/file/controller" + service4 "github.com/echochat/backend/app/file/service" + controller5 "github.com/echochat/backend/app/group/controller" + dao4 "github.com/echochat/backend/app/group/dao" + service5 "github.com/echochat/backend/app/group/service" imApp "github.com/echochat/backend/app/im" "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" + "github.com/echochat/backend/pkg/storage" ) // Injectors from wire.go: @@ -36,6 +42,11 @@ func InitializeApp(cfg *config.Config) (*App, error) { if err != nil { return nil, err } + minioConfig := provideMinioConfig(cfg) + minioClient, err := storage.NewMinioClient(minioConfig) + if err != nil { + return nil, err + } userDAO := dao.NewUserDAO(gormDB) roleDAO := dao.NewRoleDAO(gormDB) jwtConfig := provideJWTConfig(cfg) @@ -62,11 +73,26 @@ func InitializeApp(cfg *config.Config) (*App, error) { // IM 模块初始化 conversationDAO := imApp.ProvideConversationDAO(gormDB) messageDAO := imApp.ProvideMessageDAO(gormDB) - imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO) + groupDAO := dao4.NewGroupDAO(gormDB) + messageReadDAO := dao4.NewMessageReadDAO(gormDB) + imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO, groupDAO, messageReadDAO) imEventHandler := imApp.ProvideIMEventHandler(imService, hub) offlinePusher := imApp.ProvideOfflinePusher(imService, conversationDAO, pubSub) imController := imApp.ProvideIMController(imService) - app := NewApp(cfg, gormDB, client, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher) + // File 模块初始化 + fileService := service4.NewFileService(minioClient, minioConfig) + fileController := controller4.NewFileController(fileService) + + // Group 模块初始化 + joinRequestDAO := dao4.NewJoinRequestDAO(gormDB) + groupService := service5.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO) + groupController := controller5.NewGroupController(groupService) + + // Admin 群组管理初始化 + groupManageService := service2.NewGroupManageService(gormDB, groupDAO) + groupManageController := controller2.NewGroupManageController(groupManageService) + + app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController) return app, nil } diff --git a/backend/go-service/config/config.dev.yaml b/backend/go-service/config/config.dev.yaml index 62f8152..81005ae 100644 --- a/backend/go-service/config/config.dev.yaml +++ b/backend/go-service/config/config.dev.yaml @@ -45,3 +45,11 @@ log: max_backups: 10 # 保留的旧日志归档文件最大数量,超出的自动删除 max_age: 30 # 旧日志归档文件保留天数,超过天数的自动删除 compress: false # 是否对归档的旧日志文件进行 gzip 压缩(生产环境建议 true) + +# MinIO 对象存储配置 +minio: + endpoint: "localhost:9000" # MinIO 服务地址 + access_key: "echochat" # 访问密钥(与 docker-compose 中的 MINIO_ROOT_USER 一致) + secret_key: "echochat123456" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致) + bucket: "echochat" # 存储桶名称 + use_ssl: false # 开发环境不使用 HTTPS diff --git a/backend/go-service/config/config.docker.yaml b/backend/go-service/config/config.docker.yaml index 15b5bab..ee352d0 100644 --- a/backend/go-service/config/config.docker.yaml +++ b/backend/go-service/config/config.docker.yaml @@ -40,3 +40,10 @@ log: max_backups: 10 max_age: 30 compress: false + +minio: + endpoint: "minio:9000" + access_key: "echochat" + secret_key: "echochat123456" + bucket: "echochat" + use_ssl: false diff --git a/backend/go-service/config/config.go b/backend/go-service/config/config.go index 4a25ae3..44c9f15 100644 --- a/backend/go-service/config/config.go +++ b/backend/go-service/config/config.go @@ -16,6 +16,16 @@ type Config struct { Redis RedisConfig `mapstructure:"redis"` JWT JWTConfig `mapstructure:"jwt"` Log LogConfig `mapstructure:"log"` + Minio MinioConfig `mapstructure:"minio"` +} + +// MinioConfig MinIO 对象存储配置 +type MinioConfig struct { + Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port) + AccessKey string `mapstructure:"access_key"` // 访问密钥 + SecretKey string `mapstructure:"secret_key"` // 密钥 + Bucket string `mapstructure:"bucket"` // 存储桶名称 + UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS } // ServerConfig HTTP 服务配置 diff --git a/backend/go-service/go.mod b/backend/go-service/go.mod index 7115abe..df3d62d 100644 --- a/backend/go-service/go.mod +++ b/backend/go-service/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 + github.com/minio/minio-go/v7 v7.0.83 github.com/redis/go-redis/v9 v9.18.0 github.com/spf13/viper v1.21.0 go.uber.org/zap v1.27.1 @@ -23,14 +24,16 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -39,14 +42,17 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect diff --git a/backend/go-service/go.sum b/backend/go-service/go.sum index e3dcb51..7776e68 100644 --- a/backend/go-service/go.sum +++ b/backend/go-service/go.sum @@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -25,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -35,8 +39,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= @@ -64,6 +68,9 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -74,6 +81,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= +github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -90,6 +101,8 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= diff --git a/backend/go-service/pkg/storage/minio.go b/backend/go-service/pkg/storage/minio.go new file mode 100644 index 0000000..976d69d --- /dev/null +++ b/backend/go-service/pkg/storage/minio.go @@ -0,0 +1,38 @@ +// Package storage 提供对象存储基础设施 +// 封装 MinIO 客户端初始化和存储桶自动创建逻辑 +package storage + +import ( + "context" + "fmt" + + "github.com/echochat/backend/config" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// NewMinioClient 初始化 MinIO 客户端并确保存储桶存在 +// 启动时自动创建配置中指定的 bucket(如不存在) +func NewMinioClient(cfg *config.MinioConfig) (*minio.Client, error) { + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("创建 MinIO 客户端失败: %w", err) + } + + ctx := context.Background() + exists, err := client.BucketExists(ctx, cfg.Bucket) + if err != nil { + return nil, fmt.Errorf("检查存储桶 %s 失败: %w", cfg.Bucket, err) + } + + if !exists { + if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{}); err != nil { + return nil, fmt.Errorf("创建存储桶 %s 失败: %w", cfg.Bucket, err) + } + } + + return client, nil +} diff --git a/backend/go-service/pkg/utils/int64_array.go b/backend/go-service/pkg/utils/int64_array.go new file mode 100644 index 0000000..f575b8a --- /dev/null +++ b/backend/go-service/pkg/utils/int64_array.go @@ -0,0 +1,70 @@ +// Package utils 提供通用工具类型和函数 +package utils + +import ( + "database/sql/driver" + "fmt" + "strconv" + "strings" +) + +// Int64Array 自定义 PostgreSQL BIGINT[] 类型 +// 实现 sql.Scanner 和 driver.Valuer 接口,用于 GORM 与 PostgreSQL 数组字段交互 +type Int64Array []int64 + +// Scan 从数据库读取 PostgreSQL BIGINT[] 格式:{1,2,3} +func (a *Int64Array) Scan(src interface{}) error { + if src == nil { + *a = nil + return nil + } + + var s string + switch v := src.(type) { + case []byte: + s = string(v) + case string: + s = v + default: + return fmt.Errorf("Int64Array.Scan: 不支持的类型 %T", src) + } + + s = strings.TrimSpace(s) + if s == "{}" || s == "" { + *a = Int64Array{} + return nil + } + + s = strings.Trim(s, "{}") + parts := strings.Split(s, ",") + result := make(Int64Array, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + n, err := strconv.ParseInt(p, 10, 64) + if err != nil { + return fmt.Errorf("Int64Array.Scan: 解析 '%s' 失败: %w", p, err) + } + result = append(result, n) + } + *a = result + return nil +} + +// Value 写入数据库时转换为 PostgreSQL BIGINT[] 格式:{1,2,3} +func (a Int64Array) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + if len(a) == 0 { + return "{}", nil + } + + parts := make([]string, len(a)) + for i, v := range a { + parts[i] = strconv.FormatInt(v, 10) + } + return "{" + strings.Join(parts, ",") + "}", nil +} diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go index 4611115..a4e7568 100644 --- a/backend/go-service/router/router.go +++ b/backend/go-service/router/router.go @@ -9,6 +9,8 @@ import ( "github.com/echochat/backend/app/admin" "github.com/echochat/backend/app/auth" "github.com/echochat/backend/app/contact" + fileApp "github.com/echochat/backend/app/file" + groupApp "github.com/echochat/backend/app/group" imApp "github.com/echochat/backend/app/im" "github.com/echochat/backend/app/provider" wsApp "github.com/echochat/backend/app/ws" @@ -34,10 +36,12 @@ func Setup(engine *gin.Engine, app *provider.App) { // --- 各模块路由注册 --- auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth) - admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, jwtAuth) + admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, app.GroupManageController, jwtAuth) wsApp.RegisterRoutes(engine, app.WSHandler) contact.RegisterRoutes(engine, app.ContactController, jwtAuth) imApp.RegisterRoutes(engine, app.IMController, jwtAuth) + fileApp.RegisterRoutes(engine, app.FileController, jwtAuth) + groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth) // [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth) // [未来] notify.RegisterRoutes(engine, app.NotifyController, jwtAuth) diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index d087225..77992f9 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -32,6 +32,25 @@ services: timeout: 5s retries: 5 + minio: + image: minio/minio:latest + container_name: echochat-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: echochat + MINIO_ROOT_PASSWORD: echochat123456 + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + go-service: build: context: ../backend/go-service @@ -46,8 +65,11 @@ services: condition: service_healthy redis: condition: service_healthy + minio: + condition: service_healthy restart: unless-stopped volumes: pgdata: redisdata: + minio_data: diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql index 2152b1e..fd41f40 100644 --- a/deploy/docker/postgres/init.sql +++ b/deploy/docker/postgres/init.sql @@ -1,6 +1,6 @@ -- ============================================================ -- EchoChat 数据库初始化脚本 --- 包含模块:auth(用户认证)、contact(联系人)、im(即时通讯) +-- 包含模块:auth(用户认证)、contact(联系人)、im(即时通讯)、group(群聊) -- ============================================================ -- ============================================================ @@ -188,6 +188,12 @@ CREATE TABLE im_conversation_members ( unread_count INT DEFAULT 0, last_read_msg_id BIGINT DEFAULT 0, clear_before_msg_id BIGINT DEFAULT 0, + role SMALLINT NOT NULL DEFAULT 0, + nickname VARCHAR(50) DEFAULT '', + is_muted BOOLEAN NOT NULL DEFAULT FALSE, + is_do_not_disturb BOOLEAN NOT NULL DEFAULT FALSE, + joined_at TIMESTAMP(0) DEFAULT NULL, + at_me_count INT DEFAULT 0, created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), UNIQUE (conversation_id, user_id) @@ -202,6 +208,12 @@ COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删 COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数'; COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID'; COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空聊天记录时的消息截止 ID(个人视图,不影响对方)'; +COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员,2=群主'; +COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称(仅群聊有效)'; +COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言'; +COMMENT ON COLUMN im_conversation_members.is_do_not_disturb IS '是否消息免打扰(仍计未读但灰色显示,不推送通知)'; +COMMENT ON COLUMN im_conversation_members.joined_at IS '加入群聊时间'; +COMMENT ON COLUMN im_conversation_members.at_me_count IS '被@提醒未读计数,打开会话后清零'; COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间'; COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间'; @@ -222,6 +234,7 @@ CREATE TABLE im_messages ( extra JSONB DEFAULT NULL, status SMALLINT NOT NULL DEFAULT 1, client_msg_id VARCHAR(64) DEFAULT '', + at_user_ids BIGINT[] DEFAULT NULL, created_at TIMESTAMP(0) NOT NULL DEFAULT NOW() ); @@ -234,8 +247,87 @@ COMMENT ON COLUMN im_messages.content IS '消息内容(文本消息 COMMENT ON COLUMN im_messages.extra IS '扩展数据(JSON 格式),预留图片尺寸、语音时长等元信息'; COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回,3=已删除'; COMMENT ON COLUMN im_messages.client_msg_id IS '客户端消息唯一 ID,用于幂等去重防止网络重试重复发送'; +COMMENT ON COLUMN im_messages.at_user_ids IS '@提醒用户 ID 列表,NULL=无@,包含 0 表示 @所有人'; COMMENT ON COLUMN im_messages.created_at IS '消息发送时间'; CREATE INDEX idx_im_messages_conv_time ON im_messages (conversation_id, created_at DESC); CREATE INDEX idx_im_messages_conv_id ON im_messages (conversation_id, id DESC); CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector('simple', content)); + +-- ============================================================ +-- im_groups: 群聊信息表 +-- 与 im_conversations (type=2) 一对一关联,存储群聊独有属性 +-- ============================================================ +CREATE TABLE im_groups ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL UNIQUE REFERENCES im_conversations(id), + name VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(500) DEFAULT '', + owner_id BIGINT NOT NULL, + notice TEXT DEFAULT '', + max_members INT NOT NULL DEFAULT 200, + is_searchable BOOLEAN NOT NULL DEFAULT TRUE, + is_all_muted BOOLEAN NOT NULL DEFAULT FALSE, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE im_groups IS '群聊信息表'; +COMMENT ON COLUMN im_groups.id IS '群唯一标识'; +COMMENT ON COLUMN im_groups.conversation_id IS '关联 im_conversations.id'; +COMMENT ON COLUMN im_groups.name IS '群名称'; +COMMENT ON COLUMN im_groups.avatar IS '群头像 URL(MinIO)'; +COMMENT ON COLUMN im_groups.owner_id IS '群主用户 ID'; +COMMENT ON COLUMN im_groups.notice IS '群公告内容'; +COMMENT ON COLUMN im_groups.max_members IS '最大成员数,默认 200'; +COMMENT ON COLUMN im_groups.is_searchable IS '是否可被搜索发现'; +COMMENT ON COLUMN im_groups.is_all_muted IS '是否全体禁言'; +COMMENT ON COLUMN im_groups.status IS '群状态:1=正常,2=已解散'; + +CREATE INDEX idx_im_groups_owner ON im_groups(owner_id); +CREATE INDEX idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name)); + +-- ============================================================ +-- im_group_join_requests: 入群申请表 +-- 记录用户主动申请加入群聊的审批流程 +-- ============================================================ +CREATE TABLE im_group_join_requests ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES im_groups(id), + user_id BIGINT NOT NULL, + message TEXT DEFAULT '', + reviewer_id BIGINT DEFAULT NULL, + status SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE im_group_join_requests IS '入群申请表'; +COMMENT ON COLUMN im_group_join_requests.id IS '申请唯一标识'; +COMMENT ON COLUMN im_group_join_requests.group_id IS '目标群 ID'; +COMMENT ON COLUMN im_group_join_requests.user_id IS '申请人用户 ID'; +COMMENT ON COLUMN im_group_join_requests.message IS '申请附言'; +COMMENT ON COLUMN im_group_join_requests.reviewer_id IS '审批人用户 ID'; +COMMENT ON COLUMN im_group_join_requests.status IS '状态:0=待审批,1=通过,2=拒绝'; + +CREATE INDEX idx_group_join_req_group ON im_group_join_requests(group_id, status); +CREATE INDEX idx_group_join_req_user ON im_group_join_requests(user_id); + +-- ============================================================ +-- im_message_reads: 群聊消息已读记录表(消息级别) +-- 复合主键 (message_id, user_id),存储每条群消息的已读用户 +-- ============================================================ +CREATE TABLE im_message_reads ( + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + read_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + PRIMARY KEY (message_id, user_id) +); + +COMMENT ON TABLE im_message_reads IS '群聊消息已读记录表(消息级别)'; +COMMENT ON COLUMN im_message_reads.message_id IS '消息 ID'; +COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID'; +COMMENT ON COLUMN im_message_reads.read_at IS '已读时间'; + +CREATE INDEX idx_msg_reads_user ON im_message_reads(user_id, read_at); diff --git a/deploy/docker/postgres/phase2c_migration.sql b/deploy/docker/postgres/phase2c_migration.sql new file mode 100644 index 0000000..9e1abb2 --- /dev/null +++ b/deploy/docker/postgres/phase2c_migration.sql @@ -0,0 +1,109 @@ +-- ============================================================ +-- Phase 2c 数据库迁移:群聊 + 已读回执 +-- 执行环境:在已有 Phase 2b 数据库结构基础上增量升级 +-- ============================================================ + +-- ============================================================ +-- im_groups: 群聊信息表 +-- 与 im_conversations (type=2) 一对一关联,存储群聊独有属性 +-- ============================================================ +CREATE TABLE IF NOT EXISTS im_groups ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL UNIQUE REFERENCES im_conversations(id), + name VARCHAR(100) NOT NULL DEFAULT '', + avatar VARCHAR(500) DEFAULT '', + owner_id BIGINT NOT NULL, + notice TEXT DEFAULT '', + max_members INT NOT NULL DEFAULT 200, + is_searchable BOOLEAN NOT NULL DEFAULT TRUE, + is_all_muted BOOLEAN NOT NULL DEFAULT FALSE, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE im_groups IS '群聊信息表'; +COMMENT ON COLUMN im_groups.id IS '群唯一标识'; +COMMENT ON COLUMN im_groups.conversation_id IS '关联 im_conversations.id'; +COMMENT ON COLUMN im_groups.name IS '群名称'; +COMMENT ON COLUMN im_groups.avatar IS '群头像 URL(MinIO)'; +COMMENT ON COLUMN im_groups.owner_id IS '群主用户 ID'; +COMMENT ON COLUMN im_groups.notice IS '群公告内容'; +COMMENT ON COLUMN im_groups.max_members IS '最大成员数,默认 200'; +COMMENT ON COLUMN im_groups.is_searchable IS '是否可被搜索发现'; +COMMENT ON COLUMN im_groups.is_all_muted IS '是否全体禁言'; +COMMENT ON COLUMN im_groups.status IS '群状态:1=正常,2=已解散'; + +CREATE INDEX IF NOT EXISTS idx_im_groups_owner ON im_groups(owner_id); +CREATE INDEX IF NOT EXISTS idx_im_groups_name ON im_groups USING gin(to_tsvector('simple', name)); + +-- ============================================================ +-- im_group_join_requests: 入群申请表 +-- 记录用户主动申请加入群聊的审批流程 +-- ============================================================ +CREATE TABLE IF NOT EXISTS im_group_join_requests ( + id BIGSERIAL PRIMARY KEY, + group_id BIGINT NOT NULL REFERENCES im_groups(id), + user_id BIGINT NOT NULL, + message TEXT DEFAULT '', + reviewer_id BIGINT DEFAULT NULL, + status SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE im_group_join_requests IS '入群申请表'; +COMMENT ON COLUMN im_group_join_requests.id IS '申请唯一标识'; +COMMENT ON COLUMN im_group_join_requests.group_id IS '目标群 ID'; +COMMENT ON COLUMN im_group_join_requests.user_id IS '申请人用户 ID'; +COMMENT ON COLUMN im_group_join_requests.message IS '申请附言'; +COMMENT ON COLUMN im_group_join_requests.reviewer_id IS '审批人用户 ID'; +COMMENT ON COLUMN im_group_join_requests.status IS '状态:0=待审批,1=通过,2=拒绝'; + +CREATE INDEX IF NOT EXISTS idx_group_join_req_group ON im_group_join_requests(group_id, status); +CREATE INDEX IF NOT EXISTS idx_group_join_req_user ON im_group_join_requests(user_id); + +-- ============================================================ +-- im_message_reads: 群聊消息已读记录表(消息级别) +-- 复合主键 (message_id, user_id),存储每条群消息的已读用户 +-- ============================================================ +CREATE TABLE IF NOT EXISTS im_message_reads ( + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + read_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + PRIMARY KEY (message_id, user_id) +); + +COMMENT ON TABLE im_message_reads IS '群聊消息已读记录表(消息级别)'; +COMMENT ON COLUMN im_message_reads.message_id IS '消息 ID'; +COMMENT ON COLUMN im_message_reads.user_id IS '已读用户 ID'; +COMMENT ON COLUMN im_message_reads.read_at IS '已读时间'; + +CREATE INDEX IF NOT EXISTS idx_msg_reads_user ON im_message_reads(user_id, read_at); + +-- ============================================================ +-- im_conversation_members: 新增群聊相关字段 +-- role/nickname/is_muted/is_do_not_disturb/joined_at/at_me_count +-- ============================================================ +ALTER TABLE im_conversation_members + ADD COLUMN IF NOT EXISTS role SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS nickname VARCHAR(50) DEFAULT '', + ADD COLUMN IF NOT EXISTS is_muted BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS is_do_not_disturb BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS joined_at TIMESTAMP(0) DEFAULT NULL, + ADD COLUMN IF NOT EXISTS at_me_count INT DEFAULT 0; + +COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员,2=群主'; +COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称(仅群聊有效)'; +COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言'; +COMMENT ON COLUMN im_conversation_members.is_do_not_disturb IS '是否消息免打扰(仍计未读但灰色显示,不推送通知)'; +COMMENT ON COLUMN im_conversation_members.joined_at IS '加入群聊时间'; +COMMENT ON COLUMN im_conversation_members.at_me_count IS '被@提醒未读计数,打开会话后清零'; + +-- ============================================================ +-- im_messages: 新增 @提醒用户列表字段 +-- ============================================================ +ALTER TABLE im_messages + ADD COLUMN IF NOT EXISTS at_user_ids BIGINT[] DEFAULT NULL; + +COMMENT ON COLUMN im_messages.at_user_ids IS '@提醒用户 ID 列表,NULL=无@,包含 0 表示 @所有人'; diff --git a/docs/api/README.md b/docs/api/README.md index 6dc6766..6b1fb61 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -16,7 +16,7 @@ | [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 | | [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 | | [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 | -| [frontend/group.md](frontend/group.md) | 群聊管理 | 🔜 Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 | +| [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 | | [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 | | [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 | @@ -28,7 +28,7 @@ | [admin/user.md](admin/user.md) | 用户管理 | ✅ Phase 1 | 用户列表/详情、状态管理、角色分配、创建用户 | | [admin/online.md](admin/online.md) | 在线监控 | ✅ Phase 2a | 在线用户列表、在线用户计数 | | [admin/contact.md](admin/contact.md) | 好友关系管理 | ✅ Phase 2a | 好友关系列表(分页)、管理员解除好友关系 | -| [admin/group.md](admin/group.md) | 群聊管理 | 🔜 Phase 2c | 群列表/详情、管理员解散群/移除成员 | +| [admin/group.md](admin/group.md) | 群聊管理 | ✅ Phase 2c | 群列表/详情、管理员解散群聊 | | [admin/meeting.md](admin/meeting.md) | 会议管理 | 📋 后续 | 会议列表/详情、强制结束、会议统计 | | [admin/system.md](admin/system.md) | 系统管理 | 📋 待定 | 仪表盘数据、操作日志、系统配置 | @@ -196,7 +196,8 @@ docs/api/ │ ├── auth.md # 用户认证 ✅ Phase 1 │ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a │ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a -│ ├── im.md # 即时通讯(7 个 API) ✅ Phase 2b +│ ├── im.md # 即时通讯(8 个 API) ✅ Phase 2b/2c +│ ├── group.md # 群聊管理(16 个 API) ✅ Phase 2c │ ├── meeting.md # 会议 📋 后续 │ └── notify.md # 通知 📋 后续 ├── admin/ # 后台管理端 API @@ -204,7 +205,8 @@ docs/api/ │ ├── user.md # 用户管理 ✅ Phase 1 │ ├── online.md # 在线监控 ✅ Phase 2a │ ├── contact.md # 好友关系管理 ✅ Phase 2a +│ ├── group.md # 群聊管理(3 个 API) ✅ Phase 2c │ ├── meeting.md # 会议管理 📋 后续 │ └── system.md # 系统管理 📋 待定 -└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b +└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b/2c ``` diff --git a/docs/api/websocket.md b/docs/api/websocket.md index a12608d..ad7c75d 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -435,3 +435,214 @@ "message": "我是你的同事" } ``` + +--- + +## 群聊事件(Phase 2c) + +### im.group.read + +**方向:** 客户端 → 服务端 + +**说明:** 标记群聊消息已读(消息级已读回执) + +**data 内容:** +```json +{ + "conversation_id": 100, + "message_ids": [501, 502, 503] +} +``` + +**ACK 响应:** `{ "code": 0, "message": "ok" }` + +--- + +### group.created + +**方向:** 服务端 → 客户端 + +**说明:** 群聊创建成功,推送给所有初始成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "name": "项目讨论组", + "owner_id": 1 +} +``` + +--- + +### group.info.update + +**方向:** 服务端 → 客户端 + +**说明:** 群信息更新(名称、头像、公告等),推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "operator_id": 1 +} +``` + +--- + +### group.dissolved + +**方向:** 服务端 → 客户端 + +**说明:** 群聊已解散,推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "operator_id": 1 +} +``` + +--- + +### group.member.join + +**方向:** 服务端 → 客户端 + +**说明:** 新成员加入群聊(邀请加入或审批通过),推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "user_ids": [5, 6] +} +``` + +--- + +### group.member.kicked + +**方向:** 服务端 → 客户端 + +**说明:** 成员被移出群聊,推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "user_id": 5, + "operator_id": 1 +} +``` + +--- + +### group.member.leave + +**方向:** 服务端 → 客户端 + +**说明:** 成员主动退出群聊,推送给其余群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "user_id": 5 +} +``` + +--- + +### group.role.update + +**方向:** 服务端 → 客户端 + +**说明:** 成员角色变更(设为/取消管理员),推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "user_id": 5, + "new_role": 1, + "operator_id": 1 +} +``` + +> `new_role`:0=普通成员,1=管理员,2=群主 + +--- + +### group.mute.update + +**方向:** 服务端 → 客户端 + +**说明:** 禁言状态变更(个人禁言或全体禁言),推送给所有群成员 + +**data 内容(个人禁言):** +```json +{ + "group_id": 10, + "conversation_id": 100, + "user_id": 5, + "is_muted": true, + "operator_id": 1 +} +``` + +**data 内容(全体禁言):** +```json +{ + "group_id": 10, + "conversation_id": 100, + "is_all_muted": true, + "operator_id": 1 +} +``` + +--- + +### group.owner.transfer + +**方向:** 服务端 → 客户端 + +**说明:** 群主转让,推送给所有群成员 + +**data 内容:** +```json +{ + "group_id": 10, + "conversation_id": 100, + "old_owner_id": 1, + "new_owner_id": 5 +} +``` + +--- + +### group.join.request + +**方向:** 服务端 → 客户端 + +**说明:** 新的入群申请,推送给群主和管理员 + +**data 内容:** +```json +{ + "group_id": 10, + "request_id": 20, + "user_id": 8, + "user_nickname": "新用户", + "message": "请让我加入" +} +``` diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 8bf9c27..3f12657 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -92,11 +92,11 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想 | ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a | | contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a | | im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b | -| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | 🔜 Phase 2c | -| file | 文件上传(MinIO 对象存储 + 通用上传 API) | 🔜 Phase 2c | +| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | ✅ Phase 2c | +| file | 文件上传(MinIO 对象存储 + 通用上传 API) | ✅ Phase 2c | | meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 | | notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 | -| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a, 🔜 2c | +| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c | **不负责的事情:** 不处理 RTP 媒体数据、不参与音视频转发、不做 WebRTC 协议协商。 diff --git a/docs/conventions/backend-module-architecture.md b/docs/conventions/backend-module-architecture.md index 2a297c8..72aabc4 100644 --- a/docs/conventions/backend-module-architecture.md +++ b/docs/conventions/backend-module-architecture.md @@ -2,9 +2,11 @@ > **适用范围**:EchoChat Go 后端(`backend/go-service/`) > **创建日期**:2026-03-04 -> **最后更新**:2026-03-04(Phase 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 前台业务模块 Controller(contact/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` tag(POST body)或 `form` tag(GET 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` 中已有的间接依赖 diff --git a/docs/conventions/frontend-backend-integration.md b/docs/conventions/frontend-backend-integration.md index 9baec6e..ad2eec0 100644 --- a/docs/conventions/frontend-backend-integration.md +++ b/docs/conventions/frontend-backend-integration.md @@ -2,7 +2,7 @@ > **适用范围**:EchoChat 项目全端(Go 后端 + admin 管理端 + frontend 用户端) > **创建日期**:2026-03-02 -> **最后更新**:2026-03-04(Phase 2c 设计完成:新增群聊模块、MinIO 文件存储、已读回执规划) +> **最后更新**:2026-03-04(Phase 2c 全部完成:群聊模块 + MinIO 文件存储 + 已读回执 + 代码审查修复 14 项) --- diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index a7b4489..e8a7761 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -718,7 +718,7 @@ DELETE /api/v1/im/conversations/:id/messages 清空聊天记录(个人视 GET /api/v1/im/messages/search 全局消息搜索(GIN 全文索引) GET /api/v1/im/unread 全局未读消息总数 -# 群聊管理模块(Phase 2c 规划) +# 群聊管理模块(✅ Phase 2c 完成) POST /api/v1/groups 创建群聊 GET /api/v1/groups/:id 群详情 PUT /api/v1/groups/:id 更新群信息 @@ -736,11 +736,11 @@ GET /api/v1/groups/:id/join-requests 入群申请列表 PUT /api/v1/groups/:id/join-requests/:rid 审批入群申请 GET /api/v1/groups/search 搜索公开群 -# 已读回执(Phase 2c 规划) +# 已读回执(✅ Phase 2c 完成) GET /api/v1/im/messages/:id/reads 消息已读详情 GET /api/v1/im/messages/:id/read-count 消息已读计数 -# 文件上传(Phase 2c 规划) +# 文件上传(✅ Phase 2c 完成) POST /api/v1/upload 通用文件上传(MinIO) # 会议模块 @@ -782,7 +782,7 @@ GET /api/v1/admin/online/count 在线用户数 GET /api/v1/admin/contacts 所有好友关系(分页) DELETE /api/v1/admin/contacts/:id 管理员解除好友关系 -# 群聊管理(Phase 2c 规划) +# 群聊管理(✅ Phase 2c 完成) GET /api/v1/admin/groups 群列表(分页 + 筛选) GET /api/v1/admin/groups/:id 群详情(含成员列表) DELETE /api/v1/admin/groups/:id 管理员解散群 diff --git a/docs/plans/2026-03-04-phase2c-design.md b/docs/plans/2026-03-04-phase2c-design.md index bc87e7a..6d94f7c 100644 --- a/docs/plans/2026-03-04-phase2c-design.md +++ b/docs/plans/2026-03-04-phase2c-design.md @@ -1,6 +1,6 @@ # Phase 2c 设计文档:群聊与已读回执 -> **状态:** 📋 设计完成,待实施 +> **状态:** ✅ 已完成实施(含代码审查修复 14 项) > **分支:** `feature/phase2c-group-read-receipt` > **前置依赖:** Phase 2b 全部完成(单聊即时通讯) > **最后更新:** 2026-03-04 diff --git a/docs/plans/2026-03-04-phase2c-implementation.plan.md b/docs/plans/2026-03-04-phase2c-implementation.plan.md index d7f3682..c1147a2 100644 --- a/docs/plans/2026-03-04-phase2c-implementation.plan.md +++ b/docs/plans/2026-03-04-phase2c-implementation.plan.md @@ -1,9 +1,9 @@ # Phase 2c 实施计划:群聊与已读回执 -> **状态:** 📋 待执行 +> **状态:** ✅ 已完成(含代码审查修复 14 项) > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **分支:** `feature/phase2c-group-read-receipt` -> **预计 Task 数:** 14 个 +> **预计 Task 数:** 14 个(全部完成) > **最后更新:** 2026-03-04 --- @@ -12,20 +12,20 @@ | Task | 阶段 | 描述 | 依赖 | 状态 | |------|------|------|------|------| -| Task 0 | 基础设施 | MinIO Docker + SDK + 通用上传 API | 无 | 📋 | -| Task 1 | 基础设施 | 数据库迁移 + Model + 常量定义 | 无 | 📋 | -| Task 2 | 群聊后端 | Group DAO 层 | Task 1 | 📋 | -| Task 3 | 群聊后端 | Group Service 业务逻辑 | Task 2 | 📋 | -| Task 4 | 群聊后端 | Group Controller + Router + Wire | Task 3 | 📋 | -| Task 5 | 群聊后端 | WS 群管理事件处理器 | Task 3 | 📋 | -| Task 6 | 群聊后端 | IM Service 扩展(群消息 + @提醒 + 管理员撤回) | Task 2 | 📋 | -| Task 7 | 已读回执 | 已读回执后端(ReadDAO + Service + API + WS 推送) | Task 1 | 📋 | -| Task 8 | 已读回执 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | Task 7 | 📋 | -| Task 9 | 前端 | 群聊 Store + API 封装 + WS 事件监听 | Task 4, 5 | 📋 | -| Task 10 | 前端 | 群聊核心页面(Tab 改造 + 群对话页 + 创建群页) | Task 9 | 📋 | -| Task 11 | 前端 | 群聊管理页面(群设置 + 成员 + 邀请 + @选择器) | Task 10 | 📋 | -| Task 12 | 前端 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告 UI) | Task 11 | 📋 | -| Task 13 | 管理端 | 管理端群聊管理 + 全量文档更新 + 代码审查 | Task 12 | 📋 | +| Task 0 | 基础设施 | MinIO Docker + SDK + 通用上传 API | 无 | ✅ | +| Task 1 | 基础设施 | 数据库迁移 + Model + 常量定义 | 无 | ✅ | +| Task 2 | 群聊后端 | Group DAO 层 | Task 1 | ✅ | +| Task 3 | 群聊后端 | Group Service 业务逻辑 | Task 2 | ✅ | +| Task 4 | 群聊后端 | Group Controller + Router + Wire | Task 3 | ✅ | +| Task 5 | 群聊后端 | WS 群管理事件处理器 | Task 3 | ✅ | +| Task 6 | 群聊后端 | IM Service 扩展(群消息 + @提醒 + 管理员撤回) | Task 2 | ✅ | +| Task 7 | 已读回执 | 已读回执后端(ReadDAO + Service + API + WS 推送) | Task 1 | ✅ | +| Task 8 | 已读回执 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | Task 7 | ✅ | +| Task 9 | 前端 | 群聊 Store + API 封装 + WS 事件监听 | Task 4, 5 | ✅ | +| Task 10 | 前端 | 群聊核心页面(Tab 改造 + 群对话页 + 创建群页) | Task 9 | ✅ | +| Task 11 | 前端 | 群聊管理页面(群设置 + 成员 + 邀请 + @选择器) | Task 10 | ✅ | +| Task 12 | 前端 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告 UI) | Task 11 | ✅ | +| Task 13 | 管理端 | 管理端群聊管理 + 全量文档更新 + 代码审查 | Task 12 | ✅ | --- @@ -553,10 +553,75 @@ Task 0 + Task 12 ── Task 13 (管理端+文档+审查) ## 开发注意事项 -1. **代码风格一致性**:严格遵循 Phase 2b 的代码风格(日志记录、错误处理、常量命名、DTO 设计) -2. **接口注入模式**:新模块间通信必须走 interface injection,禁止直接 import -3. **批量查询优化**:群成员信息获取使用批量查询 + Map 映射,避免 N+1 -4. **前端设计规范**:所有前端页面使用 ui-ux-pro-max 技能包设计 -5. **系统消息**:群管理操作产生的系统消息统一使用 type=10(MessageTypeSystem),内容格式化 -6. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级 -7. **Wire 依赖**:Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性 +### 代码风格全局一致(最高优先级) + +**编写任何新代码前,必须先阅读同层级现有模块的实际代码,严格复制其风格。** 详细规范见 `docs/conventions/backend-module-architecture.md`。 + +**强制执行流程:** +1. 写代码前 → 用 Read/Grep 读取同类型现有文件 +2. 写代码时 → 逐行对照导入、结构体、方法签名、日志调用、错误处理 +3. 写代码后 → 与参照文件做差异比对,确认风格完全一致 + +**各层参照文件(Phase 2c 新模块必读):** + +| 新模块文件 | 参照现有文件 | +|-----------|------------| +| group/controller | `app/contact/controller/contact_controller.go`、`app/im/controller/im_controller.go` | +| group/service | `app/im/service/im_service.go`、`app/contact/service/contact_service.go` | +| group/dao | `app/im/dao/conversation_dao.go`、`app/contact/dao/friendship_dao.go` | +| group/model | `app/im/model/conversation.go`、`app/im/model/message.go` | +| group/router.go | `app/contact/router.go`、`app/im/router.go` | +| group/provider.go | `app/contact/provider.go` | +| constants/group.go | `app/constants/im.go`、`app/constants/contact.go` | +| dto/group_dto.go | `app/dto/im_dto.go`、`app/dto/contact_dto.go` | + +**严格禁止事项:** +- 禁止调用不存在的 API(如 `logs.LogFunctionEntry`) +- 禁止引入与当前 Go 版本不兼容的依赖 +- 禁止自创新封装模式(如自定义错误处理框架) +- 禁止在 Controller 层记日志(auth/admin 模块除外) +- 禁止使用未经项目验证的第三方库 + +### Code Review 遗留问题清单(Important/Minor) + +以下问题在首轮 Code Review 中被标记为 Important 或 Minor,已在后续 Task 中逐步处理。 + +#### Important 级别 + +| # | 模块 | 问题 | 修复优先级 | +|---|------|------|-----------| +| I-1 | group/service | `UpdateGroup` 中 `_ = member` 无效赋值,应改为 `_, _, err :=` | 低(代码清洁) | +| I-2 | group/service | `MuteMember` 使用 `ErrCannotKickHigherRole` 语义不精确,应新增通用错误 | 中 | +| I-3 | group/dao | `SearchGroups` 的 `to_tsquery` 对特殊字符敏感,应改用 `plainto_tsquery` | 高(用户输入安全) | +| I-4 | group/service | `InviteMembers` 循环逐条插入缺少事务包裹和批量优化 | 中(性能) | +| I-5 | group/controller | `handleError` 缺少 `ErrAlreadyMuted/ErrUserMuted/ErrGroupAllMuted` 映射 | 高 | +| I-6 | group/model | `MessageRead` 放在 `group/model` 而非 `im/model`,领域归属可议 | 低(架构决策) | +| I-7 | im/service | `sendGroupMessage` 推送未检查成员免打扰设置 | 中(业务逻辑) | +| I-8 | im/service | 群消息推送逐用户查询 N+1 问题 | 中(性能) | +| I-9 | im/service | WS 群已读事件 `im.message.read` 处理器未注册 | 高(Task 5 实现) | +| I-10 | im/service | 消息推送数据缺少 `conv_type` 字段 | 高(前端适配) | +| I-11 | im/service | `GetMessageReadDetail` 无分页参数 | 中 | +| I-12 | file/service | 文件上传缺少 MIME 类型校验 | 中(安全性) | +| I-13 | file/service | 上传返回 URL 使用内部地址,应返回可配置前缀 | 中 | +| I-14 | pkg/storage | MinIO 初始化无超时控制 | 低 | + +#### Minor 级别 + +| # | 模块 | 问题 | +|---|------|------| +| M-1 | group/dao | `GetMemberCount` / `GetMemberIDs` 缺少 funcName 和日志 | +| M-2 | group/dao | `HasRead` 缺少 funcName 和日志 | +| M-3 | group/provider | 与 IM 模块 Provider 风格略有差异(无 ProvideXxx 包装) | +| M-4 | group/service | `SearchGroups` 逐群查 `GetMemberCount`,建议批量 COUNT | +| M-5 | group/service | `imMember` 内部类型可直接使用 `imModel.ConversationMember` | +| M-6 | group/controller | `SetAllMuted` 使用匿名结构体,建议改用 DTO | + +### 其他注意事项 + +1. **接口注入模式**:新模块间通信必须走 interface injection,禁止直接 import +2. **批量查询优化**:群成员信息获取使用批量查询 + Map 映射,避免 N+1 +3. **前端设计规范**:所有前端页面使用 ui-ux-pro-max 技能包设计 +4. **系统消息**:群管理操作产生的系统消息统一使用 type=10(MessageTypeSystem),内容格式化 +5. **权限层级**:群主(2) > 管理员(1) > 成员(0),操作时必须校验层级 +6. **Wire 依赖**:Phase 2b 中 Wire 有过手动 patch 历史,注意检查 wire_gen.go 一致性 +7. **依赖管理**:当前 Go 版本 1.23.12,添加新依赖必须选择兼容版本 diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 1aa398c..5c9bdeb 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,8 +1,8 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-03-04(Phase 2c 设计完成,待实施) -> **当前阶段**:Phase 2c 设计完成,准备开始实施 -> **当前分支**:`feature/phase2c-group-read-receipt`(已从 phase2b 拉出) +> **最后更新**:2026-03-04(Phase 2c 代码审查修复完成) +> **当前阶段**:Phase 2c 代码审查修复完成,待最终验证 +> **当前分支**:`feature/phase2c-group-read-receipt` > **实施计划**:`docs/plans/2026-03-04-phase2c-implementation.plan.md` > **设计文档**:`docs/plans/2026-03-04-phase2c-design.md` @@ -178,14 +178,24 @@ EchoChat/ │ └── router/router.go ├── frontend/ # 前台(uni-app) │ └── src/ -│ ├── api/{auth,contact,user,im}.js +│ ├── api/{auth,contact,user,im,group,file}.js +│ ├── constants/group.js # [Phase 2c] 群聊角色/状态常量 │ ├── services/websocket.js -│ ├── store/{user,websocket,contact,chat}.js -│ ├── pages/chat/ # [Phase 2b] 4 个页面 -│ │ ├── index.vue # 会话列表 -│ │ ├── conversation.vue # 聊天对话 +│ ├── store/{user,websocket,contact,chat,group}.js +│ ├── pages/chat/ # [Phase 2b] 5 个页面 +│ │ ├── index.vue # 会话列表(含群聊 Tab) +│ │ ├── conversation.vue # 单聊对话 +│ │ ├── read-detail.vue # [Phase 2c] 已读详情 │ │ ├── settings.vue # 聊天设置 │ │ └── search.vue # 消息搜索 +│ ├── pages/group/ # [Phase 2c] 7 个页面 +│ │ ├── conversation.vue # 群聊对话(含 @选择器 + 已读计数 + 禁言提示) +│ │ ├── create.vue # 创建群聊 +│ │ ├── settings.vue # 群设置 +│ │ ├── members.vue # 成员管理 +│ │ ├── invite.vue # 邀请入群 +│ │ ├── join-requests.vue # 入群审批 +│ │ └── search.vue # 搜索群聊 │ ├── pages/contact/ # [Phase 2a] 6 个页面 │ └── components/CustomTabBar.vue(含 badge) ├── admin/ # 管理端(Vue 3 + Element Plus) @@ -237,12 +247,12 @@ cd frontend && npm run dev:h5 --- -## 八、下一阶段:Phase 2c — 群聊与已读回执 +## 八、Phase 2c — 群聊与已读回执 -> **状态:** 设计完成,待实施 +> **状态:** 实施中 > **设计文档:** `docs/plans/2026-03-04-phase2c-design.md` > **实施计划:** `docs/plans/2026-03-04-phase2c-implementation.plan.md` -> **分支:** `feature/phase2c-group-read-receipt`(已创建,从 phase2b 拉出) +> **分支:** `feature/phase2c-group-read-receipt` ### 功能范围 @@ -252,29 +262,84 @@ cd frontend && npm run dev:h5 | 群消息 | 复用 im.message.* 事件 + @某人/@所有人 + 管理员撤回(无时限)+ 系统消息 | | 已读回执 | 单聊会话级(last_read_msg_id)+ 群聊消息级(im_message_reads 表)+ 实时推送 | | MinIO | Docker 容器 + Go SDK + 通用上传 API(群头像) | -| 管理端 | 群列表/群详情/解散群/移除成员 | +| 管理端 | 群列表/群详情/解散群 | | 前端 | 9 个新页面 + 群聊 Store + 会话列表 Tab 改造 | -### Task 概览(14 个) +### Task 完成状态 | Task | 描述 | 状态 | |------|------|------| -| Task 0 | MinIO Docker + SDK + 通用上传 API | 📋 | -| Task 1 | 数据库迁移 + Model + 常量 | 📋 | -| Task 2 | Group DAO 层 | 📋 | -| Task 3 | Group Service 业务逻辑 | 📋 | -| Task 4 | Group Controller + Router + Wire | 📋 | -| Task 5 | WS 群管理事件处理器 | 📋 | -| Task 6 | IM Service 扩展(群消息/@提醒/管理员撤回) | 📋 | -| Task 7 | 已读回执后端 | 📋 | -| Task 8 | 前端已读回执 UI | 📋 | -| Task 9 | 前端群聊 Store + API + WS 监听 | 📋 | -| Task 10 | 群聊核心页面(Tab + 对话 + 创建) | 📋 | -| Task 11 | 群聊管理页面(设置 + 成员 + 邀请 + @选择器) | 📋 | -| Task 12 | 群聊辅助功能(审批 + 搜索 + 免打扰 + 公告) | 📋 | -| Task 13 | 管理端 + 文档更新 + 代码审查 | 📋 | +| Task 0 | MinIO Docker + SDK + 通用上传 API | ✅ 完成 | +| Task 1 | 数据库迁移 + Model + 常量 | ✅ 完成 | +| Task 2 | Group DAO 层 | ✅ 完成 | +| Task 3 | Group Service 业务逻辑 + WS 推送 | ✅ 完成 | +| Task 4 | Group Controller + Router + Wire | ✅ 完成 | +| Task 5 | IM Service 扩展(群消息/@提醒/管理员撤回) | ✅ 完成 | +| Task 6 | 已读回执后端(单聊 + 群聊) | ✅ 完成 | +| Task 7 | 代码审查修复(Critical 4 项 + Important 4 项) | ✅ 完成 | +| Task 8 | 前端已读回执 UI(单聊标记 + 群聊计数 + 详情页) | ✅ 完成 | +| Task 9 | 前端群聊 Store + API 封装 + WS 事件监听 | ✅ 完成 | +| Task 10 | 群聊核心页面(Tab 切换 + 群聊对话页 + 创建群聊页) | ✅ 完成 | +| Task 11 | 群聊管理页面(群设置 + 成员管理 + 邀请入群) | ✅ 完成 | +| Task 12 | 群聊辅助功能(入群审批 + 搜索群聊) | ✅ 完成 | +| Task 13 | 管理端群组管理 + 文档更新 | ✅ 完成 | +| 代码审查修复 | 14 项修复(Critical×5 + Important×4 + Minor×2 + Suggestion×3) | ✅ 完成 | + +### 代码审查修复详情(Phase 2c) + +| # | 优先级 | 修复内容 | +|---|--------|----------| +| Fix C1 | Critical | conversation.vue 角色类型不匹配:字符串改为 GROUP_ROLE 数字常量 | +| Fix C2 | Critical | settings.vue 群公告字段名 announcement 改为 notice(对齐后端 DTO) | +| Fix C3 | Critical | chat.js sendMessage 未传递 at_user_ids 到 WS payload | +| Fix C4 | Critical | create.vue 创建成功后导航错误:改为 /pages/group/conversation + 正确参数 | +| Fix C5 | Critical | admin/provider.go Wire Set 未注册 GroupManageService/Controller | +| Fix I1 | Important | store/group.js searchGroups 添加 append 参数解决分页加载竞态 | +| Fix I2 | Important | settings.vue 改为 fetchMembers() 刷新数据,不直接修改 computed 引用 | +| Fix I3 | Important | 新增 constants/group.js 前端角色常量定义,消除魔数 | +| Fix I4 | Important | group_manage_service.go 列表查询 N+1 优化:批量查询用户名和成员数 | +| Fix M1 | Minor | file.js JSON.parse 添加 try/catch 异常保护 | +| Fix M2 | Minor | conversation.vue isSelf 移除冗余临时状态条件 | +| Fix S1 | Suggestion | 群聊对话页新增禁言状态检测和输入栏禁用提示 | +| Fix S2 | Suggestion | join-requests.vue 注册 WS group.join.request 事件实时刷新 | +| Fix S3 | Suggestion | read-detail.vue 获取失败时添加 uni.showToast 用户提示 | + +### Phase 2c 新增内容 + +#### 后端新增模块 +- **file/** — 文件上传(MinIO SDK + 通用上传 API) +- **group/** — 群聊管理(Controller + Service + DAO + Model + Router) + - 18 个群管理 REST API + 11 个 WS 群事件推送 +- **im/ 扩展** — 群消息发送/撤回 + @提醒 + 单聊/群聊已读回执 +- **admin/ 扩展** — 群组列表 + 群组详情 + 解散群聊 + +#### 前端新增页面(9 个) +| 页面 | 路径 | 功能 | +|------|------|------| +| 群聊对话页 | `pages/group/conversation.vue` | 群消息收发 + @选择器 + 已读计数 | +| 创建群聊页 | `pages/group/create.vue` | 好友多选 + 群名称输入 | +| 群设置页 | `pages/group/settings.vue` | 群信息修改 + 成员概览 + 退出/解散 | +| 群成员页 | `pages/group/members.vue` | 成员列表 + 角色管理 + 禁言操作 | +| 邀请入群页 | `pages/group/invite.vue` | 好友多选 + 排除已在群内成员 | +| 入群审批页 | `pages/group/join-requests.vue` | 申请列表 + 通过/拒绝操作 | +| 搜索群聊页 | `pages/group/search.vue` | 关键词搜索 + 申请加入 | +| 已读详情页 | `pages/chat/read-detail.vue` | 已读/未读成员列表(群聊消息级) | +| 会话列表改造 | `pages/chat/index.vue` | Tab 切换(全部/单聊/群聊)+ @标记 + 免打扰标识 | + +#### 管理端新增 +| 页面 | 路径 | 功能 | +|------|------|------| +| 群组列表 | `views/group/list.vue` | 搜索 + 分页 + 详情弹窗 + 解散群聊 | + +#### 数据库新增/变更 +- `im_groups` — 群信息表(新增) +- `im_group_join_requests` — 入群申请表(新增) +- `im_message_reads` — 群消息已读表(新增) +- `im_conversation_members` — 扩展字段(role, nickname, is_muted, is_do_not_disturb, joined_at, at_me_count) +- `im_messages` — 扩展字段(at_user_ids BIGINT[]) ### 留待后续阶段 - 消息类型扩展(图片/语音/文件) - 管理端消息管理功能 +- 群头像上传 UI 完善 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ec73f9c..502f44d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,6 +10,7 @@ import { useUserStore } from '@/store/user' import { useWebSocketStore } from '@/store/websocket' import { useChatStore } from '@/store/chat' import { useContactStore } from '@/store/contact' +import { useGroupStore } from '@/store/group' export default { onLaunch() { @@ -35,8 +36,10 @@ export default { wsStore.connect() const chatStore = useChatStore() const contactStore = useContactStore() + const groupStore = useGroupStore() chatStore.initWsListeners() contactStore.initWsListeners() + groupStore.initWsListeners() } } } diff --git a/frontend/src/api/file.js b/frontend/src/api/file.js new file mode 100644 index 0000000..e528145 --- /dev/null +++ b/frontend/src/api/file.js @@ -0,0 +1,55 @@ +/** + * 文件上传模块 API + * + * 对应后端路由:POST /api/v1/upload + * 使用 uni.uploadFile 上传文件到 MinIO + */ + +import { BASE_URL } from '@/utils/request' +import { getToken } from '@/utils/storage' + +/** + * 上传文件 + * @param {string} filePath - 本地文件路径(如从 uni.chooseImage 获取) + * @returns {Promise} 返回 { url: '...' } + */ +const uploadFile = (filePath) => { + return new Promise((resolve, reject) => { + const token = getToken() + uni.uploadFile({ + url: `${BASE_URL}/api/v1/upload`, + filePath, + name: 'file', + header: { + Authorization: token ? `Bearer ${token}` : '' + }, + success: (res) => { + if (res.statusCode === 200) { + try { + const data = JSON.parse(res.data) + if (data.code === 0) { + resolve(data) + } else { + uni.showToast({ title: data.message || '上传失败', icon: 'none' }) + reject(data) + } + } catch { + uni.showToast({ title: '响应解析失败', icon: 'none' }) + reject({ code: -1, message: '响应解析失败' }) + } + } else { + uni.showToast({ title: '上传失败', icon: 'none' }) + reject({ code: res.statusCode, message: '上传失败' }) + } + }, + fail: (err) => { + uni.showToast({ title: '网络异常', icon: 'none' }) + reject({ code: -1, message: err.errMsg || '网络异常' }) + } + }) + }) +} + +export default { + uploadFile +} diff --git a/frontend/src/api/group.js b/frontend/src/api/group.js new file mode 100644 index 0000000..9414554 --- /dev/null +++ b/frontend/src/api/group.js @@ -0,0 +1,119 @@ +/** + * 群聊模块 API + * + * 对应后端路由:/api/v1/groups/* + * 群消息发送/撤回通过 WebSocket 进行,此处仅包含 REST API + */ + +import { get, post, put, del } from '@/utils/request' + +/** 创建群聊 */ +const createGroup = (name, memberIds, avatar = '') => { + return post('/api/v1/groups', { name, member_ids: memberIds, avatar }) +} + +/** 获取群详情 */ +const getGroupDetail = (groupId) => { + return get(`/api/v1/groups/${groupId}`) +} + +/** 更新群信息(名称/头像/公告/可搜索性) */ +const updateGroup = (groupId, data) => { + return put(`/api/v1/groups/${groupId}`, data) +} + +/** 解散群聊 */ +const dissolveGroup = (groupId) => { + return del(`/api/v1/groups/${groupId}`) +} + +/** 获取群成员列表 */ +const getMembers = (groupId) => { + return get(`/api/v1/groups/${groupId}/members`) +} + +/** 邀请用户入群 */ +const inviteMembers = (groupId, userIds) => { + return post(`/api/v1/groups/${groupId}/members`, { user_ids: userIds }) +} + +/** 踢出群成员 */ +const kickMember = (groupId, userId) => { + return del(`/api/v1/groups/${groupId}/members/${userId}`) +} + +/** 退出群聊 */ +const leaveGroup = (groupId) => { + return del(`/api/v1/groups/${groupId}/members/me`) +} + +/** 转让群主 */ +const transferOwner = (groupId, newOwnerId) => { + return put(`/api/v1/groups/${groupId}/transfer`, { new_owner_id: newOwnerId }) +} + +/** 设置/取消管理员 */ +const setMemberRole = (groupId, userId, role) => { + return put(`/api/v1/groups/${groupId}/members/${userId}/role`, { role }) +} + +/** 禁言/解除禁言成员 */ +const muteMember = (groupId, userId, isMuted) => { + return put(`/api/v1/groups/${groupId}/members/${userId}/mute`, { is_muted: isMuted }) +} + +/** 设置/取消全体禁言 */ +const setAllMuted = (groupId, isMuted) => { + return put(`/api/v1/groups/${groupId}/mute`, { is_muted: isMuted }) +} + +/** 修改群内昵称 */ +const updateNickname = (groupId, nickname) => { + return put(`/api/v1/groups/${groupId}/members/me/nickname`, { nickname }) +} + +/** 提交入群申请 */ +const submitJoinRequest = (groupId, message = '') => { + return post(`/api/v1/groups/${groupId}/join-requests`, { message }) +} + +/** 获取入群申请列表(群主/管理员) */ +const getJoinRequests = (groupId) => { + return get(`/api/v1/groups/${groupId}/join-requests`) +} + +/** 审批入群申请 */ +const reviewJoinRequest = (groupId, requestId, action) => { + return put(`/api/v1/groups/${groupId}/join-requests/${requestId}`, { action }) +} + +/** 搜索公开群 */ +const searchGroups = (keyword, page = 1, pageSize = 20) => { + return get('/api/v1/groups/search', { keyword, page, page_size: pageSize }) +} + +/** 设置/取消免打扰 */ +const setDoNotDisturb = (conversationId, isDoNotDisturb) => { + return put(`/api/v1/im/conversations/${conversationId}/dnd`, { is_do_not_disturb: isDoNotDisturb }) +} + +export default { + createGroup, + getGroupDetail, + updateGroup, + dissolveGroup, + getMembers, + inviteMembers, + kickMember, + leaveGroup, + transferOwner, + setMemberRole, + muteMember, + setAllMuted, + updateNickname, + submitJoinRequest, + getJoinRequests, + reviewJoinRequest, + searchGroups, + setDoNotDisturb +} diff --git a/frontend/src/api/im.js b/frontend/src/api/im.js index 191e2fe..245361a 100644 --- a/frontend/src/api/im.js +++ b/frontend/src/api/im.js @@ -46,6 +46,11 @@ const getTotalUnread = () => { return get('/api/v1/im/unread') } +/** 获取消息已读详情(已读/未读用户列表) */ +const getMessageReadDetail = (messageId, page = 1, pageSize = 50) => { + return get(`/api/v1/im/messages/${messageId}/reads`, { page, page_size: pageSize }) +} + export default { getConversations, getHistoryMessages, @@ -53,5 +58,6 @@ export default { deleteConversation, clearHistory, searchMessages, - getTotalUnread + getTotalUnread, + getMessageReadDetail } diff --git a/frontend/src/constants/group.js b/frontend/src/constants/group.js new file mode 100644 index 0000000..f8bec5a --- /dev/null +++ b/frontend/src/constants/group.js @@ -0,0 +1,35 @@ +/** + * 群聊相关常量定义 + * + * 与后端 constants/group.go 保持一致: + * - GroupRoleNormal = 0(普通成员) + * - GroupRoleAdmin = 1(管理员) + * - GroupRoleOwner = 2(群主) + */ + +/** 群成员角色(im_conversation_members.role) */ +export const GROUP_ROLE = { + MEMBER: 0, + ADMIN: 1, + OWNER: 2 +} + +/** 群成员角色中文映射 */ +export const GROUP_ROLE_LABEL = { + [GROUP_ROLE.MEMBER]: '普通成员', + [GROUP_ROLE.ADMIN]: '管理员', + [GROUP_ROLE.OWNER]: '群主' +} + +/** 群聊状态 */ +export const GROUP_STATUS = { + NORMAL: 1, + DISSOLVED: 2 +} + +/** 入群申请状态 */ +export const JOIN_REQUEST_STATUS = { + PENDING: 0, + APPROVED: 1, + REJECTED: 2 +} diff --git a/frontend/src/pages.json b/frontend/src/pages.json index d771d50..ebbf609 100644 --- a/frontend/src/pages.json +++ b/frontend/src/pages.json @@ -87,6 +87,55 @@ "navigationBarTitleText": "搜索消息" } }, + { + "path": "pages/chat/read-detail", + "style": { + "navigationBarTitleText": "已读详情" + } + }, + { + "path": "pages/group/conversation", + "style": { + "navigationBarTitleText": "群聊", + "navigationStyle": "custom" + } + }, + { + "path": "pages/group/create", + "style": { + "navigationBarTitleText": "创建群聊" + } + }, + { + "path": "pages/group/settings", + "style": { + "navigationBarTitleText": "群设置" + } + }, + { + "path": "pages/group/members", + "style": { + "navigationBarTitleText": "群成员" + } + }, + { + "path": "pages/group/invite", + "style": { + "navigationBarTitleText": "邀请入群" + } + }, + { + "path": "pages/group/join-requests", + "style": { + "navigationBarTitleText": "入群申请" + } + }, + { + "path": "pages/group/search", + "style": { + "navigationBarTitleText": "搜索群聊" + } + }, { "path": "pages/meeting/index", "style": { diff --git a/frontend/src/pages/chat/conversation.vue b/frontend/src/pages/chat/conversation.vue index cbf8dd2..0e11c99 100644 --- a/frontend/src/pages/chat/conversation.vue +++ b/frontend/src/pages/chat/conversation.vue @@ -61,25 +61,32 @@ @@ -134,11 +141,29 @@ export default { const selfAvatar = computed(() => userStore.userInfo?.avatar || '') const selfName = computed(() => userStore.userInfo?.nickname || userStore.userInfo?.username || '我') + const convType = ref(1) + const isSelf = (msg) => { const myId = Number(userStore.userInfo?.id) || 0 return msg.sender_id === myId || msg._sending === true || msg._failed === true } + const isRead = (msg) => { + if (convType.value === 2) return false + if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return false + const lastReadId = chatStore.readStatusMap[conversationId.value] || 0 + return msg.id <= lastReadId + } + + const getReadLabel = (msg) => { + if (msg.status === 2 || msg._sending || msg._failed || !msg.id) return '' + if (convType.value === 2) { + const count = chatStore.groupReadCountMap[msg.id] + return count > 0 ? `${count}人已读` : '' + } + return isRead(msg) ? '已读' : '未读' + } + const tryFindExistingConversation = async () => { try { await chatStore.fetchConversations() @@ -158,6 +183,7 @@ export default { peerId.value = parseInt(query.peerId) || 0 peerName.value = decodeURIComponent(query.peerName || '') peerAvatar.value = decodeURIComponent(query.peerAvatar || '') + convType.value = parseInt(query.convType) || 1 chatStore.initWsListeners() @@ -265,9 +291,10 @@ export default { return { peerName, peerAvatar, selfAvatar, selfName, - inputText, scrollToId, loadingMore, + inputText, scrollToId, loadingMore, convType, messages, hasMore, isTyping, - isSelf, onSend, onInputChange, onLoadMore, + isSelf, isRead, getReadLabel, + onSend, onInputChange, onLoadMore, onMsgLongPress, onResend, goBack, goToSettings } } @@ -376,6 +403,25 @@ export default { .msg-text-self { color: #FFFFFF; } .recalled-text { font-size: 24rpx; color: #94A3B8; font-style: italic; } +/* ===== 自己消息布局(含已读标记) ===== */ +.self-msg-col { + display: flex; + flex-direction: column; + align-items: flex-end; +} +.self-msg-row { + display: flex; + align-items: flex-start; + justify-content: flex-end; +} +.read-label { + font-size: 20rpx; + margin-top: 4rpx; + margin-right: 88rpx; +} +.read-label-read { color: #2563EB; } +.read-label-unread { color: #94A3B8; } + /* ===== 发送状态 ===== */ .msg-status { display: flex; diff --git a/frontend/src/pages/chat/index.vue b/frontend/src/pages/chat/index.vue index 5414675..b240992 100644 --- a/frontend/src/pages/chat/index.vue +++ b/frontend/src/pages/chat/index.vue @@ -15,6 +15,22 @@ + + + + + + + + + + 全部 + + + 单聊 + + + 群聊 @@ -47,7 +63,7 @@ {{ (conv.peer_nickname || '?')[0] }} - + {{ conv.unread_count > 99 ? '99+' : conv.unread_count }} @@ -60,9 +76,13 @@ 对方正在输入... - - {{ conv.last_msg_content || ' ' }} - + + [{{ conv.at_me_count }}条] @了我 + 🔕 + + {{ conv.last_msg_content || ' ' }} + + @@ -87,8 +107,14 @@ export default { setup() { const chatStore = useChatStore() const loading = ref(false) + const activeTab = ref('all') - const conversations = computed(() => chatStore.sortedConversations) + const conversations = computed(() => { + const all = chatStore.sortedConversations + if (activeTab.value === 'private') return all.filter(c => c.type === 1) + if (activeTab.value === 'group') return all.filter(c => c.type === 2) + return all + }) const loadData = async () => { loading.value = true @@ -105,15 +131,25 @@ export default { }) const openChat = (conv) => { - uni.navigateTo({ - url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}` - }) + if (conv.type === 2) { + uni.navigateTo({ + url: `/pages/group/conversation?conversationId=${conv.id}&groupId=${conv.group_id || 0}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}` + }) + } else { + uni.navigateTo({ + url: `/pages/chat/conversation?conversationId=${conv.id}&peerId=${conv.peer_user_id}&peerName=${encodeURIComponent(conv.peer_nickname || '')}&peerAvatar=${encodeURIComponent(conv.peer_avatar || '')}&convType=1` + }) + } } const goToSearch = () => { uni.navigateTo({ url: '/pages/chat/search' }) } + const goToCreateGroup = () => { + uni.navigateTo({ url: '/pages/group/create' }) + } + const isTyping = (convId) => { return chatStore.typingMap[convId] || false } @@ -160,9 +196,11 @@ export default { return { loading, + activeTab, conversations, openChat, goToSearch, + goToCreateGroup, isTyping, formatTime, onLongPress, @@ -215,9 +253,25 @@ export default { background-color: #E2E8F0; } +/* Tab 筛选栏 */ +.tab-filter { + display: flex; + padding: 0 32rpx; + background-color: #FFFFFF; + border-bottom: 1rpx solid #E2E8F0; +} +.tab-item { + padding: 16rpx 24rpx; + margin-right: 8rpx; + position: relative; +} +.tab-active { border-bottom: 4rpx solid #2563EB; } +.tab-text { font-size: 26rpx; color: #475569; } +.tab-active .tab-text { color: #2563EB; font-weight: 600; } + /* 会话列表 */ .conv-list { - height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx); + height: calc(100vh - 88rpx - var(--status-bar-height, 44px) - 120rpx - 72rpx); } .conv-item { @@ -336,6 +390,31 @@ export default { font-weight: 500; } +.conv-preview-row { + display: flex; + align-items: center; + overflow: hidden; + flex: 1; +} + +.conv-at-tag { + font-size: 22rpx; + color: #EF4444; + font-weight: 600; + margin-right: 8rpx; + flex-shrink: 0; +} + +.conv-dnd-icon { + font-size: 22rpx; + margin-right: 4rpx; + flex-shrink: 0; +} + +.conv-badge-mute { + background-color: #94A3B8; +} + .conv-typing { font-size: 26rpx; color: #2563EB; diff --git a/frontend/src/pages/chat/read-detail.vue b/frontend/src/pages/chat/read-detail.vue new file mode 100644 index 0000000..69f70ab --- /dev/null +++ b/frontend/src/pages/chat/read-detail.vue @@ -0,0 +1,173 @@ + + + + + + diff --git a/frontend/src/pages/group/conversation.vue b/frontend/src/pages/group/conversation.vue new file mode 100644 index 0000000..8ad6849 --- /dev/null +++ b/frontend/src/pages/group/conversation.vue @@ -0,0 +1,736 @@ + + + + + + diff --git a/frontend/src/pages/group/create.vue b/frontend/src/pages/group/create.vue new file mode 100644 index 0000000..4f87f70 --- /dev/null +++ b/frontend/src/pages/group/create.vue @@ -0,0 +1,576 @@ + + + + + + diff --git a/frontend/src/pages/group/invite.vue b/frontend/src/pages/group/invite.vue new file mode 100644 index 0000000..322b68e --- /dev/null +++ b/frontend/src/pages/group/invite.vue @@ -0,0 +1,541 @@ + + + + + + diff --git a/frontend/src/pages/group/join-requests.vue b/frontend/src/pages/group/join-requests.vue new file mode 100644 index 0000000..e1262cc --- /dev/null +++ b/frontend/src/pages/group/join-requests.vue @@ -0,0 +1,515 @@ + + + + + + diff --git a/frontend/src/pages/group/members.vue b/frontend/src/pages/group/members.vue new file mode 100644 index 0000000..b1b226c --- /dev/null +++ b/frontend/src/pages/group/members.vue @@ -0,0 +1,453 @@ + + + + + + diff --git a/frontend/src/pages/group/search.vue b/frontend/src/pages/group/search.vue new file mode 100644 index 0000000..a333671 --- /dev/null +++ b/frontend/src/pages/group/search.vue @@ -0,0 +1,466 @@ + + + + + + diff --git a/frontend/src/pages/group/settings.vue b/frontend/src/pages/group/settings.vue new file mode 100644 index 0000000..4a03464 --- /dev/null +++ b/frontend/src/pages/group/settings.vue @@ -0,0 +1,538 @@ + + + + + + diff --git a/frontend/src/store/chat.js b/frontend/src/store/chat.js index badb1b7..b106e6d 100644 --- a/frontend/src/store/chat.js +++ b/frontend/src/store/chat.js @@ -40,6 +40,12 @@ export const useChatStore = defineStore('chat', () => { /** 是否还有更多历史消息 conversationId -> boolean */ const hasMoreMap = ref({}) + /** 单聊已读状态:conversationId -> lastReadMsgId(对方已读到的消息 ID) */ + const readStatusMap = ref({}) + + /** 群聊已读计数:messageId -> readCount */ + const groupReadCountMap = ref({}) + // ==================== Computed ==================== /** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */ @@ -85,7 +91,7 @@ export const useChatStore = defineStore('chat', () => { */ const sendMessage = (params) => { const clientMsgId = _generateClientMsgId() - const { conversationId, targetUserId, content, type = 1 } = params + const { conversationId, targetUserId, content, type = 1, at_user_ids } = params const userStore = useUserStore() const tempMsg = { @@ -105,13 +111,18 @@ export const useChatStore = defineStore('chat', () => { pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg } - const seq = wsService.send('im.message.send', { + const payload = { conversation_id: conversationId || 0, target_user_id: targetUserId || 0, type, content, client_msg_id: clientMsgId - }) + } + if (at_user_ids && at_user_ids.length > 0) { + payload.at_user_ids = at_user_ids + } + + const seq = wsService.send('im.message.send', payload) if (seq === -1) { pendingMessages.value[clientMsgId].status = 'failed' @@ -209,6 +220,8 @@ export const useChatStore = defineStore('chat', () => { wsService.on('im.message.send.ack', _onSendACK) wsService.on('im.offline.sync', _onOfflineSync) wsService.on('im.typing', _onTyping) + wsService.on('im.message.read.ack', _onReadACK) + wsService.on('im.message.read.count', _onReadCount) } /** 收到新消息推送 */ @@ -311,6 +324,29 @@ export const useChatStore = defineStore('chat', () => { fetchConversations() } + /** 单聊已读确认(对方已读到某条消息) */ + const _onReadACK = (msg) => { + if (!msg || !msg.data) return + const { conversation_id, last_read_msg_id } = msg.data + readStatusMap.value[conversation_id] = last_read_msg_id + } + + /** 群聊已读计数更新 */ + const _onReadCount = (msg) => { + if (!msg || !msg.data) return + const { message_id, read_count } = msg.data + groupReadCountMap.value[message_id] = read_count + } + + /** 群聊标记已读(发送 WS 事件) */ + const markGroupRead = (conversationId, messageIds) => { + if (!messageIds || messageIds.length === 0) return + wsService.send('im.group.read', { + conversation_id: conversationId, + message_ids: messageIds + }) + } + /** 正在输入通知 */ const _onTyping = (msg) => { if (!msg || !msg.data) return @@ -366,6 +402,8 @@ export const useChatStore = defineStore('chat', () => { totalUnread, typingMap, hasMoreMap, + readStatusMap, + groupReadCountMap, currentMessages, sortedConversations, fetchConversations, @@ -373,6 +411,7 @@ export const useChatStore = defineStore('chat', () => { sendMessage, recallMessage, markRead, + markGroupRead, sendTyping, loadHistoryMessages, pinConversation, diff --git a/frontend/src/store/group.js b/frontend/src/store/group.js new file mode 100644 index 0000000..2d1da1a --- /dev/null +++ b/frontend/src/store/group.js @@ -0,0 +1,328 @@ +/** + * 群聊状态 Store + * + * 管理群聊信息、群成员、入群申请,提供: + * - 当前群详情和成员列表 + * - 群管理操作(邀请/踢人/禁言/设管理员/解散等) + * - WebSocket 群管理事件监听(group.* 系列) + * + * 对应后端 API:/api/v1/groups/* + * 对应 WS 事件:group.created / group.member.join / group.member.leave / group.member.kicked + * group.info.update / group.dissolved / group.mute.update / group.role.update + * group.owner.transfer / group.join.request / group.join.approved + */ + +import { defineStore } from 'pinia' +import { ref } from 'vue' +import groupApi from '@/api/group' +import wsService from '@/services/websocket' +import { useChatStore } from '@/store/chat' + +export const useGroupStore = defineStore('group', () => { + /** 当前查看的群详情 */ + const currentGroup = ref(null) + + /** 当前群的成员列表 */ + const currentMembers = ref([]) + + /** 当前群的入群申请列表 */ + const joinRequests = ref([]) + + /** 群搜索结果 */ + const searchResults = ref([]) + const searchTotal = ref(0) + + // ==================== Actions:群信息 ==================== + + /** 获取群详情 */ + const fetchGroupDetail = async (groupId) => { + const res = await groupApi.getGroupDetail(groupId) + if (res.data) { + currentGroup.value = res.data + } + return currentGroup.value + } + + /** 创建群聊 */ + const createGroup = async (name, memberIds, avatar = '') => { + const res = await groupApi.createGroup(name, memberIds, avatar) + return res.data + } + + /** 更新群信息 */ + const updateGroup = async (groupId, data) => { + await groupApi.updateGroup(groupId, data) + if (currentGroup.value && currentGroup.value.id === groupId) { + Object.assign(currentGroup.value, data) + } + } + + /** 解散群聊 */ + const dissolveGroup = async (groupId) => { + await groupApi.dissolveGroup(groupId) + currentGroup.value = null + } + + // ==================== Actions:群成员管理 ==================== + + /** 获取群成员列表 */ + const fetchMembers = async (groupId) => { + const res = await groupApi.getMembers(groupId) + if (res.data) { + currentMembers.value = res.data.list || res.data || [] + } + return currentMembers.value + } + + /** 邀请入群 */ + const inviteMembers = async (groupId, userIds) => { + await groupApi.inviteMembers(groupId, userIds) + } + + /** 踢出群成员 */ + const kickMember = async (groupId, userId) => { + await groupApi.kickMember(groupId, userId) + currentMembers.value = currentMembers.value.filter(m => m.user_id !== userId) + } + + /** 退出群聊 */ + const leaveGroup = async (groupId) => { + await groupApi.leaveGroup(groupId) + currentGroup.value = null + } + + /** 转让群主 */ + const transferOwner = async (groupId, newOwnerId) => { + await groupApi.transferOwner(groupId, newOwnerId) + } + + /** 设置/取消管理员 */ + const setMemberRole = async (groupId, userId, role) => { + await groupApi.setMemberRole(groupId, userId, role) + const member = currentMembers.value.find(m => m.user_id === userId) + if (member) member.role = role + } + + /** 禁言/解除禁言 */ + const muteMember = async (groupId, userId, isMuted) => { + await groupApi.muteMember(groupId, userId, isMuted) + const member = currentMembers.value.find(m => m.user_id === userId) + if (member) member.is_muted = isMuted + } + + /** 全体禁言 */ + const setAllMuted = async (groupId, isMuted) => { + await groupApi.setAllMuted(groupId, isMuted) + if (currentGroup.value && currentGroup.value.id === groupId) { + currentGroup.value.is_all_muted = isMuted + } + } + + /** 修改群昵称 */ + const updateNickname = async (groupId, nickname) => { + await groupApi.updateNickname(groupId, nickname) + } + + /** 设置/取消免打扰 */ + const setDoNotDisturb = async (conversationId, isDoNotDisturb) => { + await groupApi.setDoNotDisturb(conversationId, isDoNotDisturb) + } + + // ==================== Actions:入群申请 ==================== + + /** 提交入群申请 */ + const submitJoinRequest = async (groupId, message = '') => { + await groupApi.submitJoinRequest(groupId, message) + } + + /** 获取入群申请列表 */ + const fetchJoinRequests = async (groupId) => { + const res = await groupApi.getJoinRequests(groupId) + if (res.data) { + joinRequests.value = res.data.list || res.data || [] + } + return joinRequests.value + } + + /** 审批入群申请 */ + const reviewJoinRequest = async (groupId, requestId, action) => { + await groupApi.reviewJoinRequest(groupId, requestId, action) + joinRequests.value = joinRequests.value.filter(r => r.id !== requestId) + } + + // ==================== Actions:搜索 ==================== + + /** + * 搜索公开群 + * @param {string} keyword - 搜索关键词 + * @param {number} page - 页码 + * @param {number} pageSize - 每页条数 + * @param {boolean} append - 是否追加到现有列表(分页加载更多时为 true) + */ + const searchGroups = async (keyword, page = 1, pageSize = 20, append = false) => { + const res = await groupApi.searchGroups(keyword, page, pageSize) + if (res.data) { + const newList = res.data.list || [] + searchResults.value = append ? [...searchResults.value, ...newList] : newList + searchTotal.value = res.data.total || 0 + } + return { list: searchResults.value, total: searchTotal.value } + } + + // ==================== WS 事件监听 ==================== + + let _wsInitialized = false + + /** 初始化 WebSocket 群管理事件监听(幂等) */ + const initWsListeners = () => { + if (_wsInitialized) return + _wsInitialized = true + + wsService.on('group.created', _onGroupCreated) + wsService.on('group.member.join', _onMemberJoin) + wsService.on('group.member.leave', _onMemberLeave) + wsService.on('group.member.kicked', _onMemberKicked) + wsService.on('group.info.update', _onInfoUpdate) + wsService.on('group.dissolved', _onDissolved) + wsService.on('group.mute.update', _onMuteUpdate) + wsService.on('group.role.update', _onRoleUpdate) + wsService.on('group.owner.transfer', _onOwnerTransfer) + wsService.on('group.join.request', _onJoinRequest) + wsService.on('group.join.approved', _onJoinApproved) + } + + /** 群聊创建通知 → 刷新会话列表 */ + const _onGroupCreated = () => { + const chatStore = useChatStore() + chatStore.fetchConversations() + } + + /** 新成员加入 → 刷新成员和会话 */ + const _onMemberJoin = (msg) => { + if (!msg || !msg.data) return + const { group_id } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + fetchMembers(group_id) + } + const chatStore = useChatStore() + chatStore.fetchConversations() + } + + /** 成员退出 → 刷新成员 */ + const _onMemberLeave = (msg) => { + if (!msg || !msg.data) return + const { group_id, user_id } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + currentMembers.value = currentMembers.value.filter(m => m.user_id !== user_id) + } + } + + /** 成员被踢 → 刷新或跳转 */ + const _onMemberKicked = (msg) => { + if (!msg || !msg.data) return + const { group_id, user_id } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + currentMembers.value = currentMembers.value.filter(m => m.user_id !== user_id) + } + const chatStore = useChatStore() + chatStore.fetchConversations() + } + + /** 群信息变更 → 更新当前群详情 + 刷新会话 */ + const _onInfoUpdate = (msg) => { + if (!msg || !msg.data) return + const { group_id, updates } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id && updates) { + Object.assign(currentGroup.value, updates) + } + const chatStore = useChatStore() + chatStore.fetchConversations() + } + + /** 群解散 → 清空当前群 + 刷新会话 */ + const _onDissolved = (msg) => { + if (!msg || !msg.data) return + const { group_id } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + currentGroup.value = null + uni.showToast({ title: '该群已被解散', icon: 'none' }) + } + const chatStore = useChatStore() + chatStore.fetchConversations() + } + + /** 禁言变更 → 更新成员 / 群信息 */ + const _onMuteUpdate = (msg) => { + if (!msg || !msg.data) return + const { group_id, user_id, is_muted, is_all_muted } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + if (is_all_muted !== undefined) { + currentGroup.value.is_all_muted = is_all_muted + } + if (user_id) { + const member = currentMembers.value.find(m => m.user_id === user_id) + if (member) member.is_muted = is_muted + } + } + } + + /** 角色变更 → 更新成员角色 */ + const _onRoleUpdate = (msg) => { + if (!msg || !msg.data) return + const { group_id, user_id, role } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + const member = currentMembers.value.find(m => m.user_id === user_id) + if (member) member.role = role + } + } + + /** 群主转让 → 刷新群详情和成员 */ + const _onOwnerTransfer = (msg) => { + if (!msg || !msg.data) return + const { group_id } = msg.data + if (currentGroup.value && currentGroup.value.id === group_id) { + fetchGroupDetail(group_id) + fetchMembers(group_id) + } + } + + /** 新入群申请 → 提示通知 */ + const _onJoinRequest = (msg) => { + if (!msg || !msg.data) return + uni.showToast({ title: '收到新的入群申请', icon: 'none' }) + } + + /** 入群申请通过 → 刷新会话列表 */ + const _onJoinApproved = () => { + const chatStore = useChatStore() + chatStore.fetchConversations() + uni.showToast({ title: '入群申请已通过', icon: 'none' }) + } + + return { + currentGroup, + currentMembers, + joinRequests, + searchResults, + searchTotal, + fetchGroupDetail, + createGroup, + updateGroup, + dissolveGroup, + fetchMembers, + inviteMembers, + kickMember, + leaveGroup, + transferOwner, + setMemberRole, + muteMember, + setAllMuted, + updateNickname, + setDoNotDisturb, + submitJoinRequest, + fetchJoinRequests, + reviewJoinRequest, + searchGroups, + initWsListeners + } +})