chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
675
internal/store/postgres/groupcall.go
Normal file
675
internal/store/postgres/groupcall.go
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"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, state, title, join_muted,
|
||||
version, participants_count, created_at, discarded_at, duration, started_msg_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, left_call, last_check_date`
|
||||
|
||||
func scanGroupCall(row rowScanner) (domain.GroupCall, error) {
|
||||
var c domain.GroupCall
|
||||
var state string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &state, &c.Title, &c.JoinMuted,
|
||||
&c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID,
|
||||
); err != nil {
|
||||
return domain.GroupCall{}, err
|
||||
}
|
||||
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.Left, &p.LastCheckDate,
|
||||
); err != nil {
|
||||
return domain.GroupCallParticipant{}, err
|
||||
}
|
||||
return p, 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 (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, state, title, join_muted, version, participants_count, created_at)
|
||||
VALUES ($1, $2, $3, $4, '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.ParticipantsCount = 0
|
||||
return call, 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) 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,
|
||||
}
|
||||
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, left_call, last_check_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, FALSE, $9)
|
||||
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,
|
||||
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), 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)
|
||||
}
|
||||
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 := bumpGroupCallVersionTx(ctx, tx, callID, -1)
|
||||
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) 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
|
||||
WHERE call_id = $1 RETURNING `+groupCallColumns, callID))
|
||||
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 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) {
|
||||
// 全局单调序号用 sequence;group_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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue