diff --git a/backend/go-service/app/meeting/controller/meeting_controller.go b/backend/go-service/app/meeting/controller/meeting_controller.go index 71db87e..f83120b 100644 --- a/backend/go-service/app/meeting/controller/meeting_controller.go +++ b/backend/go-service/app/meeting/controller/meeting_controller.go @@ -4,6 +4,7 @@ package controller import ( "errors" "strconv" + "strings" "github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/dto" @@ -32,6 +33,8 @@ func NewMeetingController(meetingService *service.MeetingService, meetingConfig type cloudLawCreateMeetingRequest struct { CallID string `json:"callId"` CallerID int64 `json:"callerId"` + CallerPhone string `json:"callerPhone"` + CallerName string `json:"callerName"` ReceiverID int64 `json:"receiverId"` ReceiverType string `json:"receiverType"` Title string `json:"title"` @@ -103,6 +106,8 @@ func (ctl *MeetingController) handleError(c *gin.Context, err error, fallbackMsg errors.Is(err, service.ErrKickSelfForbidden), errors.Is(err, service.ErrTransferToSelf), errors.Is(err, service.ErrTransferTargetInvalid), + errors.Is(err, service.ErrCloudLawCallerPhoneRequired), + errors.Is(err, service.ErrCloudLawCallerPhoneInvalid), // P2-7 会议聊天服务端校验失败:内容为空 / 超长 / 触发限流 // 当前使用 400(由 ResponseBadRequest 返回),保持与其余业务错误一致; // 未来若需要精细区分(如 ErrChatRateLimited → 429、ErrChatContentTooLong → 413),可拆分分支 @@ -213,11 +218,22 @@ func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) { utils.ResponseBadRequest(c, "callerId不能为空") return } + req.CallerPhone = strings.TrimSpace(req.CallerPhone) + req.CallerName = strings.TrimSpace(req.CallerName) + if req.CallerPhone == "" { + utils.ResponseBadRequest(c, "callerPhone不能为空") + return + } title := req.Title if title == "" { title = "云律立即连线" } - room, _, _, err := ctl.meetingService.CreateRoom(c.Request.Context(), req.CallerID, &dto.CreateMeetingRoomRequest{ + hostUser, created, err := ctl.meetingService.EnsureCloudLawUserByPhone(c.Request.Context(), req.CallerPhone, req.CallerName) + if err != nil { + ctl.handleError(c, err, "创建云律会议主持人失败") + return + } + room, _, _, err := ctl.meetingService.CreateRoomForInternal(c.Request.Context(), hostUser.ID, &dto.CreateMeetingRoomRequest{ Title: title, MaxMembers: 2, }) @@ -225,8 +241,12 @@ func (ctl *MeetingController) CloudLawCreateMeeting(c *gin.Context) { ctl.handleError(c, err, "创建云律会议失败") return } - payload := cloudLawMeetingPayload(room, 1) + payload := cloudLawMeetingPayload(room, 0) payload["callId"] = req.CallID + payload["callerId"] = req.CallerID + payload["callerPhone"] = req.CallerPhone + payload["echoChatHostUserId"] = strconv.FormatInt(hostUser.ID, 10) + payload["echoChatHostUserCreated"] = created payload["receiverId"] = req.ReceiverID payload["receiverType"] = req.ReceiverType payload["callType"] = req.CallType diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go index 1293c33..99dcee0 100644 --- a/backend/go-service/app/meeting/service/meeting_service.go +++ b/backend/go-service/app/meeting/service/meeting_service.go @@ -11,6 +11,7 @@ import ( "time" "unicode/utf8" + 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/meeting/dao" @@ -18,6 +19,7 @@ import ( notifyService "github.com/echochat/backend/app/notify/service" "github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/utils" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" @@ -46,7 +48,9 @@ var ( ErrResourceNotOwned = errors.New("媒体资源归属校验失败,禁止操作他人资源") // ErrMediaServiceUnavailable 媒体服务当前不可用(Router 创建失败 / Node 宕机等) // 用于 CreateRoom / JoinRoom 的补偿路径,将前台错误与"用户输入错误"区分开 - ErrMediaServiceUnavailable = errors.New("媒体服务暂时不可用,请稍后重试") + ErrMediaServiceUnavailable = errors.New("媒体服务暂时不可用,请稍后重试") + ErrCloudLawCallerPhoneRequired = errors.New("发起人手机号不能为空") + ErrCloudLawCallerPhoneInvalid = errors.New("发起人手机号长度超过限制") // Task 16 P2-7:会议聊天服务端限流 ErrChatContentEmpty = errors.New("消息内容不能为空") ErrChatContentTooLong = errors.New("消息长度超过上限") @@ -249,13 +253,112 @@ func (s *MeetingService) ResolveUsersDisplay(ctx context.Context, userIDs []int6 return out } +func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone string, nickname string) (*authModel.User, bool, error) { + funcName := "service.meeting_service.EnsureCloudLawUserByPhone" + phone = strings.TrimSpace(phone) + nickname = strings.TrimSpace(nickname) + if phone == "" { + return nil, false, ErrCloudLawCallerPhoneRequired + } + if utf8.RuneCountInString(phone) > 20 { + return nil, false, ErrCloudLawCallerPhoneInvalid + } + + var existing authModel.User + err := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&existing).Error + if err == nil { + return &existing, false, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, err + } + + username := cloudLawUsername(phone) + passwordHash, err := utils.HashPassword(uuid.New().String()) + if err != nil { + return nil, false, err + } + user := &authModel.User{ + Username: username, + Email: username + "@cloud-law.local", + PasswordHash: passwordHash, + Nickname: cloudLawNickname(nickname, phone), + Phone: &phone, + Status: constants.UserStatusActive, + } + if err = s.db.WithContext(ctx).Create(user).Error; err != nil { + var retry authModel.User + if retryErr := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&retry).Error; retryErr == nil { + return &retry, false, nil + } + return nil, false, err + } + + s.assignCloudLawDefaultRole(ctx, user.ID) + logs.Info(ctx, funcName, "创建云律 EchoChat 用户成功", + zap.Int64("user_id", user.ID), + zap.String("phone", logs.MaskPhone(phone)), + ) + return user, true, nil +} + +func (s *MeetingService) assignCloudLawDefaultRole(ctx context.Context, userID int64) { + funcName := "service.meeting_service.assignCloudLawDefaultRole" + var role authModel.Role + if err := s.db.WithContext(ctx).Where("code = ?", constants.RoleUser).First(&role).Error; err != nil { + logs.Warn(ctx, funcName, "查找默认角色失败,跳过角色分配", zap.Int64("user_id", userID), zap.Error(err)) + return + } + if err := s.db.WithContext(ctx).Create(&authModel.UserRole{UserID: userID, RoleID: role.ID}).Error; err != nil { + logs.Warn(ctx, funcName, "分配默认角色失败", zap.Int64("user_id", userID), zap.Error(err)) + } +} + +func cloudLawUsername(phone string) string { + accountKey := strings.NewReplacer("+", "", "-", "", " ", "", "(", "", ")").Replace(phone) + accountKey = strings.TrimSpace(accountKey) + if accountKey == "" { + accountKey = strings.ReplaceAll(uuid.New().String(), "-", "")[:12] + } + return "cloudlaw_" + accountKey +} + +func cloudLawNickname(nickname string, phone string) string { + if nickname == "" { + nickname = "云律用户" + cloudLawPhoneSuffix(phone) + } + return truncateCloudLawText(nickname, 50) +} + +func cloudLawPhoneSuffix(phone string) string { + runes := []rune(phone) + if len(runes) <= 4 { + return phone + } + return string(runes[len(runes)-4:]) +} + +func truncateCloudLawText(value string, limit int) string { + if limit <= 0 || utf8.RuneCountInString(value) <= limit { + return value + } + runes := []rune(value) + return string(runes[:limit]) +} + // ====== 会议生命周期 ====== // CreateRoom 创建即时会议 // 流程:生成唯一会议号 → bcrypt 密码 → 写入 meeting_rooms(status=Active + started_at=now)→ 主持人落 participant 表 → 驱动 mediasoup Router 创建 func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest) (*model.MeetingRoom, *model.MeetingParticipant, string, error) { - funcName := "service.meeting_service.CreateRoom" + return s.createRoom(ctx, hostID, req, true, true, "service.meeting_service.CreateRoom") +} +func (s *MeetingService) CreateRoomForInternal(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest) (*model.MeetingRoom, *model.MeetingParticipant, string, error) { + return s.createRoom(ctx, hostID, req, false, false, "service.meeting_service.CreateRoomForInternal") +} + +func (s *MeetingService) createRoom(ctx context.Context, hostID int64, req *dto.CreateMeetingRoomRequest, enforceSingleActive bool, addHostParticipant bool, funcName string) (*model.MeetingRoom, *model.MeetingParticipant, string, error) { var err error defer func() { if err != nil { @@ -263,13 +366,15 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto. } }() - active, err := s.participantDAO.FindActiveByUser(ctx, hostID) - if err != nil { - return nil, nil, "", err - } - if active != nil { - err = ErrAlreadyInOtherMeeting - return nil, nil, "", err + if enforceSingleActive { + active, err := s.participantDAO.FindActiveByUser(ctx, hostID) + if err != nil { + return nil, nil, "", err + } + if active != nil { + err = ErrAlreadyInOtherMeeting + return nil, nil, "", err + } } code, err := s.generateUniqueRoomCode(ctx) @@ -308,9 +413,12 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto. return nil, nil, "", err } - participant, err := s.participantDAO.JoinRoom(ctx, room.ID, hostID, constants.MeetingRoleHost) - if err != nil { - return nil, nil, "", err + var participant *model.MeetingParticipant + if addHostParticipant { + participant, err = s.participantDAO.JoinRoom(ctx, room.ID, hostID, constants.MeetingRoleHost) + if err != nil { + return nil, nil, "", err + } } // Task 7 起 CreateRouter 对接真实 mediasoup;Task 8 起仅在会议首次创建时调一次(房间级资源) @@ -323,9 +431,11 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto. logs.Warn(ctx, funcName, "mediasoup Router 创建失败,执行补偿回滚", zap.String("room_code", code), zap.Int64("host_id", hostID), zap.Error(mediaErr)) - if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, hostID, constants.MeetingLeftReasonSelf); leaveErr != nil { - logs.Warn(ctx, funcName, "补偿阶段 LeaveRoom 失败(已记录,清理任务会兜底)", - zap.Int64("room_id", room.ID), zap.Int64("host_id", hostID), zap.Error(leaveErr)) + if participant != nil { + if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, hostID, constants.MeetingLeftReasonSelf); leaveErr != nil { + logs.Warn(ctx, funcName, "补偿阶段 LeaveRoom 失败(已记录,清理任务会兜底)", + zap.Int64("room_id", room.ID), zap.Int64("host_id", hostID), zap.Error(leaveErr)) + } } if _, markErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonSystemError, time.Now()); markErr != nil { logs.Warn(ctx, funcName, "补偿阶段 MarkEnded 失败(已记录,清理任务会兜底)",