签入签出
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -47,6 +49,8 @@ var (
|
||||
// ErrMediaServiceUnavailable 媒体服务当前不可用(Router 创建失败 / Node 宕机等)
|
||||
// 用于 CreateRoom / JoinRoom 的补偿路径,将前台错误与"用户输入错误"区分开
|
||||
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,6 +366,7 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.
|
||||
}
|
||||
}()
|
||||
|
||||
if enforceSingleActive {
|
||||
active, err := s.participantDAO.FindActiveByUser(ctx, hostID)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
@@ -271,6 +375,7 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.
|
||||
err = ErrAlreadyInOtherMeeting
|
||||
return nil, nil, "", err
|
||||
}
|
||||
}
|
||||
|
||||
code, err := s.generateUniqueRoomCode(ctx)
|
||||
if err != nil {
|
||||
@@ -308,10 +413,13 @@ 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)
|
||||
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 起仅在会议首次创建时调一次(房间级资源)
|
||||
// P0-3 修复(审计报告):
|
||||
@@ -323,10 +431,12 @@ 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 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 失败(已记录,清理任务会兜底)",
|
||||
zap.Int64("room_id", room.ID), zap.Error(markErr))
|
||||
|
||||
Reference in New Issue
Block a user