feat(call): implement group calling and enhance call management
All checks were successful
Build Backend / build (push) Successful in 2m14s
Build Backend / build-docker (push) Successful in 1m15s

Introduces group calling capabilities, including group invites and participant
lifecycle management. Enhances the existing call system with support for
media types (voice/video) and improved participant tracking.

- Add `GroupInvite` and `ParticipantJoin/Leave` to `CallService`.
- Implement WebSocket handlers for `call_group_invite` and
  `call_participant_join`.
- Update `CallSession` model to support group IDs, media types, and
  tracking who ended the call.
- Integrate `GroupService` into `CallService` via dependency injection.
- Add error constants for group call limitations and participant capacity.
- Update LiveKit webhook handling to process participant leave events.

Refactor grade synchronization to remove semester-based filtering in favor
of a more flexible user-based approach and improve GPA summary updates.
This commit is contained in:
2026-06-07 00:11:47 +08:00
parent c741a7d61d
commit 60654bc0f4
11 changed files with 394 additions and 69 deletions

View File

@@ -62,19 +62,14 @@ func (h *GradeHandler) ListGrades(c *gin.Context) {
response.Unauthorized(c, "")
return
}
semester := c.Query("semester")
if semester == "" {
response.BadRequest(c, "semester is required")
return
}
grades, err := h.gradeRepo.ListByUserAndSemester(userID, semester)
grades, err := h.gradeRepo.ListByUser(userID)
if err != nil {
response.HandleError(c, err, "failed to list grades")
return
}
gpaSummary, _ := h.gradeRepo.GetGpaSummary(userID, semester)
gpaSummary, _ := h.gradeRepo.GetLatestGpaSummary(userID)
response.Success(c, gin.H{
"grades": grades,

View File

@@ -127,10 +127,21 @@ func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
case "room_finished":
h.handleRoomFinished(c.Request.Context(), event)
case "participant_left":
identity := event.Participant.GetIdentity()
roomName := event.Room.GetName()
h.logger.Info("participant left room",
zap.String("room", event.Room.GetName()),
zap.String("identity", event.Participant.GetIdentity()),
zap.String("room", roomName),
zap.String("identity", identity),
)
if identity != "" && roomName != "" {
if err := h.callService.ParticipantLeave(c.Request.Context(), roomName, identity, "left_livekit"); err != nil {
h.logger.Warn("failed to handle participant_left",
zap.String("room", roomName),
zap.String("identity", identity),
zap.Error(err),
)
}
}
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})

View File

@@ -377,6 +377,8 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
// 通话信令
case "call_invite":
h.handleCallInvite(ctx, client, msg.Payload)
case "call_group_invite":
h.handleCallGroupInvite(ctx, client, msg.Payload)
case "call_answer":
h.handleCallAnswer(ctx, client, msg.Payload)
case "call_reject":
@@ -389,6 +391,8 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
h.handleCallEnd(ctx, client, msg.Payload)
case "call_mute":
h.handleCallMute(ctx, client, msg.Payload)
case "call_participant_join":
h.handleCallParticipantJoin(ctx, client, msg.Payload)
default:
h.publisher.SendError(client, "unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
}
@@ -648,6 +652,61 @@ func (h *WSHandler) handleCallInvite(ctx context.Context, client *ws.Client, pay
}
}
// handleCallGroupInvite 处理群组通话邀请
func (h *WSHandler) handleCallGroupInvite(ctx context.Context, client *ws.Client, payload json.RawMessage) {
if !h.isVerified(ctx, client) {
return
}
var req struct {
GroupID string `json:"group_id"`
ConversationID string `json:"conversation_id"`
MediaType string `json:"call_type"`
}
if err := json.Unmarshal(payload, &req); err != nil {
h.publisher.SendError(client, "parse_error", "invalid call_group_invite format")
return
}
if req.GroupID == "" || req.ConversationID == "" {
h.publisher.SendError(client, "invalid_params", "group_id and conversation_id are required")
return
}
mediaType := req.MediaType
if mediaType != "video" {
mediaType = "voice"
}
call, err := h.callService.GroupInvite(ctx, client.UserID, req.GroupID, req.ConversationID, mediaType)
if err != nil {
zap.L().Warn("Failed to invite group call",
zap.String("caller_id", client.UserID),
zap.String("group_id", req.GroupID),
zap.Error(err),
)
h.publisher.SendError(client, "call_group_invite_failed", err.Error())
return
}
resp := ws.ResponseMessage{
EventID: h.publisher.NextID(),
Type: "call_invited",
TS: time.Now().UnixMilli(),
Payload: map[string]any{
"call_id": call.ID,
"conversation_id": req.ConversationID,
"group_id": req.GroupID,
"call_type": "group",
"lifetime": service.CallLifetimeMs,
},
}
data, _ := json.Marshal(resp)
select {
case <-client.Quit:
case client.Send <- data:
}
}
// handleCallAnswer 处理接听
func (h *WSHandler) handleCallAnswer(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
@@ -778,3 +837,18 @@ func (h *WSHandler) handleCallMute(ctx context.Context, client *ws.Client, paylo
h.publisher.SendError(client, "call_mute_failed", err.Error())
}
}
// handleCallParticipantJoin 处理群组通话参与者加入
func (h *WSHandler) handleCallParticipantJoin(ctx context.Context, client *ws.Client, payload json.RawMessage) {
var req struct {
CallID string `json:"call_id"`
}
if err := json.Unmarshal(payload, &req); err != nil || req.CallID == "" {
h.publisher.SendError(client, "invalid_params", "call_id is required")
return
}
if err := h.callService.ParticipantJoin(ctx, req.CallID, client.UserID); err != nil {
h.publisher.SendError(client, "call_participant_join_failed", err.Error())
}
}