owpengram-server/internal/app/groupcalls/service.go

289 lines
10 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.

// Package groupcalls 实现超级群语音聊天group call的信令业务层
// ID/access_hash 分配与 store 编排。权限admin/成员资格)由 rpc 层校验,
// version 单调性与并发串行化由 store 层事务保证。
package groupcalls
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"fmt"
"net/url"
"strconv"
"strings"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
)
// Service 是群通话业务服务。
type Service struct {
store store.GroupCallStore
publicBaseURL string
}
type Option func(*Service)
func WithPublicBaseURL(baseURL string) Option {
return func(s *Service) {
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
}
}
// NewService 创建群通话服务。
func NewService(st store.GroupCallStore, opts ...Option) *Service {
s := &Service{store: st, publicBaseURL: links.DefaultPublicBaseURL}
for _, opt := range opts {
opt(s)
}
return s
}
// Create 分配 id/access_hash 并建会。rtmpStream=true 创建 RTMP 直播房间;
// joinMuted=true广播频道直播让非管理员入会即被静音且不可自解
// scheduleDate>0 创建定时通话(客户端倒计时等待 startScheduled
func (s *Service) Create(ctx context.Context, channelID, creatorUserID int64, title string, rtmpStream, joinMuted bool, scheduleDate, now int) (domain.GroupCall, error) {
id, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
return s.store.CreateGroupCall(ctx, domain.GroupCall{
ID: id,
AccessHash: accessHash,
ChannelID: channelID,
CreatorUserID: creatorUserID,
Title: title,
RtmpStream: rtmpStream,
JoinMuted: joinMuted,
ScheduleDate: scheduleDate,
Version: 1,
CreatedAt: now,
})
}
// StartScheduled 把定时通话转为进行中(清 schedule_datechanged=false 幂等。
func (s *Service) StartScheduled(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
return s.store.StartScheduledGroupCall(ctx, callID)
}
// SetScheduleSubscription 写入/清除开播提醒订阅。
func (s *Service) SetScheduleSubscription(ctx context.Context, callID, userID int64, subscribed bool) error {
return s.store.SetScheduleStartSubscription(ctx, callID, userID, subscribed)
}
// ScheduleSubscriberIDs 返回订阅开播提醒的 userID。
func (s *Service) ScheduleSubscriberIDs(ctx context.Context, callID int64) ([]int64, error) {
return s.store.ListScheduleSubscriberIDs(ctx, callID)
}
// RtmpStreamKey 返回 channel 的持久 RTMP 推流密钥;不存在或 rotate=true 时生成
// 新 key覆盖写入旧 key 即刻失效。key 形如 "<channelID>_<hex>"ingest 端
// 据前缀定位 channel、再整串比对鉴权。
func (s *Service) RtmpStreamKey(ctx context.Context, channelID int64, rotate bool, now int) (string, error) {
if !rotate {
key, found, err := s.store.GetRtmpStreamKey(ctx, channelID)
if err != nil {
return "", err
}
if found {
return key, nil
}
}
var buf [24]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("groupcalls: random rtmp key: %w", err)
}
key := fmt.Sprintf("%d_%x", channelID, buf)
if err := s.store.SetRtmpStreamKey(ctx, channelID, key, now); err != nil {
return "", err
}
return key, nil
}
// VerifyRtmpStreamKey 校验推流密钥并返回其所属 channelIDRTMP ingest 鉴权入口)。
func (s *Service) VerifyRtmpStreamKey(ctx context.Context, key string) (int64, bool, error) {
sep := strings.IndexByte(key, '_')
if sep <= 0 {
return 0, false, nil
}
channelID, err := strconv.ParseInt(key[:sep], 10, 64)
if err != nil || channelID <= 0 {
return 0, false, nil
}
stored, found, err := s.store.GetRtmpStreamKey(ctx, channelID)
if err != nil {
return 0, false, err
}
if !found || subtle.ConstantTimeCompare([]byte(stored), []byte(key)) != 1 {
return 0, false, nil
}
return channelID, true, nil
}
// CreateConference 分配 id/access_hash/slug 并创建 ad-hoc conference call。
func (s *Service) CreateConference(ctx context.Context, creatorUserID, randomID, migratedFromPhoneCallID int64, now int) (domain.GroupCall, error) {
for i := 0; i < 8; i++ {
id, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
accessHash, err := randomPositiveInt64()
if err != nil {
return domain.GroupCall{}, err
}
slug, err := randomSlug()
if err != nil {
return domain.GroupCall{}, err
}
call, err := s.store.CreateConferenceCall(ctx, domain.GroupCall{
ID: id,
AccessHash: accessHash,
CreatorUserID: creatorUserID,
Kind: domain.GroupCallKindConference,
Version: 1,
CreatedAt: now,
InviteSlug: slug,
InviteLink: s.conferenceInviteLink(slug),
RandomID: randomID,
MigratedFromPhoneCallID: migratedFromPhoneCallID,
})
if err == nil {
return call, nil
}
if err != domain.ErrGroupCallInvalid {
return domain.GroupCall{}, err
}
}
return domain.GroupCall{}, fmt.Errorf("groupcalls: exhausted conference slug attempts")
}
func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
return s.store.GetGroupCall(ctx, callID)
}
func (s *Service) GetBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
return s.store.GetGroupCallBySlug(ctx, slug)
}
func (s *Service) GetByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
return s.store.GetGroupCallByInviteMessage(ctx, userID, msgID)
}
func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
return s.store.JoinGroupCall(ctx, req)
}
func (s *Service) Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) {
return s.store.LeaveGroupCall(ctx, callID, userID, now)
}
func (s *Service) RemoveConferenceParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
return s.store.RemoveConferenceCallParticipants(ctx, req)
}
func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
return s.store.DiscardGroupCall(ctx, callID, now)
}
func (s *Service) Touch(ctx context.Context, callID, userID int64, now int) ([]int64, bool, error) {
return s.store.TouchParticipant(ctx, callID, userID, now)
}
func (s *Service) Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) {
return s.store.GetParticipant(ctx, callID, userID)
}
func (s *Service) Participants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) {
return s.store.ListParticipants(ctx, callID, offset, limit)
}
func (s *Service) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) {
return s.store.UpdateParticipant(ctx, callID, userID, update)
}
func (s *Service) SetTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) {
return s.store.SetGroupCallTitle(ctx, callID, title)
}
func (s *Service) SetJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) {
return s.store.SetGroupCallJoinMuted(ctx, callID, joinMuted)
}
func (s *Service) SetStartedMessageID(ctx context.Context, callID int64, msgID int) error {
return s.store.SetStartedMessageID(ctx, callID, msgID)
}
func (s *Service) SweepStale(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) {
return s.store.SweepStaleParticipants(ctx, checkOlderThan, now, limit)
}
func (s *Service) ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) {
return s.store.ResetAllParticipants(ctx, now)
}
func (s *Service) NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) {
return s.store.NextRaiseHandRating(ctx, callID)
}
func (s *Service) SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error {
return s.store.SetParticipantOverride(ctx, callID, setterUserID, targetUserID, override, clear)
}
func (s *Service) ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) {
return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID)
}
func (s *Service) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
return s.store.CreateConferenceInvite(ctx, invite)
}
func (s *Service) SetConferenceInviteStatus(ctx context.Context, callID, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
return s.store.SetConferenceInviteStatus(ctx, callID, inviteeUserID, msgID, status, now)
}
func (s *Service) ConferenceRecipients(ctx context.Context, callID int64) ([]int64, error) {
return s.store.ListConferenceRecipientUserIDs(ctx, callID)
}
func (s *Service) AppendChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
return s.store.AppendGroupCallChainBlock(ctx, block)
}
func (s *Service) ChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
return s.store.ListGroupCallChainBlocks(ctx, callID, subChainID, offset, limit)
}
func randomPositiveInt64() (int64, error) {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return 0, fmt.Errorf("groupcalls: random id: %w", err)
}
v := int64(binary.BigEndian.Uint64(buf[:]) >> 1)
if v == 0 {
v = 1
}
return v, nil
}
func randomSlug() (string, error) {
var buf [18]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("groupcalls: random slug: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
}
func (s *Service) conferenceInviteLink(slug string) string {
baseURL := links.DefaultPublicBaseURL
if s != nil && s.publicBaseURL != "" {
baseURL = s.publicBaseURL
}
return links.Build(baseURL, "call/"+slug, url.Values{"slug": []string{slug}})
}