// Copyright © 2023 OpenIM. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "fmt" "math/big" "math/rand" "time" "github.com/gin-gonic/gin" "go.mongodb.org/mongo-driver/mongo" "git.imall.cloud/openim/open-im-server-deploy/pkg/apistruct" "git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/database" "git.imall.cloud/openim/open-im-server-deploy/pkg/common/storage/model" "git.imall.cloud/openim/open-im-server-deploy/pkg/common/webhook" "git.imall.cloud/openim/open-im-server-deploy/pkg/rpcli" "git.imall.cloud/openim/protocol/constant" "git.imall.cloud/openim/protocol/group" "git.imall.cloud/openim/protocol/sdkws" "git.imall.cloud/openim/protocol/wrapperspb" "github.com/openimsdk/tools/apiresp" "github.com/openimsdk/tools/errs" "github.com/openimsdk/tools/log" "github.com/openimsdk/tools/mcontext" "github.com/openimsdk/tools/utils/datautil" "github.com/openimsdk/tools/utils/idutil" "github.com/openimsdk/tools/utils/timeutil" ) type MeetingApi struct { meetingDB database.Meeting meetingCheckInDB database.MeetingCheckIn groupClient *rpcli.GroupClient userClient *rpcli.UserClient conversationClient *rpcli.ConversationClient systemConfigDB database.SystemConfig } func NewMeetingApi(meetingDB database.Meeting, meetingCheckInDB database.MeetingCheckIn, groupClient *rpcli.GroupClient, userClient *rpcli.UserClient, conversationClient *rpcli.ConversationClient, systemConfigDB database.SystemConfig) *MeetingApi { return &MeetingApi{ meetingDB: meetingDB, meetingCheckInDB: meetingCheckInDB, groupClient: groupClient, userClient: userClient, conversationClient: conversationClient, systemConfigDB: systemConfigDB, } } // IsNotFound 检查错误是否是记录不存在 func (m *MeetingApi) IsNotFound(err error) bool { return errs.ErrRecordNotFound.Is(err) || errs.Unwrap(err) == mongo.ErrNoDocuments } // loadAnchorUsers 加载主播用户信息 func (m *MeetingApi) loadAnchorUsers(ctx context.Context, anchorUserIDs []string) []*sdkws.UserInfo { if len(anchorUserIDs) == 0 { return nil } users, err := m.userClient.GetUsersInfo(ctx, anchorUserIDs) if err != nil { log.ZWarn(ctx, "loadAnchorUsers: failed to get anchor users info", err, "anchorUserIDs", anchorUserIDs) return nil } return users } // genMeetingPassword 生成6位数字密码 func (m *MeetingApi) genMeetingPassword() string { return fmt.Sprintf("%06d", rand.Intn(1000000)) } // genMeetingID 生成会议ID func (m *MeetingApi) genMeetingID(ctx context.Context, meetingID string) (string, error) { if meetingID != "" { _, err := m.meetingDB.Take(ctx, meetingID) if err == nil { return "", errs.ErrArgs.WrapMsg("meeting id already exists: " + meetingID) } // 如果记录不存在,说明可以使用这个ID if m.IsNotFound(err) { return meetingID, nil } // 其他错误直接返回 return "", err } // 生成唯一ID for i := 0; i < 10; i++ { opID := mcontext.GetOperationID(ctx) timestamp := time.Now().UnixNano() random := rand.Int() data := fmt.Sprintf("%s,%d,%d", opID, timestamp, random) hash := md5.Sum([]byte(data)) id := hex.EncodeToString(hash[:])[:16] bi := big.NewInt(0) bi.SetString(id, 16) id = bi.String() _, err := m.meetingDB.Take(ctx, id) if err == nil { // ID已存在,继续生成下一个 continue } // 如果记录不存在,说明可以使用这个ID if m.IsNotFound(err) { return id, nil } // 其他错误直接返回 return "", err } return "", errs.ErrInternalServer.WrapMsg("failed to generate meeting id") } // paginationWrapper 实现 pagination.Pagination 接口 type meetingPaginationWrapper struct { pageNumber int32 showNumber int32 } func (p *meetingPaginationWrapper) GetPageNumber() int32 { if p.pageNumber <= 0 { return 1 } return p.pageNumber } func (p *meetingPaginationWrapper) GetShowNumber() int32 { if p.showNumber <= 0 { return 20 } return p.showNumber } // CreateMeeting 创建会议 func (m *MeetingApi) CreateMeeting(c *gin.Context) { var ( req apistruct.CreateMeetingReq resp apistruct.CreateMeetingResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 获取当前用户ID creatorUserID := mcontext.GetOpUserID(c) if creatorUserID == "" { apiresp.GinError(c, errs.ErrArgs.WrapMsg("user id not found")) return } // 生成会议ID meetingID, err := m.genMeetingID(c, req.MeetingID) if err != nil { log.ZError(c, "CreateMeeting: failed to generate meeting id", err) apiresp.GinError(c, err) return } // 验证预约时间不能早于当前时间 scheduledTime := time.UnixMilli(req.ScheduledTime) if scheduledTime.Before(time.Now()) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("scheduled time cannot be earlier than current time")) return } // 处理密码:如果未提供,自动生成6位数字密码 password := req.Password if password == "" { password = m.genMeetingPassword() } else { // 验证密码格式:必须是6位数字 if len(password) != 6 { apiresp.GinError(c, errs.ErrArgs.WrapMsg("password must be 6 digits")) return } for _, char := range password { if char < '0' || char > '9' { apiresp.GinError(c, errs.ErrArgs.WrapMsg("password must be 6 digits")) return } } } // 创建群聊,群聊名称为"会议群-[会议主题]" groupName := fmt.Sprintf("会议群-%s", req.Subject) // 在Ex字段中设置会议标识 exData := map[string]interface{}{ "isMeetingGroup": true, "meetingID": meetingID, } exJSON, err := json.Marshal(exData) if err != nil { log.ZError(c, "CreateMeeting: failed to marshal ex data", err) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to marshal ex data")) return } groupInfo := &sdkws.GroupInfo{ GroupName: groupName, GroupType: constant.WorkingGroup, FaceURL: req.CoverURL, // 使用会议封面作为群头像 Ex: string(exJSON), // 设置会议标识 NeedVerification: constant.Directly, // 设置为直接加入,任何人都能随意加群 } // 设置群主和管理员:第一个主播为群主,其他主播为管理员 var ownerUserID string var adminUserIDs []string var memberUserIDs []string if len(req.AnchorUserIDs) > 0 { // 有主播时,第一个主播为群主,其他主播为管理员 ownerUserID = req.AnchorUserIDs[0] if len(req.AnchorUserIDs) > 1 { adminUserIDs = req.AnchorUserIDs[1:] } // 如果创建者不在主播列表中,将创建者作为普通成员加入 anchorMap := make(map[string]bool) for _, anchorID := range req.AnchorUserIDs { anchorMap[anchorID] = true } if !anchorMap[creatorUserID] { memberUserIDs = []string{creatorUserID} } } else { // 没有主播时,创建者为群主 ownerUserID = creatorUserID } createGroupReq := &group.CreateGroupReq{ OwnerUserID: ownerUserID, AdminUserIDs: adminUserIDs, MemberUserIDs: memberUserIDs, GroupInfo: groupInfo, } createGroupResp, err := m.groupClient.GroupClient.CreateGroup(c, createGroupReq) if err != nil { log.ZError(c, "CreateMeeting: failed to create group", err, "meetingID", meetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to create group")) return } groupID := createGroupResp.GroupInfo.GroupID // 兜底:再次对主播列表进行角色同步,确保主播=群主/管理员 if len(req.AnchorUserIDs) > 0 { if err := m.updateGroupAnchors(c, groupID, req.AnchorUserIDs); err != nil { log.ZWarn(c, "CreateMeeting: failed to sync anchors to owner/admin", err, "groupID", groupID, "anchorUserIDs", req.AnchorUserIDs) } } // 收集所有群成员ID(群主、管理员、普通成员),用于创建会话 allMemberUserIDs := make([]string, 0) allMemberUserIDs = append(allMemberUserIDs, ownerUserID) allMemberUserIDs = append(allMemberUserIDs, adminUserIDs...) allMemberUserIDs = append(allMemberUserIDs, memberUserIDs...) // 去重 allMemberUserIDs = datautil.Distinct(allMemberUserIDs) // 为所有群成员创建会话记录 if len(allMemberUserIDs) > 0 { if err := m.conversationClient.CreateGroupChatConversations(c, groupID, allMemberUserIDs); err != nil { log.ZWarn(c, "CreateMeeting: failed to create group chat conversations", err, "groupID", groupID, "userIDs", allMemberUserIDs) // 不阻断流程,继续创建会议,只是会话可能不会立即显示 } } // 根据评论开关设置群的禁言状态 // 如果开启评论,取消群禁言;如果关闭评论,禁言群 if req.EnableComment { // 开启评论,取消群禁言 _, err := m.groupClient.GroupClient.CancelMuteGroup(c, &group.CancelMuteGroupReq{ GroupID: groupID, }) if err != nil { log.ZWarn(c, "CreateMeeting: failed to cancel mute group", err, "groupID", groupID) // 不阻断流程,继续创建会议 } } else { // 关闭评论,禁言群 _, err := m.groupClient.GroupClient.MuteGroup(c, &group.MuteGroupReq{ GroupID: groupID, }) if err != nil { log.ZWarn(c, "CreateMeeting: failed to mute group", err, "groupID", groupID) // 不阻断流程,继续创建会议 } } // 创建会议记录 meeting := &model.Meeting{ MeetingID: meetingID, Subject: req.Subject, CoverURL: req.CoverURL, ScheduledTime: scheduledTime, Status: model.MeetingStatusScheduled, CreatorUserID: creatorUserID, Description: req.Description, Duration: req.Duration, EstimatedCount: req.EstimatedCount, EnableMic: req.EnableMic, EnableComment: req.EnableComment, AnchorUserIDs: req.AnchorUserIDs, GroupID: groupID, CheckInCount: 0, // 初始化签到统计为0 Password: password, // 会议密码 } if err := m.meetingDB.Create(c, meeting); err != nil { log.ZError(c, "CreateMeeting: failed to create meeting", err, "meetingID", meetingID) // 如果创建会议失败,尝试删除已创建的群聊,避免孤立群 if groupID != "" { if dismissErr := m.groupClient.DismissGroup(c, groupID, true); dismissErr != nil { log.ZWarn(c, "CreateMeeting: failed to rollback created group", dismissErr, "meetingID", meetingID, "groupID", groupID) } } apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to create meeting")) return } // 加载主播信息 anchorUsers := m.loadAnchorUsers(c, meeting.AnchorUserIDs) // 转换为响应格式 resp.MeetingInfo = &apistruct.MeetingInfo{ MeetingID: meeting.MeetingID, Subject: meeting.Subject, CoverURL: meeting.CoverURL, ScheduledTime: meeting.ScheduledTime.UnixMilli(), Status: meeting.Status, CreatorUserID: meeting.CreatorUserID, Description: meeting.Description, Duration: meeting.Duration, EstimatedCount: meeting.EstimatedCount, EnableMic: meeting.EnableMic, EnableComment: meeting.EnableComment, AnchorUserIDs: meeting.AnchorUserIDs, AnchorUsers: anchorUsers, CreateTime: meeting.CreateTime.UnixMilli(), UpdateTime: meeting.UpdateTime.UnixMilli(), Ex: meeting.Ex, GroupID: meeting.GroupID, CheckInCount: meeting.CheckInCount, Password: meeting.Password, } resp.GroupID = groupID // 将会议群ID添加到webhook配置的attentionIds中 if m.systemConfigDB != nil && groupID != "" { if err := webhook.UpdateAttentionIds(c, m.systemConfigDB, groupID, true); err != nil { log.ZWarn(c, "CreateMeeting: failed to add groupID to webhook attentionIds", err, "meetingID", meetingID, "groupID", groupID) } } log.ZInfo(c, "CreateMeeting: success", "meetingID", meetingID, "groupID", groupID, "creatorUserID", creatorUserID) apiresp.GinSuccess(c, resp) } // UpdateMeeting 更新会议(可以单独编辑某个字段) func (m *MeetingApi) UpdateMeeting(c *gin.Context) { var ( req apistruct.UpdateMeetingReq resp apistruct.UpdateMeetingResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 获取当前用户ID opUserID := mcontext.GetOpUserID(c) // 查询会议是否存在 meeting, err := m.meetingDB.Take(c, req.MeetingID) if err != nil { if errs.ErrRecordNotFound.Is(err) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("meeting not found")) return } log.ZError(c, "UpdateMeeting: failed to get meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get meeting")) return } // 验证权限(只有创建者可以修改) if meeting.CreatorUserID != opUserID { apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("only creator can update meeting")) return } // 已结束或已取消的会议不允许修改 if meeting.Status == model.MeetingStatusFinished || meeting.Status == model.MeetingStatusCancelled { apiresp.GinError(c, errs.ErrArgs.WrapMsg("cannot update finished or cancelled meeting")) return } // 构建更新数据(只更新提供的字段) updateData := make(map[string]any) if req.Subject != "" { updateData["subject"] = req.Subject // 如果更新了主题,同步更新群聊名称 if meeting.GroupID != "" { groupName := fmt.Sprintf("会议群-%s", req.Subject) _, err := m.groupClient.GroupClient.SetGroupInfo(c, &group.SetGroupInfoReq{ GroupInfoForSet: &sdkws.GroupInfoForSet{ GroupID: meeting.GroupID, GroupName: groupName, }, }) if err != nil { log.ZWarn(c, "UpdateMeeting: failed to update group name", err, "groupID", meeting.GroupID) } } } if req.CoverURL != "" { updateData["cover_url"] = req.CoverURL // 如果更新了封面,同步更新群聊头像 if meeting.GroupID != "" { _, err := m.groupClient.GroupClient.SetGroupInfo(c, &group.SetGroupInfoReq{ GroupInfoForSet: &sdkws.GroupInfoForSet{ GroupID: meeting.GroupID, FaceURL: req.CoverURL, }, }) if err != nil { log.ZWarn(c, "UpdateMeeting: failed to update group face", err, "groupID", meeting.GroupID) } } } if req.ScheduledTime > 0 { scheduledTime := time.UnixMilli(req.ScheduledTime) // 验证预约时间不能早于当前时间 if scheduledTime.Before(time.Now()) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("scheduled time cannot be earlier than current time")) return } updateData["scheduled_time"] = scheduledTime } if req.Status > 0 { // 验证状态值 if req.Status < 1 || req.Status > 4 { apiresp.GinError(c, errs.ErrArgs.WrapMsg("invalid status, must be 1-4")) return } updateData["status"] = req.Status } if req.Description != "" { updateData["description"] = req.Description } if req.Duration > 0 { updateData["duration"] = req.Duration } if req.EstimatedCount > 0 { updateData["estimated_count"] = req.EstimatedCount } if req.EnableMic != nil { updateData["enable_mic"] = *req.EnableMic } if req.EnableComment != nil { updateData["enable_comment"] = *req.EnableComment // 如果更新了评论开关,同步更新群的禁言状态 if meeting.GroupID != "" { if *req.EnableComment { // 开启评论,取消群禁言 _, err := m.groupClient.GroupClient.CancelMuteGroup(c, &group.CancelMuteGroupReq{ GroupID: meeting.GroupID, }) if err != nil { log.ZWarn(c, "UpdateMeeting: failed to cancel mute group", err, "groupID", meeting.GroupID) // 不阻断流程,继续更新会议 } } else { // 关闭评论,禁言群 _, err := m.groupClient.GroupClient.MuteGroup(c, &group.MuteGroupReq{ GroupID: meeting.GroupID, }) if err != nil { log.ZWarn(c, "UpdateMeeting: failed to mute group", err, "groupID", meeting.GroupID) // 不阻断流程,继续更新会议 } } } } if req.AnchorUserIDs != nil { updateData["anchor_user_ids"] = req.AnchorUserIDs // 如果更新了主播列表,需要同步更新群的管理员和群主 if meeting.GroupID != "" { if err := m.updateGroupAnchors(c, meeting.GroupID, req.AnchorUserIDs); err != nil { log.ZWarn(c, "UpdateMeeting: failed to update group anchors", err, "groupID", meeting.GroupID, "anchorUserIDs", req.AnchorUserIDs) // 不阻断流程,继续更新会议 } } } if req.Password != nil { password := *req.Password // 验证密码格式:必须是6位数字 if password != "" { if len(password) != 6 { apiresp.GinError(c, errs.ErrArgs.WrapMsg("password must be 6 digits")) return } for _, char := range password { if char < '0' || char > '9' { apiresp.GinError(c, errs.ErrArgs.WrapMsg("password must be 6 digits")) return } } } updateData["password"] = password } if req.Ex != "" { updateData["ex"] = req.Ex } if len(updateData) == 0 { apiresp.GinError(c, errs.ErrArgs.WrapMsg("no fields to update")) return } // 更新会议 if err := m.meetingDB.Update(c, req.MeetingID, updateData); err != nil { log.ZError(c, "UpdateMeeting: failed to update meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to update meeting")) return } // 重新查询会议信息 meeting, err = m.meetingDB.Take(c, req.MeetingID) if err != nil { log.ZError(c, "UpdateMeeting: failed to get updated meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get updated meeting")) return } // 加载主播信息 anchorUsers := m.loadAnchorUsers(c, meeting.AnchorUserIDs) // 转换为响应格式 resp.MeetingInfo = &apistruct.MeetingInfo{ MeetingID: meeting.MeetingID, Subject: meeting.Subject, CoverURL: meeting.CoverURL, ScheduledTime: meeting.ScheduledTime.UnixMilli(), Status: meeting.Status, CreatorUserID: meeting.CreatorUserID, Description: meeting.Description, Duration: meeting.Duration, EstimatedCount: meeting.EstimatedCount, EnableMic: meeting.EnableMic, EnableComment: meeting.EnableComment, AnchorUserIDs: meeting.AnchorUserIDs, AnchorUsers: anchorUsers, CreateTime: meeting.CreateTime.UnixMilli(), UpdateTime: meeting.UpdateTime.UnixMilli(), Ex: meeting.Ex, GroupID: meeting.GroupID, CheckInCount: meeting.CheckInCount, Password: meeting.Password, } log.ZInfo(c, "UpdateMeeting: success", "meetingID", req.MeetingID) apiresp.GinSuccess(c, resp) } // updateGroupAnchors 更新群的主播(群主和管理员) func (m *MeetingApi) updateGroupAnchors(ctx context.Context, groupID string, newAnchorUserIDs []string) error { // 获取当前群的所有成员 currentMemberUserIDs, err := m.groupClient.GetGroupMemberUserIDs(ctx, groupID) if err != nil { return errs.WrapMsg(err, "failed to get group members") } // 构建当前成员的映射 currentMemberMap := make(map[string]bool) for _, userID := range currentMemberUserIDs { currentMemberMap[userID] = true } // 找出不在群里的主播,先把他们拉进群 var usersToInvite []string for _, anchorID := range newAnchorUserIDs { if !currentMemberMap[anchorID] { usersToInvite = append(usersToInvite, anchorID) } } // 邀请不在群里的主播进群 if len(usersToInvite) > 0 { _, err := m.groupClient.GroupClient.InviteUserToGroup(ctx, &group.InviteUserToGroupReq{ GroupID: groupID, InvitedUserIDs: usersToInvite, }) if err != nil { return errs.WrapMsg(err, "failed to invite anchors to group") } // 为新加入的成员创建会话记录 if err := m.conversationClient.CreateGroupChatConversations(ctx, groupID, usersToInvite); err != nil { log.ZWarn(ctx, "updateGroupAnchors: failed to create conversations for new members", err, "groupID", groupID, "userIDs", usersToInvite) // 不阻断流程,继续执行 } } // 如果没有主播,不需要更新群主和管理员 if len(newAnchorUserIDs) == 0 { return nil } // 获取当前群主 groupInfo, err := m.groupClient.GetGroupInfo(ctx, groupID) if err != nil { return errs.WrapMsg(err, "failed to get group info") } currentOwnerUserID := groupInfo.OwnerUserID newOwnerUserID := newAnchorUserIDs[0] // 第一个主播为群主 var newAdminUserIDs []string if len(newAnchorUserIDs) > 1 { newAdminUserIDs = newAnchorUserIDs[1:] // 其他主播为管理员 } // 获取所有群成员信息(包括角色) allMemberUserIDs := append([]string{currentOwnerUserID}, newAnchorUserIDs...) allMemberUserIDs = datautil.Distinct(allMemberUserIDs) members, err := m.groupClient.GetGroupMembersInfo(ctx, groupID, allMemberUserIDs) if err != nil { return errs.WrapMsg(err, "failed to get group members info") } // 构建成员映射 memberMap := make(map[string]*sdkws.GroupMemberFullInfo) for _, member := range members { memberMap[member.UserID] = member } // 如果群主需要变更,先转移群主 if currentOwnerUserID != newOwnerUserID { // 确保新群主在群里 if _, exists := memberMap[newOwnerUserID]; !exists { return errs.ErrArgs.WrapMsg("new owner not in group") } // 获取当前群主的角色级别(用于转移后设置) currentOwnerMember := memberMap[currentOwnerUserID] var oldOwnerNewRoleLevel int32 = constant.GroupOrdinaryUsers if currentOwnerMember != nil { oldOwnerNewRoleLevel = currentOwnerMember.RoleLevel } // 如果当前群主不在新主播列表中,降级为普通成员 anchorMap := make(map[string]bool) for _, anchorID := range newAnchorUserIDs { anchorMap[anchorID] = true } if !anchorMap[currentOwnerUserID] { oldOwnerNewRoleLevel = constant.GroupOrdinaryUsers } else { // 如果当前群主在新主播列表中但不是第一个,降级为管理员 if currentOwnerUserID != newOwnerUserID { oldOwnerNewRoleLevel = constant.GroupAdmin } } // 转移群主 _, err := m.groupClient.GroupClient.TransferGroupOwner(ctx, &group.TransferGroupOwnerReq{ GroupID: groupID, OldOwnerUserID: currentOwnerUserID, NewOwnerUserID: newOwnerUserID, }) if err != nil { return errs.WrapMsg(err, "failed to transfer group owner") } // 如果旧群主需要降级,设置其角色 if oldOwnerNewRoleLevel != constant.GroupOwner { _, err := m.groupClient.GroupClient.SetGroupMemberInfo(ctx, &group.SetGroupMemberInfoReq{ Members: []*group.SetGroupMemberInfo{ { GroupID: groupID, UserID: currentOwnerUserID, RoleLevel: &wrapperspb.Int32Value{Value: oldOwnerNewRoleLevel}, }, }, }) if err != nil { log.ZWarn(ctx, "updateGroupAnchors: failed to set old owner role", err, "groupID", groupID, "userID", currentOwnerUserID, "roleLevel", oldOwnerNewRoleLevel) } } } // 设置其他主播为管理员 if len(newAdminUserIDs) > 0 { var membersToUpdate []*group.SetGroupMemberInfo for _, adminID := range newAdminUserIDs { // 检查该成员当前角色 member := memberMap[adminID] if member == nil || member.RoleLevel != constant.GroupAdmin { membersToUpdate = append(membersToUpdate, &group.SetGroupMemberInfo{ GroupID: groupID, UserID: adminID, RoleLevel: &wrapperspb.Int32Value{Value: constant.GroupAdmin}, }) } } if len(membersToUpdate) > 0 { _, err := m.groupClient.GroupClient.SetGroupMemberInfo(ctx, &group.SetGroupMemberInfoReq{ Members: membersToUpdate, }) if err != nil { log.ZWarn(ctx, "updateGroupAnchors: failed to set admins", err, "groupID", groupID, "adminUserIDs", newAdminUserIDs) } } } // 如果原来的管理员不在新主播列表中,降级为普通成员 // 获取所有当前管理员 var currentAdminsToDemote []string for userID, member := range memberMap { if member.RoleLevel == constant.GroupAdmin { // 检查是否在新主播列表中 isInNewAnchors := false for _, anchorID := range newAnchorUserIDs { if anchorID == userID { isInNewAnchors = true break } } // 如果不在新主播列表中,需要降级 if !isInNewAnchors { currentAdminsToDemote = append(currentAdminsToDemote, userID) } } } // 降级不在新主播列表中的管理员 if len(currentAdminsToDemote) > 0 { var membersToDemote []*group.SetGroupMemberInfo for _, userID := range currentAdminsToDemote { membersToDemote = append(membersToDemote, &group.SetGroupMemberInfo{ GroupID: groupID, UserID: userID, RoleLevel: &wrapperspb.Int32Value{Value: constant.GroupOrdinaryUsers}, }) } _, err := m.groupClient.GroupClient.SetGroupMemberInfo(ctx, &group.SetGroupMemberInfoReq{ Members: membersToDemote, }) if err != nil { log.ZWarn(ctx, "updateGroupAnchors: failed to demote admins", err, "groupID", groupID, "userIDs", currentAdminsToDemote) } } return nil } // GetMeetings 获取会议列表 func (m *MeetingApi) GetMeetings(c *gin.Context) { var ( req apistruct.GetMeetingsReq resp apistruct.GetMeetingsResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 设置默认分页参数 if req.Pagination.PageNumber <= 0 { req.Pagination.PageNumber = 1 } if req.Pagination.ShowNumber <= 0 { req.Pagination.ShowNumber = 20 } // 创建分页对象 pagination := &meetingPaginationWrapper{ pageNumber: req.Pagination.PageNumber, showNumber: req.Pagination.ShowNumber, } var total int64 var meetings []*model.Meeting var err error // 根据查询条件查询会议 if req.CreatorUserID != "" { // 按创建者查询 total, meetings, err = m.meetingDB.FindByCreator(c, req.CreatorUserID, pagination) } else if req.Status > 0 { // 按状态查询 total, meetings, err = m.meetingDB.FindByStatus(c, req.Status, pagination) } else if req.Keyword != "" { // 按关键词搜索 total, meetings, err = m.meetingDB.Search(c, req.Keyword, pagination) } else if req.StartTime > 0 && req.EndTime > 0 { // 按时间范围查询 total, meetings, err = m.meetingDB.FindByScheduledTimeRange(c, req.StartTime, req.EndTime, pagination) } else { // 查询所有会议 total, meetings, err = m.meetingDB.FindAll(c, pagination) } if err != nil { log.ZError(c, "GetMeetings: failed to find meetings", err) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find meetings")) return } // 收集所有主播ID allAnchorUserIDs := make([]string, 0) for _, meeting := range meetings { allAnchorUserIDs = append(allAnchorUserIDs, meeting.AnchorUserIDs...) } // 去重 allAnchorUserIDs = datautil.Distinct(allAnchorUserIDs) // 批量获取主播用户信息 anchorUserMap := make(map[string]*sdkws.UserInfo) if len(allAnchorUserIDs) > 0 { anchorUsers, err := m.userClient.GetUsersInfo(c, allAnchorUserIDs) if err != nil { log.ZWarn(c, "GetMeetings: failed to get anchor users info", err, "anchorUserIDs", allAnchorUserIDs) // 不阻断流程,继续返回会议列表,只是没有主播信息 } else { for _, user := range anchorUsers { anchorUserMap[user.UserID] = user } } } // 转换为响应格式 meetingInfos := make([]*apistruct.MeetingInfo, 0, len(meetings)) for _, meeting := range meetings { // 获取该会议的主播信息 anchorUsers := make([]*sdkws.UserInfo, 0, len(meeting.AnchorUserIDs)) for _, anchorID := range meeting.AnchorUserIDs { if user, ok := anchorUserMap[anchorID]; ok { anchorUsers = append(anchorUsers, user) } } meetingInfos = append(meetingInfos, &apistruct.MeetingInfo{ MeetingID: meeting.MeetingID, Subject: meeting.Subject, CoverURL: meeting.CoverURL, ScheduledTime: meeting.ScheduledTime.UnixMilli(), Status: meeting.Status, CreatorUserID: meeting.CreatorUserID, Description: meeting.Description, Duration: meeting.Duration, EstimatedCount: meeting.EstimatedCount, EnableMic: meeting.EnableMic, EnableComment: meeting.EnableComment, AnchorUserIDs: meeting.AnchorUserIDs, AnchorUsers: anchorUsers, CreateTime: meeting.CreateTime.UnixMilli(), UpdateTime: meeting.UpdateTime.UnixMilli(), Ex: meeting.Ex, GroupID: meeting.GroupID, CheckInCount: meeting.CheckInCount, Password: meeting.Password, }) } resp.Total = total resp.Meetings = meetingInfos log.ZInfo(c, "GetMeetings: success", "total", total, "count", len(meetingInfos)) apiresp.GinSuccess(c, resp) } // DeleteMeeting 删除会议 func (m *MeetingApi) DeleteMeeting(c *gin.Context) { var ( req apistruct.DeleteMeetingReq resp apistruct.DeleteMeetingResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 获取当前用户ID opUserID := mcontext.GetOpUserID(c) // 查询会议是否存在 meeting, err := m.meetingDB.Take(c, req.MeetingID) if err != nil { if errs.ErrRecordNotFound.Is(err) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("meeting not found")) return } log.ZError(c, "DeleteMeeting: failed to get meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get meeting")) return } // 验证权限(只有创建者可以删除) if meeting.CreatorUserID != opUserID { apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("only creator can delete meeting")) return } // 如果有关联的群聊,先解散群聊 if meeting.GroupID != "" { err := m.groupClient.DismissGroup(c, meeting.GroupID, false) if err != nil { log.ZWarn(c, "DeleteMeeting: failed to dismiss group", err, "meetingID", req.MeetingID, "groupID", meeting.GroupID) // 不阻断流程,继续删除会议 } else { log.ZInfo(c, "DeleteMeeting: successfully dismissed group", "meetingID", req.MeetingID, "groupID", meeting.GroupID) } // 从webhook配置的attentionIds中移除会议群ID if m.systemConfigDB != nil { if err := webhook.UpdateAttentionIds(c, m.systemConfigDB, meeting.GroupID, false); err != nil { log.ZWarn(c, "DeleteMeeting: failed to remove groupID from webhook attentionIds", err, "meetingID", req.MeetingID, "groupID", meeting.GroupID) } } } // 删除会议 if err := m.meetingDB.Delete(c, req.MeetingID); err != nil { log.ZError(c, "DeleteMeeting: failed to delete meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to delete meeting")) return } log.ZInfo(c, "DeleteMeeting: success", "meetingID", req.MeetingID) apiresp.GinSuccess(c, resp) } // GetMeetingPublic 获取会议信息(用户端) func (m *MeetingApi) GetMeetingPublic(c *gin.Context) { var ( req apistruct.GetMeetingReq resp apistruct.GetMeetingResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 查询会议 meeting, err := m.meetingDB.Take(c, req.MeetingID) if err != nil { if m.IsNotFound(err) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("meeting not found")) return } log.ZError(c, "GetMeetingPublic: failed to get meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get meeting")) return } // 用户端只能查看已预约(1)和进行中(2)的会议 if meeting.Status != model.MeetingStatusScheduled && meeting.Status != model.MeetingStatusOngoing { apiresp.GinError(c, errs.ErrArgs.WrapMsg("meeting not available")) return } // 加载主播信息 anchorUsers := m.loadAnchorUsers(c, meeting.AnchorUserIDs) // 转换为用户端响应格式(过滤管理字段) resp.MeetingInfo = &apistruct.MeetingPublicInfo{ MeetingID: meeting.MeetingID, Subject: meeting.Subject, CoverURL: meeting.CoverURL, ScheduledTime: meeting.ScheduledTime.UnixMilli(), Status: meeting.Status, Description: meeting.Description, Duration: meeting.Duration, EnableMic: meeting.EnableMic, EnableComment: meeting.EnableComment, AnchorUsers: anchorUsers, GroupID: meeting.GroupID, CheckInCount: meeting.CheckInCount, Password: meeting.Password, } apiresp.GinSuccess(c, resp) } // GetMeetingsPublic 获取会议列表(用户端) func (m *MeetingApi) GetMeetingsPublic(c *gin.Context) { var ( req apistruct.GetMeetingsPublicReq resp apistruct.GetMeetingsPublicResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 设置默认分页参数 if req.Pagination.PageNumber <= 0 { req.Pagination.PageNumber = 1 } if req.Pagination.ShowNumber <= 0 { req.Pagination.ShowNumber = 20 } // 创建分页对象 pagination := &meetingPaginationWrapper{ pageNumber: req.Pagination.PageNumber, showNumber: req.Pagination.ShowNumber, } var total int64 var meetings []*model.Meeting var err error // 用户端只显示已预约(1)和进行中(2)的会议 // 如果指定了状态,验证是否为允许的状态 if req.Status > 0 { if req.Status != model.MeetingStatusScheduled && req.Status != model.MeetingStatusOngoing { apiresp.GinError(c, errs.ErrArgs.WrapMsg("user can only view scheduled (1) or ongoing (2) meetings")) return } // 按状态查询 total, meetings, err = m.meetingDB.FindByStatus(c, req.Status, pagination) } else { // 对于其他查询方式,先查询再过滤状态 var allMeetings []*model.Meeting if req.Keyword != "" { // 按关键词搜索 total, allMeetings, err = m.meetingDB.Search(c, req.Keyword, pagination) } else if req.StartTime > 0 && req.EndTime > 0 { // 按时间范围查询 total, allMeetings, err = m.meetingDB.FindByScheduledTimeRange(c, req.StartTime, req.EndTime, pagination) } else { // 查询所有会议 total, allMeetings, err = m.meetingDB.FindAll(c, pagination) } if err == nil { // 过滤只保留已预约和进行中的会议 meetings = make([]*model.Meeting, 0, len(allMeetings)) for _, meeting := range allMeetings { if meeting.Status == model.MeetingStatusScheduled || meeting.Status == model.MeetingStatusOngoing { meetings = append(meetings, meeting) } } total = int64(len(meetings)) } } if err != nil { log.ZError(c, "GetMeetingsPublic: failed to find meetings", err) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find meetings")) return } // 收集所有主播ID allAnchorUserIDs := make([]string, 0) for _, meeting := range meetings { allAnchorUserIDs = append(allAnchorUserIDs, meeting.AnchorUserIDs...) } // 去重 allAnchorUserIDs = datautil.Distinct(allAnchorUserIDs) // 批量获取主播用户信息 anchorUserMap := make(map[string]*sdkws.UserInfo) if len(allAnchorUserIDs) > 0 { anchorUsers, err := m.userClient.GetUsersInfo(c, allAnchorUserIDs) if err != nil { log.ZWarn(c, "GetMeetingsPublic: failed to get anchor users info", err, "anchorUserIDs", allAnchorUserIDs) // 不阻断流程,继续返回会议列表,只是没有主播信息 } else { for _, user := range anchorUsers { anchorUserMap[user.UserID] = user } } } // 转换为用户端响应格式(过滤管理字段) meetingInfos := make([]*apistruct.MeetingPublicInfo, 0, len(meetings)) for _, meeting := range meetings { // 获取该会议的主播信息 anchorUsers := make([]*sdkws.UserInfo, 0, len(meeting.AnchorUserIDs)) for _, anchorID := range meeting.AnchorUserIDs { if user, ok := anchorUserMap[anchorID]; ok { anchorUsers = append(anchorUsers, user) } } meetingInfos = append(meetingInfos, &apistruct.MeetingPublicInfo{ MeetingID: meeting.MeetingID, Subject: meeting.Subject, CoverURL: meeting.CoverURL, ScheduledTime: meeting.ScheduledTime.UnixMilli(), Status: meeting.Status, Description: meeting.Description, Duration: meeting.Duration, EnableMic: meeting.EnableMic, EnableComment: meeting.EnableComment, AnchorUsers: anchorUsers, GroupID: meeting.GroupID, CheckInCount: meeting.CheckInCount, Password: meeting.Password, }) } resp.Total = total resp.Meetings = meetingInfos log.ZInfo(c, "GetMeetingsPublic: success", "total", total, "count", len(meetingInfos)) apiresp.GinSuccess(c, resp) } // CheckInMeeting 会议签到 func (m *MeetingApi) CheckInMeeting(c *gin.Context) { var ( req apistruct.CheckInMeetingReq resp apistruct.CheckInMeetingResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 获取当前用户ID userID := mcontext.GetOpUserID(c) if userID == "" { apiresp.GinError(c, errs.ErrArgs.WrapMsg("user id not found")) return } // 验证会议是否存在 meeting, err := m.meetingDB.Take(c, req.MeetingID) if err != nil { if m.IsNotFound(err) { apiresp.GinError(c, errs.ErrArgs.WrapMsg("meeting not found")) return } log.ZError(c, "CheckInMeeting: failed to get meeting", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to get meeting")) return } // 验证会议状态(只能对已预约和进行中的会议签到) if meeting.Status != model.MeetingStatusScheduled && meeting.Status != model.MeetingStatusOngoing { apiresp.GinError(c, errs.ErrArgs.WrapMsg("can only check in for scheduled or ongoing meetings")) return } // 检查是否已经签到 existingCheckIn, err := m.meetingCheckInDB.FindByUserAndMeetingID(c, userID, req.MeetingID) if err == nil && existingCheckIn != nil { apiresp.GinError(c, errs.ErrArgs.WrapMsg("already checked in")) return } if err != nil && !m.IsNotFound(err) { log.ZError(c, "CheckInMeeting: failed to check existing check-in", err, "userID", userID, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to check existing check-in")) return } // 生成签到ID checkInID := idutil.GetMsgIDByMD5(userID + req.MeetingID + timeutil.GetCurrentTimeFormatted()) // 创建签到记录 checkIn := &model.MeetingCheckIn{ CheckInID: checkInID, MeetingID: req.MeetingID, UserID: userID, CheckInTime: time.Now(), } if err := m.meetingCheckInDB.Create(c, checkIn); err != nil { log.ZError(c, "CheckInMeeting: failed to create check-in", err, "meetingID", req.MeetingID, "userID", userID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to create check-in")) return } // 更新会议的签到统计 checkInCount, err := m.meetingCheckInDB.CountByMeetingID(c, req.MeetingID) if err != nil { log.ZWarn(c, "CheckInMeeting: failed to count check-ins", err, "meetingID", req.MeetingID) // 不阻断流程,继续返回成功 } else { if err := m.meetingDB.Update(c, req.MeetingID, map[string]any{"check_in_count": int32(checkInCount)}); err != nil { log.ZWarn(c, "CheckInMeeting: failed to update meeting check-in count", err, "meetingID", req.MeetingID) // 不阻断流程,继续返回成功 } } resp.CheckInID = checkInID resp.CheckInTime = checkIn.CheckInTime.UnixMilli() log.ZInfo(c, "CheckInMeeting: success", "meetingID", req.MeetingID, "userID", userID, "checkInID", checkInID) apiresp.GinSuccess(c, resp) } // GetMeetingCheckIns 获取会议签到列表 func (m *MeetingApi) GetMeetingCheckIns(c *gin.Context) { var ( req apistruct.GetMeetingCheckInsReq resp apistruct.GetMeetingCheckInsResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 设置默认分页参数 if req.Pagination.PageNumber <= 0 { req.Pagination.PageNumber = 1 } if req.Pagination.ShowNumber <= 0 { req.Pagination.ShowNumber = 20 } // 创建分页对象 pagination := &meetingPaginationWrapper{ pageNumber: req.Pagination.PageNumber, showNumber: req.Pagination.ShowNumber, } // 查询签到列表 total, checkIns, err := m.meetingCheckInDB.FindByMeetingID(c, req.MeetingID, pagination) if err != nil { log.ZError(c, "GetMeetingCheckIns: failed to find check-ins", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find check-ins")) return } // 收集所有用户ID userIDs := make([]string, 0, len(checkIns)) for _, checkIn := range checkIns { userIDs = append(userIDs, checkIn.UserID) } // 批量获取用户信息 userMap := make(map[string]*sdkws.UserInfo) if len(userIDs) > 0 { users, err := m.userClient.GetUsersInfo(c, userIDs) if err != nil { log.ZWarn(c, "GetMeetingCheckIns: failed to get users info", err, "userIDs", userIDs) // 不阻断流程,继续返回签到列表,只是没有用户信息 } else { for _, user := range users { userMap[user.UserID] = user } } } // 转换为响应格式 checkInInfos := make([]*apistruct.MeetingCheckInInfo, 0, len(checkIns)) for _, checkIn := range checkIns { checkInInfo := &apistruct.MeetingCheckInInfo{ CheckInID: checkIn.CheckInID, MeetingID: checkIn.MeetingID, UserID: checkIn.UserID, CheckInTime: checkIn.CheckInTime.UnixMilli(), } if user, ok := userMap[checkIn.UserID]; ok { checkInInfo.UserInfo = user } checkInInfos = append(checkInInfos, checkInInfo) } resp.Total = total resp.CheckIns = checkInInfos log.ZInfo(c, "GetMeetingCheckIns: success", "meetingID", req.MeetingID, "total", total, "count", len(checkInInfos)) apiresp.GinSuccess(c, resp) } // GetMeetingCheckInStats 获取会议签到统计 func (m *MeetingApi) GetMeetingCheckInStats(c *gin.Context) { var ( req apistruct.GetMeetingCheckInStatsReq resp apistruct.GetMeetingCheckInStatsResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 查询签到统计 checkInCount, err := m.meetingCheckInDB.CountByMeetingID(c, req.MeetingID) if err != nil { log.ZError(c, "GetMeetingCheckInStats: failed to count check-ins", err, "meetingID", req.MeetingID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to count check-ins")) return } resp.MeetingID = req.MeetingID resp.CheckInCount = checkInCount log.ZInfo(c, "GetMeetingCheckInStats: success", "meetingID", req.MeetingID, "checkInCount", checkInCount) apiresp.GinSuccess(c, resp) } // CheckUserCheckIn 检查用户是否已签到 func (m *MeetingApi) CheckUserCheckIn(c *gin.Context) { var ( req apistruct.CheckUserCheckInReq resp apistruct.CheckUserCheckInResp ) if err := c.BindJSON(&req); err != nil { apiresp.GinError(c, errs.ErrArgs.WithDetail(err.Error()).Wrap()) return } // 获取当前用户ID userID := mcontext.GetOpUserID(c) if userID == "" { apiresp.GinError(c, errs.ErrArgs.WrapMsg("user id not found")) return } // 查询用户是否已签到 checkIn, err := m.meetingCheckInDB.FindByUserAndMeetingID(c, userID, req.MeetingID) if err != nil { if m.IsNotFound(err) { // 未签到 resp.IsCheckedIn = false log.ZInfo(c, "CheckUserCheckIn: user not checked in", "meetingID", req.MeetingID, "userID", userID) apiresp.GinSuccess(c, resp) return } log.ZError(c, "CheckUserCheckIn: failed to find check-in", err, "meetingID", req.MeetingID, "userID", userID) apiresp.GinError(c, errs.ErrInternalServer.WrapMsg("failed to find check-in")) return } // 已签到,获取用户信息 var userInfo *sdkws.UserInfo users, err := m.userClient.GetUsersInfo(c, []string{userID}) if err != nil { log.ZWarn(c, "CheckUserCheckIn: failed to get user info", err, "userID", userID) // 不阻断流程,继续返回签到信息,只是没有用户详细信息 } else if len(users) > 0 { userInfo = users[0] } resp.IsCheckedIn = true resp.CheckInInfo = &apistruct.MeetingCheckInInfo{ CheckInID: checkIn.CheckInID, MeetingID: checkIn.MeetingID, UserID: checkIn.UserID, CheckInTime: checkIn.CheckInTime.UnixMilli(), UserInfo: userInfo, } log.ZInfo(c, "CheckUserCheckIn: success", "meetingID", req.MeetingID, "userID", userID, "isCheckedIn", true) apiresp.GinSuccess(c, resp) }