feat: sync group call livestream support

This commit is contained in:
A 2026-07-06 14:31:19 +08:00
parent 56d995474c
commit f1a27996d3
37 changed files with 2219 additions and 83 deletions

View file

@ -63,4 +63,15 @@ type GroupCallStore interface {
ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error)
AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error)
ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error)
// GetRtmpStreamKey / SetRtmpStreamKey 维护 per-channel 的持久 RTMP 推流密钥。
// revoke 语义由上层实现:Set 覆盖旧 key,旧 key 推流即刻失效。
GetRtmpStreamKey(ctx context.Context, channelID int64) (string, bool, error)
SetRtmpStreamKey(ctx context.Context, channelID int64, key string, now int) error
// StartScheduledGroupCall 清零 schedule_date(定时通话正式开始)。
// changed=false 表示本来就已开始(幂等);discarded 返回 ErrGroupCallDiscarded。
StartScheduledGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error)
// SetScheduleStartSubscription 写入/清除 userID 的开播提醒订阅。
SetScheduleStartSubscription(ctx context.Context, callID, userID int64, subscribed bool) error
// ListScheduleSubscriberIDs 返回订阅了开播提醒的 userID(升序)。
ListScheduleSubscriberIDs(ctx context.Context, callID int64) ([]int64, error)
}

View file

@ -45,7 +45,9 @@ type GroupCallStore struct {
inviteByMessage map[inviteMessageKey]domain.GroupCallInvite
chainBlocks map[chainKey][]domain.GroupCallChainBlock
overrides map[overrideKey]domain.GroupCallParticipantOverride
raiseHandSeq map[int64]int64 // callID → 单调举手序号
raiseHandSeq map[int64]int64 // callID → 单调举手序号
rtmpKeys map[int64]string // channelID → RTMP 推流密钥
scheduleSubs map[int64]map[int64]struct{} // callID → 订阅开播提醒的 userID
nextSyntheticID int64
}
@ -62,6 +64,8 @@ func NewGroupCallStore() *GroupCallStore {
chainBlocks: make(map[chainKey][]domain.GroupCallChainBlock),
overrides: make(map[overrideKey]domain.GroupCallParticipantOverride),
raiseHandSeq: make(map[int64]int64),
rtmpKeys: make(map[int64]string),
scheduleSubs: make(map[int64]map[int64]struct{}),
}
}
@ -176,12 +180,13 @@ func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCa
existing, rejoining := rows[req.UserID]
wasActive := rejoining && !existing.Left
p := domain.GroupCallParticipant{
CallID: req.CallID,
UserID: req.UserID,
SSRC: req.SSRC,
JoinDate: req.Now,
ActiveDate: req.Now,
LastCheckDate: req.Now,
CallID: req.CallID,
UserID: req.UserID,
JoinAsChannelID: req.JoinAsChannelID,
SSRC: req.SSRC,
JoinDate: req.Now,
ActiveDate: req.Now,
LastCheckDate: req.Now,
// VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端
// 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。
VideoJSON: append([]byte(nil), req.VideoJSON...),
@ -803,6 +808,72 @@ func (s *GroupCallStore) ListGroupCallChainBlocks(_ context.Context, callID int6
return page, nil
}
func (s *GroupCallStore) StartScheduledGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
call, ok := s.calls[callID]
if !ok {
return domain.GroupCall{}, false, domain.ErrGroupCallInvalid
}
if !call.Active() {
return domain.GroupCall{}, false, domain.ErrGroupCallDiscarded
}
if call.ScheduleDate == 0 {
return call, false, nil
}
call.ScheduleDate = 0
s.calls[callID] = call
return call, true, nil
}
func (s *GroupCallStore) SetScheduleStartSubscription(_ context.Context, callID, userID int64, subscribed bool) error {
if callID == 0 || userID == 0 {
return domain.ErrGroupCallInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
subs := s.scheduleSubs[callID]
if subscribed {
if subs == nil {
subs = make(map[int64]struct{})
s.scheduleSubs[callID] = subs
}
subs[userID] = struct{}{}
return nil
}
delete(subs, userID)
return nil
}
func (s *GroupCallStore) ListScheduleSubscriberIDs(_ context.Context, callID int64) ([]int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
subs := s.scheduleSubs[callID]
out := make([]int64, 0, len(subs))
for id := range subs {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *GroupCallStore) GetRtmpStreamKey(_ context.Context, channelID int64) (string, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key, ok := s.rtmpKeys[channelID]
return key, ok, nil
}
func (s *GroupCallStore) SetRtmpStreamKey(_ context.Context, channelID int64, key string, _ int) error {
if channelID == 0 || key == "" {
return domain.ErrGroupCallInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
s.rtmpKeys[channelID] = key
return nil
}
func max(a, b int) int {
if a > b {
return a

View file

@ -27,9 +27,9 @@ func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore {
const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
version, participants_count, created_at, discarded_at, duration, started_msg_id,
invite_slug, invite_link, random_id, migrated_from_phone_call_id`
invite_slug, invite_link, random_id, migrated_from_phone_call_id, rtmp_stream, schedule_date`
const groupCallParticipantColumns = `call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin,
const groupCallParticipantColumns = `call_id, user_id, join_as_channel_id, ssrc, join_date, active_date, muted, muted_by_admin,
volume_by_admin, raise_hand_rating, video_json, presentation_json, public_key, join_block, left_call, last_check_date`
func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
@ -38,7 +38,7 @@ func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
if err := row.Scan(
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID, &c.RtmpStream, &c.ScheduleDate,
); err != nil {
return domain.GroupCall{}, err
}
@ -50,7 +50,7 @@ func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
func scanGroupCallParticipant(row rowScanner) (domain.GroupCallParticipant, error) {
var p domain.GroupCallParticipant
if err := row.Scan(
&p.CallID, &p.UserID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin,
&p.CallID, &p.UserID, &p.JoinAsChannelID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin,
&p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.PublicKey, &p.JoinBlock, &p.Left, &p.LastCheckDate,
); err != nil {
return domain.GroupCallParticipant{}, err
@ -73,7 +73,7 @@ func scanGroupCallInviteJoined(row rowScanner) (domain.GroupCall, domain.GroupCa
if err := row.Scan(
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &kind, &state, &c.Title, &c.JoinMuted,
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID,
&c.InviteSlug, &c.InviteLink, &c.RandomID, &c.MigratedFromPhoneCallID, &c.RtmpStream, &c.ScheduleDate,
&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &status, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt,
); err != nil {
return domain.GroupCall{}, domain.GroupCallInvite{}, err
@ -155,9 +155,9 @@ func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupC
call.Version = 1
}
_, err := s.db.Exec(ctx, `
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted, version, participants_count, created_at)
VALUES ($1, $2, $3, $4, 'channel', 'active', $5, $6, $7, 0, $8)`,
call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt)
INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted, version, participants_count, created_at, rtmp_stream, schedule_date)
VALUES ($1, $2, $3, $4, 'channel', 'active', $5, $6, $7, 0, $8, $9, $10)`,
call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt, call.RtmpStream, call.ScheduleDate)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@ -299,14 +299,15 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
return domain.GroupCallMutation{}, fmt.Errorf("load group call participant: %w", err)
}
p := domain.GroupCallParticipant{
CallID: req.CallID,
UserID: req.UserID,
SSRC: req.SSRC,
JoinDate: req.Now,
ActiveDate: req.Now,
LastCheckDate: req.Now,
PublicKey: append([]byte(nil), req.PublicKey...),
JoinBlock: append([]byte(nil), req.JoinBlock...),
CallID: req.CallID,
UserID: req.UserID,
JoinAsChannelID: req.JoinAsChannelID,
SSRC: req.SSRC,
JoinDate: req.Now,
ActiveDate: req.Now,
LastCheckDate: req.Now,
PublicKey: append([]byte(nil), req.PublicKey...),
JoinBlock: append([]byte(nil), req.JoinBlock...),
}
if wasActive {
// 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。
@ -320,9 +321,10 @@ func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroup
// video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发
// joinGroupCallPresentation,旧屏幕登记必须作废)。
if _, err := tx.Exec(ctx, `
INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, public_key, join_block, left_call, last_check_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, $9, $10, FALSE, $11)
INSERT INTO group_call_participants (call_id, user_id, join_as_channel_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, public_key, join_block, left_call, last_check_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, 0, $9, $10, $11, FALSE, $12)
ON CONFLICT (call_id, user_id) DO UPDATE SET
join_as_channel_id = EXCLUDED.join_as_channel_id,
ssrc = EXCLUDED.ssrc,
join_date = EXCLUDED.join_date,
active_date = EXCLUDED.active_date,
@ -336,7 +338,7 @@ ON CONFLICT (call_id, user_id) DO UPDATE SET
join_block = EXCLUDED.join_block,
left_call = FALSE,
last_check_date = EXCLUDED.last_check_date`,
req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin,
req.CallID, req.UserID, req.JoinAsChannelID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin,
nullableJSON(p.VideoJSON), nullableGroupCallBytes(p.PublicKey), nullableGroupCallBytes(p.JoinBlock), p.LastCheckDate); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@ -1147,6 +1149,108 @@ RETURNING call_id, sub_chain_id, block_offset, author_user_id, block, created_at
return block, nil
}
func (s *GroupCallStore) GetRtmpStreamKey(ctx context.Context, channelID int64) (string, bool, error) {
var key string
err := s.db.QueryRow(ctx,
`SELECT stream_key FROM group_call_rtmp_keys WHERE channel_id = $1`, channelID).Scan(&key)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("get rtmp stream key: %w", err)
}
return key, true, nil
}
func (s *GroupCallStore) SetRtmpStreamKey(ctx context.Context, channelID int64, key string, now int) error {
if channelID == 0 || key == "" {
return domain.ErrGroupCallInvalid
}
if _, err := s.db.Exec(ctx, `
INSERT INTO group_call_rtmp_keys (channel_id, stream_key, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id) DO UPDATE SET stream_key = EXCLUDED.stream_key, updated_at = EXCLUDED.updated_at`,
channelID, key, now); err != nil {
return fmt.Errorf("set rtmp stream key: %w", err)
}
return nil
}
func (s *GroupCallStore) StartScheduledGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
tx, err := s.begin(ctx, "start scheduled group call")
if err != nil {
return domain.GroupCall{}, false, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, callID)
if err != nil {
return domain.GroupCall{}, false, err
}
if !call.Active() {
return domain.GroupCall{}, false, domain.ErrGroupCallDiscarded
}
if call.ScheduleDate == 0 {
// 幂等:已开始。
if err := tx.Commit(ctx); err != nil {
return domain.GroupCall{}, false, fmt.Errorf("commit start scheduled noop: %w", err)
}
committed = true
return call, false, nil
}
call, err = scanGroupCall(tx.QueryRow(ctx, `
UPDATE group_calls SET schedule_date = 0 WHERE call_id = $1 RETURNING `+groupCallColumns, callID))
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("start scheduled group call: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCall{}, false, fmt.Errorf("commit start scheduled group call: %w", err)
}
committed = true
return call, true, nil
}
func (s *GroupCallStore) SetScheduleStartSubscription(ctx context.Context, callID, userID int64, subscribed bool) error {
if callID == 0 || userID == 0 {
return domain.ErrGroupCallInvalid
}
if !subscribed {
if _, err := s.db.Exec(ctx,
`DELETE FROM group_call_schedule_subscribers WHERE call_id = $1 AND user_id = $2`, callID, userID); err != nil {
return fmt.Errorf("clear schedule subscription: %w", err)
}
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO group_call_schedule_subscribers (call_id, user_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING`, callID, userID); err != nil {
return fmt.Errorf("set schedule subscription: %w", err)
}
return nil
}
func (s *GroupCallStore) ListScheduleSubscriberIDs(ctx context.Context, callID int64) ([]int64, error) {
rows, err := s.db.Query(ctx,
`SELECT user_id FROM group_call_schedule_subscribers WHERE call_id = $1 ORDER BY user_id`, callID)
if err != nil {
return nil, fmt.Errorf("list schedule subscribers: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func (s *GroupCallStore) ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
if limit <= 0 || limit > 100 {
limit = 100