owpengram-server/internal/store/postgres/groupcall.go

1199 lines
42 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 postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// GroupCallStore 是 store.GroupCallStore 的 PostgreSQL 实现(迁移 0092
// 所有参与者维度变更在事务内对 group_calls 行 FOR UPDATE 后 version+1
// 行锁天然串行化并发变更,保证 version 单调无跳号。
type GroupCallStore struct {
db sqlcgen.DBTX
}
// NewGroupCallStore 基于 pgx 连接池创建 GroupCallStore。
func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore {
return &GroupCallStore{db: db}
}
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`
const groupCallParticipantColumns = `call_id, user_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) {
var c domain.GroupCall
var kind, state string
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,
); err != nil {
return domain.GroupCall{}, err
}
c.Kind = domain.GroupCallKind(kind)
c.State = domain.GroupCallState(state)
return c, nil
}
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.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.PublicKey, &p.JoinBlock, &p.Left, &p.LastCheckDate,
); err != nil {
return domain.GroupCallParticipant{}, err
}
return p, nil
}
func prefixedGroupCallColumns(alias string) string {
parts := strings.Split(groupCallColumns, ",")
for i, part := range parts {
parts[i] = alias + "." + strings.TrimSpace(part)
}
return strings.Join(parts, ", ")
}
func scanGroupCallInviteJoined(row rowScanner) (domain.GroupCall, domain.GroupCallInvite, error) {
var c domain.GroupCall
var inv domain.GroupCallInvite
var kind, state, status string
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,
&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &status, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt,
); err != nil {
return domain.GroupCall{}, domain.GroupCallInvite{}, err
}
c.Kind = domain.GroupCallKind(kind)
c.State = domain.GroupCallState(state)
inv.Status = domain.GroupCallInviteStatus(status)
return c, inv, nil
}
func (s *GroupCallStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return nil, fmt.Errorf("%s: db does not support transactions", op)
}
tx, err := beginner.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin %s: %w", op, err)
}
return tx, nil
}
// lockGroupCallTx 锁定 call 行(串行化参与者变更的唯一入口)。
func lockGroupCallTx(ctx context.Context, tx pgx.Tx, callID int64) (domain.GroupCall, error) {
call, err := scanGroupCall(tx.QueryRow(ctx,
`SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1 FOR UPDATE`, callID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, domain.ErrGroupCallInvalid
}
if err != nil {
return domain.GroupCall{}, fmt.Errorf("lock group call: %w", err)
}
return call, nil
}
func bumpGroupCallVersionTx(ctx context.Context, tx pgx.Tx, callID int64, countDelta int) (domain.GroupCall, error) {
call, err := scanGroupCall(tx.QueryRow(ctx, `
UPDATE group_calls
SET version = version + 1,
participants_count = GREATEST(0, participants_count + $2)
WHERE call_id = $1
RETURNING `+groupCallColumns, callID, countDelta))
if err != nil {
return domain.GroupCall{}, fmt.Errorf("bump group call version: %w", err)
}
return call, nil
}
func bumpGroupCallParticipantsTx(ctx context.Context, tx pgx.Tx, callID int64, countDelta, now int) (domain.GroupCall, error) {
call, err := scanGroupCall(tx.QueryRow(ctx, `
UPDATE group_calls
SET version = version + 1,
participants_count = GREATEST(0, participants_count + $2),
state = CASE
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN 'discarded'
ELSE state
END,
discarded_at = CASE
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN $3
ELSE discarded_at
END,
duration = CASE
WHEN kind = 'conference' AND state = 'active' AND GREATEST(0, participants_count + $2) = 0 THEN GREATEST(0, $3 - created_at)
ELSE duration
END
WHERE call_id = $1
RETURNING `+groupCallColumns, callID, countDelta, now))
if err != nil {
return domain.GroupCall{}, fmt.Errorf("bump group call participants: %w", err)
}
return call, nil
}
func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 {
return domain.GroupCall{}, domain.ErrGroupCallInvalid
}
if call.Version <= 0 {
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)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if pgErr.ConstraintName == "group_calls_active_channel_uniq" {
return domain.GroupCall{}, domain.ErrGroupCallAlreadyStarted
}
return domain.GroupCall{}, domain.ErrGroupCallInvalid
}
return domain.GroupCall{}, fmt.Errorf("insert group call: %w", err)
}
call.State = domain.GroupCallStateActive
call.Kind = domain.GroupCallKindChannel
call.ParticipantsCount = 0
return call, nil
}
func (s *GroupCallStore) CreateConferenceCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) {
if call.ID == 0 || call.AccessHash == 0 || call.CreatorUserID == 0 || call.InviteSlug == "" || call.InviteLink == "" {
return domain.GroupCall{}, domain.ErrGroupCallInvalid
}
if call.Version <= 0 {
call.Version = 1
}
call.ChannelID = 0
call.Kind = domain.GroupCallKindConference
call.State = domain.GroupCallStateActive
call.ParticipantsCount = 0
created, err := scanGroupCall(s.db.QueryRow(ctx, `
INSERT INTO group_calls (
call_id, access_hash, channel_id, creator_user_id, kind, state, title, join_muted,
version, participants_count, created_at, invite_slug, invite_link, random_id, migrated_from_phone_call_id
) VALUES ($1, $2, 0, $3, 'conference', 'active', $4, FALSE, $5, 0, $6, $7, $8, $9, $10)
ON CONFLICT DO NOTHING
RETURNING `+groupCallColumns,
call.ID, call.AccessHash, call.CreatorUserID, call.Title, call.Version, call.CreatedAt,
call.InviteSlug, call.InviteLink, call.RandomID, call.MigratedFromPhoneCallID))
if err == nil {
return created, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, fmt.Errorf("insert conference call: %w", err)
}
if call.RandomID != 0 {
existing, found, getErr := s.getConferenceByRandom(ctx, call.CreatorUserID, call.RandomID)
if getErr != nil {
return domain.GroupCall{}, getErr
}
if found {
return existing, nil
}
}
if existing, found, getErr := s.GetGroupCallBySlug(ctx, call.InviteSlug); getErr != nil {
return domain.GroupCall{}, getErr
} else if found {
return existing, nil
}
return domain.GroupCall{}, domain.ErrGroupCallInvalid
}
func (s *GroupCallStore) getConferenceByRandom(ctx context.Context, creatorID, randomID int64) (domain.GroupCall, bool, error) {
call, err := scanGroupCall(s.db.QueryRow(ctx,
`SELECT `+groupCallColumns+` FROM group_calls WHERE kind = 'conference' AND creator_user_id = $1 AND random_id = $2`,
creatorID, randomID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, false, nil
}
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("get conference by random: %w", err)
}
return call, true, nil
}
func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) {
call, err := scanGroupCall(s.db.QueryRow(ctx,
`SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1`, callID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, false, nil
}
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("get group call: %w", err)
}
return call, true, nil
}
func (s *GroupCallStore) GetGroupCallBySlug(ctx context.Context, slug string) (domain.GroupCall, bool, error) {
call, err := scanGroupCall(s.db.QueryRow(ctx,
`SELECT `+groupCallColumns+` FROM group_calls WHERE invite_slug = $1`, slug))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, false, nil
}
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("get group call by slug: %w", err)
}
return call, true, nil
}
func (s *GroupCallStore) GetGroupCallByInviteMessage(ctx context.Context, userID int64, msgID int) (domain.GroupCall, domain.GroupCallInvite, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT `+prefixedGroupCallColumns("c")+`, i.call_id, i.inviter_user_id, i.invitee_user_id, i.message_id, i.status, i.video, i.created_at, i.updated_at
FROM group_call_invites i
JOIN group_calls c ON c.call_id = i.call_id
WHERE i.invitee_user_id = $1 AND i.message_id = $2`, userID, msgID)
call, inv, err := scanGroupCallInviteJoined(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, domain.GroupCallInvite{}, false, nil
}
if err != nil {
return domain.GroupCall{}, domain.GroupCallInvite{}, false, fmt.Errorf("get group call by invite message: %w", err)
}
return call, inv, true, nil
}
func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) {
if req.SSRC == 0 {
return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid
}
tx, err := s.begin(ctx, "join group call")
if err != nil {
return domain.GroupCallMutation{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, req.CallID)
if err != nil {
return domain.GroupCallMutation{}, err
}
if !call.Active() {
return domain.GroupCallMutation{}, domain.ErrGroupCallDiscarded
}
existing, err := scanGroupCallParticipant(tx.QueryRow(ctx,
`SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`,
req.CallID, req.UserID))
wasActive := err == nil && !existing.Left
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
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...),
}
if wasActive {
// 同人换 ssrc 的 rejoin 保留原 join_date列表排序稳定
p.JoinDate = existing.JoinDate
}
if call.JoinMuted && !req.IsAdmin {
p.Muted = true
p.MutedByAdmin = true
}
p.VideoJSON = append([]byte(nil), req.VideoJSON...)
// 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)
ON CONFLICT (call_id, user_id) DO UPDATE SET
ssrc = EXCLUDED.ssrc,
join_date = EXCLUDED.join_date,
active_date = EXCLUDED.active_date,
muted = EXCLUDED.muted,
muted_by_admin = EXCLUDED.muted_by_admin,
volume_by_admin = 0,
raise_hand_rating = 0,
video_json = EXCLUDED.video_json,
presentation_json = NULL,
public_key = EXCLUDED.public_key,
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,
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" {
return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate
}
return domain.GroupCallMutation{}, fmt.Errorf("upsert group call participant: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE group_call_invites
SET status = 'accepted', updated_at = $3
WHERE call_id = $1 AND invitee_user_id = $2 AND status = 'pending'`,
req.CallID, req.UserID, req.Now); err != nil {
return domain.GroupCallMutation{}, fmt.Errorf("accept conference invites: %w", err)
}
countDelta := 0
if !wasActive {
countDelta = 1
}
call, err = bumpGroupCallVersionTx(ctx, tx, req.CallID, countDelta)
if err != nil {
return domain.GroupCallMutation{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCallMutation{}, fmt.Errorf("commit join group call: %w", err)
}
committed = true
return domain.GroupCallMutation{Call: call, Participant: p}, nil
}
func (s *GroupCallStore) LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) {
tx, err := s.begin(ctx, "leave group call")
if err != nil {
return domain.GroupCallMutation{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if _, err := lockGroupCallTx(ctx, tx, callID); err != nil {
return domain.GroupCallMutation{}, err
}
p, err := scanGroupCallParticipant(tx.QueryRow(ctx, `
UPDATE group_call_participants
SET left_call = TRUE, active_date = $3
WHERE call_id = $1 AND user_id = $2 AND NOT left_call
RETURNING `+groupCallParticipantColumns, callID, userID, now))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallMutation{}, domain.ErrGroupCallNotJoined
}
if err != nil {
return domain.GroupCallMutation{}, fmt.Errorf("leave group call participant: %w", err)
}
call, err := bumpGroupCallParticipantsTx(ctx, tx, callID, -1, now)
if err != nil {
return domain.GroupCallMutation{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCallMutation{}, fmt.Errorf("commit leave group call: %w", err)
}
committed = true
return domain.GroupCallMutation{Call: call, Participant: p}, nil
}
func (s *GroupCallStore) RemoveConferenceCallParticipants(ctx context.Context, req domain.RemoveConferenceCallParticipantsRequest) (domain.RemoveConferenceCallParticipantsResult, error) {
if req.CallID == 0 || len(req.TargetUserIDs) == 0 || req.OnlyLeft == req.Kick {
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
}
tx, err := s.begin(ctx, "remove conference call participants")
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, req.CallID)
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
if !call.Conference() {
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallInvalid
}
if !call.Active() {
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrGroupCallDiscarded
}
targets := uniqueNonZeroInt64s(req.TargetUserIDs...)
if len(targets) == 0 {
if err := tx.Commit(ctx); err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit no-op conference participant removal: %w", err)
}
committed = true
return domain.RemoveConferenceCallParticipantsResult{Call: call}, nil
}
rows, err := tx.Query(ctx, `
SELECT `+groupCallParticipantColumns+`
FROM group_call_participants
WHERE call_id = $1 AND user_id = ANY($2)
FOR UPDATE`, req.CallID, targets)
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("lock conference participants: %w", err)
}
byID := make(map[int64]domain.GroupCallParticipant, len(targets))
for rows.Next() {
p, err := scanGroupCallParticipant(rows)
if err != nil {
rows.Close()
return domain.RemoveConferenceCallParticipantsResult{}, err
}
byID[p.UserID] = p
}
rows.Close()
if err := rows.Err(); err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
e2eTargets := make([]int64, 0, len(targets))
mediaTargets := make([]int64, 0, len(targets))
for _, targetID := range targets {
p, ok := byID[targetID]
if !ok {
continue
}
hasE2EMarker := len(p.JoinBlock) > 0
if req.OnlyLeft {
if p.Left && hasE2EMarker {
e2eTargets = append(e2eTargets, targetID)
}
continue
}
if req.Kick {
if hasE2EMarker {
e2eTargets = append(e2eTargets, targetID)
}
if !p.Left {
mediaTargets = append(mediaTargets, targetID)
}
}
}
out := domain.RemoveConferenceCallParticipantsResult{Call: call}
if len(e2eTargets) > 0 {
if len(req.Block) == 0 {
return domain.RemoveConferenceCallParticipantsResult{}, domain.ErrConferenceChainInvalid
}
block, err := appendGroupCallChainBlockTx(ctx, tx, domain.GroupCallChainBlock{
CallID: req.CallID,
SubChainID: 0,
Offset: -1,
AuthorUserID: req.AuthorUserID,
Block: req.Block,
CreatedAt: req.Now,
})
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
out.ChainBlock = block
out.ChainBlockAppended = true
if _, err := tx.Exec(ctx, `
UPDATE group_call_participants
SET public_key = NULL, join_block = NULL
WHERE call_id = $1 AND user_id = ANY($2)`, req.CallID, e2eTargets); err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("clear conference e2e participants: %w", err)
}
}
if len(mediaTargets) > 0 {
rows, err := tx.Query(ctx, `
UPDATE group_call_participants
SET left_call = TRUE, active_date = $3
WHERE call_id = $1 AND user_id = ANY($2) AND NOT left_call
RETURNING `+groupCallParticipantColumns, req.CallID, mediaTargets, req.Now)
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("leave kicked conference participants: %w", err)
}
for rows.Next() {
p, err := scanGroupCallParticipant(rows)
if err != nil {
rows.Close()
return domain.RemoveConferenceCallParticipantsResult{}, err
}
out.ParticipantsChanged = append(out.ParticipantsChanged, p)
}
rows.Close()
if err := rows.Err(); err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
if len(out.ParticipantsChanged) > 0 {
call, err = bumpGroupCallParticipantsTx(ctx, tx, req.CallID, -len(out.ParticipantsChanged), req.Now)
if err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, err
}
out.Call = call
}
}
if err := tx.Commit(ctx); err != nil {
return domain.RemoveConferenceCallParticipantsResult{}, fmt.Errorf("commit conference participant removal: %w", err)
}
committed = true
return out, nil
}
func (s *GroupCallStore) DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) {
tx, err := s.begin(ctx, "discard group call")
if err != nil {
return domain.GroupCall{}, nil, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, callID)
if err != nil {
return domain.GroupCall{}, nil, err
}
if !call.Active() {
return domain.GroupCall{}, nil, domain.ErrGroupCallDiscarded
}
rows, err := tx.Query(ctx, `
UPDATE group_call_participants
SET left_call = TRUE, active_date = $2
WHERE call_id = $1 AND NOT left_call
RETURNING `+groupCallParticipantColumns, callID, now)
if err != nil {
return domain.GroupCall{}, nil, fmt.Errorf("clear group call participants: %w", err)
}
var active []domain.GroupCallParticipant
for rows.Next() {
p, err := scanGroupCallParticipant(rows)
if err != nil {
rows.Close()
return domain.GroupCall{}, nil, err
}
active = append(active, p)
}
rows.Close()
if err := rows.Err(); err != nil {
return domain.GroupCall{}, nil, err
}
call, err = scanGroupCall(tx.QueryRow(ctx, `
UPDATE group_calls
SET state = 'discarded', discarded_at = $2, duration = GREATEST(0, $2 - created_at),
participants_count = 0, version = version + 1
WHERE call_id = $1
RETURNING `+groupCallColumns, callID, now))
if err != nil {
return domain.GroupCall{}, nil, fmt.Errorf("discard group call: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCall{}, nil, fmt.Errorf("commit discard group call: %w", err)
}
committed = true
return call, active, nil
}
func (s *GroupCallStore) TouchParticipant(ctx context.Context, callID, userID int64, now int) ([]int64, bool, error) {
call, found, err := s.GetGroupCall(ctx, callID)
if err != nil {
return nil, false, err
}
if !found {
return nil, false, domain.ErrGroupCallInvalid
}
if !call.Active() {
return nil, false, nil
}
var ssrc int64
err = s.db.QueryRow(ctx, `
UPDATE group_call_participants
SET last_check_date = $3, active_date = $3
WHERE call_id = $1 AND user_id = $2 AND NOT left_call
RETURNING ssrc`, callID, userID, now).Scan(&ssrc)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("touch group call participant: %w", err)
}
return []int64{ssrc}, true, nil
}
func (s *GroupCallStore) GetParticipant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) {
p, err := scanGroupCallParticipant(s.db.QueryRow(ctx,
`SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`,
callID, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallParticipant{}, false, nil
}
if err != nil {
return domain.GroupCallParticipant{}, false, fmt.Errorf("get group call participant: %w", err)
}
return p, true, nil
}
func (s *GroupCallStore) ListParticipants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) {
if limit <= 0 || limit > 200 {
limit = 200
}
call, found, err := s.GetGroupCall(ctx, callID)
if err != nil {
return domain.GroupCallParticipantPage{}, err
}
if !found {
return domain.GroupCallParticipantPage{}, domain.ErrGroupCallInvalid
}
page := domain.GroupCallParticipantPage{Version: call.Version}
if err := s.db.QueryRow(ctx,
`SELECT COUNT(*) FROM group_call_participants WHERE call_id = $1 AND NOT left_call`, callID,
).Scan(&page.Count); err != nil {
return domain.GroupCallParticipantPage{}, fmt.Errorf("count group call participants: %w", err)
}
offDate, offUser, hasOffset := parseGroupCallOffset(offset)
query := `SELECT ` + groupCallParticipantColumns + ` FROM group_call_participants
WHERE call_id = $1 AND NOT left_call`
args := []any{callID}
if hasOffset {
query += ` AND (join_date, user_id) > ($2, $3)`
args = append(args, offDate, offUser)
}
query += fmt.Sprintf(` ORDER BY join_date ASC, user_id ASC LIMIT %d`, limit)
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return domain.GroupCallParticipantPage{}, fmt.Errorf("list group call participants: %w", err)
}
defer rows.Close()
for rows.Next() {
p, err := scanGroupCallParticipant(rows)
if err != nil {
return domain.GroupCallParticipantPage{}, err
}
page.Participants = append(page.Participants, p)
}
if err := rows.Err(); err != nil {
return domain.GroupCallParticipantPage{}, err
}
if n := len(page.Participants); n == limit && n < page.Count {
last := page.Participants[n-1]
page.NextOffset = formatGroupCallOffset(last.JoinDate, last.UserID)
}
return page, nil
}
func (s *GroupCallStore) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) {
tx, err := s.begin(ctx, "update group call participant")
if err != nil {
return domain.GroupCallMutation{}, false, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, callID)
if err != nil {
return domain.GroupCallMutation{}, false, err
}
if !call.Active() {
return domain.GroupCallMutation{}, false, domain.ErrGroupCallDiscarded
}
p, err := scanGroupCallParticipant(tx.QueryRow(ctx,
`SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`,
callID, userID))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined
}
if err != nil {
return domain.GroupCallMutation{}, false, fmt.Errorf("load group call participant: %w", err)
}
if p.Left {
return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined
}
changed := applyGroupCallParticipantUpdateRow(&p, update)
if !changed {
return domain.GroupCallMutation{Call: call, Participant: p}, false, nil
}
if update.Now > 0 {
p.ActiveDate = update.Now
}
if _, err := tx.Exec(ctx, `
UPDATE group_call_participants
SET muted = $3, muted_by_admin = $4, volume_by_admin = $5, raise_hand_rating = $6,
video_json = $7, presentation_json = $8, active_date = $9
WHERE call_id = $1 AND user_id = $2`,
callID, userID, p.Muted, p.MutedByAdmin, p.VolumeByAdmin, p.RaiseHandRating,
nullableJSON(p.VideoJSON), nullableJSON(p.PresentationJSON), p.ActiveDate); err != nil {
return domain.GroupCallMutation{}, false, fmt.Errorf("update group call participant: %w", err)
}
call, err = bumpGroupCallVersionTx(ctx, tx, callID, 0)
if err != nil {
return domain.GroupCallMutation{}, false, err
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCallMutation{}, false, fmt.Errorf("commit update group call participant: %w", err)
}
committed = true
return domain.GroupCallMutation{Call: call, Participant: p}, true, nil
}
func (s *GroupCallStore) SetGroupCallTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) {
call, err := scanGroupCall(s.db.QueryRow(ctx, `
UPDATE group_calls SET title = $2 WHERE call_id = $1 RETURNING `+groupCallColumns, callID, title))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, false, domain.ErrGroupCallInvalid
}
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("set group call title: %w", err)
}
return call, true, nil
}
func (s *GroupCallStore) SetGroupCallJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) {
call, err := scanGroupCall(s.db.QueryRow(ctx, `
UPDATE group_calls SET join_muted = $2 WHERE call_id = $1 RETURNING `+groupCallColumns, callID, joinMuted))
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCall{}, false, domain.ErrGroupCallInvalid
}
if err != nil {
return domain.GroupCall{}, false, fmt.Errorf("set group call join muted: %w", err)
}
return call, true, nil
}
func (s *GroupCallStore) SetStartedMessageID(ctx context.Context, callID int64, msgID int) error {
if _, err := s.db.Exec(ctx,
`UPDATE group_calls SET started_msg_id = $2 WHERE call_id = $1`, callID, msgID); err != nil {
return fmt.Errorf("set group call started msg: %w", err)
}
return nil
}
func (s *GroupCallStore) SweepStaleParticipants(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.Query(ctx, `
SELECT p.call_id, p.user_id
FROM group_call_participants p
JOIN group_calls c ON c.call_id = p.call_id AND c.state = 'active'
WHERE NOT p.left_call AND p.last_check_date < $1
ORDER BY p.call_id, p.user_id
LIMIT $2`, checkOlderThan, limit)
if err != nil {
return nil, fmt.Errorf("list stale group call participants: %w", err)
}
type key struct{ callID, userID int64 }
var stale []key
for rows.Next() {
var k key
if err := rows.Scan(&k.callID, &k.userID); err != nil {
rows.Close()
return nil, err
}
stale = append(stale, k)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var out []domain.GroupCallMutation
for _, k := range stale {
mut, err := s.LeaveGroupCall(ctx, k.callID, k.userID, now)
if errors.Is(err, domain.ErrGroupCallNotJoined) || errors.Is(err, domain.ErrGroupCallInvalid) {
continue // 并发 leave/discard 竞态,无害
}
if err != nil {
return out, err
}
out = append(out, mut)
}
return out, nil
}
func (s *GroupCallStore) ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) {
rows, err := s.db.Query(ctx, `
SELECT DISTINCT c.call_id
FROM group_calls c
JOIN group_call_participants p ON p.call_id = c.call_id AND NOT p.left_call
WHERE c.state = 'active'`)
if err != nil {
return nil, fmt.Errorf("list active group calls: %w", err)
}
var callIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
callIDs = append(callIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var out []domain.GroupCall
for _, callID := range callIDs {
tx, err := s.begin(ctx, "reset group call participants")
if err != nil {
return out, err
}
if _, err := lockGroupCallTx(ctx, tx, callID); err != nil {
_ = tx.Rollback(ctx)
continue
}
if _, err := tx.Exec(ctx, `
UPDATE group_call_participants SET left_call = TRUE, active_date = $2
WHERE call_id = $1 AND NOT left_call`, callID, now); err != nil {
_ = tx.Rollback(ctx)
return out, fmt.Errorf("reset group call participants: %w", err)
}
call, err := scanGroupCall(tx.QueryRow(ctx, `
UPDATE group_calls
SET participants_count = 0,
version = version + 1,
state = CASE
WHEN kind = 'conference' AND state = 'active' THEN 'discarded'
ELSE state
END,
discarded_at = CASE
WHEN kind = 'conference' AND state = 'active' THEN $2
ELSE discarded_at
END,
duration = CASE
WHEN kind = 'conference' AND state = 'active' THEN GREATEST(0, $2 - created_at)
ELSE duration
END
WHERE call_id = $1
RETURNING `+groupCallColumns, callID, now))
if err != nil {
_ = tx.Rollback(ctx)
return out, fmt.Errorf("reset group call version: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return out, fmt.Errorf("commit reset group call: %w", err)
}
out = append(out, call)
}
return out, nil
}
// applyGroupCallParticipantUpdateRow 与 memory 实现共享同一字段语义contract test 钉死)。
func applyGroupCallParticipantUpdateRow(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool {
changed := false
if u.Muted != nil && p.Muted != *u.Muted {
p.Muted = *u.Muted
changed = true
}
if u.MutedByAdmin != nil && p.MutedByAdmin != *u.MutedByAdmin {
p.MutedByAdmin = *u.MutedByAdmin
changed = true
}
if u.VolumeByAdmin != nil && p.VolumeByAdmin != *u.VolumeByAdmin {
p.VolumeByAdmin = *u.VolumeByAdmin
changed = true
}
if u.RaiseHandRating != nil && p.RaiseHandRating != *u.RaiseHandRating {
p.RaiseHandRating = *u.RaiseHandRating
changed = true
}
if u.VideoJSON != nil && string(p.VideoJSON) != string(*u.VideoJSON) {
p.VideoJSON = append([]byte(nil), *u.VideoJSON...)
changed = true
}
if u.PresentationJSON != nil && string(p.PresentationJSON) != string(*u.PresentationJSON) {
p.PresentationJSON = append([]byte(nil), *u.PresentationJSON...)
changed = true
}
return changed
}
func nullableJSON(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}
func nullableGroupCallBytes(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}
func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) {
if offset == "" {
return 0, 0, false
}
var d int
var u int64
if _, err := fmt.Sscanf(offset, "j:%d:%d", &d, &u); err != nil {
return 0, 0, false
}
return d, u, true
}
func formatGroupCallOffset(joinDate int, userID int64) string {
return fmt.Sprintf("j:%d:%d", joinDate, userID)
}
func (s *GroupCallStore) NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) {
// 全局单调序号用 sequencegroup_calls.version 同步推进保证跨进程单调。
var rating int64
if err := s.db.QueryRow(ctx, `
UPDATE group_calls SET version = version + 1 WHERE call_id = $1 RETURNING version`, callID).Scan(&rating); err != nil {
return 0, fmt.Errorf("next raise hand rating: %w", err)
}
return rating, nil
}
func (s *GroupCallStore) SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error {
if clear {
if _, err := s.db.Exec(ctx,
`DELETE FROM group_call_participant_overrides WHERE call_id = $1 AND setter_user_id = $2 AND target_user_id = $3`,
callID, setterUserID, targetUserID); err != nil {
return fmt.Errorf("clear participant override: %w", err)
}
return nil
}
if _, err := s.db.Exec(ctx, `
INSERT INTO group_call_participant_overrides (call_id, setter_user_id, target_user_id, muted_by_you, volume)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (call_id, setter_user_id, target_user_id) DO UPDATE SET
muted_by_you = EXCLUDED.muted_by_you,
volume = EXCLUDED.volume`,
callID, setterUserID, targetUserID, override.MutedByYou, override.Volume); err != nil {
return fmt.Errorf("upsert participant override: %w", err)
}
return nil
}
func (s *GroupCallStore) GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) {
var ov domain.GroupCallParticipantOverride
err := s.db.QueryRow(ctx,
`SELECT muted_by_you, volume FROM group_call_participant_overrides WHERE call_id = $1 AND setter_user_id = $2 AND target_user_id = $3`,
callID, setterUserID, targetUserID).Scan(&ov.MutedByYou, &ov.Volume)
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallParticipantOverride{}, false, nil
}
if err != nil {
return domain.GroupCallParticipantOverride{}, false, fmt.Errorf("get participant override: %w", err)
}
return ov, true, nil
}
func (s *GroupCallStore) CreateConferenceInvite(ctx context.Context, invite domain.GroupCallInvite) (domain.GroupCallInvite, error) {
if invite.CallID == 0 || invite.InviterUserID == 0 || invite.InviteeUserID == 0 || invite.MessageID == 0 {
return domain.GroupCallInvite{}, domain.ErrGroupCallInvalid
}
if invite.Status == "" {
invite.Status = domain.GroupCallInvitePending
}
var status string
err := s.db.QueryRow(ctx, `
INSERT INTO group_call_invites (call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (call_id, invitee_user_id, message_id) DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
video = EXCLUDED.video
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
invite.CallID, invite.InviterUserID, invite.InviteeUserID, invite.MessageID, string(invite.Status), invite.Video, invite.CreatedAt, invite.UpdatedAt,
).Scan(&invite.CallID, &invite.InviterUserID, &invite.InviteeUserID, &invite.MessageID, &status, &invite.Video, &invite.CreatedAt, &invite.UpdatedAt)
if err != nil {
return domain.GroupCallInvite{}, fmt.Errorf("create conference invite: %w", err)
}
invite.Status = domain.GroupCallInviteStatus(status)
return invite, nil
}
func (s *GroupCallStore) SetConferenceInviteStatus(ctx context.Context, callID int64, inviteeUserID int64, msgID int, status domain.GroupCallInviteStatus, now int) (domain.GroupCallInvite, bool, error) {
var inv domain.GroupCallInvite
var newStatus string
err := s.db.QueryRow(ctx, `
UPDATE group_call_invites
SET status = $4, updated_at = $5
WHERE call_id = $1 AND invitee_user_id = $2 AND message_id = $3
RETURNING call_id, inviter_user_id, invitee_user_id, message_id, status, video, created_at, updated_at`,
callID, inviteeUserID, msgID, string(status), now,
).Scan(&inv.CallID, &inv.InviterUserID, &inv.InviteeUserID, &inv.MessageID, &newStatus, &inv.Video, &inv.CreatedAt, &inv.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallInvite{}, false, nil
}
if err != nil {
return domain.GroupCallInvite{}, false, fmt.Errorf("set conference invite status: %w", err)
}
inv.Status = domain.GroupCallInviteStatus(newStatus)
return inv, true, nil
}
func (s *GroupCallStore) ListConferenceRecipientUserIDs(ctx context.Context, callID int64) ([]int64, error) {
rows, err := s.db.Query(ctx, `
WITH c AS (
SELECT creator_user_id, state FROM group_calls WHERE call_id = $1
)
SELECT creator_user_id FROM c
UNION
SELECT p.user_id
FROM group_call_participants p CROSS JOIN c
WHERE p.call_id = $1 AND (c.state <> 'active' OR NOT p.left_call)
UNION
SELECT i.inviter_user_id
FROM group_call_invites i CROSS JOIN c
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
UNION
SELECT i.invitee_user_id
FROM group_call_invites i CROSS JOIN c
WHERE i.call_id = $1 AND (c.state <> 'active' OR i.status IN ('pending', 'accepted'))
ORDER BY 1`, callID)
if err != nil {
return nil, fmt.Errorf("list conference recipients: %w", err)
}
defer rows.Close()
var out []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
if id != 0 {
out = append(out, id)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func (s *GroupCallStore) AppendGroupCallChainBlock(ctx context.Context, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
if block.CallID == 0 || len(block.Block) == 0 {
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
}
tx, err := s.begin(ctx, "append group call chain block")
if err != nil {
return domain.GroupCallChainBlock{}, err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
call, err := lockGroupCallTx(ctx, tx, block.CallID)
if err != nil {
return domain.GroupCallChainBlock{}, err
}
if !call.Conference() {
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
}
block, err = appendGroupCallChainBlockTx(ctx, tx, block)
if err != nil {
return domain.GroupCallChainBlock{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.GroupCallChainBlock{}, fmt.Errorf("commit append group call chain block: %w", err)
}
committed = true
return block, nil
}
func appendGroupCallChainBlockTx(ctx context.Context, tx pgx.Tx, block domain.GroupCallChainBlock) (domain.GroupCallChainBlock, error) {
if block.CallID == 0 || len(block.Block) == 0 {
return domain.GroupCallChainBlock{}, domain.ErrGroupCallInvalid
}
var existing domain.GroupCallChainBlock
err := tx.QueryRow(ctx, `
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
FROM group_call_chain_blocks
WHERE call_id = $1 AND sub_chain_id = $2 AND block = $3
ORDER BY block_offset ASC
LIMIT 1`, block.CallID, block.SubChainID, block.Block).Scan(
&existing.CallID, &existing.SubChainID, &existing.Offset, &existing.AuthorUserID, &existing.Block, &existing.CreatedAt,
)
if err == nil {
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallChainBlock{}, fmt.Errorf("get existing group call chain block: %w", err)
}
var nextOffset int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(block_offset) + 1, 0)
FROM group_call_chain_blocks
WHERE call_id = $1 AND sub_chain_id = $2`, block.CallID, block.SubChainID).Scan(&nextOffset); err != nil {
return domain.GroupCallChainBlock{}, fmt.Errorf("next chain block offset: %w", err)
}
if block.Offset < 0 {
block.Offset = nextOffset
}
if block.Offset != nextOffset {
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
}
err = tx.QueryRow(ctx, `
INSERT INTO group_call_chain_blocks (call_id, sub_chain_id, block_offset, author_user_id, block, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING call_id, sub_chain_id, block_offset, author_user_id, block, created_at`,
block.CallID, block.SubChainID, block.Offset, block.AuthorUserID, block.Block, block.CreatedAt,
).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
if err != nil {
if isUniqueViolation(err) {
return domain.GroupCallChainBlock{}, domain.ErrConferenceChainInvalid
}
return domain.GroupCallChainBlock{}, fmt.Errorf("append group call chain block: %w", err)
}
return block, nil
}
func (s *GroupCallStore) ListGroupCallChainBlocks(ctx context.Context, callID int64, subChainID, offset, limit int) (domain.GroupCallChainBlockPage, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
if offset == domain.GroupCallChainBlockLatestOffset {
var block domain.GroupCallChainBlock
err := s.db.QueryRow(ctx, `
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
FROM group_call_chain_blocks
WHERE call_id = $1 AND sub_chain_id = $2
ORDER BY block_offset DESC
LIMIT 1`, callID, subChainID).Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.GroupCallChainBlockPage{NextOffset: 0}, nil
}
if err != nil {
return domain.GroupCallChainBlockPage{}, fmt.Errorf("get latest group call chain block: %w", err)
}
return domain.GroupCallChainBlockPage{
Blocks: []domain.GroupCallChainBlock{block},
NextOffset: block.Offset + 1,
}, nil
}
if offset < 0 {
return domain.GroupCallChainBlockPage{}, domain.ErrGroupCallInvalid
}
rows, err := s.db.Query(ctx, `
SELECT call_id, sub_chain_id, block_offset, author_user_id, block, created_at
FROM group_call_chain_blocks
WHERE call_id = $1 AND sub_chain_id = $2 AND block_offset >= $3
ORDER BY block_offset ASC
LIMIT $4`, callID, subChainID, offset, limit)
if err != nil {
return domain.GroupCallChainBlockPage{}, fmt.Errorf("list group call chain blocks: %w", err)
}
defer rows.Close()
page := domain.GroupCallChainBlockPage{NextOffset: offset}
for rows.Next() {
var block domain.GroupCallChainBlock
if err := rows.Scan(&block.CallID, &block.SubChainID, &block.Offset, &block.AuthorUserID, &block.Block, &block.CreatedAt); err != nil {
return domain.GroupCallChainBlockPage{}, err
}
page.Blocks = append(page.Blocks, block)
page.NextOffset = block.Offset + 1
}
if err := rows.Err(); err != nil {
return domain.GroupCallChainBlockPage{}, err
}
return page, nil
}