签入签出

This commit is contained in:
duoaohui
2026-05-22 01:02:34 +08:00
parent c9765c0f80
commit 19aac86f48
2 changed files with 147 additions and 17 deletions

View File

@@ -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

View File

@@ -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_roomsstatus=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 对接真实 mediasoupTask 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 失败(已记录,清理任务会兜底)",