package voiceroomredpacket import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/config" "chatapp3-golang/internal/model" "gorm.io/gorm" ) // Heartbeat 记录用户当前语音房在线态,供 Go 侧红包广播和房间校验使用。 func (s *Service) Heartbeat(ctx context.Context, user AuthUser, req PresenceRequest) (*PresenceResponse, error) { roomID := req.RoomID.Int64() if roomID <= 0 { return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") } sysOrigin := normalizeSysOrigin(user.SysOrigin) regionCode, err := s.resolveUserRegion(ctx, sysOrigin, user.UserID) if err != nil { return nil, err } ttl := presenceTTL(s.cfg) roomKey := onlineRoomKey(sysOrigin, roomID) if err := s.redis.SAdd(ctx, roomKey, user.UserID).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if err := s.redis.Expire(ctx, roomKey, ttl).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if err := s.redis.Set(ctx, userRoomKey(sysOrigin, user.UserID), strconv.FormatInt(roomID, 10), ttl).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if err := s.redis.Set(ctx, userRegionKey(sysOrigin, user.UserID), regionCode, ttl).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if err := s.redis.Set(ctx, userSeenKey(sysOrigin, user.UserID), strconv.FormatInt(time.Now().UnixMilli(), 10), ttl).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } return &PresenceResponse{ UserID: user.UserID, SysOrigin: sysOrigin, RoomID: roomID, RegionCode: regionCode, TTLSeconds: int(ttl / time.Second), }, nil } // Leave 清理用户当前房间在线态。 func (s *Service) Leave(ctx context.Context, user AuthUser, req PresenceRequest) (*PresenceResponse, error) { roomID := req.RoomID.Int64() if roomID <= 0 { return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") } sysOrigin := normalizeSysOrigin(user.SysOrigin) regionCode, _ := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) if err := s.redis.SRem(ctx, onlineRoomKey(sysOrigin, roomID), user.UserID).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if err := s.redis.Del(ctx, userRoomKey(sysOrigin, user.UserID), userSeenKey(sysOrigin, user.UserID)).Err(); err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } return &PresenceResponse{ UserID: user.UserID, SysOrigin: sysOrigin, RoomID: roomID, RegionCode: regionCode, TTLSeconds: int(presenceTTL(s.cfg) / time.Second), }, nil } func (s *Service) ensureUserInRoom(ctx context.Context, sysOrigin string, userID, roomID int64) error { online, err := s.redis.SIsMember(ctx, onlineRoomKey(sysOrigin, roomID), userID).Result() if err != nil { return NewAppError(http.StatusServiceUnavailable, "voice_room_presence_failed", err.Error()) } if !online { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_room_presence_required", "user is not online in this room") } return nil } func (s *Service) resolveCachedUserRegion(ctx context.Context, sysOrigin string, userID int64) (string, error) { region, err := s.redis.Get(ctx, userRegionKey(sysOrigin, userID)).Result() if err == nil && strings.TrimSpace(region) != "" { return strings.ToUpper(strings.TrimSpace(region)), nil } return s.resolveUserRegion(ctx, sysOrigin, userID) } func (s *Service) resolveUserRegion(ctx context.Context, sysOrigin string, userID int64) (string, error) { var user model.UserBaseInfo err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user region is missing") } if err != nil { return "", err } countryCode := strings.ToUpper(strings.TrimSpace(user.CountryCode)) if countryCode == "" { return "", NewAppError(http.StatusBadRequest, "voice_room_region_missing", "user country code is missing") } if s != nil && s.regionResolver != nil { regionCode, err := s.regionResolver.ResolveRegionCodeByCountryCode(ctx, sysOrigin, countryCode) if err == nil { if normalized := strings.ToUpper(strings.TrimSpace(regionCode)); normalized != "" { return normalized, nil } } } var mapping model.VoiceRoomRegionCountry err = s.db.WithContext(ctx). Where("sys_origin = ? AND country_code = ? AND enabled = ?", sysOrigin, countryCode, true). First(&mapping).Error if errors.Is(err, gorm.ErrRecordNotFound) { return countryCode, nil } if err != nil { return "", err } regionCode := strings.ToUpper(strings.TrimSpace(mapping.RegionCode)) if regionCode == "" { return countryCode, nil } return regionCode, nil } func onlineRoomKey(sysOrigin string, roomID int64) string { return fmt.Sprintf("voice_room:online:%s:%d", normalizeSysOrigin(sysOrigin), roomID) } func userRoomKey(sysOrigin string, userID int64) string { return fmt.Sprintf("voice_room:user_room:%s:%d", normalizeSysOrigin(sysOrigin), userID) } func userRegionKey(sysOrigin string, userID int64) string { return fmt.Sprintf("voice_room:user_region:%s:%d", normalizeSysOrigin(sysOrigin), userID) } func userSeenKey(sysOrigin string, userID int64) string { return fmt.Sprintf("voice_room:user_seen:%s:%d", normalizeSysOrigin(sysOrigin), userID) } func presenceTTL(cfg config.Config) time.Duration { seconds := cfg.VoiceRoomRedPacket.PresenceTTLSeconds if seconds <= 0 { seconds = defaultPresenceTTLSeconds } return time.Duration(seconds) * time.Second }