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

@@ -9,6 +9,7 @@ import (
"time"
redispkg "with_you/internal/pkg/redis"
"with_you/internal/pkg/utils"
"with_you/internal/config"
apperrors "with_you/internal/errors"
@@ -34,15 +35,17 @@ const (
// activeCallRedisData Redis 中存储的通话数据(精简字段)
type activeCallRedisData struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
CallerID string `json:"caller_id"`
CalleeID string `json:"callee_id"`
CallType string `json:"call_type"`
Status string `json:"status"`
MediaType string `json:"media_type"`
CreatedAt int64 `json:"created_at"`
StartedAt int64 `json:"started_at"` // 0 means nil
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
CallerID string `json:"caller_id"`
CalleeID string `json:"callee_id,omitempty"` // 兼容旧数据1v1时仍有值
CallType string `json:"call_type"`
Status string `json:"status"`
MediaType string `json:"media_type"`
GroupID string `json:"group_id,omitempty"`
CreatedAt int64 `json:"created_at"`
StartedAt int64 `json:"started_at"` // 0 means nil
ParticipantIDs []string `json:"participant_ids"`
}
// ActiveCall 内存中的活跃通话
@@ -50,7 +53,8 @@ type ActiveCall struct {
ID string
ConversationID string
CallerID string
CalleeID string
CalleeID string // 1v1通话的被叫方ID兼容
GroupID string // 群组通话的群组ID
CallType model.CallType
Status model.CallStatus
MediaType string // voice 或 video
@@ -66,15 +70,19 @@ type ActiveParticipant struct {
UserID string
Status model.ParticipantStatus
JoinedAt *time.Time
LeftAt *time.Time
}
// CallService 通话服务接口
type CallService interface {
Invite(ctx context.Context, callerID, calleeID, conversationID, mediaType string) (*ActiveCall, bool, error)
GroupInvite(ctx context.Context, callerID, groupID, conversationID, mediaType string) (*ActiveCall, error)
Accept(ctx context.Context, callID, userID string) (*ActiveCall, error)
Reject(ctx context.Context, callID, userID string) error
Busy(ctx context.Context, callID, userID string) error
End(ctx context.Context, callID, userID string, reason string) (*ActiveCall, error)
ParticipantJoin(ctx context.Context, callID, userID string) error
ParticipantLeave(ctx context.Context, callID, userID string, reason string) error
SetMuted(ctx context.Context, callID, userID string, muted bool) error
Ready(ctx context.Context, callID, userID string) error
GetActiveCall(ctx context.Context, callID string) *ActiveCall
@@ -83,11 +91,12 @@ type CallService interface {
}
type callService struct {
callRepo repository.CallRepository
hub ws.MessagePublisher
config *config.Config
db *gorm.DB
redis *redispkg.Client
callRepo repository.CallRepository
hub ws.MessagePublisher
config *config.Config
db *gorm.DB
redis *redispkg.Client
groupService GroupService
// 内存中的活跃通话状态
activeCalls map[string]*ActiveCall // callID -> ActiveCall
@@ -103,6 +112,7 @@ func NewCallService(
cfg *config.Config,
db *gorm.DB,
redisClient *redispkg.Client,
groupService GroupService,
) CallService {
svc := &callService{
callRepo: callRepo,
@@ -110,6 +120,7 @@ func NewCallService(
config: cfg,
db: db,
redis: redisClient,
groupService: groupService,
activeCalls: make(map[string]*ActiveCall),
activeCallsByUser: make(map[string]map[string]bool),
activeCallsByConv: make(map[string]string),
@@ -124,9 +135,13 @@ func NewCallService(
return svc
}
// generateCallID 生成通话ID
// generateCallID 生成通话ID使用雪花算法与数据库ID一致
func (s *callService) generateCallID() string {
id := time.Now().UnixNano()
id, err := utils.GetSnowflake().GenerateID()
if err != nil {
zap.L().Error("Failed to generate snowflake call ID", zap.Error(err))
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
return strconv.FormatInt(id, 10)
}
@@ -141,6 +156,12 @@ func (s *callService) redisStoreCall(call *ActiveCall) {
if call.StartedAt != nil {
startedAt = call.StartedAt.UnixMilli()
}
participantIDs := make([]string, 0, len(call.Participants))
for id := range call.Participants {
participantIDs = append(participantIDs, id)
}
data := activeCallRedisData{
ID: call.ID,
ConversationID: call.ConversationID,
@@ -149,8 +170,10 @@ func (s *callService) redisStoreCall(call *ActiveCall) {
CallType: string(call.CallType),
Status: string(call.Status),
MediaType: call.MediaType,
GroupID: call.GroupID,
CreatedAt: call.CreatedAt.UnixMilli(),
StartedAt: startedAt,
ParticipantIDs: participantIDs,
}
jsonData, err := json.Marshal(data)
if err != nil {
@@ -159,13 +182,13 @@ func (s *callService) redisStoreCall(call *ActiveCall) {
}
callKey := redisCallKeyPrefix + call.ID
callerKey := redisCallByUserPrefix + call.CallerID
calleeKey := redisCallByUserPrefix + call.CalleeID
pipe := s.redis.Pipeline()
pipe.Set(ctx, callKey, jsonData, redisCallTTL)
pipe.Set(ctx, callerKey, call.ID, redisCallTTL)
pipe.Set(ctx, calleeKey, call.ID, redisCallTTL)
// 为每个参与者建立索引
for userID := range call.Participants {
pipe.Set(ctx, redisCallByUserPrefix+userID, call.ID, redisCallTTL)
}
if _, err := pipe.Exec(ctx); err != nil {
zap.L().Error("Failed to store call in Redis", zap.String("call_id", call.ID), zap.Error(err))
}
@@ -180,10 +203,11 @@ func (s *callService) redisRemoveCall(call *ActiveCall) {
keys := []string{
redisCallKeyPrefix + call.ID,
redisCallByUserPrefix + call.CallerID,
redisCallByUserPrefix + call.CalleeID,
redisCallAcceptPrefix + call.ID,
}
for userID := range call.Participants {
keys = append(keys, redisCallByUserPrefix+userID)
}
if err := s.redis.Del(ctx, keys...); err != nil {
zap.L().Error("Failed to remove call from Redis", zap.String("call_id", call.ID), zap.Error(err))
}
@@ -207,18 +231,38 @@ func (s *callService) redisGetCall(callID string) *ActiveCall {
return nil
}
// 构建参与者列表
participants := make(map[string]*ActiveParticipant)
for _, pid := range data.ParticipantIDs {
status := model.ParticipantStatusInvited
if pid == data.CallerID {
status = model.ParticipantStatusJoined
}
participants[pid] = &ActiveParticipant{
UserID: pid,
Status: status,
}
}
// 兼容旧数据:无 ParticipantIDs 时使用 CallerID/CalleeID
if len(participants) == 0 && data.CalleeID != "" {
participants[data.CallerID] = &ActiveParticipant{
UserID: data.CallerID, Status: model.ParticipantStatusJoined,
}
participants[data.CalleeID] = &ActiveParticipant{
UserID: data.CalleeID, Status: model.ParticipantStatusInvited,
}
}
call := &ActiveCall{
ID: data.ID,
ConversationID: data.ConversationID,
CallerID: data.CallerID,
CalleeID: data.CalleeID,
GroupID: data.GroupID,
CallType: model.CallType(data.CallType),
Status: model.CallStatus(data.Status),
MediaType: data.MediaType,
Participants: map[string]*ActiveParticipant{
data.CallerID: {UserID: data.CallerID, Status: model.ParticipantStatusJoined},
data.CalleeID: {UserID: data.CalleeID, Status: model.ParticipantStatusInvited},
},
Participants: participants,
}
call.CreatedAt = time.UnixMilli(data.CreatedAt)
@@ -226,10 +270,11 @@ func (s *callService) redisGetCall(callID string) *ActiveCall {
t := time.UnixMilli(data.StartedAt)
call.StartedAt = &t
// 如果已经开始,被叫方也已加入
call.Participants[data.CalleeID] = &ActiveParticipant{
UserID: data.CalleeID,
Status: model.ParticipantStatusJoined,
JoinedAt: call.StartedAt,
if data.CalleeID != "" {
if p, ok := call.Participants[data.CalleeID]; ok {
p.Status = model.ParticipantStatusJoined
p.JoinedAt = call.StartedAt
}
}
}
@@ -243,10 +288,9 @@ func (s *callService) redisRefreshTTL(call *ActiveCall) {
}
ctx := context.Background()
keys := []string{
redisCallKeyPrefix + call.ID,
redisCallByUserPrefix + call.CallerID,
redisCallByUserPrefix + call.CalleeID,
keys := []string{redisCallKeyPrefix + call.ID}
for userID := range call.Participants {
keys = append(keys, redisCallByUserPrefix+userID)
}
for _, key := range keys {
_, _ = s.redis.Expire(ctx, key, redisCallTTL)
@@ -506,7 +550,7 @@ func (s *callService) Reject(ctx context.Context, callID, userID string) error {
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, model.CallStatusRejected, 0)
s.saveCallHistory(call, model.CallStatusRejected, 0, userID)
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_rejected", map[string]any{
@@ -543,7 +587,7 @@ func (s *callService) Busy(ctx context.Context, callID, userID string) error {
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, model.CallStatusMissed, 0)
s.saveCallHistory(call, model.CallStatusMissed, 0, "")
// 通知拨打方
s.hub.PublishToUserOnline(call.CallerID, "call_busy", map[string]any{
@@ -602,7 +646,7 @@ func (s *callService) End(ctx context.Context, callID, userID string, reason str
s.redisRemoveCall(call)
// 保存到数据库作为历史记录
s.saveCallHistory(call, endStatus, duration)
s.saveCallHistory(call, endStatus, duration, userID)
// 通知其他参与者
for pUserID := range call.Participants {
@@ -654,6 +698,174 @@ func (s *callService) GetActiveCall(ctx context.Context, callID string) *ActiveC
return s.getActiveCall(callID)
}
// GroupInvite 发起群组通话
func (s *callService) GroupInvite(ctx context.Context, callerID, groupID, conversationID, mediaType string) (*ActiveCall, error) {
if s.groupService == nil {
return nil, apperrors.ErrGroupCallNotSupported
}
// 验证调用者是群组成员
member, err := s.groupService.GetMember(groupID, callerID)
if err != nil {
return nil, apperrors.ErrNotGroupMember
}
if member == nil {
return nil, apperrors.ErrNotGroupMember
}
// 检查会话是否有活跃通话
if active := s.getActiveCallByConversation(conversationID); active != nil {
return nil, apperrors.Wrap(fmt.Errorf("call %s", active.ID), apperrors.ErrCallInProgress)
}
// 获取群组成员
members, _, err := s.groupService.GetMembers(groupID, 1, 50)
if err != nil {
return nil, apperrors.Wrap(err, apperrors.ErrInternal)
}
now := time.Now()
callID := s.generateCallID()
// 构建参与者列表
participants := map[string]*ActiveParticipant{
callerID: {UserID: callerID, Status: model.ParticipantStatusJoined, JoinedAt: &now},
}
for _, m := range members {
if m.UserID != callerID {
participants[m.UserID] = &ActiveParticipant{
UserID: m.UserID,
Status: model.ParticipantStatusInvited,
}
}
}
call := &ActiveCall{
ID: callID,
ConversationID: conversationID,
CallerID: callerID,
GroupID: groupID,
CallType: model.CallTypeGroup,
Status: model.CallStatusCalling,
MediaType: mediaType,
CreatedAt: now,
Participants: participants,
}
s.storeActiveCall(call)
s.redisStoreCall(call)
// 通知所有在线成员
payload := map[string]any{
"call_id": call.ID,
"conversation_id": conversationID,
"caller_id": callerID,
"call_type": "group",
"media_type": mediaType,
"group_id": groupID,
"created_at": now.UnixMilli(),
"lifetime": CallLifetimeMs,
}
for pID := range participants {
if pID != callerID {
s.hub.PublishToUserOnline(pID, "call_incoming", payload)
}
}
return call, nil
}
// ParticipantJoin 参与者加入通话
func (s *callService) ParticipantJoin(ctx context.Context, callID, userID string) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.Lock()
p, ok := call.Participants[userID]
if !ok {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant
}
now := time.Now()
p.Status = model.ParticipantStatusJoined
p.JoinedAt = &now
// 如果是第一个被叫方加入,标记通话已接通
if call.Status == model.CallStatusCalling {
call.Status = model.CallStatusConnected
call.StartedAt = &now
}
s.mu.Unlock()
s.redisStoreCall(call)
// 通知其他参与者
for pID := range call.Participants {
if pID != userID {
s.hub.PublishToUserOnline(pID, "call_participant_joined", map[string]any{
"call_id": callID,
"user_id": userID,
})
}
}
return nil
}
// ParticipantLeave 参与者离开通话
func (s *callService) ParticipantLeave(ctx context.Context, callID, userID string, reason string) error {
call := s.getActiveCall(callID)
if call == nil {
return apperrors.ErrCallNotFound
}
s.mu.Lock()
p, ok := call.Participants[userID]
if !ok {
s.mu.Unlock()
return apperrors.ErrNotCallParticipant
}
now := time.Now()
p.Status = model.ParticipantStatusLeft
p.LeftAt = &now
// 计算剩余活跃参与者数量
activeCount := 0
for _, pp := range call.Participants {
if pp.Status == model.ParticipantStatusJoined {
activeCount++
}
}
shouldEnd := false
if call.CallType == model.CallTypeGroup && activeCount <= 1 {
shouldEnd = true
} else if call.CallType == model.CallTypePrivate {
shouldEnd = true
}
s.mu.Unlock()
// 通知其他参与者
for pID := range call.Participants {
if pID != userID {
s.hub.PublishToUserOnline(pID, "call_participant_left", map[string]any{
"call_id": callID,
"user_id": userID,
"reason": reason,
})
}
}
if shouldEnd {
_, _ = s.End(ctx, callID, userID, reason)
}
return nil
}
// Ready 标记参与者已加入 LiveKit 房间
func (s *callService) Ready(ctx context.Context, callID, userID string) error {
call := s.getActiveCall(callID)
@@ -686,16 +898,23 @@ func (s *callService) GetCallHistory(ctx context.Context, userID string, page, p
// saveCallHistory 保存通话历史到数据库
func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64) {
func (s *callService) saveCallHistory(call *ActiveCall, status model.CallStatus, duration int64, endedBy string) {
ctx := context.Background()
now := time.Now()
var endedByPtr *string
if endedBy != "" {
endedByPtr = &endedBy
}
dbCall := &model.CallSession{
ID: call.ID,
ConversationID: call.ConversationID,
CallerID: call.CallerID,
CallType: call.CallType,
MediaType: call.MediaType,
Status: status,
EndedBy: endedByPtr,
StartedAt: call.StartedAt,
EndedAt: &now,
Duration: duration,

View File

@@ -81,7 +81,7 @@ func (s *gradeSyncService) SyncUserGrades(ctx context.Context, userID string, re
}
grades := s.convertGrades(userID, &resultData)
if err := s.saveGrades(ctx, userID, resultData.Semester, grades); err != nil {
if err := s.saveGrades(ctx, userID, grades); err != nil {
s.logger.Error("failed to save grades",
zap.String("user_id", userID),
zap.Error(err))
@@ -89,7 +89,12 @@ func (s *gradeSyncService) SyncUserGrades(ctx context.Context, userID string, re
}
if resultData.GpaSummary != nil {
summary := s.convertGpaSummary(userID, resultData.Semester, resultData.GpaSummary)
if err := s.gradeRepo.DeleteGpaSummaryByUser(ctx, userID); err != nil {
s.logger.Warn("failed to delete old gpa summary",
zap.String("user_id", userID),
zap.Error(err))
}
summary := s.convertGpaSummary(userID, resultData.GpaSummary)
if err := s.gradeRepo.SaveGpaSummary(ctx, summary); err != nil {
s.logger.Warn("failed to save gpa summary",
zap.String("user_id", userID),
@@ -128,11 +133,11 @@ func (s *gradeSyncService) convertGrades(userID string, data *pb.GradesResultDat
return grades
}
func (s *gradeSyncService) convertGpaSummary(userID, semester string, gpa *pb.GpaSummary) *model.GpaSummary {
func (s *gradeSyncService) convertGpaSummary(userID string, gpa *pb.GpaSummary) *model.GpaSummary {
return &model.GpaSummary{
ID: uuid.New().String(),
UserID: userID,
Semester: semester,
Semester: gpa.Semesters,
AverageGpa: gpa.AverageGpa,
Rank: gpa.Rank,
ExamTotalGpa: gpa.ExamTotalGpa,
@@ -142,8 +147,8 @@ func (s *gradeSyncService) convertGpaSummary(userID, semester string, gpa *pb.Gp
}
}
func (s *gradeSyncService) saveGrades(ctx context.Context, userID, semester string, grades []*model.Grade) error {
if err := s.gradeRepo.DeleteByUserAndSemester(ctx, userID, semester); err != nil {
func (s *gradeSyncService) saveGrades(ctx context.Context, userID string, grades []*model.Grade) error {
if err := s.gradeRepo.DeleteByUser(ctx, userID); err != nil {
return fmt.Errorf("failed to delete old grades: %w", err)
}
if len(grades) == 0 {

View File

@@ -17,9 +17,15 @@ import (
"google.golang.org/protobuf/encoding/protojson"
)
// RoomConfig 房间配置选项
type RoomConfig struct {
MaxParticipants uint32
EmptyTimeout uint32 // 秒
}
// LiveKitService LiveKit 令牌生成和 Webhook 处理服务
type LiveKitService interface {
GenerateToken(roomName, userID string, canPublish, canSubscribe bool) (string, error)
GenerateToken(roomName, userID string, canPublish, canSubscribe bool, roomCfg ...RoomConfig) (string, error)
ProcessWebhook(ctx context.Context, body []byte, authHeader string) (*livekit.WebhookEvent, error)
}
@@ -37,7 +43,7 @@ func NewLiveKitService(cfg *config.Config, logger *zap.Logger) LiveKitService {
}
// GenerateToken 为指定 room 和 user 生成 LiveKit 访问令牌
func (s *liveKitService) GenerateToken(roomName, userID string, canPublish, canSubscribe bool) (string, error) {
func (s *liveKitService) GenerateToken(roomName, userID string, canPublish, canSubscribe bool, roomCfg ...RoomConfig) (string, error) {
if !s.config.Enabled {
return "", fmt.Errorf("livekit is not enabled")
}
@@ -57,8 +63,9 @@ func (s *liveKitService) GenerateToken(roomName, userID string, canPublish, canS
Room: roomName,
CanPublish: &canPublish,
CanSubscribe: &canSubscribe,
CanPublishData: &canSubscribe,
CanPublishData: &canPublish,
}
at.SetVideoGrant(grant)
token, err := at.ToJWT()