2026-04-13 23:14:18 +08:00

748 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © 2024 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 rtc
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/livekit/protocol/auth"
livekit "github.com/livekit/protocol/livekit"
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
"github.com/openimsdk/protocol/constant"
pbmsg "github.com/openimsdk/protocol/msg"
"github.com/openimsdk/protocol/rtc"
"github.com/openimsdk/protocol/sdkws"
"github.com/openimsdk/tools/errs"
"github.com/openimsdk/tools/log"
"github.com/openimsdk/tools/mcontext"
"github.com/openimsdk/tools/utils/datautil"
"go.mongodb.org/mongo-driver/mongo"
"google.golang.org/protobuf/proto"
)
// SignalMessageAssemble processes a signal request from the WebSocket gateway
// and assembles the appropriate signal response, sending notifications to peers.
func (s *rtcServer) SignalMessageAssemble(ctx context.Context, req *rtc.SignalMessageAssembleReq) (*rtc.SignalMessageAssembleResp, error) {
if req.SignalReq == nil {
return nil, errs.ErrArgs.WrapMsg("signalReq is nil")
}
var (
resp rtc.SignalResp
respErr error
)
switch payload := req.SignalReq.Payload.(type) {
case *rtc.SignalReq_Invite:
log.ZInfo(ctx, "SignalMessageAssemble", "payload", payload.Invite)
r, err := s.handleInvite(ctx, payload.Invite, req.SignalReq)
resp.Payload = &rtc.SignalResp_Invite{Invite: r}
respErr = err
case *rtc.SignalReq_InviteInGroup:
r, err := s.handleInviteInGroup(ctx, payload.InviteInGroup, req.SignalReq)
resp.Payload = &rtc.SignalResp_InviteInGroup{InviteInGroup: r}
respErr = err
case *rtc.SignalReq_Cancel:
r, err := s.handleCancel(ctx, payload.Cancel, req.SignalReq)
resp.Payload = &rtc.SignalResp_Cancel{Cancel: r}
respErr = err
case *rtc.SignalReq_Accept:
r, err := s.handleAccept(ctx, payload.Accept, req.SignalReq)
resp.Payload = &rtc.SignalResp_Accept{Accept: r}
respErr = err
case *rtc.SignalReq_HungUp:
r, err := s.handleHungUp(ctx, payload.HungUp, req.SignalReq)
resp.Payload = &rtc.SignalResp_HungUp{HungUp: r}
respErr = err
case *rtc.SignalReq_Reject:
r, err := s.handleReject(ctx, payload.Reject, req.SignalReq)
resp.Payload = &rtc.SignalResp_Reject{Reject: r}
respErr = err
case *rtc.SignalReq_GetTokenByRoomID:
r, err := s.handleGetTokenByRoomID(ctx, payload.GetTokenByRoomID)
resp.Payload = &rtc.SignalResp_GetTokenByRoomID{GetTokenByRoomID: r}
respErr = err
default:
// Fix P0: 原代码在此调用 respErr.Error(),而 respErr 为 nil会直接 panic
return nil, errs.ErrArgs.WrapMsg("unknown signal payload type")
}
if respErr != nil {
log.ZError(ctx, "SignalMessageAssemble", respErr, "err", respErr.Error())
return nil, respErr
}
return &rtc.SignalMessageAssembleResp{SignalResp: &resp}, nil
}
// handleInvite processes a 1-to-1 call invitation.
func (s *rtcServer) handleInvite(ctx context.Context, req *rtc.SignalInviteReq, signalReq *rtc.SignalReq) (*rtc.SignalInviteResp, error) {
inv := req.Invitation
if inv == nil {
log.ZError(ctx, "handleInvite", errs.ErrArgs, "r", "invitation is nil")
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
// Fix P3: RoomID 统一由服务端生成,忽略客户端传入的值(客户端不应决定 RoomID
inv.RoomID = newRoomID()
inv.InviterUserID = req.UserID
inv.InitiateTime = time.Now().UnixMilli()
// 校验每位被邀请者的通话接受权限1-to-1 场景:有任一被邀请者拒绝则直接返错
for _, inviteeID := range inv.InviteeUserIDList {
allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID)
if err != nil {
log.ZError(ctx, "handleInvite: isCallAllowed failed", err, "inviteeID", inviteeID)
return nil, err
}
if !allowed {
return nil, errs.ErrNoPermission.WrapMsg("the invitee does not accept calls from you", "inviteeID", inviteeID)
}
}
if _, err := s.roomClient.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: inv.RoomID}); err != nil {
log.ZError(ctx, "handleInvite", err, "r", err.Error())
return nil, errs.WrapMsg(err, "LiveKit CreateRoom failed", "roomID", inv.RoomID)
}
token, err := s.genToken(inv.RoomID, req.UserID)
if err != nil {
// LiveKit Room 已创建,需要回滚
if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil {
log.ZWarn(ctx, "handleInvite: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID)
}
return nil, err
}
// Fix P1/幂等: CreateInvitation 失败分两种情况:
// - 重复 key相同 roomID 重试)→ 认为幂等成功,直接返回
// - 其他错误 → 回滚 LiveKit Room返回错误
// Fix P0: 原代码对失败仅打 warn导致 DB 无记录、Room 泄漏、后续流程断裂
if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil {
if mongo.IsDuplicateKeyError(err) {
log.ZWarn(ctx, "handleInvite: duplicate invitation (idempotent retry)", err, "roomID", inv.RoomID)
} else {
if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil {
log.ZWarn(ctx, "handleInvite: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID)
}
return nil, errs.WrapMsg(err, "CreateInvitation failed", "roomID", inv.RoomID)
}
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
// Fix P1: 1v1 场景下,通知失败应返回错误(被叫收不到来电意味着主叫白等)
for _, inviteeID := range inv.InviteeUserIDList {
log.ZInfo(ctx, "sendSignalingNotification to invitee", "sendID", req.UserID, "recvID", inviteeID)
if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, int32(constant.SingleChatType), req.OfflinePushInfo, content); err != nil {
log.ZError(ctx, "sendSignalingNotification to invitee failed", err, "inviteeID", inviteeID)
return nil, errs.WrapMsg(err, "failed to notify invitee", "inviteeID", inviteeID)
}
}
log.ZDebug(ctx, "handleInvite", "token", token, "roomID", inv.RoomID, "liveURL", s.config.RpcConfig.LiveKit.ExternalAddress)
return &rtc.SignalInviteResp{
Token: token,
RoomID: inv.RoomID,
LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress,
}, nil
}
// handleInviteInGroup processes a group call invitation.
func (s *rtcServer) handleInviteInGroup(ctx context.Context, req *rtc.SignalInviteInGroupReq, signalReq *rtc.SignalReq) (*rtc.SignalInviteInGroupResp, error) {
inv := req.Invitation
if inv == nil {
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
// Fix P3: RoomID 统一由服务端生成
inv.RoomID = newRoomID()
inv.InviterUserID = req.UserID
inv.InitiateTime = time.Now().UnixMilli()
if _, err := s.roomClient.CreateRoom(ctx, &livekit.CreateRoomRequest{Name: inv.RoomID}); err != nil {
return nil, errs.WrapMsg(err, "LiveKit CreateRoom failed", "roomID", inv.RoomID)
}
token, err := s.genToken(inv.RoomID, req.UserID)
if err != nil {
if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil {
log.ZWarn(ctx, "handleInviteInGroup: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID)
}
return nil, err
}
// Fix P0: CreateInvitation 失败需要回滚 LiveKit Room
if err := s.db.CreateInvitation(ctx, invitationToModel(inv, req.OfflinePushInfo)); err != nil {
if !mongo.IsDuplicateKeyError(err) {
if _, delErr := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: inv.RoomID}); delErr != nil {
log.ZWarn(ctx, "handleInviteInGroup: rollback DeleteRoom failed", delErr, "roomID", inv.RoomID)
}
return nil, errs.WrapMsg(err, "CreateInvitation failed", "roomID", inv.RoomID)
}
log.ZWarn(ctx, "handleInviteInGroup: duplicate invitation (idempotent retry)", err, "roomID", inv.RoomID)
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
for _, inviteeID := range inv.InviteeUserIDList {
allowed, err := s.isCallAllowed(ctx, req.UserID, inviteeID)
if err != nil {
log.ZWarn(ctx, "handleInviteInGroup: isCallAllowed failed, skipping invitee", err, "inviteeID", inviteeID)
continue
}
if !allowed {
log.ZInfo(ctx, "handleInviteInGroup: skipping invitee (call setting blocked)", "inviteeID", inviteeID)
continue
}
if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, int32(constant.ReadGroupChatType), req.OfflinePushInfo, content); err != nil {
log.ZWarn(ctx, "sendSignalingNotification to group invitee failed", err, "inviteeID", inviteeID)
}
}
return &rtc.SignalInviteInGroupResp{
Token: token,
RoomID: inv.RoomID,
LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress,
}, nil
}
// isCallAllowed 判断 inviterID 是否被允许向 inviteeID 发起音视频通话。
// 规则:
// - CallAcceptSettingPublic(0) → 所有人均可
// - CallAcceptSettingFriends(1) → 仅当 inviterID 在 inviteeID 好友列表中
// - CallAcceptSettingNobody(2) → 任何人均不可
func (s *rtcServer) isCallAllowed(ctx context.Context, inviterID, inviteeID string) (bool, error) {
userInfo, err := s.userClient.GetUserInfo(ctx, inviteeID)
if err != nil {
return false, err
}
switch userInfo.CallAcceptSetting {
case model.CallAcceptSettingNobody:
return false, nil
case model.CallAcceptSettingFriends:
isFriend, err := s.relationClient.IsFriend(ctx, inviteeID, inviterID)
if err != nil {
return false, err
}
return isFriend, nil
default: // CallAcceptSettingPublic
return true, nil
}
}
// handleAccept processes a call acceptance.
// Fix P1(安全): 原代码完全信任客户端传入的 Invitation未从 DB 校验邀请真实存在。
// 攻击者可伪造任意 RoomID/InviterUserID 来获取 LiveKit Token 并加入房间。
func (s *rtcServer) handleAccept(ctx context.Context, req *rtc.SignalAcceptReq, signalReq *rtc.SignalReq) (*rtc.SignalAcceptResp, error) {
if req.Invitation == nil {
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
// 从 DB 获取权威邀请数据,验证邀请存在且 userID 在被邀请人列表中
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID)
}
if !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) {
return nil, errs.ErrNoPermission.WrapMsg("user not in invitee list", "userID", req.UserID)
}
token, err := s.genToken(dbInv.RoomID, req.UserID)
if err != nil {
return nil, err
}
sessionType := int32(constant.SingleChatType)
if dbInv.GroupID != "" {
sessionType = int32(constant.ReadGroupChatType)
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
// 使用 DB 中的 InviterUserID防止客户端伪造通知目标
if err := s.sendSignalingNotification(ctx, req.UserID, dbInv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil {
log.ZWarn(ctx, "sendSignalingNotification accept to inviter failed", err, "inviterID", dbInv.InviterUserID)
}
// Fix P2: 1v1 通话接受后删除邀请记录,避免冷启动时重复弹出已接通的来电
// TODO: 群通话可通过 RemoveInvitee 实现精细化状态管理
if dbInv.GroupID == "" {
if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil {
log.ZWarn(ctx, "handleAccept: DeleteInvitation failed (non-fatal)", err, "roomID", dbInv.RoomID)
}
}
return &rtc.SignalAcceptResp{
Token: token,
RoomID: dbInv.RoomID,
LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress,
}, nil
}
// handleReject processes a call rejection.
// Fix P1(安全): 从 DB 验证邀请存在,并使用 DB 中的 InviterUserID防止客户端伪造通知目标。
func (s *rtcServer) handleReject(ctx context.Context, req *rtc.SignalRejectReq, signalReq *rtc.SignalReq) (*rtc.SignalRejectResp, error) {
if req.Invitation == nil {
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID)
}
if !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) {
return nil, errs.ErrNoPermission.WrapMsg("user not in invitee list", "userID", req.UserID)
}
sessionType := int32(constant.SingleChatType)
if dbInv.GroupID != "" {
sessionType = int32(constant.ReadGroupChatType)
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
if err := s.sendSignalingNotification(ctx, req.UserID, dbInv.InviterUserID, sessionType, req.OfflinePushInfo, content); err != nil {
log.ZWarn(ctx, "sendSignalingNotification reject to inviter failed", err, "inviterID", dbInv.InviterUserID)
}
if dbInv.GroupID != "" {
if err := s.db.RemoveInvitee(ctx, dbInv.RoomID, req.UserID); err != nil {
log.ZWarn(ctx, "RemoveInvitee failed", err, "roomID", dbInv.RoomID, "userID", req.UserID)
}
} else {
if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil {
log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID)
}
}
return &rtc.SignalRejectResp{}, nil
}
// handleCancel processes a call cancellation.
// Fix P1(安全): 从 DB 验证操作者是邀请发起方,防止被叫方冒充取消通话。
func (s *rtcServer) handleCancel(ctx context.Context, req *rtc.SignalCancelReq, signalReq *rtc.SignalReq) (*rtc.SignalCancelResp, error) {
if req.Invitation == nil {
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID)
}
if req.UserID != dbInv.InviterUserID {
return nil, errs.ErrNoPermission.WrapMsg("only the inviter can cancel", "userID", req.UserID, "inviterUserID", dbInv.InviterUserID)
}
sessionType := int32(constant.SingleChatType)
if dbInv.GroupID != "" {
sessionType = int32(constant.ReadGroupChatType)
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
for _, inviteeID := range dbInv.InviteeUserIDList {
if err := s.sendSignalingNotification(ctx, req.UserID, inviteeID, sessionType, req.OfflinePushInfo, content); err != nil {
log.ZWarn(ctx, "sendSignalingNotification cancel to invitee failed", err, "inviteeID", inviteeID)
}
}
if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil {
log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID)
}
return &rtc.SignalCancelResp{}, nil
}
// handleHungUp processes a call hang-up.
// Fix P1(安全): 从 DB 验证操作者是通话参与者,防止任意用户挂断他人通话并删除 LiveKit Room。
func (s *rtcServer) handleHungUp(ctx context.Context, req *rtc.SignalHungUpReq, signalReq *rtc.SignalReq) (*rtc.SignalHungUpResp, error) {
if req.Invitation == nil {
return nil, errs.ErrArgs.WrapMsg("invitation is nil")
}
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.Invitation.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "invitation not found or expired", "roomID", req.Invitation.RoomID)
}
if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) {
return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this call", "userID", req.UserID)
}
sessionType := int32(constant.SingleChatType)
if dbInv.GroupID != "" {
sessionType = int32(constant.ReadGroupChatType)
}
content, err := marshalSignalReq(signalReq)
if err != nil {
return nil, err
}
// 使用 DB 中的参与者列表,不信任客户端传入的 InviteeUserIDList
for _, peerID := range hungUpPeerIDsFromDB(dbInv, req.UserID) {
if err := s.sendSignalingNotification(ctx, req.UserID, peerID, sessionType, req.OfflinePushInfo, content); err != nil {
log.ZWarn(ctx, "sendSignalingNotification hungUp to peer failed", err, "peerID", peerID)
}
}
// Terminate the LiveKit room
if _, err := s.roomClient.DeleteRoom(ctx, &livekit.DeleteRoomRequest{Room: dbInv.RoomID}); err != nil {
log.ZWarn(ctx, "LiveKit DeleteRoom failed", err, "roomID", dbInv.RoomID)
}
if err := s.db.DeleteInvitation(ctx, dbInv.RoomID); err != nil {
log.ZWarn(ctx, "DeleteInvitation failed", err, "roomID", dbInv.RoomID)
}
return &rtc.SignalHungUpResp{}, nil
}
// handleGetTokenByRoomID returns a LiveKit token for an existing room.
// Fix P0(安全): 原代码无权限校验,任意已登录用户可获取任意房间的 Token可窃听他人通话。
func (s *rtcServer) handleGetTokenByRoomID(ctx context.Context, req *rtc.SignalGetTokenByRoomIDReq) (*rtc.SignalGetTokenByRoomIDResp, error) {
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "room not found or expired", "roomID", req.RoomID)
}
if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) {
return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this room", "userID", req.UserID)
}
token, err := s.genToken(req.RoomID, req.UserID)
if err != nil {
return nil, err
}
return &rtc.SignalGetTokenByRoomIDResp{
Token: token,
LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress,
}, nil
}
// SignalGetRoomByGroupID returns room information for a group.
func (s *rtcServer) SignalGetRoomByGroupID(ctx context.Context, req *rtc.SignalGetRoomByGroupIDReq) (*rtc.SignalGetRoomByGroupIDResp, error) {
inv, err := s.db.GetInvitationByGroupID(ctx, req.GroupID)
if err != nil {
return nil, err
}
return &rtc.SignalGetRoomByGroupIDResp{
Invitation: modelToInvitationInfo(inv),
RoomID: inv.RoomID,
}, nil
}
// SignalGetTokenByRoomID returns a token for joining a room directly (HTTP API path).
// Fix P0(安全): 同 handleGetTokenByRoomID添加参与者身份校验。
func (s *rtcServer) SignalGetTokenByRoomID(ctx context.Context, req *rtc.SignalGetTokenByRoomIDReq) (*rtc.SignalGetTokenByRoomIDResp, error) {
dbInv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID)
if err != nil {
return nil, errs.WrapMsg(err, "room not found or expired", "roomID", req.RoomID)
}
if req.UserID != dbInv.InviterUserID && !datautil.Contain(req.UserID, dbInv.InviteeUserIDList...) {
return nil, errs.ErrNoPermission.WrapMsg("user is not a participant of this room", "userID", req.UserID)
}
token, err := s.genToken(req.RoomID, req.UserID)
if err != nil {
return nil, err
}
return &rtc.SignalGetTokenByRoomIDResp{
Token: token,
LiveURL: s.config.RpcConfig.LiveKit.ExternalAddress,
}, nil
}
// SignalGetRooms returns room info for a list of room IDs.
func (s *rtcServer) SignalGetRooms(ctx context.Context, req *rtc.SignalGetRoomsReq) (*rtc.SignalGetRoomsResp, error) {
if len(req.RoomIDs) == 0 {
return &rtc.SignalGetRoomsResp{}, nil
}
invs, err := s.db.GetInvitationsByRoomIDs(ctx, req.RoomIDs)
if err != nil {
return nil, err
}
roomList := make([]*rtc.SignalGetRoomByGroupIDResp, 0, len(invs))
for _, inv := range invs {
roomList = append(roomList, &rtc.SignalGetRoomByGroupIDResp{
Invitation: modelToInvitationInfo(inv),
RoomID: inv.RoomID,
})
}
return &rtc.SignalGetRoomsResp{RoomList: roomList}, nil
}
// GetSignalInvitationInfo retrieves a pending invitation by roomID.
func (s *rtcServer) GetSignalInvitationInfo(ctx context.Context, req *rtc.GetSignalInvitationInfoReq) (*rtc.GetSignalInvitationInfoResp, error) {
inv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID)
if err != nil {
return nil, err
}
return &rtc.GetSignalInvitationInfoResp{
InvitationInfo: modelToInvitationInfo(inv),
OfflinePushInfo: &sdkws.OfflinePushInfo{
Title: inv.OfflinePushTitle,
Desc: inv.OfflinePushDesc,
Ex: inv.OfflinePushEx,
},
}, nil
}
// GetSignalInvitationInfoStartApp retrieves a pending invitation for a user when the app starts.
func (s *rtcServer) GetSignalInvitationInfoStartApp(ctx context.Context, req *rtc.GetSignalInvitationInfoStartAppReq) (*rtc.GetSignalInvitationInfoStartAppResp, error) {
inv, err := s.db.GetInvitationByInviteeUserID(ctx, req.UserID)
if err != nil {
return nil, err
}
return &rtc.GetSignalInvitationInfoStartAppResp{
Invitation: modelToInvitationInfo(inv),
OfflinePushInfo: &sdkws.OfflinePushInfo{
Title: inv.OfflinePushTitle,
Desc: inv.OfflinePushDesc,
Ex: inv.OfflinePushEx,
},
}, nil
}
// SignalSendCustomSignal forwards a custom signal to all participants in a room.
func (s *rtcServer) SignalSendCustomSignal(ctx context.Context, req *rtc.SignalSendCustomSignalReq) (*rtc.SignalSendCustomSignalResp, error) {
inv, err := s.db.GetInvitationByRoomID(ctx, req.RoomID)
if err != nil {
log.ZWarn(ctx, "GetInvitationByRoomID failed for custom signal", err, "roomID", req.RoomID)
return &rtc.SignalSendCustomSignalResp{}, nil
}
opUserID := mcontext.GetOpUserID(ctx)
// Fix P3: 处理 json.Marshal 错误
content, err := json.Marshal(map[string]any{
"roomID": req.RoomID,
"customInfo": req.CustomInfo,
})
if err != nil {
return nil, errs.WrapMsg(err, "marshal custom signal content failed")
}
recipients := make([]string, 0, len(inv.InviteeUserIDList)+1)
recipients = append(recipients, inv.InviteeUserIDList...)
recipients = append(recipients, inv.InviterUserID)
for _, uid := range recipients {
if uid == opUserID {
continue
}
if err := s.sendCustomSignalNotification(ctx, opUserID, uid, int32(constant.SingleChatType), content); err != nil {
log.ZWarn(ctx, "sendCustomSignalNotification failed", err, "to", uid)
}
}
return &rtc.SignalSendCustomSignalResp{}, nil
}
// GetSignalInvitationRecords returns paginated call history.
func (s *rtcServer) GetSignalInvitationRecords(ctx context.Context, req *rtc.GetSignalInvitationRecordsReq) (*rtc.GetSignalInvitationRecordsResp, error) {
total, records, err := s.db.SearchRecords(ctx, req.SendID, req.RecvID, req.SessionType, req.StartTime, req.EndTime, req.Pagination)
if err != nil {
return nil, err
}
signalRecords := datautil.Slice(records, func(r *model.SignalRecord) *rtc.SignalRecord {
return &rtc.SignalRecord{
RoomID: r.RoomID,
SID: r.SID,
FileName: r.FileName,
MediaType: r.MediaType,
SessionType: r.SessionType,
InviterUserID: r.InviterUserID,
InviterUserNickname: r.InviterUserNickname,
GroupID: r.GroupID,
GroupName: r.GroupName,
CreateTime: r.CreateTime,
EndTime: r.EndTime,
Size: r.FileSize,
FileURL: r.FileURL,
}
})
return &rtc.GetSignalInvitationRecordsResp{
Total: int32(total),
SignalRecords: signalRecords,
}, nil
}
// DeleteSignalRecords removes call history records by their SIDs.
func (s *rtcServer) DeleteSignalRecords(ctx context.Context, req *rtc.DeleteSignalRecordsReq) (*rtc.DeleteSignalRecordsResp, error) {
if err := s.db.DeleteRecords(ctx, req.SIDs); err != nil {
return nil, err
}
return &rtc.DeleteSignalRecordsResp{}, nil
}
// ---- helpers ----
// genToken generates a LiveKit access token for the given room and identity.
func (s *rtcServer) genToken(roomID, userID string) (string, error) {
lk := s.config.RpcConfig.LiveKit
at := auth.NewAccessToken(lk.APIKey, lk.APISecret)
grant := &auth.VideoGrant{
RoomJoin: true,
Room: roomID,
}
at.SetVideoGrant(grant).
SetIdentity(userID).
SetValidFor(s.tokenExpiry)
return at.ToJWT()
}
// signalingMsgOptions 返回信令通知消息应设置的 Options。
//
// Fix P2+P2(安全): 原代码传 make(map[string]bool) 空 map导致
// 1. IsNotificationByMsg 将信令消息误判为普通聊天消息,触发黑名单/好友关系等权限拦截
// 2. IsHistory/IsPersistent 默认为 true信令消息被写入历史记录占用存储
// 3. IsUnreadCount/IsConversationUpdate 默认 true污染未读数和会话列表
//
// 信令消息应走 Notification 通道(对话 ID 前缀 "n_"),绕过聊天消息权限校验,
// 且不写历史、不计未读、不更新会话。离线推送根据 offlinePushInfo 控制,此处不强制关闭。
func signalingMsgOptions() map[string]bool {
opts := make(map[string]bool, 8)
// IsNotNotification=false 表示"这是通知消息",让 IsNotificationByMsg 返回 true
// 从而跳过 modifyMessageByUserMessageReceiveOpt 中的黑名单/好友关系等校验
datautil.SetSwitchFromOptions(opts, constant.IsNotNotification, false)
datautil.SetSwitchFromOptions(opts, constant.IsHistory, false)
datautil.SetSwitchFromOptions(opts, constant.IsPersistent, false)
datautil.SetSwitchFromOptions(opts, constant.IsUnreadCount, false)
datautil.SetSwitchFromOptions(opts, constant.IsConversationUpdate, false)
datautil.SetSwitchFromOptions(opts, constant.IsSenderConversationUpdate, false)
datautil.SetSwitchFromOptions(opts, constant.IsSenderSync, false)
return opts
}
// sendSignalingNotification sends a SignalingNotification message to a user via the msg service.
func (s *rtcServer) sendSignalingNotification(ctx context.Context, sendID, recvID string, sessionType int32, offlinePush *sdkws.OfflinePushInfo, content []byte) error {
now := time.Now().UnixMilli()
msgData := &sdkws.MsgData{
SendID: sendID,
RecvID: recvID,
SessionType: sessionType,
ContentType: int32(constant.SignalingNotification),
MsgFrom: int32(constant.SysMsgType),
Content: content,
CreateTime: now,
SendTime: now,
ServerMsgID: uuid.New().String(),
ClientMsgID: uuid.New().String(),
Options: signalingMsgOptions(),
}
if offlinePush != nil {
msgData.OfflinePushInfo = offlinePush
}
_, err := s.msgClient.MsgClient.SendMsg(ctx, &pbmsg.SendMsgReq{MsgData: msgData})
if err != nil {
log.ZError(ctx, "sendSignalingNotification", err, "msgdata", msgData)
return err
}
log.ZInfo(ctx, "sendSignalingNotification", "msgData", msgData)
return nil
}
// sendCustomSignalNotification sends a CustomSignalNotification (1605) to a user.
func (s *rtcServer) sendCustomSignalNotification(ctx context.Context, sendID, recvID string, sessionType int32, content []byte) error {
now := time.Now().UnixMilli()
msgData := &sdkws.MsgData{
SendID: sendID,
RecvID: recvID,
SessionType: sessionType,
ContentType: int32(constant.CustomSignalNotification),
MsgFrom: int32(constant.SysMsgType),
Content: content,
CreateTime: now,
SendTime: now,
ServerMsgID: uuid.New().String(),
ClientMsgID: uuid.New().String(),
Options: signalingMsgOptions(),
}
_, err := s.msgClient.MsgClient.SendMsg(ctx, &pbmsg.SendMsgReq{MsgData: msgData})
return err
}
// marshalSignalReq serializes a SignalReq to bytes.
// Fix P2: 原代码使用 _ 吞掉错误,序列化失败时返回 nil导致被叫收到空 Content 消息,来电通知丢失。
func marshalSignalReq(req *rtc.SignalReq) ([]byte, error) {
b, err := proto.Marshal(req)
if err != nil {
return nil, errs.WrapMsg(err, "marshal SignalReq failed")
}
return b, nil
}
// newRoomID generates a unique room ID.
func newRoomID() string {
return fmt.Sprintf("room-%s", uuid.New().String())
}
// invitationToModel converts a proto InvitationInfo to the database model.
func invitationToModel(inv *rtc.InvitationInfo, push *sdkws.OfflinePushInfo) *model.SignalInvitation {
now := time.Now()
m := &model.SignalInvitation{
RoomID: inv.RoomID,
InviterUserID: inv.InviterUserID,
InviteeUserIDList: inv.InviteeUserIDList,
CustomData: inv.CustomData,
GroupID: inv.GroupID,
Timeout: inv.Timeout,
MediaType: inv.MediaType,
PlatformID: inv.PlatformID,
SessionType: inv.SessionType,
InitiateTime: inv.InitiateTime,
BusyLineUserIDList: inv.BusyLineUserIDList,
CreateTime: now.UnixMilli(),
// Fix P1(TTL): 根据 Timeout 设置过期时间,配合 MongoDB TTL 索引自动清理
ExpireAt: now.Add(time.Duration(inv.Timeout+30) * time.Second),
}
if push != nil {
m.OfflinePushTitle = push.Title
m.OfflinePushDesc = push.Desc
m.OfflinePushEx = push.Ex
}
return m
}
// modelToInvitationInfo converts a database model to proto InvitationInfo.
func modelToInvitationInfo(m *model.SignalInvitation) *rtc.InvitationInfo {
if m == nil {
return nil
}
return &rtc.InvitationInfo{
InviterUserID: m.InviterUserID,
InviteeUserIDList: m.InviteeUserIDList,
CustomData: m.CustomData,
GroupID: m.GroupID,
RoomID: m.RoomID,
Timeout: m.Timeout,
MediaType: m.MediaType,
PlatformID: m.PlatformID,
SessionType: m.SessionType,
InitiateTime: m.InitiateTime,
BusyLineUserIDList: m.BusyLineUserIDList,
}
}
// hungUpPeerIDsFromDB returns IDs that should receive hang-up notification, based on authoritative DB data.
// Fix P1(安全): 原 hungUpPeerIDs 使用客户端传入的 inv改为使用从 DB 获取的记录。
func hungUpPeerIDsFromDB(inv *model.SignalInvitation, callerID string) []string {
if callerID == inv.InviterUserID {
return inv.InviteeUserIDList
}
return []string{inv.InviterUserID}
}