feat(call): enhance call handling with error differentiation and online notifications
- Added error handling for call invite and answer processes to differentiate between callee offline and call already answered scenarios. - Introduced a new method to publish messages only to online users, improving real-time communication during call signaling. - Updated call service to check callee's online status before sending call invites, ensuring better user experience. - Enhanced call acceptance logic to notify both caller and callee about call status changes, including when a call is answered on another device.
This commit is contained in:
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -466,6 +467,15 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
|
|||||||
zap.String("callee_id", req.CalleeID),
|
zap.String("callee_id", req.CalleeID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
|
// === 区分不同错误类型 ===
|
||||||
|
if errors.Is(err, service.ErrCalleeOffline) {
|
||||||
|
h.wsHub.SendError(client, "callee_offline", "对方当前离线,无法拨打")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrCallInProgress) {
|
||||||
|
h.wsHub.SendError(client, "call_in_progress", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
h.wsHub.SendError(client, "call_invite_failed", err.Error())
|
h.wsHub.SendError(client, "call_invite_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -499,6 +509,11 @@ func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, pay
|
|||||||
|
|
||||||
call, err := h.callService.Accept(ctx, req.CallID, client.UserID)
|
call, err := h.callService.Accept(ctx, req.CallID, client.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// === 区分已接听错误 ===
|
||||||
|
if errors.Is(err, service.ErrCallAlreadyAnswered) {
|
||||||
|
h.wsHub.SendError(client, "call_already_answered", "通话已被其他设备接听")
|
||||||
|
return
|
||||||
|
}
|
||||||
h.wsHub.SendError(client, "call_accept_failed", err.Error())
|
h.wsHub.SendError(client, "call_accept_failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,41 @@ func (h *Hub) PublishToUsers(userIDs []string, eventType string, payload interfa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishToUserOnline 仅在用户在线时发送事件,不存入历史回放
|
||||||
|
// 适用于通话信令等实时性消息,避免断线重连时重放过期消息
|
||||||
|
func (h *Hub) PublishToUserOnline(userID string, eventType string, payload interface{}) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
targets := make([]*Client, 0, len(h.clients[userID]))
|
||||||
|
for _, c := range h.clients[userID] {
|
||||||
|
targets = append(targets, c)
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := ResponseMessage{
|
||||||
|
EventID: h.NextID(),
|
||||||
|
Type: eventType,
|
||||||
|
TS: time.Now().UnixMilli(),
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range targets {
|
||||||
|
select {
|
||||||
|
case <-c.Quit:
|
||||||
|
case c.Send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// publish 内部发布方法
|
// publish 内部发布方法
|
||||||
func (h *Hub) publish(userID string, ev Event) {
|
func (h *Hub) publish(userID string, ev Event) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ var (
|
|||||||
ErrCallInProgress = errors.New("call already in progress between users")
|
ErrCallInProgress = errors.New("call already in progress between users")
|
||||||
ErrNotParticipant = errors.New("user is not a participant of this call")
|
ErrNotParticipant = errors.New("user is not a participant of this call")
|
||||||
ErrInvalidCallState = errors.New("invalid call state for this operation")
|
ErrInvalidCallState = errors.New("invalid call state for this operation")
|
||||||
|
ErrCalleeOffline = errors.New("callee is offline")
|
||||||
|
ErrCallAlreadyAnswered = errors.New("call already answered on another device")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 通话相关常量
|
||||||
|
const (
|
||||||
|
CallLifetimeMs = 60000 // 通话邀请有效期 60秒 (参考 Matrix)
|
||||||
|
CallTimeoutMs = 120000 // 通话超时(无人接听) 120秒
|
||||||
)
|
)
|
||||||
|
|
||||||
// CallService 通话服务接口
|
// CallService 通话服务接口
|
||||||
@@ -54,6 +62,11 @@ func NewCallService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversationID string) (*model.CallSession, error) {
|
func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversationID string) (*model.CallSession, error) {
|
||||||
|
// === Telegram: 检查被叫方是否在线 ===
|
||||||
|
if !s.hub.HasClients(calleeID) {
|
||||||
|
return nil, ErrCalleeOffline
|
||||||
|
}
|
||||||
|
|
||||||
active, err := s.callRepo.GetActiveCallByConversationID(conversationID)
|
active, err := s.callRepo.GetActiveCallByConversationID(conversationID)
|
||||||
if err == nil && active != nil {
|
if err == nil && active != nil {
|
||||||
return nil, fmt.Errorf("%w: call %s", ErrCallInProgress, active.ID)
|
return nil, fmt.Errorf("%w: call %s", ErrCallInProgress, active.ID)
|
||||||
@@ -83,15 +96,26 @@ func (s *callService) Invite(ctx context.Context, callerID, calleeID, conversati
|
|||||||
return nil, fmt.Errorf("get call with participants: %w", err)
|
return nil, fmt.Errorf("get call with participants: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Element: 包含 lifetime 字段 ===
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"call_id": call.ID,
|
"call_id": call.ID,
|
||||||
"conversation_id": conversationID,
|
"conversation_id": conversationID,
|
||||||
"caller_id": callerID,
|
"caller_id": callerID,
|
||||||
"call_type": call.CallType,
|
"call_type": call.CallType,
|
||||||
"created_at": now.UnixMilli(),
|
"created_at": now.UnixMilli(),
|
||||||
|
"lifetime": CallLifetimeMs, // 新增: 有效期 60秒
|
||||||
"ice_servers": s.config.WebRTC.ICEServers,
|
"ice_servers": s.config.WebRTC.ICEServers,
|
||||||
}
|
}
|
||||||
s.hub.PublishToUser(calleeID, "call_incoming", payload)
|
|
||||||
|
// === Telegram: 不存 history ===
|
||||||
|
online := s.hub.PublishToUserOnline(calleeID, "call_incoming", payload)
|
||||||
|
if !online {
|
||||||
|
// 极端情况: 检查时在线但发送时离线了,清理通话记录
|
||||||
|
call.Status = model.CallStatusCancelled
|
||||||
|
call.EndedAt = &now
|
||||||
|
s.callRepo.UpdateCall(call)
|
||||||
|
return nil, ErrCalleeOffline
|
||||||
|
}
|
||||||
|
|
||||||
return call, nil
|
return call, nil
|
||||||
}
|
}
|
||||||
@@ -101,8 +125,9 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*model
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrCallNotFound, err)
|
return nil, fmt.Errorf("%w: %v", ErrCallNotFound, err)
|
||||||
}
|
}
|
||||||
if !call.IsActive() {
|
// === 状态原子检查: 确保单设备接听 ===
|
||||||
return nil, ErrCallNotActive
|
if call.Status != model.CallStatusCalling {
|
||||||
|
return nil, ErrCallAlreadyAnswered
|
||||||
}
|
}
|
||||||
if !isParticipant(call, userID) {
|
if !isParticipant(call, userID) {
|
||||||
return nil, ErrNotParticipant
|
return nil, ErrNotParticipant
|
||||||
@@ -124,12 +149,19 @@ func (s *callService) Accept(ctx context.Context, callID, userID string) (*model
|
|||||||
s.callRepo.UpdateCallParticipant(participant)
|
s.callRepo.UpdateCallParticipant(participant)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.hub.PublishToUser(call.CallerID, "call_accepted", map[string]interface{}{
|
// === Telegram: 通知拨打方已接听 ===
|
||||||
|
s.hub.PublishToUserOnline(call.CallerID, "call_accepted", map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
"started_at": now.UnixMilli(),
|
"started_at": now.UnixMilli(),
|
||||||
"ice_servers": s.config.WebRTC.ICEServers,
|
"ice_servers": s.config.WebRTC.ICEServers,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// === Telegram: 通知被叫方其他设备 call_answered_elsewhere ===
|
||||||
|
s.hub.PublishToUserOnline(userID, "call_answered_elsewhere", map[string]interface{}{
|
||||||
|
"call_id": callID,
|
||||||
|
"reason": "answered_on_another_device",
|
||||||
|
})
|
||||||
|
|
||||||
return call, nil
|
return call, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +193,7 @@ func (s *callService) Reject(ctx context.Context, callID, userID string) error {
|
|||||||
s.callRepo.UpdateCallParticipant(participant)
|
s.callRepo.UpdateCallParticipant(participant)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.hub.PublishToUser(call.CallerID, "call_rejected", map[string]interface{}{
|
s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
"reason": "rejected",
|
"reason": "rejected",
|
||||||
})
|
})
|
||||||
@@ -189,7 +221,7 @@ func (s *callService) Busy(ctx context.Context, callID, userID string) error {
|
|||||||
return fmt.Errorf("update call missed: %w", err)
|
return fmt.Errorf("update call missed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.hub.PublishToUser(call.CallerID, "call_busy", map[string]interface{}{
|
s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
@@ -237,7 +269,7 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
|
|||||||
participants, _ := s.callRepo.GetCallParticipants(callID)
|
participants, _ := s.callRepo.GetCallParticipants(callID)
|
||||||
for _, p := range participants {
|
for _, p := range participants {
|
||||||
if p.UserID != userID {
|
if p.UserID != userID {
|
||||||
s.hub.PublishToUser(p.UserID, "call_ended", map[string]interface{}{
|
s.hub.PublishToUserOnline(p.UserID, "call_ended", map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
"ended_by": userID,
|
"ended_by": userID,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
@@ -265,7 +297,7 @@ func (s *callService) RelaySignal(ctx context.Context, callID, fromUserID string
|
|||||||
participants, _ := s.callRepo.GetCallParticipants(callID)
|
participants, _ := s.callRepo.GetCallParticipants(callID)
|
||||||
for _, p := range participants {
|
for _, p := range participants {
|
||||||
if p.UserID != fromUserID {
|
if p.UserID != fromUserID {
|
||||||
s.hub.PublishToUser(p.UserID, signalType, map[string]interface{}{
|
s.hub.PublishToUserOnline(p.UserID, signalType, map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
"from_id": fromUserID,
|
"from_id": fromUserID,
|
||||||
"payload": json.RawMessage(payload),
|
"payload": json.RawMessage(payload),
|
||||||
@@ -290,7 +322,7 @@ func (s *callService) SetMuted(ctx context.Context, callID, userID string, muted
|
|||||||
participants, _ := s.callRepo.GetCallParticipants(callID)
|
participants, _ := s.callRepo.GetCallParticipants(callID)
|
||||||
for _, p := range participants {
|
for _, p := range participants {
|
||||||
if p.UserID != userID {
|
if p.UserID != userID {
|
||||||
s.hub.PublishToUser(p.UserID, "call_peer_muted", map[string]interface{}{
|
s.hub.PublishToUserOnline(p.UserID, "call_peer_muted", map[string]interface{}{
|
||||||
"call_id": callID,
|
"call_id": callID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"muted": muted,
|
"muted": muted,
|
||||||
|
|||||||
Reference in New Issue
Block a user